commit 815aa04fe897fb571fa1e689ca149d6fa86154dc Author: haomingming Date: Wed May 20 18:21:39 2026 +0800 first diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d0900de --- /dev/null +++ b/.gitignore @@ -0,0 +1,40 @@ +# OS files +.DS_Store +Thumbs.db + +# Editor / IDE +.idea/ +.vscode/ +*.iml + +# Local agent / tool state +.agents/ +.npm-cache/ + +# Environment files +.env +.env.* +!.env.example +!.env.*.example + +# Logs +*.log +logs/ +backend/logs/ + +# Frontend +frontend/node_modules/ +frontend/dist/ +frontend/.vite/ +frontend/coverage/ +frontend/tmp-*.cjs + +# Backend / Java +backend/target/ +backend/.mvn/ +*.class + +# Build artifacts +coverage/ +tmp/ +temp/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..9358f6c --- /dev/null +++ b/README.md @@ -0,0 +1,94 @@ +# 会议核销SaaS 开发启动说明 + +## 目录 +- `backend/`:Spring Boot 后端工程(按模块分包)。 +- `frontend/`:Vue3 + Element Plus 前端工程(按模块路由)。 +- `基金会、协会、北京欣欣会议核销系统_完整版.md`:业务需求文档。 +- `会议核销SaaS系统_技术开发文档.md`:技术开发文档。 + +## 当前已落地MVP模块 +- 后端核心接口 + - `GET /api/system/health` + - `GET/POST /api/projects` + - `POST /api/projects/{id}/freeze` + - `GET/POST /api/meetings` + - `POST /api/meetings/{id}/submit` + - `GET /api/audits/tasks` + - `POST /api/audits/tasks/{id}/approve` + - `POST /api/audits/tasks/{id}/reject` + - `POST /api/audits/tasks/{id}/return` + - `GET /api/finance/projects` + - `POST /api/finance/payments` +- 后端分层结构 + - 已按 `controller/service/repository/model/dto` 分层。 + - 已实现业务异常、参数校验、统一响应结构、错误码处理。 +- 数据库与初始化 + - `backend/src/main/resources/db/schema.sql` + - `backend/src/main/resources/db/data.sql` +- 调度任务(无MQ) + - 已实现 `Scheduler + async_job` 内存版执行机制、重试与幂等防重。 +- 前端模块页面 + - 项目管理(新建/冻结/列表) + - 会议管理(新建/会议级提交) + - 审核管理(通过/拒绝/退回) + - 财务管理(支付确认/列表) + +## 第二阶段已落地 +- 持久化升级 + - 新增 JDBC 仓储实现(项目/会议/审核/支付/任务)。 + - 支持通过 `APP_REPOSITORY_MODE` 切换 `jdbc` 与 `in-memory`。 +- 数据库迁移 + - 新增 Flyway 迁移脚本: + - `backend/src/main/resources/db/migration/V1__init_schema.sql` + - `backend/src/main/resources/db/migration/V2__seed_data.sql` +- 系统设置基础模块 + - 后端新增用户与角色接口:`/api/users`、`/api/roles` + - 前端新增用户管理、角色管理页面与菜单。 +- 文件能力 + - 新增 OSS 预签名下载接口:`GET /api/files/presign-download` + +## 第三阶段当前进展(已完成) +- 认证与鉴权 + - 新增 JWT 登录接口:`POST /api/auth/login` + - 新增鉴权拦截器:统一校验 `Authorization: Bearer ` + - 新增 RBAC 权限注解:`@RequirePermission` +- 权限落地 + - 项目、会议、审核、财务关键写操作已接入权限码校验。 +- Flyway 迁移增强 + - 新增 `V3__auth_rbac_seed.sql`,补齐用户、角色、权限与映射初始化数据。 +- 前端登录与路由守卫 + - 新增登录页 `/login` + - 未登录自动跳转登录,token 过期自动清理并回登录页。 + - 新增退出登录按钮。 + +## 启动方式 + +### 后端 +1. 配置 `backend/src/main/resources/application.yml` 中的 MySQL 与 OSS 参数。 +2. 在 `backend` 目录执行: + - `mvn spring-boot:run` + +### 前端 +1. 在 `frontend` 目录执行: + - `npm install` + - `npm run dev` + +## 测试与构建 + +### 后端 +- 在 `backend` 目录执行: + - `mvn clean test` + +### 前端 +- 在 `frontend` 目录执行: + - `npm run build` + +## 试运行与发布 +- 试运行与回滚预案见: + - `docs/MVP_试运行与发布回滚预案.md` + +## 下一阶段建议 +1. 接入真实 MySQL 持久化仓储(替换内存仓储)。 +2. 完成租户/用户/角色/权限模块。 +3. 完善审核流配置化与任务告警通知通道。 +4. 落地 OSS 上传、模板治理、专家模块。 diff --git a/backend/pom.xml b/backend/pom.xml new file mode 100644 index 0000000..b11abed --- /dev/null +++ b/backend/pom.xml @@ -0,0 +1,107 @@ + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 2.7.18 + + + + com.writeoff + writeoff-backend + 0.0.1-SNAPSHOT + writeoff-backend + Meeting Write-off SaaS Backend + + + 1.8 + UTF-8 + 7.15.0 + + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-validation + + + org.springframework.boot + spring-boot-starter-jdbc + + + org.springframework.boot + spring-boot-starter-mail + + + org.springframework.boot + spring-boot-starter-websocket + + + org.flywaydb + flyway-core + + + org.springdoc + springdoc-openapi-ui + 1.7.0 + + + com.aliyun.oss + aliyun-sdk-oss + 3.17.4 + + + org.apache.poi + poi-ooxml + 5.2.5 + + + io.jsonwebtoken + jjwt-api + 0.11.5 + + + io.jsonwebtoken + jjwt-impl + 0.11.5 + runtime + + + io.jsonwebtoken + jjwt-jackson + 0.11.5 + runtime + + + com.mysql + mysql-connector-j + runtime + + + org.projectlombok + lombok + true + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/backend/src/main/java/com/writeoff/WriteOffApplication.java b/backend/src/main/java/com/writeoff/WriteOffApplication.java new file mode 100644 index 0000000..7592a44 --- /dev/null +++ b/backend/src/main/java/com/writeoff/WriteOffApplication.java @@ -0,0 +1,14 @@ +package com.writeoff; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableScheduling; + +@SpringBootApplication +@EnableScheduling +public class WriteOffApplication { + + public static void main(String[] args) { + SpringApplication.run(WriteOffApplication.class, args); + } +} diff --git a/backend/src/main/java/com/writeoff/common/api/ApiErrorResponse.java b/backend/src/main/java/com/writeoff/common/api/ApiErrorResponse.java new file mode 100644 index 0000000..c1ee771 --- /dev/null +++ b/backend/src/main/java/com/writeoff/common/api/ApiErrorResponse.java @@ -0,0 +1,45 @@ +package com.writeoff.common.api; + +import com.writeoff.common.web.RequestIdContext; +import java.time.Instant; +import java.util.Map; + +public class ApiErrorResponse { + private int code; + private String message; + private Map errors; + private String requestId; + private String timestamp; + + public ApiErrorResponse(int code, String message, Map errors, String requestId, String timestamp) { + this.code = code; + this.message = message; + this.errors = errors; + this.requestId = requestId; + this.timestamp = timestamp; + } + + public static ApiErrorResponse of(int code, String message, Map errors) { + return new ApiErrorResponse(code, message, errors, RequestIdContext.get(), Instant.now().toString()); + } + + public int getCode() { + return code; + } + + public String getMessage() { + return message; + } + + public Map getErrors() { + return errors; + } + + public String getRequestId() { + return requestId; + } + + public String getTimestamp() { + return timestamp; + } +} diff --git a/backend/src/main/java/com/writeoff/common/api/ApiResponse.java b/backend/src/main/java/com/writeoff/common/api/ApiResponse.java new file mode 100644 index 0000000..2362ffc --- /dev/null +++ b/backend/src/main/java/com/writeoff/common/api/ApiResponse.java @@ -0,0 +1,44 @@ +package com.writeoff.common.api; + +import com.writeoff.common.web.RequestIdContext; +import java.time.Instant; + +public class ApiResponse { + private int code; + private String message; + private T data; + private String requestId; + private String timestamp; + + public ApiResponse(int code, String message, T data, String requestId, String timestamp) { + this.code = code; + this.message = message; + this.data = data; + this.requestId = requestId; + this.timestamp = timestamp; + } + + public static ApiResponse success(T data) { + return new ApiResponse<>(0, "success", data, RequestIdContext.get(), Instant.now().toString()); + } + + public int getCode() { + return code; + } + + public String getMessage() { + return message; + } + + public T getData() { + return data; + } + + public String getRequestId() { + return requestId; + } + + public String getTimestamp() { + return timestamp; + } +} diff --git a/backend/src/main/java/com/writeoff/common/api/PageResult.java b/backend/src/main/java/com/writeoff/common/api/PageResult.java new file mode 100644 index 0000000..23cd181 --- /dev/null +++ b/backend/src/main/java/com/writeoff/common/api/PageResult.java @@ -0,0 +1,33 @@ +package com.writeoff.common.api; + +import java.util.List; + +public class PageResult { + private List list; + private long total; + private int pageNo; + private int pageSize; + + public PageResult(List list, long total, int pageNo, int pageSize) { + this.list = list; + this.total = total; + this.pageNo = pageNo; + this.pageSize = pageSize; + } + + public List getList() { + return list; + } + + public long getTotal() { + return total; + } + + public int getPageNo() { + return pageNo; + } + + public int getPageSize() { + return pageSize; + } +} diff --git a/backend/src/main/java/com/writeoff/common/exception/BusinessException.java b/backend/src/main/java/com/writeoff/common/exception/BusinessException.java new file mode 100644 index 0000000..3e11882 --- /dev/null +++ b/backend/src/main/java/com/writeoff/common/exception/BusinessException.java @@ -0,0 +1,14 @@ +package com.writeoff.common.exception; + +public class BusinessException extends RuntimeException { + private final int code; + + public BusinessException(int code, String message) { + super(message); + this.code = code; + } + + public int getCode() { + return code; + } +} diff --git a/backend/src/main/java/com/writeoff/common/exception/ErrorCodes.java b/backend/src/main/java/com/writeoff/common/exception/ErrorCodes.java new file mode 100644 index 0000000..edb6441 --- /dev/null +++ b/backend/src/main/java/com/writeoff/common/exception/ErrorCodes.java @@ -0,0 +1,30 @@ +package com.writeoff.common.exception; + +public final class ErrorCodes { + private ErrorCodes() { + } + + public static final int VALIDATION_ERROR = 10001; + public static final int IDEMPOTENCY_CONFLICT = 10002; + public static final int RESOURCE_NOT_FOUND = 10003; + public static final int RATE_LIMITED = 10005; + + public static final int UNAUTHORIZED = 11001; + public static final int TOKEN_EXPIRED = 11002; + public static final int SESSION_INVALID = 11003; + public static final int ACCOUNT_EXPIRED = 11004; + public static final int REFRESH_TOKEN_INVALID = 11005; + public static final int REFRESH_TOKEN_EXPIRED = 11006; + public static final int REFRESH_RISK_REJECTED = 11007; + + public static final int NO_PERMISSION = 20001; + public static final int NO_DATA_PERMISSION = 20002; + + public static final int INVALID_STATE = 30001; + public static final int TASK_ALREADY_PROCESSED = 30003; + + public static final int PAYMENT_STATE_INVALID = 40003; + public static final int PAYMENT_LOCKED = 40004; + + public static final int INTERNAL_ERROR = 90001; +} diff --git a/backend/src/main/java/com/writeoff/common/exception/GlobalExceptionHandler.java b/backend/src/main/java/com/writeoff/common/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..cd855e2 --- /dev/null +++ b/backend/src/main/java/com/writeoff/common/exception/GlobalExceptionHandler.java @@ -0,0 +1,45 @@ +package com.writeoff.common.exception; + +import com.writeoff.common.api.ApiErrorResponse; +import javax.validation.ConstraintViolationException; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(BusinessException.class) + public ResponseEntity handleBusiness(BusinessException ex) { + HttpStatus status = ex.getCode() >= 90000 ? HttpStatus.INTERNAL_SERVER_ERROR : HttpStatus.UNPROCESSABLE_ENTITY; + return ResponseEntity.status(status) + .body(ApiErrorResponse.of(ex.getCode(), ex.getMessage(), Collections.emptyMap())); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleValidation(MethodArgumentNotValidException ex) { + Map errors = new HashMap<>(); + ex.getBindingResult().getFieldErrors() + .forEach(fieldError -> errors.put(fieldError.getField(), fieldError.getDefaultMessage())); + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(ApiErrorResponse.of(10001, "参数校验失败", errors)); + } + + @ExceptionHandler(ConstraintViolationException.class) + public ResponseEntity handleConstraint(ConstraintViolationException ex) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(ApiErrorResponse.of(10001, "参数校验失败", Collections.singletonMap("message", ex.getMessage()))); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity handleUnknown(Exception ex) { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(ApiErrorResponse.of(90001, "系统内部异常", Collections.singletonMap("message", ex.getMessage()))); + } +} diff --git a/backend/src/main/java/com/writeoff/common/model/ImportResult.java b/backend/src/main/java/com/writeoff/common/model/ImportResult.java new file mode 100644 index 0000000..c224651 --- /dev/null +++ b/backend/src/main/java/com/writeoff/common/model/ImportResult.java @@ -0,0 +1,74 @@ +package com.writeoff.common.model; + +import java.util.ArrayList; +import java.util.List; + +public class ImportResult { + private int total; + private int success; + private int failed; + private boolean partialSuccess; + private List errors = new ArrayList(); + + public int getTotal() { + return total; + } + + public void setTotal(int total) { + this.total = total; + refreshFlags(); + } + + public int getSuccess() { + return success; + } + + public void setSuccess(int success) { + this.success = success; + refreshFlags(); + } + + public int getFailed() { + return failed; + } + + public void setFailed(int failed) { + this.failed = failed; + refreshFlags(); + } + + public boolean isPartialSuccess() { + return partialSuccess; + } + + public void setPartialSuccess(boolean partialSuccess) { + this.partialSuccess = partialSuccess; + } + + public List getErrors() { + return errors; + } + + public void setErrors(List errors) { + this.errors = errors == null ? new ArrayList() : errors; + refreshFlags(); + } + + public void markSuccess() { + this.success++; + refreshFlags(); + } + + public void addError(int rowNo, String identifier, String message) { + this.errors.add(new ImportRowError(rowNo, identifier, message)); + this.failed++; + refreshFlags(); + } + + private void refreshFlags() { + if (this.total <= 0) { + this.total = this.success + this.failed; + } + this.partialSuccess = this.success > 0 && this.failed > 0; + } +} diff --git a/backend/src/main/java/com/writeoff/common/model/ImportRowError.java b/backend/src/main/java/com/writeoff/common/model/ImportRowError.java new file mode 100644 index 0000000..b5f2fc0 --- /dev/null +++ b/backend/src/main/java/com/writeoff/common/model/ImportRowError.java @@ -0,0 +1,40 @@ +package com.writeoff.common.model; + +public class ImportRowError { + private int rowNo; + private String identifier; + private String message; + + public ImportRowError() { + } + + public ImportRowError(int rowNo, String identifier, String message) { + this.rowNo = rowNo; + this.identifier = identifier; + this.message = message; + } + + public int getRowNo() { + return rowNo; + } + + public void setRowNo(int rowNo) { + this.rowNo = rowNo; + } + + public String getIdentifier() { + return identifier; + } + + public void setIdentifier(String identifier) { + this.identifier = identifier; + } + + public String getMessage() { + return message; + } + + public void setMessage(String message) { + this.message = message; + } +} diff --git a/backend/src/main/java/com/writeoff/common/util/ImportValidationUtils.java b/backend/src/main/java/com/writeoff/common/util/ImportValidationUtils.java new file mode 100644 index 0000000..8da6e91 --- /dev/null +++ b/backend/src/main/java/com/writeoff/common/util/ImportValidationUtils.java @@ -0,0 +1,86 @@ +package com.writeoff.common.util; + +import com.writeoff.common.exception.BusinessException; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.regex.Pattern; + +public final class ImportValidationUtils { + private static final Pattern PHONE_PATTERN = Pattern.compile("^1\\d{10}$"); + private static final Pattern EMAIL_PATTERN = Pattern.compile("^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$"); + private static final Pattern ID_NO_PATTERN = Pattern.compile("(^\\d{15}$)|(^\\d{17}[\\dXx]$)"); + private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + + private ImportValidationUtils() { + } + + public static void validatePhone(String phone) { + String value = trim(phone); + if (value.isEmpty()) { + throw new BusinessException(10001, "\u624b\u673a\u53f7\u4e0d\u80fd\u4e3a\u7a7a"); + } + if (!PHONE_PATTERN.matcher(value).matches()) { + throw new BusinessException(10001, "\u624b\u673a\u53f7\u683c\u5f0f\u4e0d\u6b63\u786e"); + } + } + + public static void validateOptionalEmail(String email) { + String value = trim(email); + if (value.isEmpty()) { + return; + } + if (!EMAIL_PATTERN.matcher(value).matches()) { + throw new BusinessException(10001, "\u90ae\u7bb1\u683c\u5f0f\u4e0d\u6b63\u786e"); + } + } + + public static void validateRequiredEmail(String email) { + String value = trim(email); + if (value.isEmpty()) { + throw new BusinessException(10001, "\u90ae\u7bb1\u4e0d\u80fd\u4e3a\u7a7a"); + } + if (!EMAIL_PATTERN.matcher(value).matches()) { + throw new BusinessException(10001, "\u90ae\u7bb1\u683c\u5f0f\u4e0d\u6b63\u786e"); + } + } + + public static void validateIdNo(String idNo) { + String value = trim(idNo); + if (value.isEmpty()) { + throw new BusinessException(10001, "\u8eab\u4efd\u8bc1\u53f7\u4e0d\u80fd\u4e3a\u7a7a"); + } + if (!ID_NO_PATTERN.matcher(value).matches()) { + throw new BusinessException(10001, "\u8eab\u4efd\u8bc1\u53f7\u683c\u5f0f\u4e0d\u6b63\u786e"); + } + } + + public static LocalDateTime parseOptionalDateTime(String raw, String fieldName) { + String value = trim(raw); + if (value.isEmpty()) { + return null; + } + try { + return LocalDateTime.parse(normalizeDateTime(value), DATE_TIME_FORMATTER); + } catch (Exception ex) { + throw new BusinessException(10001, fieldName + "\u683c\u5f0f\u4e0d\u6b63\u786e\uff0c\u5e94\u4e3a yyyy-MM-dd HH:mm:ss"); + } + } + + public static void validateDateRange(String validFrom, String validTo) { + LocalDateTime from = parseOptionalDateTime(validFrom, "\u751f\u6548\u65f6\u95f4"); + LocalDateTime to = parseOptionalDateTime(validTo, "\u5931\u6548\u65f6\u95f4"); + if (from != null && to != null && to.isBefore(from)) { + throw new BusinessException(10001, "\u5931\u6548\u65f6\u95f4\u4e0d\u80fd\u65e9\u4e8e\u751f\u6548\u65f6\u95f4"); + } + } + + public static String trim(String value) { + return value == null ? "" : value.trim(); + } + + private static String normalizeDateTime(String value) { + String normalized = value.replace("T", " "); + return normalized.length() == 16 ? normalized + ":00" : normalized; + } +} diff --git a/backend/src/main/java/com/writeoff/common/web/RequestIdContext.java b/backend/src/main/java/com/writeoff/common/web/RequestIdContext.java new file mode 100644 index 0000000..8e797bc --- /dev/null +++ b/backend/src/main/java/com/writeoff/common/web/RequestIdContext.java @@ -0,0 +1,21 @@ +package com.writeoff.common.web; + +public final class RequestIdContext { + private static final ThreadLocal HOLDER = new ThreadLocal(); + + private RequestIdContext() { + } + + public static void set(String requestId) { + HOLDER.set(requestId); + } + + public static String get() { + String requestId = HOLDER.get(); + return requestId == null ? "" : requestId; + } + + public static void clear() { + HOLDER.remove(); + } +} diff --git a/backend/src/main/java/com/writeoff/config/WebConfig.java b/backend/src/main/java/com/writeoff/config/WebConfig.java new file mode 100644 index 0000000..2e50e03 --- /dev/null +++ b/backend/src/main/java/com/writeoff/config/WebConfig.java @@ -0,0 +1,29 @@ +package com.writeoff.config; + +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.WebMvcConfigurer; +import com.writeoff.security.AuthInterceptor; + +@Configuration +public class WebConfig implements WebMvcConfigurer { + private final AuthInterceptor authInterceptor; + + public WebConfig(AuthInterceptor authInterceptor) { + this.authInterceptor = authInterceptor; + } + + @Override + public void addCorsMappings(CorsRegistry registry) { + registry.addMapping("/api/**") + .allowedOrigins("*") + .allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS") + .allowedHeaders("*"); + } + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(authInterceptor).addPathPatterns("/api/**"); + } +} diff --git a/backend/src/main/java/com/writeoff/module/audit/controller/AuditController.java b/backend/src/main/java/com/writeoff/module/audit/controller/AuditController.java new file mode 100644 index 0000000..cc3586a --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/audit/controller/AuditController.java @@ -0,0 +1,123 @@ +package com.writeoff.module.audit.controller; + +import com.writeoff.common.api.ApiResponse; +import com.writeoff.common.api.PageResult; +import com.writeoff.module.audit.dto.AuditActionRequest; +import com.writeoff.module.audit.dto.AuditMaterialItemRejectRequest; +import com.writeoff.module.audit.dto.AuditMaterialModuleApproveRequest; +import com.writeoff.module.audit.dto.BatchAuditActionRequest; +import com.writeoff.module.audit.dto.BatchRemindRequest; +import com.writeoff.module.audit.dto.TransferAuditTaskRequest; +import com.writeoff.module.audit.model.AuditTask; +import com.writeoff.security.DataScopeType; +import com.writeoff.module.audit.service.AuditService; +import com.writeoff.security.RequirePermission; +import javax.validation.Valid; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; + +@RestController +@RequestMapping("/api/audits") +public class AuditController { + private final AuditService auditService; + + public AuditController(AuditService auditService) { + this.auditService = auditService; + } + + @GetMapping("/tasks") + public ApiResponse> tasks( + @RequestParam(value = "mine", required = false, defaultValue = "false") boolean mine, + @RequestParam(value = "scope", required = false) String scope, + @RequestParam(value = "meetingId", required = false) Long meetingId, + @RequestParam(value = "pageNo", required = false) Integer pageNo, + @RequestParam(value = "pageSize", required = false) Integer pageSize, + @RequestParam(value = "sortBy", required = false) String sortBy, + @RequestParam(value = "order", required = false) String order + ) { + if (meetingId == null && pageNo == null && pageSize == null && sortBy == null && order == null) { + return ApiResponse.success(auditService.listTasks(mine, scope)); + } + return ApiResponse.success(auditService.listTasks(mine, scope, meetingId, pageNo, pageSize, sortBy, order)); + } + + @PostMapping("/tasks/{id}/approve") + @RequirePermission(value = "audit.approve", dataScope = DataScopeType.MEETING, auditAction = "AUDIT_APPROVE") + public ApiResponse> approve(@PathVariable("id") Long taskId, + @RequestBody @Valid AuditActionRequest request) { + return ApiResponse.success(auditService.approve(taskId, request)); + } + + @PostMapping("/tasks/{id}/reject") + @RequirePermission(value = "audit.reject", dataScope = DataScopeType.MEETING, auditAction = "AUDIT_REJECT") + public ApiResponse> reject(@PathVariable("id") Long taskId, + @RequestBody @Valid AuditActionRequest request) { + return ApiResponse.success(auditService.reject(taskId, request)); + } + + @PostMapping("/tasks/{id}/return") + @RequirePermission(value = "audit.return", dataScope = DataScopeType.MEETING, auditAction = "AUDIT_RETURN") + public ApiResponse> back(@PathVariable("id") Long taskId, + @RequestBody @Valid AuditActionRequest request) { + return ApiResponse.success(auditService.back(taskId, request)); + } + + @GetMapping("/export-opinions") + @RequirePermission(value = "audit.export.opinions", dataScope = DataScopeType.TENANT, auditAction = "AUDIT_EXPORT_OPINIONS") + public ApiResponse> exportOpinions() { + return ApiResponse.success(auditService.exportOpinions()); + } + + @GetMapping("/tasks/{id}/material") + @RequirePermission(value = "audit.material.read", dataScope = DataScopeType.MEETING_MODULE, auditAction = "AUDIT_MATERIAL_READ") + public ApiResponse> readTaskMaterial(@PathVariable("id") Long id, + @RequestParam("moduleCode") String moduleCode) { + return ApiResponse.success(auditService.readTaskMaterial(id, moduleCode)); + } + + @PostMapping("/tasks/{id}/material/approve-module") + @RequirePermission(value = "audit.approve", dataScope = DataScopeType.MEETING_MODULE, auditAction = "AUDIT_MATERIAL_APPROVE_MODULE") + public ApiResponse> approveMaterialModule(@PathVariable("id") Long id, + @RequestBody @Valid AuditMaterialModuleApproveRequest request) { + return ApiResponse.success(auditService.approveMaterialModule(id, request)); + } + + @PostMapping("/tasks/{id}/material/reject-item") + @RequirePermission(value = "audit.reject", dataScope = DataScopeType.MEETING_MODULE, auditAction = "AUDIT_MATERIAL_REJECT_ITEM") + public ApiResponse> rejectMaterialItem(@PathVariable("id") Long id, + @RequestBody @Valid AuditMaterialItemRejectRequest request) { + return ApiResponse.success(auditService.rejectMaterialItem(id, request)); + } + + @PostMapping("/tasks/{id}/transfer") + @RequirePermission(value = "audit.transfer", dataScope = DataScopeType.MEETING, auditAction = "AUDIT_TRANSFER") + public ApiResponse> transfer(@PathVariable("id") Long id, + @RequestBody @Valid TransferAuditTaskRequest request) { + return ApiResponse.success(auditService.transfer(id, request)); + } + + @PostMapping("/tasks/batch-remind") + @RequirePermission(value = "audit.remind", dataScope = DataScopeType.TENANT, auditAction = "AUDIT_BATCH_REMIND") + public ApiResponse> batchRemind(@RequestBody @Valid BatchRemindRequest request) { + return ApiResponse.success(auditService.batchRemind(request)); + } + + @GetMapping("/tasks/sla-stat") + @RequirePermission(value = "audit.sla.read", dataScope = DataScopeType.TENANT, auditAction = "AUDIT_SLA_STAT") + public ApiResponse> slaStat() { + return ApiResponse.success(auditService.slaStat()); + } + + @PostMapping("/tasks/batch-approve") + @RequirePermission(value = "audit.approve", dataScope = DataScopeType.TENANT, auditAction = "AUDIT_BATCH_APPROVE") + public ApiResponse> batchApprove(@RequestBody @Valid BatchAuditActionRequest request) { + return ApiResponse.success(auditService.batchApprove(request)); + } + + @PostMapping("/tasks/batch-reject") + @RequirePermission(value = "audit.reject", dataScope = DataScopeType.TENANT, auditAction = "AUDIT_BATCH_REJECT") + public ApiResponse> batchReject(@RequestBody @Valid BatchAuditActionRequest request) { + return ApiResponse.success(auditService.batchReject(request)); + } +} diff --git a/backend/src/main/java/com/writeoff/module/audit/controller/AuditFlowController.java b/backend/src/main/java/com/writeoff/module/audit/controller/AuditFlowController.java new file mode 100644 index 0000000..24e69db --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/audit/controller/AuditFlowController.java @@ -0,0 +1,84 @@ +package com.writeoff.module.audit.controller; + +import com.writeoff.common.api.ApiResponse; +import com.writeoff.common.api.PageResult; +import com.writeoff.module.audit.dto.CreateAuditFlowRequest; +import com.writeoff.module.audit.dto.UpdateAuditFlowRequest; +import com.writeoff.module.audit.model.AuditFlowInfo; +import com.writeoff.module.audit.service.AuditFlowManageService; +import com.writeoff.security.DataScopeType; +import com.writeoff.security.RequirePermission; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import javax.validation.Valid; + +@RestController +@RequestMapping("/api/audit-flows") +public class AuditFlowController { + private final AuditFlowManageService auditFlowManageService; + + public AuditFlowController(AuditFlowManageService auditFlowManageService) { + this.auditFlowManageService = auditFlowManageService; + } + + @GetMapping + @RequirePermission(value = "audit.flow.read", dataScope = DataScopeType.TENANT, auditAction = "AUDIT_FLOW_LIST") + public ApiResponse> list( + @RequestParam(value = "pageNo", defaultValue = "1") int pageNo, + @RequestParam(value = "pageSize", defaultValue = "20") int pageSize) { + return ApiResponse.success(auditFlowManageService.list(pageNo, pageSize)); + } + + @PostMapping + @RequirePermission(value = "audit.flow.manage", dataScope = DataScopeType.TENANT, auditAction = "AUDIT_FLOW_CREATE") + public ApiResponse create(@RequestBody @Valid CreateAuditFlowRequest request) { + return ApiResponse.success(auditFlowManageService.create(request)); + } + + @PutMapping("/{id}") + @RequirePermission(value = "audit.flow.manage", dataScope = DataScopeType.TENANT, auditAction = "AUDIT_FLOW_UPDATE") + public ApiResponse update(@PathVariable("id") Long id, @RequestBody @Valid UpdateAuditFlowRequest request) { + return ApiResponse.success(auditFlowManageService.update(id, request)); + } + + @PostMapping("/{id}/copy") + @RequirePermission(value = "audit.flow.manage", dataScope = DataScopeType.TENANT, auditAction = "AUDIT_FLOW_COPY") + public ApiResponse copy(@PathVariable("id") Long id) { + return ApiResponse.success(auditFlowManageService.copy(id)); + } + + @PostMapping("/{id}/default") + @RequirePermission(value = "audit.flow.manage", dataScope = DataScopeType.TENANT, auditAction = "AUDIT_FLOW_SET_DEFAULT") + public ApiResponse setDefault(@PathVariable("id") Long id) { + auditFlowManageService.setDefault(id); + return ApiResponse.success("OK"); + } + + @PostMapping("/{id}/enable") + @RequirePermission(value = "audit.flow.manage", dataScope = DataScopeType.TENANT, auditAction = "AUDIT_FLOW_ENABLE") + public ApiResponse enable(@PathVariable("id") Long id) { + auditFlowManageService.enable(id); + return ApiResponse.success("OK"); + } + + @PostMapping("/{id}/disable") + @RequirePermission(value = "audit.flow.manage", dataScope = DataScopeType.TENANT, auditAction = "AUDIT_FLOW_DISABLE") + public ApiResponse disable(@PathVariable("id") Long id) { + auditFlowManageService.disable(id); + return ApiResponse.success("OK"); + } + + @PostMapping("/{id}/delete") + @RequirePermission(value = "audit.flow.manage", dataScope = DataScopeType.TENANT, auditAction = "AUDIT_FLOW_DELETE") + public ApiResponse delete(@PathVariable("id") Long id) { + auditFlowManageService.softDelete(id); + return ApiResponse.success("OK"); + } +} diff --git a/backend/src/main/java/com/writeoff/module/audit/dto/AuditActionRequest.java b/backend/src/main/java/com/writeoff/module/audit/dto/AuditActionRequest.java new file mode 100644 index 0000000..6020191 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/audit/dto/AuditActionRequest.java @@ -0,0 +1,26 @@ +package com.writeoff.module.audit.dto; + +import javax.validation.constraints.NotBlank; + +public class AuditActionRequest { + @NotBlank(message = "幂等键不能为空") + private String idempotencyKey; + @NotBlank(message = "审核意见不能为空") + private String opinion; + + public String getIdempotencyKey() { + return idempotencyKey; + } + + public void setIdempotencyKey(String idempotencyKey) { + this.idempotencyKey = idempotencyKey; + } + + public String getOpinion() { + return opinion; + } + + public void setOpinion(String opinion) { + this.opinion = opinion; + } +} diff --git a/backend/src/main/java/com/writeoff/module/audit/dto/AuditFlowNodeRequest.java b/backend/src/main/java/com/writeoff/module/audit/dto/AuditFlowNodeRequest.java new file mode 100644 index 0000000..05217f1 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/audit/dto/AuditFlowNodeRequest.java @@ -0,0 +1,55 @@ +package com.writeoff.module.audit.dto; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; + +public class AuditFlowNodeRequest { + @NotBlank(message = "节点编码不能为空") + private String nodeCode; + @NotBlank(message = "节点名称不能为空") + private String nodeName; + @NotNull(message = "节点排序不能为空") + private Integer sortNo; + private String assigneeType; + private Long assigneeRefId; + + public String getNodeCode() { + return nodeCode; + } + + public void setNodeCode(String nodeCode) { + this.nodeCode = nodeCode; + } + + public String getNodeName() { + return nodeName; + } + + public void setNodeName(String nodeName) { + this.nodeName = nodeName; + } + + public Integer getSortNo() { + return sortNo; + } + + public void setSortNo(Integer sortNo) { + this.sortNo = sortNo; + } + + public String getAssigneeType() { + return assigneeType; + } + + public void setAssigneeType(String assigneeType) { + this.assigneeType = assigneeType; + } + + public Long getAssigneeRefId() { + return assigneeRefId; + } + + public void setAssigneeRefId(Long assigneeRefId) { + this.assigneeRefId = assigneeRefId; + } +} diff --git a/backend/src/main/java/com/writeoff/module/audit/dto/AuditMaterialItemRejectRequest.java b/backend/src/main/java/com/writeoff/module/audit/dto/AuditMaterialItemRejectRequest.java new file mode 100644 index 0000000..4f514b4 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/audit/dto/AuditMaterialItemRejectRequest.java @@ -0,0 +1,56 @@ +package com.writeoff.module.audit.dto; + +import javax.validation.constraints.NotBlank; + +public class AuditMaterialItemRejectRequest { + @NotBlank(message = "幂等键不能为空") + private String idempotencyKey; + @NotBlank(message = "模块编码不能为空") + private String moduleCode; + @NotBlank(message = "条目编码不能为空") + private String itemKey; + @NotBlank(message = "条目名称不能为空") + private String itemLabel; + @NotBlank(message = "不通过原因不能为空") + private String reason; + + public String getIdempotencyKey() { + return idempotencyKey; + } + + public void setIdempotencyKey(String idempotencyKey) { + this.idempotencyKey = idempotencyKey; + } + + public String getModuleCode() { + return moduleCode; + } + + public void setModuleCode(String moduleCode) { + this.moduleCode = moduleCode; + } + + public String getItemKey() { + return itemKey; + } + + public void setItemKey(String itemKey) { + this.itemKey = itemKey; + } + + public String getItemLabel() { + return itemLabel; + } + + public void setItemLabel(String itemLabel) { + this.itemLabel = itemLabel; + } + + public String getReason() { + return reason; + } + + public void setReason(String reason) { + this.reason = reason; + } +} diff --git a/backend/src/main/java/com/writeoff/module/audit/dto/AuditMaterialModuleApproveRequest.java b/backend/src/main/java/com/writeoff/module/audit/dto/AuditMaterialModuleApproveRequest.java new file mode 100644 index 0000000..31c9ae5 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/audit/dto/AuditMaterialModuleApproveRequest.java @@ -0,0 +1,26 @@ +package com.writeoff.module.audit.dto; + +import javax.validation.constraints.NotBlank; + +public class AuditMaterialModuleApproveRequest { + @NotBlank(message = "幂等键不能为空") + private String idempotencyKey; + @NotBlank(message = "模块编码不能为空") + private String moduleCode; + + public String getIdempotencyKey() { + return idempotencyKey; + } + + public void setIdempotencyKey(String idempotencyKey) { + this.idempotencyKey = idempotencyKey; + } + + public String getModuleCode() { + return moduleCode; + } + + public void setModuleCode(String moduleCode) { + this.moduleCode = moduleCode; + } +} diff --git a/backend/src/main/java/com/writeoff/module/audit/dto/BatchAuditActionRequest.java b/backend/src/main/java/com/writeoff/module/audit/dto/BatchAuditActionRequest.java new file mode 100644 index 0000000..3180244 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/audit/dto/BatchAuditActionRequest.java @@ -0,0 +1,40 @@ +package com.writeoff.module.audit.dto; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotEmpty; +import javax.validation.constraints.Size; +import java.util.List; + +public class BatchAuditActionRequest { + @NotBlank(message = "幂等键不能为空") + private String idempotencyKey; + @NotEmpty(message = "任务ID列表不能为空") + @Size(max = 50, message = "单次批量操作最多50条") + private List taskIds; + @NotBlank(message = "审核意见不能为空") + private String opinion; + + public String getIdempotencyKey() { + return idempotencyKey; + } + + public void setIdempotencyKey(String idempotencyKey) { + this.idempotencyKey = idempotencyKey; + } + + public List getTaskIds() { + return taskIds; + } + + public void setTaskIds(List taskIds) { + this.taskIds = taskIds; + } + + public String getOpinion() { + return opinion; + } + + public void setOpinion(String opinion) { + this.opinion = opinion; + } +} diff --git a/backend/src/main/java/com/writeoff/module/audit/dto/BatchRemindRequest.java b/backend/src/main/java/com/writeoff/module/audit/dto/BatchRemindRequest.java new file mode 100644 index 0000000..1d047ce --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/audit/dto/BatchRemindRequest.java @@ -0,0 +1,26 @@ +package com.writeoff.module.audit.dto; + +import javax.validation.constraints.NotBlank; +import java.util.List; + +public class BatchRemindRequest { + @NotBlank(message = "幂等键不能为空") + private String idempotencyKey; + private List taskIds; + + public String getIdempotencyKey() { + return idempotencyKey; + } + + public void setIdempotencyKey(String idempotencyKey) { + this.idempotencyKey = idempotencyKey; + } + + public List getTaskIds() { + return taskIds; + } + + public void setTaskIds(List taskIds) { + this.taskIds = taskIds; + } +} diff --git a/backend/src/main/java/com/writeoff/module/audit/dto/CreateAuditFlowRequest.java b/backend/src/main/java/com/writeoff/module/audit/dto/CreateAuditFlowRequest.java new file mode 100644 index 0000000..431cea2 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/audit/dto/CreateAuditFlowRequest.java @@ -0,0 +1,58 @@ +package com.writeoff.module.audit.dto; + +import javax.validation.Valid; +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotEmpty; +import java.util.List; + +public class CreateAuditFlowRequest { + @NotBlank(message = "流程编码不能为空") + private String flowCode; + @NotBlank(message = "流程名称不能为空") + private String flowName; + private String effectiveStartAt; + private String effectiveEndAt; + @NotEmpty(message = "至少配置一个节点") + @Valid + private List nodes; + + public String getFlowCode() { + return flowCode; + } + + public void setFlowCode(String flowCode) { + this.flowCode = flowCode; + } + + public String getFlowName() { + return flowName; + } + + public void setFlowName(String flowName) { + this.flowName = flowName; + } + + public String getEffectiveStartAt() { + return effectiveStartAt; + } + + public void setEffectiveStartAt(String effectiveStartAt) { + this.effectiveStartAt = effectiveStartAt; + } + + public String getEffectiveEndAt() { + return effectiveEndAt; + } + + public void setEffectiveEndAt(String effectiveEndAt) { + this.effectiveEndAt = effectiveEndAt; + } + + public List getNodes() { + return nodes; + } + + public void setNodes(List nodes) { + this.nodes = nodes; + } +} diff --git a/backend/src/main/java/com/writeoff/module/audit/dto/TransferAuditTaskRequest.java b/backend/src/main/java/com/writeoff/module/audit/dto/TransferAuditTaskRequest.java new file mode 100644 index 0000000..3958b1e --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/audit/dto/TransferAuditTaskRequest.java @@ -0,0 +1,37 @@ +package com.writeoff.module.audit.dto; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; + +public class TransferAuditTaskRequest { + @NotBlank(message = "幂等键不能为空") + private String idempotencyKey; + @NotNull(message = "转交目标用户不能为空") + private Long toUserId; + @NotBlank(message = "转审原因不能为空") + private String reason; + + public String getIdempotencyKey() { + return idempotencyKey; + } + + public void setIdempotencyKey(String idempotencyKey) { + this.idempotencyKey = idempotencyKey; + } + + public Long getToUserId() { + return toUserId; + } + + public void setToUserId(Long toUserId) { + this.toUserId = toUserId; + } + + public String getReason() { + return reason; + } + + public void setReason(String reason) { + this.reason = reason; + } +} diff --git a/backend/src/main/java/com/writeoff/module/audit/dto/UpdateAuditFlowRequest.java b/backend/src/main/java/com/writeoff/module/audit/dto/UpdateAuditFlowRequest.java new file mode 100644 index 0000000..a5e8f42 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/audit/dto/UpdateAuditFlowRequest.java @@ -0,0 +1,4 @@ +package com.writeoff.module.audit.dto; + +public class UpdateAuditFlowRequest extends CreateAuditFlowRequest { +} diff --git a/backend/src/main/java/com/writeoff/module/audit/model/AuditFlowInfo.java b/backend/src/main/java/com/writeoff/module/audit/model/AuditFlowInfo.java new file mode 100644 index 0000000..5f51e4d --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/audit/model/AuditFlowInfo.java @@ -0,0 +1,57 @@ +package com.writeoff.module.audit.model; + +import java.util.List; + +public class AuditFlowInfo { + private Long id; + private String flowCode; + private String flowName; + private String status; + private Boolean isDefault; + private String effectiveStartAt; + private String effectiveEndAt; + private List nodes; + + public AuditFlowInfo(Long id, String flowCode, String flowName, String status, Boolean isDefault, String effectiveStartAt, String effectiveEndAt, List nodes) { + this.id = id; + this.flowCode = flowCode; + this.flowName = flowName; + this.status = status; + this.isDefault = isDefault; + this.effectiveStartAt = effectiveStartAt; + this.effectiveEndAt = effectiveEndAt; + this.nodes = nodes; + } + + public Long getId() { + return id; + } + + public String getFlowCode() { + return flowCode; + } + + public String getFlowName() { + return flowName; + } + + public String getStatus() { + return status; + } + + public Boolean getIsDefault() { + return isDefault; + } + + public String getEffectiveStartAt() { + return effectiveStartAt; + } + + public String getEffectiveEndAt() { + return effectiveEndAt; + } + + public List getNodes() { + return nodes; + } +} diff --git a/backend/src/main/java/com/writeoff/module/audit/model/AuditFlowNodeInfo.java b/backend/src/main/java/com/writeoff/module/audit/model/AuditFlowNodeInfo.java new file mode 100644 index 0000000..1be151d --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/audit/model/AuditFlowNodeInfo.java @@ -0,0 +1,49 @@ +package com.writeoff.module.audit.model; + +public class AuditFlowNodeInfo { + private Long id; + private String nodeCode; + private String nodeName; + private Integer sortNo; + private String status; + private String assigneeType; + private Long assigneeRefId; + + public AuditFlowNodeInfo(Long id, String nodeCode, String nodeName, Integer sortNo, String status, String assigneeType, Long assigneeRefId) { + this.id = id; + this.nodeCode = nodeCode; + this.nodeName = nodeName; + this.sortNo = sortNo; + this.status = status; + this.assigneeType = assigneeType; + this.assigneeRefId = assigneeRefId; + } + + public Long getId() { + return id; + } + + public String getNodeCode() { + return nodeCode; + } + + public String getNodeName() { + return nodeName; + } + + public Integer getSortNo() { + return sortNo; + } + + public String getStatus() { + return status; + } + + public String getAssigneeType() { + return assigneeType; + } + + public Long getAssigneeRefId() { + return assigneeRefId; + } +} diff --git a/backend/src/main/java/com/writeoff/module/audit/model/AuditNode.java b/backend/src/main/java/com/writeoff/module/audit/model/AuditNode.java new file mode 100644 index 0000000..0a49983 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/audit/model/AuditNode.java @@ -0,0 +1,7 @@ +package com.writeoff.module.audit.model; + +public enum AuditNode { + INIT_REVIEW, + RE_REVIEW, + FINAL_REVIEW +} diff --git a/backend/src/main/java/com/writeoff/module/audit/model/AuditTask.java b/backend/src/main/java/com/writeoff/module/audit/model/AuditTask.java new file mode 100644 index 0000000..f790d9c --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/audit/model/AuditTask.java @@ -0,0 +1,198 @@ +package com.writeoff.module.audit.model; + +import java.util.List; + +public class AuditTask { + private Long id; + private Long meetingId; + private AuditNode node; + private Long assigneeUserId; + private String assigneeUserName; + private AuditTaskStatus status; + private String opinion; + private String slaDeadlineAt; + private Integer timeoutLevel; + private Integer overtimeHours; + private Boolean overtime; + private Long transferFromUserId; + private String transferReason; + private String returnReason; + private Integer rejectCount; + private String lastRejectReason; + private String lastActionAt; + private List flowNodes; + + public AuditTask(Long id, Long meetingId, AuditNode node, Long assigneeUserId, AuditTaskStatus status, String opinion) { + this(id, meetingId, node, assigneeUserId, status, opinion, null, 0, 0, false, null, null, null, 0, null, null); + } + + public AuditTask(Long id, Long meetingId, AuditNode node, Long assigneeUserId, AuditTaskStatus status, String opinion, String slaDeadlineAt, Integer timeoutLevel) { + this(id, meetingId, node, assigneeUserId, status, opinion, slaDeadlineAt, timeoutLevel, 0, false, null, null, null, 0, null, null); + } + + public AuditTask(Long id, + Long meetingId, + AuditNode node, + Long assigneeUserId, + AuditTaskStatus status, + String opinion, + String slaDeadlineAt, + Integer timeoutLevel, + Integer overtimeHours, + Boolean overtime, + Long transferFromUserId, + String transferReason, + String returnReason, + Integer rejectCount, + String lastRejectReason, + String lastActionAt) { + this.id = id; + this.meetingId = meetingId; + this.node = node; + this.assigneeUserId = assigneeUserId; + this.status = status; + this.opinion = opinion; + this.slaDeadlineAt = slaDeadlineAt; + this.timeoutLevel = timeoutLevel; + this.overtimeHours = overtimeHours; + this.overtime = overtime; + this.transferFromUserId = transferFromUserId; + this.transferReason = transferReason; + this.returnReason = returnReason; + this.rejectCount = rejectCount; + this.lastRejectReason = lastRejectReason; + this.lastActionAt = lastActionAt; + } + + public Long getId() { + return id; + } + + public Long getMeetingId() { + return meetingId; + } + + public AuditNode getNode() { + return node; + } + + public Long getAssigneeUserId() { + return assigneeUserId; + } + + public String getAssigneeUserName() { + return assigneeUserName; + } + + public AuditTaskStatus getStatus() { + return status; + } + + public String getOpinion() { + return opinion; + } + + public String getSlaDeadlineAt() { + return slaDeadlineAt; + } + + public Integer getTimeoutLevel() { + return timeoutLevel; + } + + public Integer getOvertimeHours() { + return overtimeHours; + } + + public Boolean getOvertime() { + return overtime; + } + + public Long getTransferFromUserId() { + return transferFromUserId; + } + + public String getTransferReason() { + return transferReason; + } + + public String getReturnReason() { + return returnReason; + } + + public Integer getRejectCount() { + return rejectCount; + } + + public String getLastRejectReason() { + return lastRejectReason; + } + + public String getLastActionAt() { + return lastActionAt; + } + + public List getFlowNodes() { + return flowNodes; + } + + public void setStatus(AuditTaskStatus status) { + this.status = status; + } + + public void setOpinion(String opinion) { + this.opinion = opinion; + } + + public void setAssigneeUserId(Long assigneeUserId) { + this.assigneeUserId = assigneeUserId; + } + + public void setAssigneeUserName(String assigneeUserName) { + this.assigneeUserName = assigneeUserName; + } + + public void setSlaDeadlineAt(String slaDeadlineAt) { + this.slaDeadlineAt = slaDeadlineAt; + } + + public void setTimeoutLevel(Integer timeoutLevel) { + this.timeoutLevel = timeoutLevel; + } + + public void setOvertimeHours(Integer overtimeHours) { + this.overtimeHours = overtimeHours; + } + + public void setOvertime(Boolean overtime) { + this.overtime = overtime; + } + + public void setTransferFromUserId(Long transferFromUserId) { + this.transferFromUserId = transferFromUserId; + } + + public void setTransferReason(String transferReason) { + this.transferReason = transferReason; + } + + public void setReturnReason(String returnReason) { + this.returnReason = returnReason; + } + + public void setRejectCount(Integer rejectCount) { + this.rejectCount = rejectCount; + } + + public void setLastRejectReason(String lastRejectReason) { + this.lastRejectReason = lastRejectReason; + } + + public void setLastActionAt(String lastActionAt) { + this.lastActionAt = lastActionAt; + } + + public void setFlowNodes(List flowNodes) { + this.flowNodes = flowNodes; + } +} diff --git a/backend/src/main/java/com/writeoff/module/audit/model/AuditTaskStatus.java b/backend/src/main/java/com/writeoff/module/audit/model/AuditTaskStatus.java new file mode 100644 index 0000000..1af9daa --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/audit/model/AuditTaskStatus.java @@ -0,0 +1,7 @@ +package com.writeoff.module.audit.model; + +public enum AuditTaskStatus { + PENDING, + APPROVED, + REJECTED +} diff --git a/backend/src/main/java/com/writeoff/module/audit/repository/AuditTaskRepository.java b/backend/src/main/java/com/writeoff/module/audit/repository/AuditTaskRepository.java new file mode 100644 index 0000000..476431c --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/audit/repository/AuditTaskRepository.java @@ -0,0 +1,25 @@ +package com.writeoff.module.audit.repository; + +import com.writeoff.module.audit.model.AuditTask; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +public interface AuditTaskRepository { + AuditTask save(AuditTask task); + + Optional findById(Long id); + + List findAll(); + + Optional findLatestByMeetingId(Long meetingId); + + int withdrawPendingByMeetingId(Long meetingId, String reason, Long operatorUserId); + + void transfer(Long taskId, Long toUserId, String reason, Long operatorUserId); + + int batchRemind(List taskIds, Long operatorUserId); + + Map slaStat(); +} diff --git a/backend/src/main/java/com/writeoff/module/audit/repository/InMemoryAuditTaskRepository.java b/backend/src/main/java/com/writeoff/module/audit/repository/InMemoryAuditTaskRepository.java new file mode 100644 index 0000000..f760e8c --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/audit/repository/InMemoryAuditTaskRepository.java @@ -0,0 +1,106 @@ +package com.writeoff.module.audit.repository; + +import com.writeoff.module.audit.model.AuditTask; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Repository; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; + +@Repository +@ConditionalOnProperty(prefix = "app.repository", name = "mode", havingValue = "in-memory") +public class InMemoryAuditTaskRepository implements AuditTaskRepository { + private final ConcurrentHashMap store = new ConcurrentHashMap<>(); + private final AtomicLong idGenerator = new AtomicLong(3000); + + @Override + public AuditTask save(AuditTask task) { + if (task.getId() == null) { + AuditTask newTask = new AuditTask( + idGenerator.incrementAndGet(), + task.getMeetingId(), + task.getNode(), + task.getAssigneeUserId(), + task.getStatus(), + task.getOpinion() + ); + store.put(newTask.getId(), newTask); + return newTask; + } + store.put(task.getId(), task); + return task; + } + + @Override + public Optional findById(Long id) { + return Optional.ofNullable(store.get(id)); + } + + @Override + public List findAll() { + return new ArrayList<>(store.values()); + } + + @Override + public Optional findLatestByMeetingId(Long meetingId) { + return store.values().stream() + .filter(t -> t.getMeetingId().equals(meetingId)) + .max(Comparator.comparingLong(AuditTask::getId)); + } + + @Override + public int withdrawPendingByMeetingId(Long meetingId, String reason, Long operatorUserId) { + int count = 0; + List toRemove = new ArrayList(); + for (Map.Entry e : store.entrySet()) { + AuditTask task = e.getValue(); + if (task.getMeetingId().equals(meetingId) && "PENDING".equals(task.getStatus().name())) { + toRemove.add(e.getKey()); + } + } + for (Long id : toRemove) { + store.remove(id); + count++; + } + return count; + } + + @Override + public void transfer(Long taskId, Long toUserId, String reason, Long operatorUserId) { + AuditTask task = store.get(taskId); + if (task == null) { + throw new IllegalArgumentException("task not found"); + } + task.setAssigneeUserId(toUserId); + task.setOpinion(reason == null ? task.getOpinion() : ("转审:" + reason)); + task.setTimeoutLevel(0); + store.put(taskId, task); + } + + @Override + public int batchRemind(List taskIds, Long operatorUserId) { + int count = 0; + for (Long taskId : taskIds) { + if (store.containsKey(taskId)) { + count++; + } + } + return count; + } + + @Override + public Map slaStat() { + Map data = new LinkedHashMap<>(); + data.put("pendingTotal", store.size()); + data.put("timeout4h", 0); + data.put("timeout12h", 0); + data.put("timeout24h", 0); + return data; + } +} diff --git a/backend/src/main/java/com/writeoff/module/audit/repository/JdbcAuditTaskRepository.java b/backend/src/main/java/com/writeoff/module/audit/repository/JdbcAuditTaskRepository.java new file mode 100644 index 0000000..5d47eb1 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/audit/repository/JdbcAuditTaskRepository.java @@ -0,0 +1,240 @@ +package com.writeoff.module.audit.repository; + +import com.writeoff.module.audit.model.AuditNode; +import com.writeoff.module.audit.model.AuditTask; +import com.writeoff.module.audit.model.AuditTaskStatus; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.jdbc.support.GeneratedKeyHolder; +import org.springframework.jdbc.support.KeyHolder; +import org.springframework.stereotype.Repository; +import com.writeoff.security.AuthContext; + +import java.sql.PreparedStatement; +import java.sql.Statement; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +@Repository +@ConditionalOnProperty(prefix = "app.repository", name = "mode", havingValue = "jdbc", matchIfMissing = true) +public class JdbcAuditTaskRepository implements AuditTaskRepository { + private final JdbcTemplate jdbcTemplate; + private static final RowMapper ROW_MAPPER = (rs, n) -> new AuditTask( + rs.getLong("id"), + rs.getLong("meeting_id"), + AuditNode.valueOf(rs.getString("audit_node")), + rs.getObject("assignee_user_id") == null ? null : rs.getLong("assignee_user_id"), + AuditTaskStatus.valueOf(rs.getString("status")), + rs.getString("opinion"), + rs.getString("sla_deadline_at"), + rs.getInt("timeout_level"), + rs.getInt("overtime_hours"), + rs.getInt("is_overtime") == 1, + rs.getObject("transfer_from_user_id") == null ? null : rs.getLong("transfer_from_user_id"), + rs.getString("transfer_reason"), + rs.getString("return_reason"), + rs.getInt("reject_count"), + rs.getString("last_reject_reason"), + rs.getString("last_action_at") + ); + + public JdbcAuditTaskRepository(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + @Override + public AuditTask save(AuditTask task) { + if (task.getId() == null) { + KeyHolder keyHolder = new GeneratedKeyHolder(); + jdbcTemplate.update(connection -> { + PreparedStatement ps = connection.prepareStatement( + "INSERT INTO audit_task (tenant_id, meeting_id, audit_node, assignee_user_id, status, opinion, sla_deadline_at, timeout_level, overtime_hours, is_overtime, " + + "transfer_from_user_id, transfer_reason, return_reason, reject_count, last_reject_reason, last_action_at, created_by, updated_by) " + + "VALUES (?, ?, ?, ?, ?, ?, DATE_ADD(NOW(), INTERVAL 24 HOUR), 0, 0, 0, NULL, NULL, NULL, 0, NULL, NOW(), 0, 0)", + Statement.RETURN_GENERATED_KEYS + ); + ps.setLong(1, tenantId()); + ps.setLong(2, task.getMeetingId()); + ps.setString(3, task.getNode().name()); + ps.setObject(4, task.getAssigneeUserId()); + ps.setString(5, task.getStatus().name()); + ps.setString(6, task.getOpinion()); + return ps; + }, keyHolder); + Number key = keyHolder.getKey(); + Long id = key == null ? null : key.longValue(); + return new AuditTask(id, task.getMeetingId(), task.getNode(), task.getAssigneeUserId(), task.getStatus(), task.getOpinion()); + } + jdbcTemplate.update( + "UPDATE audit_task SET status=?, opinion=?, assignee_user_id=?, transfer_from_user_id=?, transfer_reason=?, return_reason=?, reject_count=?, " + + "last_reject_reason=?, last_action_at=NOW(), updated_by=0 WHERE tenant_id=? AND id=?", + task.getStatus().name(), + task.getOpinion(), + task.getAssigneeUserId(), + task.getTransferFromUserId(), + task.getTransferReason(), + task.getReturnReason(), + task.getRejectCount() == null ? 0 : task.getRejectCount(), + task.getLastRejectReason(), + tenantId(), + task.getId() + ); + return task; + } + + @Override + public Optional findById(Long id) { + List list = jdbcTemplate.query( + "SELECT id, meeting_id, audit_node, assignee_user_id, status, opinion, " + + "DATE_FORMAT(sla_deadline_at, '%Y-%m-%d %H:%i:%s') AS sla_deadline_at, IFNULL(timeout_level, 0) AS timeout_level, " + + "IFNULL(overtime_hours, 0) AS overtime_hours, IFNULL(is_overtime, 0) AS is_overtime, " + + "transfer_from_user_id, transfer_reason, return_reason, IFNULL(reject_count, 0) AS reject_count, last_reject_reason, " + + "DATE_FORMAT(last_action_at, '%Y-%m-%d %H:%i:%s') AS last_action_at " + + "FROM audit_task WHERE tenant_id=? AND id=? AND is_deleted=0", + ROW_MAPPER, tenantId(), id + ); + return list.stream().findFirst(); + } + + @Override + public List findAll() { + refreshTimeoutLevels(); + return jdbcTemplate.query( + "SELECT id, meeting_id, audit_node, assignee_user_id, status, opinion, " + + "DATE_FORMAT(sla_deadline_at, '%Y-%m-%d %H:%i:%s') AS sla_deadline_at, IFNULL(timeout_level, 0) AS timeout_level, " + + "IFNULL(overtime_hours, 0) AS overtime_hours, IFNULL(is_overtime, 0) AS is_overtime, " + + "transfer_from_user_id, transfer_reason, return_reason, IFNULL(reject_count, 0) AS reject_count, last_reject_reason, " + + "DATE_FORMAT(last_action_at, '%Y-%m-%d %H:%i:%s') AS last_action_at " + + "FROM audit_task WHERE tenant_id=? AND is_deleted=0 ORDER BY id DESC", + ROW_MAPPER, tenantId() + ); + } + + @Override + public Optional findLatestByMeetingId(Long meetingId) { + List list = jdbcTemplate.query( + "SELECT id, meeting_id, audit_node, assignee_user_id, status, opinion, " + + "DATE_FORMAT(sla_deadline_at, '%Y-%m-%d %H:%i:%s') AS sla_deadline_at, IFNULL(timeout_level, 0) AS timeout_level, " + + "IFNULL(overtime_hours, 0) AS overtime_hours, IFNULL(is_overtime, 0) AS is_overtime, " + + "transfer_from_user_id, transfer_reason, return_reason, IFNULL(reject_count, 0) AS reject_count, last_reject_reason, " + + "DATE_FORMAT(last_action_at, '%Y-%m-%d %H:%i:%s') AS last_action_at " + + "FROM audit_task WHERE tenant_id=? AND meeting_id=? AND is_deleted=0 ORDER BY id DESC LIMIT 1", + ROW_MAPPER, tenantId(), meetingId + ); + return list.stream().findFirst(); + } + + @Override + public int withdrawPendingByMeetingId(Long meetingId, String reason, Long operatorUserId) { + return jdbcTemplate.update( + "UPDATE audit_task SET is_deleted=1, opinion=?, updated_by=?, updated_at=CURRENT_TIMESTAMP " + + "WHERE tenant_id=? AND meeting_id=? AND status='PENDING' AND is_deleted=0", + reason == null ? "会议撤回提交" : ("会议撤回提交:" + reason), + operatorUserId == null ? 0L : operatorUserId, + tenantId(), + meetingId + ); + } + + @Override + public void transfer(Long taskId, Long toUserId, String reason, Long operatorUserId) { + AuditTask task = findById(taskId).orElseThrow(() -> new IllegalArgumentException("task not found")); + if (task.getStatus() != AuditTaskStatus.PENDING) { + throw new IllegalStateException("task is not pending"); + } + jdbcTemplate.update( + "UPDATE audit_task SET assignee_user_id=?, opinion=?, sla_deadline_at=DATE_ADD(NOW(), INTERVAL 24 HOUR), timeout_level=0, updated_by=?, updated_at=CURRENT_TIMESTAMP " + + ", overtime_hours=0, is_overtime=0, transfer_from_user_id=?, transfer_reason=?, last_action_at=NOW() " + + "WHERE tenant_id=? AND id=?", + toUserId, + reason == null ? task.getOpinion() : ("转审:" + reason), + operatorUserId == null ? 0L : operatorUserId, + task.getAssigneeUserId(), + reason, + tenantId(), + taskId + ); + jdbcTemplate.update( + "INSERT INTO audit_transfer_log (tenant_id, task_id, from_user_id, to_user_id, reason, created_by) VALUES (?, ?, ?, ?, ?, ?)", + tenantId(), + taskId, + task.getAssigneeUserId(), + toUserId, + reason, + operatorUserId == null ? 0L : operatorUserId + ); + } + + @Override + public int batchRemind(List taskIds, Long operatorUserId) { + if (taskIds == null || taskIds.isEmpty()) { + return 0; + } + int count = 0; + for (Long taskId : taskIds) { + Integer updated = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM audit_task WHERE tenant_id=? AND id=? AND status='PENDING' AND is_deleted=0", + Integer.class, + tenantId(), + taskId + ); + if (updated != null && updated > 0) { + count++; + } + } + return count; + } + + @Override + public Map slaStat() { + refreshTimeoutLevels(); + Integer pending = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM audit_task WHERE tenant_id=? AND status='PENDING' AND is_deleted=0", + Integer.class, + tenantId() + ); + Integer timeout4h = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM audit_task WHERE tenant_id=? AND status='PENDING' AND timeout_level>=1 AND is_deleted=0", + Integer.class, + tenantId() + ); + Integer timeout12h = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM audit_task WHERE tenant_id=? AND status='PENDING' AND timeout_level>=2 AND is_deleted=0", + Integer.class, + tenantId() + ); + Integer timeout24h = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM audit_task WHERE tenant_id=? AND status='PENDING' AND timeout_level>=3 AND is_deleted=0", + Integer.class, + tenantId() + ); + Map data = new LinkedHashMap<>(); + data.put("pendingTotal", pending == null ? 0 : pending); + data.put("timeout4h", timeout4h == null ? 0 : timeout4h); + data.put("timeout12h", timeout12h == null ? 0 : timeout12h); + data.put("timeout24h", timeout24h == null ? 0 : timeout24h); + return data; + } + + private void refreshTimeoutLevels() { + jdbcTemplate.update( + "UPDATE audit_task SET timeout_level = CASE " + + "WHEN status='PENDING' AND sla_deadline_at IS NOT NULL AND TIMESTAMPDIFF(HOUR, sla_deadline_at, NOW()) >= 24 THEN 3 " + + "WHEN status='PENDING' AND sla_deadline_at IS NOT NULL AND TIMESTAMPDIFF(HOUR, sla_deadline_at, NOW()) >= 12 THEN 2 " + + "WHEN status='PENDING' AND sla_deadline_at IS NOT NULL AND TIMESTAMPDIFF(HOUR, sla_deadline_at, NOW()) >= 4 THEN 1 " + + "ELSE 0 END, " + + "overtime_hours = CASE WHEN status='PENDING' AND sla_deadline_at IS NOT NULL AND TIMESTAMPDIFF(HOUR, sla_deadline_at, NOW()) > 0 " + + "THEN TIMESTAMPDIFF(HOUR, sla_deadline_at, NOW()) ELSE 0 END, " + + "is_overtime = CASE WHEN status='PENDING' AND sla_deadline_at IS NOT NULL AND TIMESTAMPDIFF(HOUR, sla_deadline_at, NOW()) > 0 THEN 1 ELSE 0 END " + + "WHERE tenant_id=? AND is_deleted=0", + tenantId() + ); + } + + private Long tenantId() { + return AuthContext.requireTenantId(); + } +} diff --git a/backend/src/main/java/com/writeoff/module/audit/service/AuditFlowConfigService.java b/backend/src/main/java/com/writeoff/module/audit/service/AuditFlowConfigService.java new file mode 100644 index 0000000..8e56673 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/audit/service/AuditFlowConfigService.java @@ -0,0 +1,89 @@ +package com.writeoff.module.audit.service; + +import com.writeoff.module.audit.model.AuditNode; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +@Service +public class AuditFlowConfigService { + private final JdbcTemplate jdbcTemplate; + + public AuditFlowConfigService(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + public List getEnabledNodes(Long tenantId) { + List codes = jdbcTemplate.queryForList( + "SELECT n.node_code FROM audit_flow f " + + "JOIN audit_flow_node n ON f.id=n.flow_id " + + "WHERE f.tenant_id=? AND f.is_default=1 AND f.status='ENABLED' AND n.status='ENABLED' " + + "ORDER BY n.sort_no ASC", + String.class, + tenantId + ); + List result = new ArrayList<>(); + for (String code : codes) { + result.add(AuditNode.valueOf(code)); + } + if (result.isEmpty()) { + result.add(AuditNode.INIT_REVIEW); + result.add(AuditNode.RE_REVIEW); + result.add(AuditNode.FINAL_REVIEW); + } + return result; + } + + public AuditNode firstNode(Long tenantId) { + return getEnabledNodes(tenantId).get(0); + } + + public AuditNode nextNode(Long tenantId, AuditNode current) { + List nodes = getEnabledNodes(tenantId); + for (int i = 0; i < nodes.size(); i++) { + if (nodes.get(i) == current) { + if (i + 1 < nodes.size()) { + return nodes.get(i + 1); + } + return null; + } + } + return null; + } + + public Long resolveAssigneeUserId(Long tenantId, AuditNode node) { + List> rows = jdbcTemplate.queryForList( + "SELECT a.assignee_type, a.assignee_ref_id FROM audit_flow f " + + "JOIN audit_flow_node n ON f.id=n.flow_id " + + "LEFT JOIN audit_flow_node_assignee a ON n.id=a.flow_node_id " + + "WHERE f.tenant_id=? AND f.is_default=1 AND f.status='ENABLED' AND n.status='ENABLED' AND n.node_code=? " + + "ORDER BY a.id ASC LIMIT 1", + tenantId, + node.name() + ); + if (rows.isEmpty()) { + return null; + } + String assigneeType = rows.get(0).get("assignee_type") == null ? null : String.valueOf(rows.get(0).get("assignee_type")); + Object ref = rows.get(0).get("assignee_ref_id"); + if ("USER".equals(assigneeType) && ref != null) { + return ((Number) ref).longValue(); + } + if ("ROLE".equals(assigneeType) && ref != null) { + List userIds = jdbcTemplate.queryForList( + "SELECT ur.user_id FROM user_role ur " + + "JOIN sys_user u ON ur.user_id=u.id " + + "WHERE ur.tenant_id=? AND ur.role_id=? AND u.status='ENABLED' AND u.is_deleted=0 " + + "ORDER BY ur.user_id ASC LIMIT 1", + Long.class, + tenantId, + ((Number) ref).longValue() + ); + return userIds.isEmpty() ? null : userIds.get(0); + } + return null; + } +} diff --git a/backend/src/main/java/com/writeoff/module/audit/service/AuditFlowManageService.java b/backend/src/main/java/com/writeoff/module/audit/service/AuditFlowManageService.java new file mode 100644 index 0000000..a804f13 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/audit/service/AuditFlowManageService.java @@ -0,0 +1,272 @@ +package com.writeoff.module.audit.service; + +import com.writeoff.common.api.PageResult; +import com.writeoff.common.exception.BusinessException; +import com.writeoff.module.audit.dto.AuditFlowNodeRequest; +import com.writeoff.module.audit.dto.CreateAuditFlowRequest; +import com.writeoff.module.audit.dto.UpdateAuditFlowRequest; +import com.writeoff.module.audit.model.AuditFlowInfo; +import com.writeoff.module.audit.model.AuditFlowNodeInfo; +import com.writeoff.security.AuthContext; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.jdbc.support.GeneratedKeyHolder; +import org.springframework.jdbc.support.KeyHolder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.sql.PreparedStatement; +import java.sql.Statement; +import java.util.List; + +@Service +public class AuditFlowManageService { + private final JdbcTemplate jdbcTemplate; + + private static final RowMapper NODE_ROW_MAPPER = (rs, n) -> new AuditFlowNodeInfo( + rs.getLong("id"), + rs.getString("node_code"), + rs.getString("node_name"), + rs.getInt("sort_no"), + rs.getString("status"), + rs.getString("assignee_type"), + rs.getObject("assignee_ref_id") == null ? null : rs.getLong("assignee_ref_id") + ); + + public AuditFlowManageService(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + public PageResult list(int pageNo, int pageSize) { + int safePage = Math.max(pageNo, 1); + int safeSize = Math.min(Math.max(pageSize, 1), 100); + int offset = (safePage - 1) * safeSize; + + Integer total = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM audit_flow WHERE tenant_id=? AND is_deleted=0", + Integer.class, + tenantId() + ); + long totalCount = total == null ? 0 : total; + + List list = jdbcTemplate.query( + "SELECT id, flow_code, flow_name, status, is_default, " + + "DATE_FORMAT(effective_start_at, '%Y-%m-%d %H:%i:%s') AS effective_start_at, " + + "DATE_FORMAT(effective_end_at, '%Y-%m-%d %H:%i:%s') AS effective_end_at " + + "FROM audit_flow WHERE tenant_id=? AND is_deleted=0 ORDER BY id DESC LIMIT ? OFFSET ?", + (rs, n) -> { + Long id = rs.getLong("id"); + return new AuditFlowInfo( + id, + rs.getString("flow_code"), + rs.getString("flow_name"), + rs.getString("status"), + rs.getInt("is_default") == 1, + rs.getString("effective_start_at"), + rs.getString("effective_end_at"), + loadNodes(id) + ); + }, + tenantId(), + safeSize, + offset + ); + return new PageResult<>(list, totalCount, safePage, safeSize); + } + + @Transactional + public AuditFlowInfo create(CreateAuditFlowRequest request) { + Integer exists = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM audit_flow WHERE tenant_id=? AND flow_code=?", + Integer.class, + tenantId(), + request.getFlowCode() + ); + if (exists != null && exists > 0) { + throw new BusinessException(10001, "流程编码已存在"); + } + KeyHolder keyHolder = new GeneratedKeyHolder(); + jdbcTemplate.update(connection -> { + PreparedStatement ps = connection.prepareStatement( + "INSERT INTO audit_flow (tenant_id, flow_code, flow_name, status, is_default, effective_start_at, effective_end_at) " + + "VALUES (?, ?, ?, 'ENABLED', 0, ?, ?)", + Statement.RETURN_GENERATED_KEYS + ); + ps.setLong(1, tenantId()); + ps.setString(2, request.getFlowCode()); + ps.setString(3, request.getFlowName()); + ps.setString(4, request.getEffectiveStartAt()); + ps.setString(5, request.getEffectiveEndAt()); + return ps; + }, keyHolder); + Long flowId = keyHolder.getKey() == null ? null : keyHolder.getKey().longValue(); + if (flowId == null) { + throw new BusinessException(10001, "流程创建失败"); + } + saveNodes(flowId, request.getNodes()); + return findById(flowId); + } + + @Transactional + public AuditFlowInfo update(Long flowId, UpdateAuditFlowRequest request) { + assertFlowExists(flowId); + jdbcTemplate.update( + "UPDATE audit_flow SET flow_name=?, effective_start_at=?, effective_end_at=?, updated_at=CURRENT_TIMESTAMP WHERE id=? AND tenant_id=?", + request.getFlowName(), + request.getEffectiveStartAt(), + request.getEffectiveEndAt(), + flowId, + tenantId() + ); + jdbcTemplate.update( + "DELETE a FROM audit_flow_node_assignee a JOIN audit_flow_node n ON a.flow_node_id=n.id WHERE n.flow_id=?", + flowId + ); + jdbcTemplate.update("DELETE FROM audit_flow_node WHERE flow_id=?", flowId); + saveNodes(flowId, request.getNodes()); + return findById(flowId); + } + + @Transactional + public AuditFlowInfo copy(Long flowId) { + AuditFlowInfo source = findById(flowId); + String newCode = source.getFlowCode() + "_COPY_" + System.currentTimeMillis(); + CreateAuditFlowRequest request = new CreateAuditFlowRequest(); + request.setFlowCode(newCode); + request.setFlowName(source.getFlowName() + " - 复制"); + request.setEffectiveStartAt(source.getEffectiveStartAt()); + request.setEffectiveEndAt(source.getEffectiveEndAt()); + List nodes = loadNodes(flowId).stream().map(node -> { + AuditFlowNodeRequest item = new AuditFlowNodeRequest(); + item.setNodeCode(node.getNodeCode()); + item.setNodeName(node.getNodeName()); + item.setSortNo(node.getSortNo()); + item.setAssigneeType(node.getAssigneeType()); + item.setAssigneeRefId(node.getAssigneeRefId()); + return item; + }).collect(java.util.stream.Collectors.toList()); + request.setNodes(nodes); + return create(request); + } + + @Transactional + public void setDefault(Long flowId) { + assertFlowExists(flowId); + jdbcTemplate.update("UPDATE audit_flow SET is_default=0 WHERE tenant_id=?", tenantId()); + jdbcTemplate.update("UPDATE audit_flow SET is_default=1 WHERE tenant_id=? AND id=?", tenantId(), flowId); + } + + public void enable(Long flowId) { + updateStatus(flowId, "ENABLED"); + } + + public void disable(Long flowId) { + updateStatus(flowId, "DISABLED"); + } + + @Transactional + public void softDelete(Long flowId) { + assertFlowExists(flowId); + // 不允许删除默认流程 + Integer isDefault = jdbcTemplate.queryForObject( + "SELECT is_default FROM audit_flow WHERE id=? AND tenant_id=? AND is_deleted=0", + Integer.class, flowId, tenantId()); + if (isDefault != null && isDefault == 1) { + throw new BusinessException(10001, "默认审核流不能删除"); + } + // 不允许删除启用状态的流程 + String status = jdbcTemplate.queryForObject( + "SELECT status FROM audit_flow WHERE id=? AND tenant_id=? AND is_deleted=0", + String.class, flowId, tenantId()); + if ("ENABLED".equals(status)) { + throw new BusinessException(10001, "请先停用审核流再删除"); + } + jdbcTemplate.update( + "UPDATE audit_flow SET is_deleted=1, updated_at=CURRENT_TIMESTAMP WHERE id=? AND tenant_id=?", + flowId, tenantId()); + } + + private void updateStatus(Long flowId, String status) { + assertFlowExists(flowId); + jdbcTemplate.update("UPDATE audit_flow SET status=?, updated_at=CURRENT_TIMESTAMP WHERE id=? AND tenant_id=?", status, flowId, tenantId()); + } + + private void assertFlowExists(Long flowId) { + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM audit_flow WHERE id=? AND tenant_id=? AND is_deleted=0", + Integer.class, + flowId, + tenantId() + ); + if (count == null || count == 0) { + throw new BusinessException(10003, "审核流不存在"); + } + } + + private AuditFlowInfo findById(Long flowId) { + List list = jdbcTemplate.query( + "SELECT id, flow_code, flow_name, status, is_default, " + + "DATE_FORMAT(effective_start_at, '%Y-%m-%d %H:%i:%s') AS effective_start_at, " + + "DATE_FORMAT(effective_end_at, '%Y-%m-%d %H:%i:%s') AS effective_end_at " + + "FROM audit_flow WHERE tenant_id=? AND id=? AND is_deleted=0", + (rs, n) -> new AuditFlowInfo( + rs.getLong("id"), + rs.getString("flow_code"), + rs.getString("flow_name"), + rs.getString("status"), + rs.getInt("is_default") == 1, + rs.getString("effective_start_at"), + rs.getString("effective_end_at"), + loadNodes(rs.getLong("id")) + ), + tenantId(), + flowId + ); + if (list.isEmpty()) { + throw new BusinessException(10003, "审核流不存在"); + } + return list.get(0); + } + + private List loadNodes(Long flowId) { + return jdbcTemplate.query( + "SELECT n.id, n.node_code, n.node_name, n.sort_no, n.status, a.assignee_type, a.assignee_ref_id " + + "FROM audit_flow_node n " + + "LEFT JOIN audit_flow_node_assignee a ON n.id=a.flow_node_id " + + "WHERE n.flow_id=? ORDER BY n.sort_no ASC", + NODE_ROW_MAPPER, + flowId + ); + } + + private void saveNodes(Long flowId, List nodes) { + for (AuditFlowNodeRequest node : nodes) { + KeyHolder keyHolder = new GeneratedKeyHolder(); + jdbcTemplate.update(connection -> { + PreparedStatement ps = connection.prepareStatement( + "INSERT INTO audit_flow_node (flow_id, node_code, node_name, sort_no, status) VALUES (?, ?, ?, ?, 'ENABLED')", + Statement.RETURN_GENERATED_KEYS + ); + ps.setLong(1, flowId); + ps.setString(2, node.getNodeCode()); + ps.setString(3, node.getNodeName()); + ps.setInt(4, node.getSortNo()); + return ps; + }, keyHolder); + Number key = keyHolder.getKey(); + Long flowNodeId = key == null ? null : key.longValue(); + if (flowNodeId != null && node.getAssigneeType() != null && !node.getAssigneeType().trim().isEmpty()) { + jdbcTemplate.update( + "INSERT INTO audit_flow_node_assignee (flow_node_id, assignee_type, assignee_ref_id) VALUES (?, ?, ?)", + flowNodeId, + node.getAssigneeType(), + node.getAssigneeRefId() + ); + } + } + } + + private Long tenantId() { + return AuthContext.requireTenantId(); + } +} diff --git a/backend/src/main/java/com/writeoff/module/audit/service/AuditService.java b/backend/src/main/java/com/writeoff/module/audit/service/AuditService.java new file mode 100644 index 0000000..bc270a7 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/audit/service/AuditService.java @@ -0,0 +1,708 @@ +package com.writeoff.module.audit.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.writeoff.common.api.PageResult; +import com.writeoff.common.exception.BusinessException; +import com.writeoff.common.exception.ErrorCodes; +import com.writeoff.module.audit.dto.AuditActionRequest; +import com.writeoff.module.audit.dto.AuditMaterialItemRejectRequest; +import com.writeoff.module.audit.dto.AuditMaterialModuleApproveRequest; +import com.writeoff.module.audit.dto.BatchAuditActionRequest; +import com.writeoff.module.audit.dto.BatchRemindRequest; +import com.writeoff.module.audit.dto.TransferAuditTaskRequest; +import com.writeoff.module.audit.model.AuditNode; +import com.writeoff.module.audit.model.AuditFlowNodeInfo; +import com.writeoff.module.audit.model.AuditTask; +import com.writeoff.module.audit.model.AuditTaskStatus; +import com.writeoff.module.audit.repository.AuditTaskRepository; +import com.writeoff.module.notification.dto.DispatchNotificationRequest; +import com.writeoff.module.notification.service.NotificationDispatchService; +import com.writeoff.module.meeting.model.MeetingAuditStatus; +import com.writeoff.module.meeting.model.MeetingMaterial; +import com.writeoff.module.expert.service.ExpertService; +import com.writeoff.module.meeting.service.MeetingMaterialService; +import com.writeoff.module.meeting.service.MeetingService; +import com.writeoff.module.scheduler.service.AsyncJobService; +import com.writeoff.module.system.service.DataPermissionService; +import com.writeoff.security.AuthContext; +import com.writeoff.security.AuthScope; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; + +import java.util.LinkedHashMap; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; + +@Service +public class AuditService { + private static final Logger log = LoggerFactory.getLogger(AuditService.class); + private final AuditTaskRepository auditTaskRepository; + private final MeetingService meetingService; + private final MeetingMaterialService meetingMaterialService; + private final AsyncJobService asyncJobService; + private final AuditFlowConfigService auditFlowConfigService; + private final DataPermissionService dataPermissionService; + private final NotificationDispatchService notificationDispatchService; + private final JdbcTemplate jdbcTemplate; + private final ExpertService expertService; + private final Map actionIdempotency = new ConcurrentHashMap<>(); + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Autowired + public AuditService(AuditTaskRepository auditTaskRepository, MeetingService meetingService, MeetingMaterialService meetingMaterialService, AsyncJobService asyncJobService, AuditFlowConfigService auditFlowConfigService, DataPermissionService dataPermissionService, NotificationDispatchService notificationDispatchService, JdbcTemplate jdbcTemplate, ExpertService expertService) { + this.auditTaskRepository = auditTaskRepository; + this.meetingService = meetingService; + this.meetingMaterialService = meetingMaterialService; + this.asyncJobService = asyncJobService; + this.auditFlowConfigService = auditFlowConfigService; + this.dataPermissionService = dataPermissionService; + this.notificationDispatchService = notificationDispatchService; + this.jdbcTemplate = jdbcTemplate; + this.expertService = expertService; + } + + public AuditService(AuditTaskRepository auditTaskRepository, MeetingService meetingService, AsyncJobService asyncJobService) { + this(auditTaskRepository, meetingService, null, asyncJobService, null, null, null, null, null); + } + + public PageResult listTasks(boolean mine) { + List filtered = filterTasks(mine, null, null); + return new PageResult<>(filtered, filtered.size(), 1, filtered.size() == 0 ? 20 : filtered.size()); + } + + public PageResult listTasks(boolean mine, String scope) { + List filtered = filterTasks(mine, scope, null); + return new PageResult<>(filtered, filtered.size(), 1, filtered.size() == 0 ? 20 : filtered.size()); + } + + public PageResult listTasks( + boolean mine, + String scope, + Long meetingId, + Integer pageNo, + Integer pageSize, + String sortBy, + String order + ) { + List filtered = filterTasks(mine, scope, meetingId); + sortTasks(filtered, sortBy, order); + int normalizedPageNo = pageNo == null || pageNo < 1 ? 1 : pageNo; + int normalizedPageSize = pageSize == null || pageSize < 1 ? 20 : Math.min(pageSize, 200); + int from = Math.max((normalizedPageNo - 1) * normalizedPageSize, 0); + if (from >= filtered.size()) { + return new PageResult<>(Collections.emptyList(), filtered.size(), normalizedPageNo, normalizedPageSize); + } + int to = Math.min(from + normalizedPageSize, filtered.size()); + return new PageResult<>(filtered.subList(from, to), filtered.size(), normalizedPageNo, normalizedPageSize); + } + + private List filterTasks(boolean mine, String scope, Long meetingId) { + List list = auditTaskRepository.findAll(); + if (dataPermissionService != null) { + DataPermissionService.DataScope dataScope = dataPermissionService.resolveCurrentUserScope(); + Set meetingIds = list.stream().map(AuditTask::getMeetingId).collect(Collectors.toCollection(HashSet::new)); + Map meetingCreatorMap = dataPermissionService.listMeetingCreators(meetingIds); + Map meetingProjectMap = dataPermissionService.listMeetingProjectIds(meetingIds); + Set projectIds = new HashSet<>(meetingProjectMap.values()); + Map projectCreatorMap = dataPermissionService.listProjectCreators(projectIds); + list = list.stream() + .filter(task -> { + try { + Long projectId = meetingProjectMap.get(task.getMeetingId()); + Long meetingCreatedBy = meetingCreatorMap.get(task.getMeetingId()); + Long projectCreatedBy = projectId == null ? null : projectCreatorMap.get(projectId); + return dataPermissionService.canAccessMeeting(task.getMeetingId(), projectId, meetingCreatedBy, projectCreatedBy, dataScope); + } catch (Exception e) { + return false; + } + }) + .collect(Collectors.toList()); + } + if (meetingId != null) { + list = list.stream() + .filter(task -> meetingId.equals(task.getMeetingId())) + .collect(Collectors.toList()); + } + if (mine) { + Long currentUserId = AuthContext.userId(); + String normalizedScope = String.valueOf(scope == null ? "" : scope).trim().toUpperCase(Locale.ROOT); + if ("HANDLED_MINE".equals(normalizedScope)) { + // 我处理过的:我曾是处理人,且任务已不在待处理状态 + list = list.stream() + .filter(task -> task.getAssigneeUserId() != null && task.getAssigneeUserId().equals(currentUserId)) + .filter(task -> task.getStatus() != AuditTaskStatus.PENDING) + .collect(Collectors.toList()); + } else { + // 默认:待我处理 + list = list.stream() + .filter(task -> task.getAssigneeUserId() != null && task.getAssigneeUserId().equals(currentUserId)) + .filter(task -> task.getStatus() == AuditTaskStatus.PENDING) + .collect(Collectors.toList()); + } + } + fillAssigneeUserName(list); + fillFlowNodes(list); + return new ArrayList<>(list); + } + + private void fillAssigneeUserName(List list) { + if (list == null || list.isEmpty() || jdbcTemplate == null) { + return; + } + Set userIds = list.stream() + .map(AuditTask::getAssigneeUserId) + .filter(id -> id != null && id > 0) + .collect(Collectors.toSet()); + if (userIds.isEmpty()) { + return; + } + String placeholders = userIds.stream().map(id -> "?").collect(Collectors.joining(",")); + List args = new ArrayList<>(); + String sql; + if (AuthContext.scope() == AuthScope.PLATFORM) { + sql = "SELECT id, user_name FROM platform_user WHERE is_deleted=0 AND id IN (" + placeholders + ")"; + args.addAll(userIds); + } else { + sql = "SELECT id, user_name FROM sys_user WHERE tenant_id=? AND is_deleted=0 AND id IN (" + placeholders + ")"; + args.add(tenantId()); + args.addAll(userIds); + } + List> rows = jdbcTemplate.queryForList(sql, args.toArray()); + Map userNameMap = new HashMap<>(); + for (Map row : rows) { + Number idNum = (Number) row.get("id"); + if (idNum == null) { + continue; + } + Long id = idNum.longValue(); + String userName = String.valueOf(row.get("user_name") == null ? "" : row.get("user_name")).trim(); + if (!userName.isEmpty()) { + userNameMap.put(id, userName); + } + } + for (AuditTask task : list) { + Long assigneeUserId = task.getAssigneeUserId(); + if (assigneeUserId == null || assigneeUserId <= 0) { + continue; + } + String userName = userNameMap.get(assigneeUserId); + if (userName != null && !userName.isEmpty()) { + task.setAssigneeUserName(userName); + } + } + } + + private void fillFlowNodes(List list) { + if (list == null || list.isEmpty()) { + return; + } + List nodes = buildFlowNodes(); + if (nodes.isEmpty()) { + return; + } + for (AuditTask task : list) { + task.setFlowNodes(nodes); + } + } + + private List buildFlowNodes() { + List enabledNodes; + if (auditFlowConfigService == null) { + enabledNodes = new ArrayList<>(); + enabledNodes.add(AuditNode.INIT_REVIEW); + enabledNodes.add(AuditNode.RE_REVIEW); + enabledNodes.add(AuditNode.FINAL_REVIEW); + } else { + enabledNodes = auditFlowConfigService.getEnabledNodes(tenantId()); + } + List result = new ArrayList<>(); + for (int i = 0; i < enabledNodes.size(); i++) { + AuditNode node = enabledNodes.get(i); + if (node == null) { + continue; + } + String code = node.name(); + result.add(new AuditFlowNodeInfo(null, code, code, i + 1, "ENABLED", null, null)); + } + return result.stream().filter(Objects::nonNull).collect(Collectors.toList()); + } + + private void sortTasks(List list, String sortBy, String order) { + String normalizedSortBy = String.valueOf(sortBy == null ? "lastActionAt" : sortBy).trim().toLowerCase(Locale.ROOT); + String normalizedOrder = String.valueOf(order == null ? "desc" : order).trim().toLowerCase(Locale.ROOT); + Comparator comparator; + if ("id".equals(normalizedSortBy)) { + comparator = Comparator.comparing(task -> task.getId() == null ? 0L : task.getId()); + } else if ("sladeadlineat".equals(normalizedSortBy)) { + comparator = Comparator.comparing(task -> String.valueOf(task.getSlaDeadlineAt() == null ? "" : task.getSlaDeadlineAt())); + } else { + comparator = Comparator + .comparing((AuditTask task) -> String.valueOf(task.getLastActionAt() == null ? "" : task.getLastActionAt())) + .thenComparing(task -> task.getId() == null ? 0L : task.getId()); + } + if (!"asc".equals(normalizedOrder)) { + comparator = comparator.reversed(); + } + list.sort(comparator); + } + + public PageResult listTasks() { + return listTasks(false); + } + + public Map approve(Long taskId, AuditActionRequest request) { + AuditTask task = getPendingTask(taskId, request.getIdempotencyKey()); + task.setStatus(AuditTaskStatus.APPROVED); + task.setOpinion(request.getOpinion()); + auditTaskRepository.save(task); + Long meetingId = task.getMeetingId(); + + AuditNode nextNode; + if (auditFlowConfigService == null) { + if (task.getNode() == AuditNode.INIT_REVIEW) { + nextNode = AuditNode.RE_REVIEW; + } else if (task.getNode() == AuditNode.RE_REVIEW) { + nextNode = AuditNode.FINAL_REVIEW; + } else { + nextNode = null; + } + } else { + nextNode = auditFlowConfigService.nextNode(tenantId(), task.getNode()); + } + if (nextNode == null) { + meetingService.updateAuditStatus(meetingId, MeetingAuditStatus.APPROVED); + asyncJobService.enqueue("EXPORT_REPORT", "meetingId=" + meetingId, "job-export-report-" + meetingId); + triggerAuditNotification(task, request.getOpinion(), "AUDIT_APPROVED"); + } else { + Long nextAssignee = auditFlowConfigService == null ? null : auditFlowConfigService.resolveAssigneeUserId(tenantId(), nextNode); + auditTaskRepository.save(new AuditTask(null, meetingId, nextNode, nextAssignee, AuditTaskStatus.PENDING, "")); + // 同步会议当前节点和审核人信息,保证前端展示与任务一致 + meetingService.updateCurrentAuditNode(meetingId, nextNode.name(), nextAssignee); + } + Map result = new LinkedHashMap<>(); + result.put("taskId", task.getId()); + result.put("nodeStatus", task.getStatus().name()); + return result; + } + + public Map reject(Long taskId, AuditActionRequest request) { + AuditTask task = getPendingTask(taskId, request.getIdempotencyKey()); + task.setStatus(AuditTaskStatus.REJECTED); + task.setOpinion(request.getOpinion()); + auditTaskRepository.save(task); + Long meetingId = task.getMeetingId(); + + if (task.getNode() == AuditNode.INIT_REVIEW) { + // 初审拒绝:整体审核结束,会议审核状态置为已拒绝 + meetingService.updateAuditStatus(meetingId, MeetingAuditStatus.REJECTED); + triggerAuditNotification(task, request.getOpinion(), "AUDIT_REJECTED"); + } else { + // 复审 / 终审拒绝:整体回到初审阶段,重新创建初审任务 + AuditNode firstNode; + Long assigneeUserId; + if (auditFlowConfigService == null) { + firstNode = AuditNode.INIT_REVIEW; + assigneeUserId = null; + } else { + Long tenantId = tenantId(); + firstNode = auditFlowConfigService.firstNode(tenantId); + assigneeUserId = auditFlowConfigService.resolveAssigneeUserId(tenantId, firstNode); + } + + meetingService.updateAuditStatus(meetingId, MeetingAuditStatus.IN_REVIEW); + meetingService.updateCurrentAuditNode(meetingId, firstNode.name(), assigneeUserId); + auditTaskRepository.save(new AuditTask( + null, + meetingId, + firstNode, + assigneeUserId, + AuditTaskStatus.PENDING, + "" + )); + } + Map result = new LinkedHashMap<>(); + result.put("taskId", task.getId()); + result.put("nodeStatus", task.getStatus().name()); + return result; + } + + public Map back(Long taskId, AuditActionRequest request) { + AuditTask task = getPendingTask(taskId, request.getIdempotencyKey()); + task.setStatus(AuditTaskStatus.REJECTED); + task.setOpinion("退回修改:" + request.getOpinion()); + auditTaskRepository.save(task); + meetingService.updateAuditStatus(task.getMeetingId(), MeetingAuditStatus.PENDING); + Map result = new LinkedHashMap<>(); + result.put("taskId", task.getId()); + result.put("nodeStatus", "RETURNED"); + return result; + } + + public Map exportOpinions() { + if (dataPermissionService != null && !dataPermissionService.canExportCurrentUser()) { + throw new BusinessException(20001, "当前账号无导出权限"); + } + List list = listTasks(false).getList(); + Map data = new LinkedHashMap<>(); + data.put("total", list.size()); + data.put("records", list); + return data; + } + + public Map readTaskMaterial(Long taskId, String moduleCode) { + AuditTask task = auditTaskRepository.findById(taskId) + .orElseThrow(() -> new BusinessException(10003, "审核任务不存在")); + if (meetingMaterialService == null) { + throw new BusinessException(10001, "资料服务不可用"); + } + MeetingMaterial material = meetingMaterialService.current(task.getMeetingId(), moduleCode); + Map data = new LinkedHashMap<>(); + data.put("taskId", taskId); + data.put("meetingId", task.getMeetingId()); + data.put("moduleCode", moduleCode); + data.put("material", material); + data.put("materialItems", meetingMaterialService.listMaterialReviewItems(task.getMeetingId(), moduleCode)); + data.put("itemReviews", meetingMaterialService.listMaterialItemReviews(taskId, task.getNode().name(), moduleCode)); + attachBasicInfoExpertSummaries(data, material, moduleCode); + return data; + } + + private Map emptyBasicInfoExpertsPayload() { + Map m = new LinkedHashMap<>(); + m.put("chairman", Collections.emptyList()); + m.put("speaker", Collections.emptyList()); + m.put("host", Collections.emptyList()); + m.put("discussionGuest", Collections.emptyList()); + return m; + } + + private List parseExpertIdListFromJson(Object raw) { + List out = new ArrayList<>(); + if (!(raw instanceof List)) { + return out; + } + for (Object x : (List) raw) { + long v = 0L; + if (x instanceof Number) { + v = ((Number) x).longValue(); + } else if (x != null) { + try { + v = Long.parseLong(String.valueOf(x).trim()); + } catch (NumberFormatException ignored) { + v = 0L; + } + } + if (v > 0) { + out.add(v); + } + } + return out; + } + + private List> toExpertDisplayRows(List orderedIds, Map> displayMap) { + List> rows = new ArrayList<>(); + for (Long id : orderedIds) { + if (id == null || id <= 0) { + continue; + } + Map row = new LinkedHashMap<>(); + row.put("expertId", id); + Map found = displayMap.get(id); + if (found != null) { + row.put("expertName", found.get("expertName")); + row.put("hospital", found.get("hospital")); + } else { + row.put("expertName", null); + row.put("hospital", null); + } + rows.add(row); + } + return rows; + } + + @SuppressWarnings("unchecked") + private void attachBasicInfoExpertSummaries(Map data, MeetingMaterial material, String moduleCode) { + if (!"BASIC_INFO".equals(moduleCode)) { + return; + } + if (material == null || material.getContentJson() == null || material.getContentJson().trim().isEmpty()) { + data.put("basicInfoExperts", emptyBasicInfoExpertsPayload()); + return; + } + try { + Map parsed = objectMapper.readValue(material.getContentJson(), LinkedHashMap.class); + List chairman = parseExpertIdListFromJson(parsed.get("chairmanExpertIds")); + List speaker = parseExpertIdListFromJson(parsed.get("speakerExpertIds")); + List host = parseExpertIdListFromJson(parsed.get("hostExpertIds")); + List discussionGuest = parseExpertIdListFromJson(parsed.get("discussionGuestExpertIds")); + Set all = new HashSet<>(); + all.addAll(chairman); + all.addAll(speaker); + all.addAll(host); + all.addAll(discussionGuest); + Map> displayMap = expertService == null + ? Collections.emptyMap() + : expertService.mapExpertDisplayByIds(all); + Map payload = new LinkedHashMap<>(); + payload.put("chairman", toExpertDisplayRows(chairman, displayMap)); + payload.put("speaker", toExpertDisplayRows(speaker, displayMap)); + payload.put("host", toExpertDisplayRows(host, displayMap)); + payload.put("discussionGuest", toExpertDisplayRows(discussionGuest, displayMap)); + data.put("basicInfoExperts", payload); + } catch (Exception e) { + log.warn("readTaskMaterial BASIC_INFO expert display parse failed taskId={}", data.get("taskId"), e); + data.put("basicInfoExperts", emptyBasicInfoExpertsPayload()); + } + } + + public Map approveMaterialModule(Long taskId, AuditMaterialModuleApproveRequest request) { + checkIdempotencyOnly(request.getIdempotencyKey(), "material-module-approve"); + AuditTask task = requirePendingTask(taskId); + if (meetingMaterialService == null) { + throw new BusinessException(10001, "资料服务不可用"); + } + List> items = meetingMaterialService.listMaterialReviewItems(task.getMeetingId(), request.getModuleCode()); + + // 查出当前已有 REJECTED 状态的 itemKey,批量通过时跳过它们,避免覆盖单项不通过结果 + List> existingReviews = meetingMaterialService.listMaterialItemReviews( + taskId, task.getNode().name(), request.getModuleCode()); + Set rejectedKeys = new HashSet<>(); + for (Map review : existingReviews) { + String result = String.valueOf(review.get("reviewResult") == null ? "" : review.get("reviewResult")).trim().toUpperCase(Locale.ROOT); + if ("REJECTED".equals(result)) { + rejectedKeys.add(String.valueOf(review.get("itemKey") == null ? "" : review.get("itemKey"))); + } + } + List> approveItems = new ArrayList<>(); + for (Map item : items) { + String itemKey = String.valueOf(item.get("itemKey") == null ? "" : item.get("itemKey")); + if (!rejectedKeys.contains(itemKey)) { + approveItems.add(item); + } + } + + int affected = meetingMaterialService.saveMaterialItemReviewRecords( + task.getMeetingId(), + taskId, + task.getNode().name(), + request.getModuleCode(), + approveItems, + "APPROVED", + null, + AuthContext.userId() + ); + Map data = new LinkedHashMap<>(); + data.put("taskId", taskId); + data.put("moduleCode", request.getModuleCode()); + data.put("reviewNode", task.getNode().name()); + data.put("itemCount", items.size()); + data.put("skippedRejected", rejectedKeys.size()); + data.put("savedCount", affected); + return data; + } + + public Map rejectMaterialItem(Long taskId, AuditMaterialItemRejectRequest request) { + checkIdempotencyOnly(request.getIdempotencyKey(), "material-item-reject"); + AuditTask task = requirePendingTask(taskId); + if (meetingMaterialService == null) { + throw new BusinessException(10001, "资料服务不可用"); + } + Map item = new LinkedHashMap<>(); + item.put("itemKey", request.getItemKey()); + item.put("itemLabel", request.getItemLabel()); + int affected = meetingMaterialService.saveMaterialItemReviewRecords( + task.getMeetingId(), + taskId, + task.getNode().name(), + request.getModuleCode(), + Collections.singletonList(item), + "REJECTED", + request.getReason(), + AuthContext.userId() + ); + Map data = new LinkedHashMap<>(); + data.put("taskId", taskId); + data.put("moduleCode", request.getModuleCode()); + data.put("itemKey", request.getItemKey()); + data.put("reviewNode", task.getNode().name()); + data.put("savedCount", affected); + return data; + } + + public Map transfer(Long taskId, TransferAuditTaskRequest request) { + AuditTask task = getPendingTask(taskId, request.getIdempotencyKey()); + Long operatorUserId = AuthContext.userId(); + auditTaskRepository.transfer(taskId, request.getToUserId(), request.getReason(), operatorUserId); + Map data = new LinkedHashMap<>(); + data.put("taskId", taskId); + data.put("fromUserId", task.getAssigneeUserId()); + data.put("toUserId", request.getToUserId()); + data.put("status", "TRANSFERRED"); + return data; + } + + public Map batchRemind(BatchRemindRequest request) { + checkIdempotencyOnly(request.getIdempotencyKey(), "batch-remind"); + List taskIds = request.getTaskIds(); + if (taskIds == null || taskIds.isEmpty()) { + taskIds = listTasks(false).getList().stream() + .filter(t -> t.getStatus() == AuditTaskStatus.PENDING) + .map(AuditTask::getId) + .collect(Collectors.toList()); + } + Long operatorUserId = AuthContext.userId(); + int count = auditTaskRepository.batchRemind(taskIds, operatorUserId); + for (Long taskId : taskIds) { + asyncJobService.enqueue( + "AUDIT_REMIND", + "taskId=" + taskId + "&operatorUserId=" + (operatorUserId == null ? 0L : operatorUserId), + "job-audit-remind-" + taskId + "-" + request.getIdempotencyKey() + ); + } + Map data = new LinkedHashMap<>(); + data.put("taskCount", taskIds.size()); + data.put("acceptedCount", count); + return data; + } + + public Map slaStat() { + return auditTaskRepository.slaStat(); + } + + public Map batchApprove(BatchAuditActionRequest request) { + checkIdempotencyOnly(request.getIdempotencyKey(), "batch-approve"); + List taskIds = request.getTaskIds(); + int successCount = 0; + List failedTaskIds = new ArrayList<>(); + List errors = new ArrayList<>(); + + for (Long taskId : taskIds) { + try { + AuditActionRequest actionRequest = new AuditActionRequest(); + actionRequest.setIdempotencyKey(request.getIdempotencyKey() + "-approve-" + taskId); + actionRequest.setOpinion(request.getOpinion()); + approve(taskId, actionRequest); + successCount++; + } catch (Exception e) { + failedTaskIds.add(taskId); + errors.add("任务" + taskId + ": " + e.getMessage()); + } + } + + Map result = new LinkedHashMap<>(); + result.put("totalCount", taskIds.size()); + result.put("successCount", successCount); + result.put("failCount", failedTaskIds.size()); + result.put("failedTaskIds", failedTaskIds); + result.put("errors", errors); + return result; + } + + public Map batchReject(BatchAuditActionRequest request) { + checkIdempotencyOnly(request.getIdempotencyKey(), "batch-reject"); + List taskIds = request.getTaskIds(); + int successCount = 0; + List failedTaskIds = new ArrayList<>(); + List errors = new ArrayList<>(); + + for (Long taskId : taskIds) { + try { + AuditActionRequest actionRequest = new AuditActionRequest(); + actionRequest.setIdempotencyKey(request.getIdempotencyKey() + "-reject-" + taskId); + actionRequest.setOpinion(request.getOpinion()); + reject(taskId, actionRequest); + successCount++; + } catch (Exception e) { + failedTaskIds.add(taskId); + errors.add("任务" + taskId + ": " + e.getMessage()); + } + } + + Map result = new LinkedHashMap<>(); + result.put("totalCount", taskIds.size()); + result.put("successCount", successCount); + result.put("failCount", failedTaskIds.size()); + result.put("failedTaskIds", failedTaskIds); + result.put("errors", errors); + return result; + } + + private AuditTask getPendingTask(Long taskId, String idempotencyKey) { + if (actionIdempotency.containsKey(idempotencyKey)) { + throw new BusinessException(10002, "请求幂等冲突"); + } + actionIdempotency.put(idempotencyKey, taskId); + + AuditTask task = auditTaskRepository.findById(taskId) + .orElseThrow(() -> new BusinessException(10003, "审核任务不存在")); + if (task.getStatus() != AuditTaskStatus.PENDING) { + throw new BusinessException(30003, "审核任务已处理"); + } + return task; + } + + private void checkIdempotencyOnly(String idempotencyKey, String marker) { + if (actionIdempotency.containsKey(idempotencyKey)) { + throw new BusinessException(10002, "请求幂等冲突"); + } + actionIdempotency.put(idempotencyKey, -1L); + actionIdempotency.put(marker + ":" + idempotencyKey, -1L); + } + + private AuditTask requirePendingTask(Long taskId) { + AuditTask task = auditTaskRepository.findById(taskId) + .orElseThrow(() -> new BusinessException(10003, "审核任务不存在")); + if (task.getStatus() != AuditTaskStatus.PENDING) { + throw new BusinessException(30003, "审核任务已处理"); + } + return task; + } + + private Long tenantId() { + return AuthContext.requireTenantId(); + } + + private void triggerAuditNotification(AuditTask task, String opinion, String eventCode) { + if (notificationDispatchService == null) { + return; + } + try { + com.writeoff.module.meeting.model.Meeting meeting = meetingService.getById(task.getMeetingId()); + Map vars = new LinkedHashMap(); + vars.put("meetingId", task.getMeetingId()); + vars.put("meetingTopic", meeting.getTopic() == null ? "" : meeting.getTopic()); + vars.put("auditNode", task.getNode() == null ? "" : task.getNode().name()); + vars.put("auditTaskId", task.getId() == null ? 0L : task.getId()); + vars.put("result", "AUDIT_APPROVED".equals(eventCode) ? "通过" : "不通过"); + vars.put("opinion", opinion == null ? "" : opinion); + + DispatchNotificationRequest dispatchRequest = new DispatchNotificationRequest(); + dispatchRequest.setIdempotencyKey("audit-auto-notify-" + eventCode + "-" + (task.getId() == null ? 0L : task.getId())); + dispatchRequest.setEventCode(eventCode); + dispatchRequest.setBizType("MEETING"); + dispatchRequest.setBizId("meeting-" + task.getMeetingId()); + dispatchRequest.setVariablesJson(objectMapper.writeValueAsString(vars)); + notificationDispatchService.dispatch(dispatchRequest); + } catch (BusinessException ex) { + if (ex.getCode() != ErrorCodes.RESOURCE_NOT_FOUND) { + log.warn("自动触发审核通知失败, eventCode={}, taskId={}, code={}, msg={}", + eventCode, task.getId(), ex.getCode(), ex.getMessage()); + } + } catch (Exception ex) { + log.warn("自动触发审核通知异常, eventCode={}, taskId={}", eventCode, task.getId(), ex); + } + } +} diff --git a/backend/src/main/java/com/writeoff/module/auth/controller/AuthController.java b/backend/src/main/java/com/writeoff/module/auth/controller/AuthController.java new file mode 100644 index 0000000..6365432 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/auth/controller/AuthController.java @@ -0,0 +1,749 @@ +package com.writeoff.module.auth.controller; + +import com.writeoff.common.api.ApiErrorResponse; +import com.writeoff.common.api.ApiResponse; +import com.writeoff.common.exception.BusinessException; +import com.writeoff.common.exception.ErrorCodes; +import com.writeoff.module.auth.dto.LoginRequest; +import com.writeoff.module.auth.dto.PasswordSetupCompleteRequest; +import com.writeoff.module.auth.dto.SwitchTenantRequest; +import com.writeoff.module.auth.dto.TenantLoginRequest; +import com.writeoff.module.auth.model.TenantSwitchOption; +import com.writeoff.module.auth.service.RefreshTokenService; +import com.writeoff.module.system.model.ProfilePreferencesInfo; +import com.writeoff.module.system.service.PlatformIamService; +import com.writeoff.module.system.service.SystemUserService; +import com.writeoff.security.AuthContext; +import com.writeoff.security.AuthScope; +import com.writeoff.security.CaptchaService; +import com.writeoff.security.DataScopeType; +import com.writeoff.security.JwtTokenService; +import com.writeoff.security.LoginPasswordCryptoService; +import com.writeoff.security.LoginAttemptService; +import com.writeoff.security.PasswordCodecService; +import com.writeoff.security.PasswordSetupService; +import com.writeoff.security.PermissionService; +import com.writeoff.security.RequirePermission; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import javax.servlet.http.Cookie; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import javax.validation.Valid; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/auth") +public class AuthController { + private final JdbcTemplate jdbcTemplate; + private final JwtTokenService jwtTokenService; + private final PermissionService permissionService; + private final SystemUserService systemUserService; + private final PlatformIamService platformIamService; + private final RefreshTokenService refreshTokenService; + private final LoginAttemptService loginAttemptService; + private final CaptchaService captchaService; + private final LoginPasswordCryptoService loginPasswordCryptoService; + private final PasswordCodecService passwordCodecService; + private final PasswordSetupService passwordSetupService; + private final String refreshCookieName; + private final boolean refreshCookieSecure; + private final String refreshCookieSameSite; + + public AuthController(JdbcTemplate jdbcTemplate, + JwtTokenService jwtTokenService, + PermissionService permissionService, + SystemUserService systemUserService, + PlatformIamService platformIamService, + RefreshTokenService refreshTokenService, + LoginAttemptService loginAttemptService, + CaptchaService captchaService, + LoginPasswordCryptoService loginPasswordCryptoService, + PasswordCodecService passwordCodecService, + PasswordSetupService passwordSetupService, + @Value("${app.security.refresh-cookie-name:refreshToken}") String refreshCookieName, + @Value("${app.security.refresh-cookie-secure:false}") boolean refreshCookieSecure, + @Value("${app.security.refresh-cookie-same-site:Lax}") String refreshCookieSameSite) { + this.jdbcTemplate = jdbcTemplate; + this.jwtTokenService = jwtTokenService; + this.permissionService = permissionService; + this.systemUserService = systemUserService; + this.platformIamService = platformIamService; + this.refreshTokenService = refreshTokenService; + this.loginAttemptService = loginAttemptService; + this.captchaService = captchaService; + this.loginPasswordCryptoService = loginPasswordCryptoService; + this.passwordCodecService = passwordCodecService; + this.passwordSetupService = passwordSetupService; + this.refreshCookieName = refreshCookieName; + this.refreshCookieSecure = refreshCookieSecure; + this.refreshCookieSameSite = refreshCookieSameSite; + } + + @GetMapping("/password-public-key") + public ApiResponse> passwordPublicKey() { + return ApiResponse.success(Collections.singletonMap("publicKey", loginPasswordCryptoService.getEncodedPublicKey())); + } + + @PostMapping("/login") + public ResponseEntity login(@RequestBody @Valid TenantLoginRequest request, + HttpServletRequest httpRequest, + HttpServletResponse httpResponse) { + // 验证码校验 + if (!captchaService.verify(request.getCaptchaId(), request.getCaptchaCode())) { + Map errors = new LinkedHashMap<>(); + errors.put("captcha", "invalid"); + return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY) + .body(ApiErrorResponse.of(11010, "验证码错误或已过期", errors)); + } + // 登录锁定检查 + String lockKey = "tenant:" + request.getTenantCode() + ":" + request.getPhone(); + if (loginAttemptService.isLocked(lockKey)) { + long remaining = loginAttemptService.getRemainingLockSeconds(lockKey); + Map errors = new LinkedHashMap<>(); + errors.put("lockRemainingSeconds", String.valueOf(remaining)); + return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY) + .body(ApiErrorResponse.of(11005, "账号已被锁定,请" + (remaining / 60 + 1) + "分钟后重试", errors)); + } + List> rows = jdbcTemplate.queryForList( + "SELECT u.id, u.tenant_id, u.user_name, u.phone, u.status, u.password_hash, u.valid_from, u.valid_to, " + + "t.tenant_code, t.tenant_name, t.status AS tenant_status " + + "FROM sys_user u JOIN tenant t ON u.tenant_id=t.id " + + "WHERE u.phone=? AND t.tenant_code=? AND u.is_deleted=0 AND t.is_deleted=0 LIMIT 1", + request.getPhone(), + request.getTenantCode() + ); + if (rows.isEmpty()) { + return buildLoginFailResponse(lockKey); + } + Map row = rows.get(0); + String dbPassword = String.valueOf(row.get("password_hash")); + String rawPassword = resolveSubmittedPassword(request.getPassword()); + if (rawPassword == null || !passwordCodecService.matches(rawPassword, dbPassword)) { + return buildLoginFailResponse(lockKey); + } + // 登录成功,清除失败记录 + loginAttemptService.clearFailures(lockKey); + String status = String.valueOf(row.get("status")); + if (!"ENABLED".equals(status)) { + throw new BusinessException(11003, "会话失效"); + } + Long userId = ((Number) row.get("id")).longValue(); + Long tenantId = ((Number) row.get("tenant_id")).longValue(); + systemUserService.onSuccessfulLogin(userId, tenantId, request.getPhone(), rawPassword); + LocalDateTime now = LocalDateTime.now(); + LocalDateTime validFrom = toLocalDateTime(row.get("valid_from")); + LocalDateTime validTo = toLocalDateTime(row.get("valid_to")); + if (validFrom != null && now.isBefore(validFrom)) { + throw new BusinessException(11004, "账号尚未生效"); + } + if (validTo != null && now.isAfter(validTo)) { + throw new BusinessException(11004, "账号已过有效期"); + } + String tenantStatus = String.valueOf(row.get("tenant_status")); + if (!"ENABLED".equals(tenantStatus)) { + throw new BusinessException(11003, "租户已停用"); + } + Map issueResult = refreshTokenService.issueWithSession( + userId, + tenantId, + AuthScope.TENANT, + httpRequest.getRemoteAddr(), + httpRequest.getHeader("User-Agent") + ); + Long sessionId = issueResult.get("sessionId") == null ? null : ((Number) issueResult.get("sessionId")).longValue(); + String refreshToken = String.valueOf(issueResult.get("refreshToken")); + String token = jwtTokenService.createTenantToken(userId, tenantId, request.getPhone(), sessionId); + setRefreshCookie(httpResponse, refreshToken, false); + Map data = buildTenantAuthData(userId, tenantId, String.valueOf(row.get("user_name")), request.getPhone(), token); + return ResponseEntity.ok(ApiResponse.success(data)); + } + + @PostMapping("/platform-login") + public ResponseEntity platformLogin(@RequestBody @Valid LoginRequest request, + HttpServletRequest httpRequest, + HttpServletResponse httpResponse) { + // 验证码校验 + if (!captchaService.verify(request.getCaptchaId(), request.getCaptchaCode())) { + Map errors = new LinkedHashMap<>(); + errors.put("captcha", "invalid"); + return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY) + .body(ApiErrorResponse.of(11010, "验证码错误或已过期", errors)); + } + // 登录锁定检查 + String lockKey = "platform:" + request.getPhone(); + if (loginAttemptService.isLocked(lockKey)) { + long remaining = loginAttemptService.getRemainingLockSeconds(lockKey); + Map errors = new LinkedHashMap<>(); + errors.put("lockRemainingSeconds", String.valueOf(remaining)); + return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY) + .body(ApiErrorResponse.of(11005, "账号已被锁定,请" + (remaining / 60 + 1) + "分钟后重试", errors)); + } + List> rows = jdbcTemplate.queryForList( + "SELECT id, user_name, phone, status, password_hash, valid_from, valid_to " + + "FROM platform_user WHERE phone=? AND is_deleted=0 LIMIT 1", + request.getPhone() + ); + if (rows.isEmpty()) { + return buildLoginFailResponse(lockKey); + } + Map row = rows.get(0); + String dbPassword = String.valueOf(row.get("password_hash")); + String rawPassword = resolveSubmittedPassword(request.getPassword()); + if (rawPassword == null || !passwordCodecService.matches(rawPassword, dbPassword)) { + return buildLoginFailResponse(lockKey); + } + // 登录成功,清除失败记录 + loginAttemptService.clearFailures(lockKey); + String status = String.valueOf(row.get("status")); + if (!"ENABLED".equals(status)) { + throw new BusinessException(11003, "会话失效"); + } + LocalDateTime now = LocalDateTime.now(); + LocalDateTime validFrom = toLocalDateTime(row.get("valid_from")); + LocalDateTime validTo = toLocalDateTime(row.get("valid_to")); + if (validFrom != null && now.isBefore(validFrom)) { + throw new BusinessException(11004, "账号尚未生效"); + } + if (validTo != null && now.isAfter(validTo)) { + throw new BusinessException(11004, "账号已过有效期"); + } + Long userId = ((Number) row.get("id")).longValue(); + platformIamService.onSuccessfulLogin(userId, rawPassword); + Map issueResult = refreshTokenService.issueWithSession( + userId, + null, + AuthScope.PLATFORM, + httpRequest.getRemoteAddr(), + httpRequest.getHeader("User-Agent") + ); + Long sessionId = issueResult.get("sessionId") == null ? null : ((Number) issueResult.get("sessionId")).longValue(); + String refreshToken = String.valueOf(issueResult.get("refreshToken")); + String token = jwtTokenService.createPlatformToken(userId, request.getPhone(), sessionId); + setRefreshCookie(httpResponse, refreshToken, false); + Map data = buildPlatformAuthData(userId, String.valueOf(row.get("user_name")), request.getPhone(), token); + return ResponseEntity.ok(ApiResponse.success(data)); + } + + @GetMapping("/password-setup/verify") + public ApiResponse> verifyPasswordSetup(@RequestParam("tenantCode") String tenantCode, + @RequestParam("token") String token) { + return ApiResponse.success(passwordSetupService.verifyTenantPasswordSetupToken(tenantCode, token)); + } + + @PostMapping("/password-setup/complete") + public ApiResponse> completePasswordSetup(@RequestBody @Valid PasswordSetupCompleteRequest request) { + return ApiResponse.success(passwordSetupService.completeTenantPasswordSetup( + request.getTenantCode(), + request.getToken(), + request.getNewPassword() + )); + } + + /** + * 构建登录失败响应(含剩余尝试次数结构化数据)。 + */ + private ResponseEntity buildLoginFailResponse(String lockKey) { + LoginAttemptService.LoginAttemptStatus status = loginAttemptService.recordFailure(lockKey); + int remaining = status.getRemainingAttempts(); + Map errors = new LinkedHashMap<>(); + errors.put("remainingAttempts", String.valueOf(remaining)); + if (status.isLocked()) { + errors.put("lockRemainingSeconds", String.valueOf(status.getRemainingLockSeconds())); + } + String message = !status.isLocked() && remaining > 0 + ? "账号或密码错误,还可尝试" + remaining + "次" + : "账号或密码错误,账号已被锁定"; + return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY) + .body(ApiErrorResponse.of(11001, message, errors)); + } + + private String resolveSubmittedPassword(String submittedPassword) { + try { + return loginPasswordCryptoService.unwrapPassword(submittedPassword); + } catch (IllegalArgumentException ex) { + return null; + } + } + + @PostMapping("/refresh") + public ApiResponse> refresh(HttpServletRequest request, HttpServletResponse response) { + String currentRefreshToken = getCookieValue(request, refreshCookieName); + Map rotateResult = refreshTokenService.rotate(currentRefreshToken, request.getRemoteAddr(), request.getHeader("User-Agent")); + Long userId = ((Number) rotateResult.get("userId")).longValue(); + Long tenantId = rotateResult.get("tenantId") == null ? null : ((Number) rotateResult.get("tenantId")).longValue(); + AuthScope scope = (AuthScope) rotateResult.get("scope"); + String nextRefreshToken = String.valueOf(rotateResult.get("refreshToken")); + try { + Map data; + if (scope == AuthScope.TENANT) { + validateTenantSession(userId, tenantId); + String phone = jdbcTemplate.queryForObject( + "SELECT phone FROM sys_user WHERE id=? AND tenant_id=? AND is_deleted=0 LIMIT 1", + String.class, + userId, + tenantId + ); + String userName = jdbcTemplate.queryForObject( + "SELECT user_name FROM sys_user WHERE id=? AND tenant_id=? AND is_deleted=0 LIMIT 1", + String.class, + userId, + tenantId + ); + Long sessionId = rotateResult.get("sessionId") == null ? null : ((Number) rotateResult.get("sessionId")).longValue(); + String token = jwtTokenService.createTenantToken(userId, tenantId, phone, sessionId); + data = buildTenantAuthData(userId, tenantId, userName, phone, token); + } else { + validatePlatformSession(userId); + String phone = jdbcTemplate.queryForObject( + "SELECT phone FROM platform_user WHERE id=? AND is_deleted=0 LIMIT 1", + String.class, + userId + ); + String userName = jdbcTemplate.queryForObject( + "SELECT user_name FROM platform_user WHERE id=? AND is_deleted=0 LIMIT 1", + String.class, + userId + ); + Long sessionId = rotateResult.get("sessionId") == null ? null : ((Number) rotateResult.get("sessionId")).longValue(); + String token = jwtTokenService.createPlatformToken(userId, phone, sessionId); + data = buildPlatformAuthData(userId, userName, phone, token); + } + setRefreshCookie(response, nextRefreshToken, false); + return ApiResponse.success(data); + } catch (BusinessException ex) { + refreshTokenService.revokeAllByPrincipal(userId, tenantId, scope, "REFRESH_VALIDATE_FAILED"); + setRefreshCookie(response, "", true); + throw ex; + } + } + + @GetMapping("/switchable-tenants") + @RequirePermission(value = "tenant.switch", dataScope = DataScopeType.TENANT, auditAction = "AUTH_SWITCHABLE_TENANTS") + public ApiResponse> switchableTenants() { + Long currentUserId = AuthContext.userId(); + Long currentTenantId = AuthContext.requireTenantId(); + validateTenantSession(currentUserId, currentTenantId); + return ApiResponse.success(loadSwitchableTenants(currentUserId, currentTenantId)); + } + + @PostMapping("/switch-tenant") + @RequirePermission(value = "tenant.switch", dataScope = DataScopeType.TENANT, auditAction = "AUTH_SWITCH_TENANT") + public ApiResponse> switchTenant(@RequestBody @Valid SwitchTenantRequest request, + HttpServletRequest httpRequest, + HttpServletResponse httpResponse) { + Long currentUserId = AuthContext.userId(); + Long currentTenantId = AuthContext.requireTenantId(); + validateTenantSession(currentUserId, currentTenantId); + + Map currentIdentity = loadCurrentTenantIdentity(currentUserId, currentTenantId); + Long targetTenantId = request.getTenantId(); + if (targetTenantId == null) { + throw new BusinessException(10001, "\u76ee\u6807\u79df\u6237\u4e0d\u80fd\u4e3a\u7a7a"); + } + Map targetIdentity = loadSwitchTarget( + targetTenantId, + String.valueOf(currentIdentity.get("tenant_switch_account_key")), + String.valueOf(currentIdentity.get("phone")), + String.valueOf(currentIdentity.get("password_hash")) + ); + + Long targetUserId = ((Number) targetIdentity.get("user_id")).longValue(); + validateTenantSession(targetUserId, targetTenantId); + + Map issueResult = refreshTokenService.issueWithSession( + targetUserId, + targetTenantId, + AuthScope.TENANT, + httpRequest.getRemoteAddr(), + httpRequest.getHeader("User-Agent") + ); + Long sessionId = issueResult.get("sessionId") == null ? null : ((Number) issueResult.get("sessionId")).longValue(); + String refreshToken = String.valueOf(issueResult.get("refreshToken")); + String phone = String.valueOf(targetIdentity.get("phone")); + String token = jwtTokenService.createTenantToken(targetUserId, targetTenantId, phone, sessionId); + setRefreshCookie(httpResponse, refreshToken, false); + return ApiResponse.success(buildTenantAuthData(targetUserId, targetTenantId, String.valueOf(targetIdentity.get("user_name")), phone, token)); + } + + @PostMapping("/logout") + public ApiResponse> logout(HttpServletRequest request, HttpServletResponse response) { + String token = getCookieValue(request, refreshCookieName); + refreshTokenService.revokeCurrent(token, "LOGOUT"); + setRefreshCookie(response, "", true); + Map data = new LinkedHashMap(); + data.put("ok", Boolean.TRUE); + return ApiResponse.success(data); + } + + @PostMapping("/logout-all") + public ApiResponse> logoutAll(HttpServletRequest request, HttpServletResponse response) { + String currentRefreshToken = getCookieValue(request, refreshCookieName); + Map rotateResult = refreshTokenService.rotate(currentRefreshToken, request.getRemoteAddr(), request.getHeader("User-Agent")); + Long userId = ((Number) rotateResult.get("userId")).longValue(); + Long tenantId = rotateResult.get("tenantId") == null ? null : ((Number) rotateResult.get("tenantId")).longValue(); + AuthScope scope = (AuthScope) rotateResult.get("scope"); + refreshTokenService.revokeAllByPrincipal(userId, tenantId, scope, "LOGOUT_ALL"); + setRefreshCookie(response, "", true); + Map data = new LinkedHashMap(); + data.put("ok", Boolean.TRUE); + return ApiResponse.success(data); + } + + private LocalDateTime toLocalDateTime(Object value) { + if (value == null) { + return null; + } + if (value instanceof LocalDateTime) { + return (LocalDateTime) value; + } + if (value instanceof java.sql.Timestamp) { + return ((java.sql.Timestamp) value).toLocalDateTime(); + } + if (value instanceof java.util.Date) { + return new java.sql.Timestamp(((java.util.Date) value).getTime()).toLocalDateTime(); + } + if (value instanceof String) { + String text = String.valueOf(value).trim(); + if (text.isEmpty()) { + return null; + } + try { + return LocalDateTime.parse(text.replace(' ', 'T')); + } catch (Exception ignored) { + } + } + return null; + } + + private Map loadTenantInfo(Long tenantId) { + List> tenantRows = jdbcTemplate.queryForList( + "SELECT id, tenant_code, tenant_name, logo_url, status, is_deleted, created_by, created_at, updated_by, updated_at " + + "FROM tenant WHERE id=? LIMIT 1", + tenantId + ); + if (tenantRows.isEmpty()) { + return new LinkedHashMap(); + } + Map r = tenantRows.get(0); + Map tenant = new LinkedHashMap(); + tenant.put("id", r.get("id")); + tenant.put("tenantCode", r.get("tenant_code")); + tenant.put("tenantName", r.get("tenant_name")); + tenant.put("logoUrl", r.get("logo_url")); + tenant.put("status", r.get("status")); + tenant.put("isDeleted", r.get("is_deleted")); + tenant.put("createdBy", r.get("created_by")); + tenant.put("createdAt", formatDateTime(r.get("created_at"))); + tenant.put("updatedBy", r.get("updated_by")); + tenant.put("updatedAt", formatDateTime(r.get("updated_at"))); + return tenant; + } + + private String formatDateTime(Object value) { + LocalDateTime dt = toLocalDateTime(value); + if (dt == null) { + return ""; + } + return dt.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")); + } + + private Map buildTenantAuthData(Long userId, Long tenantId, String userName, String phone, String token) { + Map tenant = loadTenantInfo(tenantId); + Map data = new LinkedHashMap(); + data.put("token", token); + data.put("scope", AuthScope.TENANT.name()); + data.put("userId", userId); + data.put("tenantId", tenantId); + data.put("tenantCode", tenant.get("tenantCode")); + data.put("tenantName", tenant.get("tenantName")); + data.put("tenant", tenant); + data.put("userName", userName); + data.put("roles", systemUserService.getUserRoles(userId, tenantId)); + data.put("permissions", permissionService.getPermissions(userId, tenantId)); + data.put("phone", phone); + data.put("appearance", loadTenantPreferences(userId, tenantId)); + return data; + } + + private Map buildPlatformAuthData(Long userId, String userName, String phone, String token) { + Map data = new LinkedHashMap(); + data.put("token", token); + data.put("scope", AuthScope.PLATFORM.name()); + data.put("userId", userId); + data.put("tenantId", null); + data.put("userName", userName); + data.put("roles", permissionService.getPlatformRoles(userId)); + data.put("permissions", permissionService.getPlatformPermissions(userId)); + data.put("phone", phone); + data.put("appearance", loadPlatformPreferences(userId)); + return data; + } + + private ProfilePreferencesInfo loadTenantPreferences(Long userId, Long tenantId) { + List> rows = jdbcTemplate.queryForList( + "SELECT ui_theme_mode, ui_density, ui_theme_scheme FROM sys_user WHERE id=? AND tenant_id=? AND is_deleted=0 LIMIT 1", + userId, + tenantId + ); + if (rows.isEmpty()) { + return new ProfilePreferencesInfo("SYSTEM", "COMFORTABLE", "SLATE"); + } + Map row = rows.get(0); + return new ProfilePreferencesInfo( + systemUserService.normalizeThemeMode(row.get("ui_theme_mode") == null ? null : String.valueOf(row.get("ui_theme_mode"))), + systemUserService.normalizeDensity(row.get("ui_density") == null ? null : String.valueOf(row.get("ui_density"))), + systemUserService.normalizeThemeScheme(row.get("ui_theme_scheme") == null ? null : String.valueOf(row.get("ui_theme_scheme"))) + ); + } + + private ProfilePreferencesInfo loadPlatformPreferences(Long userId) { + List> rows = jdbcTemplate.queryForList( + "SELECT ui_theme_mode, ui_density, ui_theme_scheme FROM platform_user WHERE id=? AND is_deleted=0 LIMIT 1", + userId + ); + if (rows.isEmpty()) { + return new ProfilePreferencesInfo("SYSTEM", "COMFORTABLE", "SLATE"); + } + Map row = rows.get(0); + return new ProfilePreferencesInfo( + normalizeThemeMode(row.get("ui_theme_mode")), + normalizeDensity(row.get("ui_density")), + normalizeThemeScheme(row.get("ui_theme_scheme")) + ); + } + + private String normalizeThemeMode(Object raw) { + String value = raw == null ? "" : String.valueOf(raw).trim().toUpperCase(); + if ("LIGHT".equals(value) || "DARK".equals(value) || "SYSTEM".equals(value)) { + return value; + } + return "SYSTEM"; + } + + private String normalizeDensity(Object raw) { + String value = raw == null ? "" : String.valueOf(raw).trim().toUpperCase(); + if ("COMPACT".equals(value) || "COMFORTABLE".equals(value)) { + return value; + } + return "COMFORTABLE"; + } + + private String normalizeThemeScheme(Object raw) { + String value = raw == null ? "" : String.valueOf(raw).trim().toUpperCase(); + if ( + "SLATE".equals(value) || + "OCEAN".equals(value) || + "FOREST".equals(value) || + "GRAPHITE".equals(value) || + "AMBER".equals(value) || + "RUBY".equals(value) || + "MIST".equals(value) || + "SAGE".equals(value) || + "DAWN".equals(value) + ) { + return value; + } + return "SLATE"; + } + + private void validateTenantSession(Long userId, Long tenantId) { + List> rows = jdbcTemplate.queryForList( + "SELECT u.status, u.valid_from, u.valid_to, t.status AS tenant_status " + + "FROM sys_user u JOIN tenant t ON u.tenant_id=t.id " + + "WHERE u.id=? AND u.tenant_id=? AND u.is_deleted=0 AND t.is_deleted=0 LIMIT 1", + userId, + tenantId + ); + if (rows.isEmpty()) { + throw new BusinessException(ErrorCodes.SESSION_INVALID, "会话失效"); + } + Map row = rows.get(0); + String userStatus = String.valueOf(row.get("status")); + String tenantStatus = String.valueOf(row.get("tenant_status")); + if (!"ENABLED".equals(userStatus) || !"ENABLED".equals(tenantStatus)) { + throw new BusinessException(ErrorCodes.SESSION_INVALID, "会话失效"); + } + LocalDateTime now = LocalDateTime.now(); + LocalDateTime validFrom = toLocalDateTime(row.get("valid_from")); + LocalDateTime validTo = toLocalDateTime(row.get("valid_to")); + if (validFrom != null && now.isBefore(validFrom)) { + throw new BusinessException(ErrorCodes.ACCOUNT_EXPIRED, "账号尚未生效"); + } + if (validTo != null && now.isAfter(validTo)) { + throw new BusinessException(ErrorCodes.ACCOUNT_EXPIRED, "账号已过有效期"); + } + } + + private void validatePlatformSession(Long userId) { + List> rows = jdbcTemplate.queryForList( + "SELECT status, valid_from, valid_to FROM platform_user WHERE id=? AND is_deleted=0 LIMIT 1", + userId + ); + if (rows.isEmpty()) { + throw new BusinessException(ErrorCodes.SESSION_INVALID, "会话失效"); + } + Map row = rows.get(0); + String userStatus = String.valueOf(row.get("status")); + if (!"ENABLED".equals(userStatus)) { + throw new BusinessException(ErrorCodes.SESSION_INVALID, "会话失效"); + } + LocalDateTime now = LocalDateTime.now(); + LocalDateTime validFrom = toLocalDateTime(row.get("valid_from")); + LocalDateTime validTo = toLocalDateTime(row.get("valid_to")); + if (validFrom != null && now.isBefore(validFrom)) { + throw new BusinessException(ErrorCodes.ACCOUNT_EXPIRED, "账号尚未生效"); + } + if (validTo != null && now.isAfter(validTo)) { + throw new BusinessException(ErrorCodes.ACCOUNT_EXPIRED, "账号已过有效期"); + } + } + + private List loadSwitchableTenants(Long currentUserId, Long currentTenantId) { + Map currentIdentity = loadCurrentTenantIdentity(currentUserId, currentTenantId); + String switchAccountKey = normalizeTenantSwitchAccountKey(currentIdentity.get("tenant_switch_account_key")); + if (!switchAccountKey.isEmpty()) { + return jdbcTemplate.query( + "SELECT u.tenant_id, t.tenant_code, t.tenant_name, t.logo_url " + + "FROM sys_user u " + + "JOIN tenant t ON u.tenant_id=t.id " + + "WHERE u.tenant_switch_account_key=? AND u.is_deleted=0 AND u.status='ENABLED' " + + "AND (u.valid_from IS NULL OR u.valid_from<=NOW()) " + + "AND (u.valid_to IS NULL OR u.valid_to>=NOW()) " + + "AND t.is_deleted=0 AND t.status='ENABLED' " + + "ORDER BY CASE WHEN u.tenant_id=? THEN 0 ELSE 1 END, t.tenant_name ASC, t.id ASC", + (rs, n) -> new TenantSwitchOption( + rs.getLong("tenant_id"), + rs.getString("tenant_code"), + rs.getString("tenant_name"), + rs.getString("logo_url"), + rs.getLong("tenant_id") == currentTenantId + ), + switchAccountKey, + currentTenantId + ); + } + String phone = String.valueOf(currentIdentity.get("phone")); + String passwordHash = String.valueOf(currentIdentity.get("password_hash")); + return jdbcTemplate.query( + "SELECT u.tenant_id, t.tenant_code, t.tenant_name, t.logo_url " + + "FROM sys_user u " + + "JOIN tenant t ON u.tenant_id=t.id " + + "WHERE u.phone=? AND u.password_hash=? AND u.is_deleted=0 AND u.status='ENABLED' " + + "AND (u.valid_from IS NULL OR u.valid_from<=NOW()) " + + "AND (u.valid_to IS NULL OR u.valid_to>=NOW()) " + + "AND t.is_deleted=0 AND t.status='ENABLED' " + + "ORDER BY CASE WHEN u.tenant_id=? THEN 0 ELSE 1 END, t.tenant_name ASC, t.id ASC", + (rs, n) -> new TenantSwitchOption( + rs.getLong("tenant_id"), + rs.getString("tenant_code"), + rs.getString("tenant_name"), + rs.getString("logo_url"), + rs.getLong("tenant_id") == currentTenantId + ), + phone, + passwordHash, + currentTenantId + ); + } + + private Map loadCurrentTenantIdentity(Long userId, Long tenantId) { + List> rows = jdbcTemplate.queryForList( + "SELECT id, tenant_id, user_name, phone, password_hash, tenant_switch_account_key " + + "FROM sys_user WHERE id=? AND tenant_id=? AND is_deleted=0 LIMIT 1", + userId, + tenantId + ); + if (rows.isEmpty()) { + throw new BusinessException(ErrorCodes.SESSION_INVALID, "\u4f1a\u8bdd\u5931\u6548"); + } + return rows.get(0); + } + + private Map loadSwitchTarget(Long tenantId, String switchAccountKey, String phone, String passwordHash) { + String normalizedSwitchAccountKey = normalizeTenantSwitchAccountKey(switchAccountKey); + if (!normalizedSwitchAccountKey.isEmpty()) { + List> accountKeyRows = jdbcTemplate.queryForList( + "SELECT u.id AS user_id, u.tenant_id, u.user_name, u.phone, u.password_hash, u.tenant_switch_account_key " + + "FROM sys_user u " + + "JOIN tenant t ON u.tenant_id=t.id " + + "WHERE u.tenant_id=? AND u.tenant_switch_account_key=? " + + "AND u.is_deleted=0 AND u.status='ENABLED' AND t.is_deleted=0 AND t.status='ENABLED' " + + "LIMIT 1", + tenantId, + normalizedSwitchAccountKey + ); + if (!accountKeyRows.isEmpty()) { + return accountKeyRows.get(0); + } + } + List> rows = jdbcTemplate.queryForList( + "SELECT u.id AS user_id, u.tenant_id, u.user_name, u.phone, u.password_hash, u.tenant_switch_account_key " + + "FROM sys_user u " + + "JOIN tenant t ON u.tenant_id=t.id " + + "WHERE u.tenant_id=? AND u.phone=? AND u.password_hash=? " + + "AND u.is_deleted=0 AND u.status='ENABLED' AND t.is_deleted=0 AND t.status='ENABLED' " + + "LIMIT 1", + tenantId, + phone, + passwordHash + ); + if (rows.isEmpty()) { + throw new BusinessException(10003, "\u76ee\u6807\u79df\u6237\u4e0d\u53ef\u5207\u6362"); + } + return rows.get(0); + } + + private String normalizeTenantSwitchAccountKey(Object raw) { + return raw == null ? "" : String.valueOf(raw).trim(); + } + + private String getCookieValue(HttpServletRequest request, String cookieName) { + Cookie[] cookies = request.getCookies(); + if (cookies == null || cookies.length == 0) { + return null; + } + for (Cookie cookie : cookies) { + if (cookie == null) { + continue; + } + if (cookieName.equals(cookie.getName())) { + return cookie.getValue(); + } + } + return null; + } + + private void setRefreshCookie(HttpServletResponse response, String token, boolean clear) { + String sameSite = refreshCookieSameSite == null || refreshCookieSameSite.trim().isEmpty() + ? "Lax" + : refreshCookieSameSite.trim(); + StringBuilder sb = new StringBuilder(); + sb.append(refreshCookieName).append("=").append(clear ? "" : token); + sb.append("; Path=/api/auth"); + if (clear) { + sb.append("; Max-Age=0"); + sb.append("; Expires=Thu, 01 Jan 1970 00:00:00 GMT"); + } + sb.append("; HttpOnly"); + if (refreshCookieSecure) { + sb.append("; Secure"); + } + sb.append("; SameSite=").append(sameSite); + response.addHeader("Set-Cookie", sb.toString()); + } +} diff --git a/backend/src/main/java/com/writeoff/module/auth/controller/CaptchaController.java b/backend/src/main/java/com/writeoff/module/auth/controller/CaptchaController.java new file mode 100644 index 0000000..e407f1e --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/auth/controller/CaptchaController.java @@ -0,0 +1,29 @@ +package com.writeoff.module.auth.controller; + +import com.writeoff.common.api.ApiResponse; +import com.writeoff.security.CaptchaService; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Map; + +@RestController +@RequestMapping("/api/captcha") +public class CaptchaController { + + private final CaptchaService captchaService; + + public CaptchaController(CaptchaService captchaService) { + this.captchaService = captchaService; + } + + /** + * 获取图形验证码。 + * 返回 captchaId + Base64 编码的 PNG 图片。 + */ + @GetMapping + public ApiResponse> getCaptcha() { + return ApiResponse.success(captchaService.generate()); + } +} diff --git a/backend/src/main/java/com/writeoff/module/auth/controller/PlatformAuthSessionController.java b/backend/src/main/java/com/writeoff/module/auth/controller/PlatformAuthSessionController.java new file mode 100644 index 0000000..2ccdaad --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/auth/controller/PlatformAuthSessionController.java @@ -0,0 +1,59 @@ +package com.writeoff.module.auth.controller; + +import com.writeoff.common.api.ApiResponse; +import com.writeoff.module.auth.dto.PlatformSessionRevokePrincipalRequest; +import com.writeoff.module.auth.model.PlatformAuthSessionInfo; +import com.writeoff.module.auth.service.PlatformAuthSessionService; +import com.writeoff.security.DataScopeType; +import com.writeoff.security.PermissionDomain; +import com.writeoff.security.RequirePermission; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import javax.validation.Valid; +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/platform/auth-sessions") +public class PlatformAuthSessionController { + private final PlatformAuthSessionService platformAuthSessionService; + + public PlatformAuthSessionController(PlatformAuthSessionService platformAuthSessionService) { + this.platformAuthSessionService = platformAuthSessionService; + } + + @GetMapping + @RequirePermission(value = "platform.session.read", domain = PermissionDomain.PLATFORM, dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "PLATFORM_AUTH_SESSION_LIST") + public ApiResponse> list( + @RequestParam(value = "scope", required = false) String scope, + @RequestParam(value = "status", required = false) String status, + @RequestParam(value = "userId", required = false) Long userId, + @RequestParam(value = "tenantId", required = false) Long tenantId + ) { + return ApiResponse.success(platformAuthSessionService.list(scope, status, userId, tenantId)); + } + + @PostMapping("/{id}/revoke") + @RequirePermission(value = "platform.session.manage", domain = PermissionDomain.PLATFORM, dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "PLATFORM_AUTH_SESSION_REVOKE") + public ApiResponse> revoke(@PathVariable("id") Long id) { + return ApiResponse.success(platformAuthSessionService.revokeBySessionId(id)); + } + + @PostMapping("/revoke-principal") + @RequirePermission(value = "platform.session.manage", domain = PermissionDomain.PLATFORM, dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "PLATFORM_AUTH_SESSION_REVOKE_PRINCIPAL") + public ApiResponse> revokePrincipal(@RequestBody @Valid PlatformSessionRevokePrincipalRequest request) { + return ApiResponse.success( + platformAuthSessionService.revokeByPrincipal( + request.getUserId(), + request.getTenantId(), + request.getScope() + ) + ); + } +} diff --git a/backend/src/main/java/com/writeoff/module/auth/dto/LoginRequest.java b/backend/src/main/java/com/writeoff/module/auth/dto/LoginRequest.java new file mode 100644 index 0000000..9a4f7c6 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/auth/dto/LoginRequest.java @@ -0,0 +1,29 @@ +package com.writeoff.module.auth.dto; + +import javax.validation.constraints.NotBlank; + +public class LoginRequest { + @NotBlank(message = "手机号不能为空") + private String phone; + @NotBlank(message = "密码不能为空") + private String password; + + private String captchaId; + private String captchaCode; + + public String getPhone() { + return phone; + } + + public String getPassword() { + return password; + } + + public String getCaptchaId() { + return captchaId; + } + + public String getCaptchaCode() { + return captchaCode; + } +} diff --git a/backend/src/main/java/com/writeoff/module/auth/dto/PasswordSetupCompleteRequest.java b/backend/src/main/java/com/writeoff/module/auth/dto/PasswordSetupCompleteRequest.java new file mode 100644 index 0000000..69a45c7 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/auth/dto/PasswordSetupCompleteRequest.java @@ -0,0 +1,26 @@ +package com.writeoff.module.auth.dto; + +import javax.validation.constraints.NotBlank; + +public class PasswordSetupCompleteRequest { + @NotBlank(message = "租户编码不能为空") + private String tenantCode; + + @NotBlank(message = "设置链接不能为空") + private String token; + + @NotBlank(message = "新密码不能为空") + private String newPassword; + + public String getTenantCode() { + return tenantCode; + } + + public String getToken() { + return token; + } + + public String getNewPassword() { + return newPassword; + } +} diff --git a/backend/src/main/java/com/writeoff/module/auth/dto/PlatformSessionRevokePrincipalRequest.java b/backend/src/main/java/com/writeoff/module/auth/dto/PlatformSessionRevokePrincipalRequest.java new file mode 100644 index 0000000..9452800 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/auth/dto/PlatformSessionRevokePrincipalRequest.java @@ -0,0 +1,36 @@ +package com.writeoff.module.auth.dto; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; + +public class PlatformSessionRevokePrincipalRequest { + @NotNull(message = "用户ID不能为空") + private Long userId; + private Long tenantId; + @NotBlank(message = "scope不能为空") + private String scope; + + public Long getUserId() { + return userId; + } + + public void setUserId(Long userId) { + this.userId = userId; + } + + public Long getTenantId() { + return tenantId; + } + + public void setTenantId(Long tenantId) { + this.tenantId = tenantId; + } + + public String getScope() { + return scope; + } + + public void setScope(String scope) { + this.scope = scope; + } +} diff --git a/backend/src/main/java/com/writeoff/module/auth/dto/SwitchTenantRequest.java b/backend/src/main/java/com/writeoff/module/auth/dto/SwitchTenantRequest.java new file mode 100644 index 0000000..2d5d973 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/auth/dto/SwitchTenantRequest.java @@ -0,0 +1,16 @@ +package com.writeoff.module.auth.dto; + +import javax.validation.constraints.NotNull; + +public class SwitchTenantRequest { + @NotNull(message = "\u76ee\u6807\u79df\u6237\u4e0d\u80fd\u4e3a\u7a7a") + private Long tenantId; + + public Long getTenantId() { + return tenantId; + } + + public void setTenantId(Long tenantId) { + this.tenantId = tenantId; + } +} diff --git a/backend/src/main/java/com/writeoff/module/auth/dto/TenantLoginRequest.java b/backend/src/main/java/com/writeoff/module/auth/dto/TenantLoginRequest.java new file mode 100644 index 0000000..0ca2c2d --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/auth/dto/TenantLoginRequest.java @@ -0,0 +1,35 @@ +package com.writeoff.module.auth.dto; + +import javax.validation.constraints.NotBlank; + +public class TenantLoginRequest { + @NotBlank(message = "租户编码不能为空") + private String tenantCode; + @NotBlank(message = "手机号不能为空") + private String phone; + @NotBlank(message = "密码不能为空") + private String password; + + private String captchaId; + private String captchaCode; + + public String getTenantCode() { + return tenantCode; + } + + public String getPhone() { + return phone; + } + + public String getPassword() { + return password; + } + + public String getCaptchaId() { + return captchaId; + } + + public String getCaptchaCode() { + return captchaCode; + } +} diff --git a/backend/src/main/java/com/writeoff/module/auth/model/PlatformAuthSessionInfo.java b/backend/src/main/java/com/writeoff/module/auth/model/PlatformAuthSessionInfo.java new file mode 100644 index 0000000..ba5aa42 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/auth/model/PlatformAuthSessionInfo.java @@ -0,0 +1,97 @@ +package com.writeoff.module.auth.model; + +public class PlatformAuthSessionInfo { + private final Long id; + private final Long userId; + private final Long tenantId; + private final String scope; + private final String status; + private final String userName; + private final String phone; + private final String tenantName; + private final String issuedAt; + private final String expiresAt; + private final String lastUsedAt; + private final String revokedAt; + private final String revokedReason; + + public PlatformAuthSessionInfo(Long id, + Long userId, + Long tenantId, + String scope, + String status, + String userName, + String phone, + String tenantName, + String issuedAt, + String expiresAt, + String lastUsedAt, + String revokedAt, + String revokedReason) { + this.id = id; + this.userId = userId; + this.tenantId = tenantId; + this.scope = scope; + this.status = status; + this.userName = userName; + this.phone = phone; + this.tenantName = tenantName; + this.issuedAt = issuedAt; + this.expiresAt = expiresAt; + this.lastUsedAt = lastUsedAt; + this.revokedAt = revokedAt; + this.revokedReason = revokedReason; + } + + public Long getId() { + return id; + } + + public Long getUserId() { + return userId; + } + + public Long getTenantId() { + return tenantId; + } + + public String getScope() { + return scope; + } + + public String getStatus() { + return status; + } + + public String getUserName() { + return userName; + } + + public String getPhone() { + return phone; + } + + public String getTenantName() { + return tenantName; + } + + public String getIssuedAt() { + return issuedAt; + } + + public String getExpiresAt() { + return expiresAt; + } + + public String getLastUsedAt() { + return lastUsedAt; + } + + public String getRevokedAt() { + return revokedAt; + } + + public String getRevokedReason() { + return revokedReason; + } +} diff --git a/backend/src/main/java/com/writeoff/module/auth/model/TenantSwitchOption.java b/backend/src/main/java/com/writeoff/module/auth/model/TenantSwitchOption.java new file mode 100644 index 0000000..f2a44f2 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/auth/model/TenantSwitchOption.java @@ -0,0 +1,37 @@ +package com.writeoff.module.auth.model; + +public class TenantSwitchOption { + private final Long tenantId; + private final String tenantCode; + private final String tenantName; + private final String logoUrl; + private final boolean current; + + public TenantSwitchOption(Long tenantId, String tenantCode, String tenantName, String logoUrl, boolean current) { + this.tenantId = tenantId; + this.tenantCode = tenantCode; + this.tenantName = tenantName; + this.logoUrl = logoUrl; + this.current = current; + } + + public Long getTenantId() { + return tenantId; + } + + public String getTenantCode() { + return tenantCode; + } + + public String getTenantName() { + return tenantName; + } + + public String getLogoUrl() { + return logoUrl; + } + + public boolean isCurrent() { + return current; + } +} diff --git a/backend/src/main/java/com/writeoff/module/auth/service/PlatformAuthSessionService.java b/backend/src/main/java/com/writeoff/module/auth/service/PlatformAuthSessionService.java new file mode 100644 index 0000000..71d81c4 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/auth/service/PlatformAuthSessionService.java @@ -0,0 +1,130 @@ +package com.writeoff.module.auth.service; + +import com.writeoff.common.exception.BusinessException; +import com.writeoff.common.exception.ErrorCodes; +import com.writeoff.module.auth.model.PlatformAuthSessionInfo; +import com.writeoff.security.AuthScope; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@Service +public class PlatformAuthSessionService { + private final JdbcTemplate jdbcTemplate; + + public PlatformAuthSessionService(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + public List list(String scope, String status, Long userId, Long tenantId) { + StringBuilder sql = new StringBuilder(); + List args = new ArrayList(); + sql.append("SELECT t.id, t.user_id, t.tenant_id, t.scope, t.status, "); + sql.append("CASE WHEN t.scope='TENANT' THEN su.user_name ELSE pu.user_name END AS user_name, "); + sql.append("CASE WHEN t.scope='TENANT' THEN su.phone ELSE pu.phone END AS phone, "); + sql.append("te.tenant_name, "); + sql.append("DATE_FORMAT(t.issued_at, '%Y-%m-%d %H:%i:%s') AS issued_at, "); + sql.append("DATE_FORMAT(t.expires_at, '%Y-%m-%d %H:%i:%s') AS expires_at, "); + sql.append("DATE_FORMAT(t.last_used_at, '%Y-%m-%d %H:%i:%s') AS last_used_at, "); + sql.append("DATE_FORMAT(t.revoked_at, '%Y-%m-%d %H:%i:%s') AS revoked_at, "); + sql.append("t.revoked_reason "); + sql.append("FROM auth_refresh_token t "); + sql.append("LEFT JOIN sys_user su ON t.scope='TENANT' AND su.id=t.user_id AND su.tenant_id=t.tenant_id "); + sql.append("LEFT JOIN tenant te ON te.id=t.tenant_id "); + sql.append("LEFT JOIN platform_user pu ON t.scope='PLATFORM' AND pu.id=t.user_id "); + sql.append("WHERE t.is_deleted=0 "); + if (scope != null && !scope.trim().isEmpty()) { + sql.append("AND t.scope=? "); + args.add(scope.trim().toUpperCase()); + } + if (status != null && !status.trim().isEmpty()) { + sql.append("AND t.status=? "); + args.add(status.trim().toUpperCase()); + } + if (userId != null && userId > 0) { + sql.append("AND t.user_id=? "); + args.add(userId); + } + if (tenantId != null && tenantId > 0) { + sql.append("AND t.tenant_id=? "); + args.add(tenantId); + } + sql.append("ORDER BY t.id DESC LIMIT 500"); + List> rows = jdbcTemplate.queryForList(sql.toString(), args.toArray()); + List list = new ArrayList(); + for (Map row : rows) { + list.add(new PlatformAuthSessionInfo( + ((Number) row.get("id")).longValue(), + ((Number) row.get("user_id")).longValue(), + row.get("tenant_id") == null ? null : ((Number) row.get("tenant_id")).longValue(), + str(row.get("scope")), + str(row.get("status")), + str(row.get("user_name")), + str(row.get("phone")), + str(row.get("tenant_name")), + str(row.get("issued_at")), + str(row.get("expires_at")), + str(row.get("last_used_at")), + str(row.get("revoked_at")), + str(row.get("revoked_reason")) + )); + } + return list; + } + + @Transactional(rollbackFor = Exception.class) + public Map revokeBySessionId(Long sessionId) { + List> rows = jdbcTemplate.queryForList( + "SELECT id, status FROM auth_refresh_token WHERE id=? AND is_deleted=0 LIMIT 1", + sessionId + ); + if (rows.isEmpty()) { + throw new BusinessException(ErrorCodes.RESOURCE_NOT_FOUND, "会话不存在"); + } + jdbcTemplate.update( + "UPDATE auth_refresh_token SET status='REVOKED', revoked_at=CURRENT_TIMESTAMP, revoked_reason='ADMIN_REVOKE', updated_at=CURRENT_TIMESTAMP " + + "WHERE id=? AND status='ACTIVE'", + sessionId + ); + Map data = new LinkedHashMap(); + data.put("sessionId", sessionId); + data.put("ok", Boolean.TRUE); + return data; + } + + @Transactional(rollbackFor = Exception.class) + public Map revokeByPrincipal(Long userId, Long tenantId, String scopeRaw) { + AuthScope scope = AuthScope.fromClaim(scopeRaw); + if (scope == AuthScope.TENANT && (tenantId == null || tenantId <= 0)) { + throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "租户会话撤销时tenantId不能为空"); + } + int affected; + if (scope == AuthScope.TENANT) { + affected = jdbcTemplate.update( + "UPDATE auth_refresh_token SET status='REVOKED', revoked_at=CURRENT_TIMESTAMP, revoked_reason='ADMIN_REVOKE_PRINCIPAL', updated_at=CURRENT_TIMESTAMP " + + "WHERE user_id=? AND tenant_id=? AND scope='TENANT' AND status='ACTIVE' AND is_deleted=0", + userId, + tenantId + ); + } else { + affected = jdbcTemplate.update( + "UPDATE auth_refresh_token SET status='REVOKED', revoked_at=CURRENT_TIMESTAMP, revoked_reason='ADMIN_REVOKE_PRINCIPAL', updated_at=CURRENT_TIMESTAMP " + + "WHERE user_id=? AND tenant_id IS NULL AND scope='PLATFORM' AND status='ACTIVE' AND is_deleted=0", + userId + ); + } + Map data = new LinkedHashMap(); + data.put("affected", affected); + data.put("ok", Boolean.TRUE); + return data; + } + + private String str(Object value) { + return value == null ? "" : String.valueOf(value); + } +} diff --git a/backend/src/main/java/com/writeoff/module/auth/service/RefreshTokenService.java b/backend/src/main/java/com/writeoff/module/auth/service/RefreshTokenService.java new file mode 100644 index 0000000..3f68c05 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/auth/service/RefreshTokenService.java @@ -0,0 +1,273 @@ +package com.writeoff.module.auth.service; + +import com.writeoff.common.exception.BusinessException; +import com.writeoff.common.exception.ErrorCodes; +import com.writeoff.security.AuthScope; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.time.LocalDateTime; +import java.util.Base64; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@Service +public class RefreshTokenService { + private final JdbcTemplate jdbcTemplate; + private final SecureRandom secureRandom = new SecureRandom(); + private final long refreshExpireDays; + private final long idleTimeoutMinutes; + + public RefreshTokenService(JdbcTemplate jdbcTemplate, + @Value("${app.security.refresh-expire-days:14}") long refreshExpireDays, + @Value("${app.security.idle-timeout-minutes:60}") long idleTimeoutMinutes) { + this.jdbcTemplate = jdbcTemplate; + this.refreshExpireDays = refreshExpireDays <= 0 ? 14 : refreshExpireDays; + this.idleTimeoutMinutes = idleTimeoutMinutes <= 0 ? 60 : idleTimeoutMinutes; + } + + @Transactional(rollbackFor = Exception.class) + public String issue(Long userId, Long tenantId, AuthScope scope, String ip, String userAgent) { + return String.valueOf(issueWithSession(userId, tenantId, scope, ip, userAgent).get("refreshToken")); + } + + @Transactional(rollbackFor = Exception.class) + public Map issueWithSession(Long userId, Long tenantId, AuthScope scope, String ip, String userAgent) { + String rawToken = generateToken(); + String tokenHash = sha256Hex(rawToken); + Long safeUser = userId == null ? 0L : userId; + jdbcTemplate.update( + "INSERT INTO auth_refresh_token (user_id, tenant_id, scope, token_hash, status, expires_at, ip_hash, ua_hash, created_by, updated_by) " + + "VALUES (?, ?, ?, ?, 'ACTIVE', DATE_ADD(NOW(), INTERVAL ? DAY), ?, ?, ?, ?)", + safeUser, + tenantId, + scope.name(), + tokenHash, + refreshExpireDays, + sha256Hex(safe(ip)), + sha256Hex(safe(userAgent)), + safeUser, + safeUser + ); + Long sessionId = jdbcTemplate.queryForObject( + "SELECT id FROM auth_refresh_token WHERE token_hash=? LIMIT 1", + Long.class, + tokenHash + ); + Map data = new LinkedHashMap(); + data.put("sessionId", sessionId); + data.put("refreshToken", rawToken); + return data; + } + + @Transactional(rollbackFor = Exception.class) + public Map rotate(String rawToken, String ip, String userAgent) { + if (rawToken == null || rawToken.trim().isEmpty()) { + throw new BusinessException(ErrorCodes.REFRESH_TOKEN_INVALID, "刷新会话无效"); + } + String tokenHash = sha256Hex(rawToken.trim()); + List> rows = jdbcTemplate.queryForList( + "SELECT id, user_id, tenant_id, scope, status, issued_at, expires_at, last_used_at, is_deleted " + + "FROM auth_refresh_token WHERE token_hash=? LIMIT 1", + tokenHash + ); + if (rows.isEmpty()) { + throw new BusinessException(ErrorCodes.REFRESH_TOKEN_INVALID, "刷新会话无效"); + } + Map row = rows.get(0); + String status = String.valueOf(row.get("status")); + int isDeleted = toFlagInt(row.get("is_deleted")); + if (isDeleted == 1 || !"ACTIVE".equals(status)) { + throw new BusinessException(ErrorCodes.REFRESH_TOKEN_INVALID, "刷新会话无效"); + } + LocalDateTime now = LocalDateTime.now(); + LocalDateTime lastActivityAt = resolveLastActivityAt(row.get("last_used_at"), row.get("issued_at")); + if (lastActivityAt == null || now.isAfter(lastActivityAt.plusMinutes(idleTimeoutMinutes))) { + Long currentId = ((Number) row.get("id")).longValue(); + jdbcTemplate.update( + "UPDATE auth_refresh_token SET status='REVOKED', revoked_at=CURRENT_TIMESTAMP, revoked_reason='IDLE_TIMEOUT', updated_at=CURRENT_TIMESTAMP WHERE id=? AND status='ACTIVE'", + currentId + ); + throw new BusinessException(ErrorCodes.SESSION_INVALID, "浼氳瘽澶辨晥"); + } + LocalDateTime expiresAt = toLocalDateTime(row.get("expires_at")); + if (expiresAt == null || now.isAfter(expiresAt)) { + Long currentId = ((Number) row.get("id")).longValue(); + jdbcTemplate.update( + "UPDATE auth_refresh_token SET status='EXPIRED', revoked_at=CURRENT_TIMESTAMP, revoked_reason='EXPIRED', updated_at=CURRENT_TIMESTAMP WHERE id=?", + currentId + ); + throw new BusinessException(ErrorCodes.REFRESH_TOKEN_EXPIRED, "刷新会话已过期"); + } + + Long currentId = ((Number) row.get("id")).longValue(); + Long userId = ((Number) row.get("user_id")).longValue(); + Long tenantId = row.get("tenant_id") == null ? null : ((Number) row.get("tenant_id")).longValue(); + AuthScope scope = AuthScope.fromClaim(String.valueOf(row.get("scope"))); + + String nextRawToken = generateToken(); + String nextTokenHash = sha256Hex(nextRawToken); + jdbcTemplate.update( + "INSERT INTO auth_refresh_token (user_id, tenant_id, scope, token_hash, status, expires_at, ip_hash, ua_hash, created_by, updated_by) " + + "VALUES (?, ?, ?, ?, 'ACTIVE', DATE_ADD(NOW(), INTERVAL ? DAY), ?, ?, ?, ?)", + userId, + tenantId, + scope.name(), + nextTokenHash, + refreshExpireDays, + sha256Hex(safe(ip)), + sha256Hex(safe(userAgent)), + userId, + userId + ); + Long nextId = jdbcTemplate.queryForObject( + "SELECT id FROM auth_refresh_token WHERE token_hash=? LIMIT 1", + Long.class, + nextTokenHash + ); + jdbcTemplate.update( + "UPDATE auth_refresh_token SET status='ROTATED', rotated_to_id=?, last_used_at=CURRENT_TIMESTAMP, updated_at=CURRENT_TIMESTAMP WHERE id=?", + nextId, + currentId + ); + Map data = new LinkedHashMap(); + data.put("userId", userId); + data.put("tenantId", tenantId); + data.put("scope", scope); + data.put("refreshToken", nextRawToken); + data.put("sessionId", nextId); + return data; + } + + @Transactional(rollbackFor = Exception.class) + public void revokeCurrent(String rawToken, String reason) { + if (rawToken == null || rawToken.trim().isEmpty()) { + return; + } + String tokenHash = sha256Hex(rawToken.trim()); + jdbcTemplate.update( + "UPDATE auth_refresh_token SET status='REVOKED', revoked_at=CURRENT_TIMESTAMP, revoked_reason=?, updated_at=CURRENT_TIMESTAMP " + + "WHERE token_hash=? AND status='ACTIVE'", + safe(reason), + tokenHash + ); + } + + @Transactional(rollbackFor = Exception.class) + public void revokeAllByPrincipal(Long userId, Long tenantId, AuthScope scope, String reason) { + if (userId == null || scope == null) { + return; + } + if (scope == AuthScope.TENANT) { + jdbcTemplate.update( + "UPDATE auth_refresh_token SET status='REVOKED', revoked_at=CURRENT_TIMESTAMP, revoked_reason=?, updated_at=CURRENT_TIMESTAMP " + + "WHERE user_id=? AND tenant_id=? AND scope=? AND status='ACTIVE'", + safe(reason), + userId, + tenantId, + scope.name() + ); + return; + } + jdbcTemplate.update( + "UPDATE auth_refresh_token SET status='REVOKED', revoked_at=CURRENT_TIMESTAMP, revoked_reason=?, updated_at=CURRENT_TIMESTAMP " + + "WHERE user_id=? AND tenant_id IS NULL AND scope=? AND status='ACTIVE'", + safe(reason), + userId, + scope.name() + ); + } + + private String generateToken() { + byte[] bytes = new byte[48]; + secureRandom.nextBytes(bytes); + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + } + + private String sha256Hex(String value) { + try { + MessageDigest md = MessageDigest.getInstance("SHA-256"); + byte[] hash = md.digest(value.getBytes(StandardCharsets.UTF_8)); + StringBuilder sb = new StringBuilder(hash.length * 2); + for (byte b : hash) { + String h = Integer.toHexString(b & 0xff); + if (h.length() == 1) { + sb.append('0'); + } + sb.append(h); + } + return sb.toString(); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 unavailable", e); + } + } + + private String safe(String value) { + return value == null ? "" : value.trim(); + } + + private int toFlagInt(Object value) { + if (value == null) { + return 0; + } + if (value instanceof Number) { + return ((Number) value).intValue(); + } + if (value instanceof Boolean) { + return ((Boolean) value) ? 1 : 0; + } + String text = String.valueOf(value).trim(); + if ("true".equalsIgnoreCase(text)) { + return 1; + } + if ("false".equalsIgnoreCase(text)) { + return 0; + } + try { + return Integer.parseInt(text); + } catch (NumberFormatException ex) { + return 0; + } + } + + private LocalDateTime toLocalDateTime(Object value) { + if (value == null) { + return null; + } + if (value instanceof LocalDateTime) { + return (LocalDateTime) value; + } + if (value instanceof java.sql.Timestamp) { + return ((java.sql.Timestamp) value).toLocalDateTime(); + } + if (value instanceof java.util.Date) { + return new java.sql.Timestamp(((java.util.Date) value).getTime()).toLocalDateTime(); + } + if (value instanceof String) { + String text = String.valueOf(value).trim(); + if (text.isEmpty()) { + return null; + } + try { + return LocalDateTime.parse(text.replace(' ', 'T')); + } catch (Exception ignored) { + } + } + return null; + } + + private LocalDateTime resolveLastActivityAt(Object lastUsedAt, Object issuedAt) { + LocalDateTime lastUsed = toLocalDateTime(lastUsedAt); + if (lastUsed != null) { + return lastUsed; + } + return toLocalDateTime(issuedAt); + } +} diff --git a/backend/src/main/java/com/writeoff/module/dashboard/controller/OperationsDashboardController.java b/backend/src/main/java/com/writeoff/module/dashboard/controller/OperationsDashboardController.java new file mode 100644 index 0000000..d134309 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/dashboard/controller/OperationsDashboardController.java @@ -0,0 +1,27 @@ +package com.writeoff.module.dashboard.controller; + +import com.writeoff.common.api.ApiResponse; +import com.writeoff.module.dashboard.service.OperationsDashboardService; +import com.writeoff.security.DataScopeType; +import com.writeoff.security.RequirePermission; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Map; + +@RestController +@RequestMapping("/api/operations/dashboard") +public class OperationsDashboardController { + private final OperationsDashboardService operationsDashboardService; + + public OperationsDashboardController(OperationsDashboardService operationsDashboardService) { + this.operationsDashboardService = operationsDashboardService; + } + + @GetMapping + @RequirePermission(value = "dashboard.read", dataScope = DataScopeType.TENANT, auditAction = "OPERATIONS_DASHBOARD_SUMMARY") + public ApiResponse> summary() { + return ApiResponse.success(operationsDashboardService.summary()); + } +} diff --git a/backend/src/main/java/com/writeoff/module/dashboard/controller/TenantDashboardController.java b/backend/src/main/java/com/writeoff/module/dashboard/controller/TenantDashboardController.java new file mode 100644 index 0000000..1008463 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/dashboard/controller/TenantDashboardController.java @@ -0,0 +1,60 @@ +package com.writeoff.module.dashboard.controller; + +import com.writeoff.common.api.ApiResponse; +import com.writeoff.security.AuthContext; +import com.writeoff.security.DataScopeType; +import com.writeoff.security.RequirePermission; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * 租户工作台统计接口,聚合"活跃会议数"和"待财务确认账单数"等关键指标, + * 为 TenantDashboardPage 提供实时数据。 + */ +@RestController +@RequestMapping("/api/dashboard") +public class TenantDashboardController { + private final JdbcTemplate jdbcTemplate; + + public TenantDashboardController(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + @GetMapping("/stats") + @RequirePermission(value = "dashboard.read", dataScope = DataScopeType.TENANT, auditAction = "DASHBOARD_STATS") + public ApiResponse> stats() { + Long tenantId = AuthContext.requireTenantId(); + Map result = new LinkedHashMap<>(); + + // 活跃会议:状态为 NOT_STARTED 或 IN_PROGRESS 的会议数 + Integer activeMeetingCount = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM meeting WHERE tenant_id=? AND is_deleted=0 AND meeting_status IN ('NOT_STARTED', 'IN_PROGRESS')", + Integer.class, + tenantId + ); + result.put("activeMeetingCount", activeMeetingCount == null ? 0 : activeMeetingCount); + + // 待财务确认:finance_status 为 WAIT_FINANCE_CONFIRM 的会议数 + Integer pendingFinanceCount = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM finance_payment WHERE tenant_id=? AND is_deleted=0 AND payment_status='SUBMITTED'", + Integer.class, + tenantId + ); + result.put("pendingFinanceCount", pendingFinanceCount == null ? 0 : pendingFinanceCount); + + // 待审核任务数 + Integer pendingAuditCount = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM audit_task WHERE tenant_id=? AND is_deleted=0 AND status='PENDING'", + Integer.class, + tenantId + ); + result.put("pendingAuditCount", pendingAuditCount == null ? 0 : pendingAuditCount); + + return ApiResponse.success(result); + } +} diff --git a/backend/src/main/java/com/writeoff/module/dashboard/service/OperationsDashboardService.java b/backend/src/main/java/com/writeoff/module/dashboard/service/OperationsDashboardService.java new file mode 100644 index 0000000..686fba8 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/dashboard/service/OperationsDashboardService.java @@ -0,0 +1,116 @@ +package com.writeoff.module.dashboard.service; + +import com.writeoff.security.AuthContext; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@Service +public class OperationsDashboardService { + private final JdbcTemplate jdbcTemplate; + + public OperationsDashboardService(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + public Map summary() { + Map data = new LinkedHashMap(); + data.put("notification", notificationSummary()); + data.put("export", exportSummary()); + data.put("alert", alertSummary()); + data.put("trends", trends()); + data.put("tops", tops()); + return data; + } + + private Map notificationSummary() { + Map m = new LinkedHashMap(); + m.put("todayTotal", intVal("SELECT COUNT(1) FROM notification_task WHERE tenant_id=? AND DATE(created_at)=CURDATE()", tenantId())); + m.put("sentTotal", intVal("SELECT COUNT(1) FROM notification_task WHERE tenant_id=? AND status='SENT'", tenantId())); + m.put("deliveredTotal", intVal("SELECT COUNT(1) FROM notification_task WHERE tenant_id=? AND status='DELIVERED'", tenantId())); + m.put("failedTotal", intVal("SELECT COUNT(1) FROM notification_task WHERE tenant_id=? AND status='FAILED'", tenantId())); + m.put("avgDispatchSeconds", doubleVal( + "SELECT IFNULL(AVG(TIMESTAMPDIFF(SECOND, created_at, sent_at)),0) FROM notification_task WHERE tenant_id=? AND sent_at IS NOT NULL", + tenantId())); + return m; + } + + private Map exportSummary() { + Map m = new LinkedHashMap(); + m.put("todayTotal", intVal("SELECT COUNT(1) FROM export_task WHERE tenant_id=? AND DATE(created_at)=CURDATE()", tenantId())); + m.put("successTotal", intVal("SELECT COUNT(1) FROM export_task WHERE tenant_id=? AND status='SUCCESS'", tenantId())); + m.put("failedTotal", intVal("SELECT COUNT(1) FROM export_task WHERE tenant_id=? AND status='FAILED'", tenantId())); + m.put("avgFinishSeconds", doubleVal( + "SELECT IFNULL(AVG(TIMESTAMPDIFF(SECOND, created_at, finished_at)),0) FROM export_task WHERE tenant_id=? AND finished_at IS NOT NULL", + tenantId())); + m.put("expiring24h", intVal( + "SELECT COUNT(1) FROM export_task WHERE tenant_id=? AND status='SUCCESS' AND download_token_expire_at IS NOT NULL AND download_token_expire_at<=DATE_ADD(NOW(), INTERVAL 24 HOUR) AND download_token_expire_at>=NOW()", + tenantId())); + return m; + } + + private Map alertSummary() { + Map m = new LinkedHashMap(); + m.put("activeTotal", intVal("SELECT COUNT(1) FROM alert_event WHERE tenant_id=? AND status='ACTIVE'", tenantId())); + m.put("recoveredToday", intVal("SELECT COUNT(1) FROM alert_event WHERE tenant_id=? AND status='RECOVERED' AND DATE(recovered_at)=CURDATE()", tenantId())); + m.put("avgRecoverMinutes", doubleVal( + "SELECT IFNULL(AVG(TIMESTAMPDIFF(MINUTE, created_at, recovered_at)),0) FROM alert_event WHERE tenant_id=? AND status='RECOVERED' AND recovered_at IS NOT NULL", + tenantId())); + return m; + } + + private Map trends() { + Map data = new LinkedHashMap(); + data.put("notification7d", trendRows("SELECT DATE_FORMAT(created_at, '%Y-%m-%d') AS d, COUNT(1) AS c FROM notification_task WHERE tenant_id=? AND created_at>=DATE_SUB(CURDATE(), INTERVAL 6 DAY) GROUP BY DATE_FORMAT(created_at, '%Y-%m-%d') ORDER BY d ASC")); + data.put("export7d", trendRows("SELECT DATE_FORMAT(created_at, '%Y-%m-%d') AS d, COUNT(1) AS c FROM export_task WHERE tenant_id=? AND created_at>=DATE_SUB(CURDATE(), INTERVAL 6 DAY) GROUP BY DATE_FORMAT(created_at, '%Y-%m-%d') ORDER BY d ASC")); + data.put("alert7d", trendRows("SELECT DATE_FORMAT(created_at, '%Y-%m-%d') AS d, COUNT(1) AS c FROM alert_event WHERE tenant_id=? AND created_at>=DATE_SUB(CURDATE(), INTERVAL 6 DAY) GROUP BY DATE_FORMAT(created_at, '%Y-%m-%d') ORDER BY d ASC")); + return data; + } + + private Map tops() { + Map data = new LinkedHashMap(); + data.put("notificationFailedChannels", jdbcTemplate.queryForList( + "SELECT channel, COUNT(1) AS cnt FROM notification_task WHERE tenant_id=? AND status='FAILED' GROUP BY channel ORDER BY cnt DESC LIMIT 5", + tenantId() + )); + data.put("exportBizTypeTop", jdbcTemplate.queryForList( + "SELECT biz_type, COUNT(1) AS cnt FROM export_task WHERE tenant_id=? GROUP BY biz_type ORDER BY cnt DESC LIMIT 5", + tenantId() + )); + data.put("alertRuleTop", jdbcTemplate.queryForList( + "SELECT rule_code, COUNT(1) AS cnt FROM alert_event WHERE tenant_id=? GROUP BY rule_code ORDER BY cnt DESC LIMIT 5", + tenantId() + )); + return data; + } + + private List> trendRows(String sql) { + List> rows = jdbcTemplate.queryForList(sql, tenantId()); + List> out = new ArrayList>(); + for (Map row : rows) { + Map item = new LinkedHashMap(); + item.put("date", row.get("d")); + item.put("count", row.get("c")); + out.add(item); + } + return out; + } + + private int intVal(String sql, Object... args) { + Integer v = jdbcTemplate.queryForObject(sql, Integer.class, args); + return v == null ? 0 : v; + } + + private double doubleVal(String sql, Object... args) { + Double v = jdbcTemplate.queryForObject(sql, Double.class, args); + return v == null ? 0D : v; + } + + private Long tenantId() { + return AuthContext.requireTenantId(); + } +} diff --git a/backend/src/main/java/com/writeoff/module/expert/controller/ExpertController.java b/backend/src/main/java/com/writeoff/module/expert/controller/ExpertController.java new file mode 100644 index 0000000..581b298 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/expert/controller/ExpertController.java @@ -0,0 +1,105 @@ +package com.writeoff.module.expert.controller; + +import com.writeoff.common.api.ApiResponse; +import com.writeoff.common.api.PageResult; +import com.writeoff.common.model.ImportResult; +import com.writeoff.module.expert.dto.AddBankCardRequest; +import com.writeoff.module.expert.dto.CreateExpertRequest; +import com.writeoff.module.expert.dto.ExpertAssetUploadSignRequest; +import com.writeoff.module.expert.dto.ImportExpertsRequest; +import com.writeoff.module.expert.dto.MergeExpertRequest; +import com.writeoff.module.expert.model.ExpertBankCardInfo; +import com.writeoff.module.expert.model.ExpertInfo; +import com.writeoff.module.expert.service.ExpertService; +import com.writeoff.security.DataScopeType; +import com.writeoff.security.RequirePermission; +import org.springframework.web.bind.annotation.*; + +import javax.validation.Valid; +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/experts") +public class ExpertController { + private final ExpertService expertService; + + public ExpertController(ExpertService expertService) { + this.expertService = expertService; + } + + @GetMapping + @RequirePermission(value = "expert.read", dataScope = DataScopeType.TENANT, auditAction = "EXPERT_LIST") + public ApiResponse> list( + @RequestParam(value = "keyword", required = false) String keyword, + @RequestParam(value = "pageNo", defaultValue = "1") int pageNo, + @RequestParam(value = "pageSize", defaultValue = "20") int pageSize) { + return ApiResponse.success(expertService.list(keyword, pageNo, pageSize)); + } + + @GetMapping("/{id}") + @RequirePermission(value = "expert.read", dataScope = DataScopeType.TENANT, auditAction = "EXPERT_DETAIL") + public ApiResponse get(@PathVariable("id") Long id) { + return ApiResponse.success(expertService.get(id)); + } + + @PostMapping + @RequirePermission(value = "expert.create", dataScope = DataScopeType.TENANT, auditAction = "EXPERT_CREATE") + public ApiResponse create(@RequestBody @Valid CreateExpertRequest request) { + return ApiResponse.success(expertService.create(request)); + } + + @PostMapping("/upload-sign") + @RequirePermission(value = "expert.create", dataScope = DataScopeType.TENANT, auditAction = "EXPERT_UPLOAD_SIGN") + public ApiResponse> uploadSign(@RequestBody @Valid ExpertAssetUploadSignRequest request) { + return ApiResponse.success(expertService.presignAssetUpload(request.getFileName(), request.getContentType())); + } + + @PutMapping("/{id}") + @RequirePermission(value = "expert.create", dataScope = DataScopeType.TENANT, auditAction = "EXPERT_UPDATE") + public ApiResponse update(@PathVariable("id") Long id, @RequestBody @Valid CreateExpertRequest request) { + return ApiResponse.success(expertService.update(id, request)); + } + + @PostMapping("/import") + @RequirePermission(value = "expert.import", dataScope = DataScopeType.TENANT, auditAction = "EXPERT_IMPORT") + public ApiResponse importExperts(@RequestBody @Valid ImportExpertsRequest request) { + return ApiResponse.success(expertService.importExperts(request.getExperts())); + } + + @GetMapping("/export") + @RequirePermission(value = "expert.export", dataScope = DataScopeType.TENANT, auditAction = "EXPERT_EXPORT") + public ApiResponse> exportExperts() { + return ApiResponse.success(expertService.export()); + } + + @PostMapping("/{id}/merge") + @RequirePermission(value = "expert.merge", dataScope = DataScopeType.TENANT, auditAction = "EXPERT_MERGE") + public ApiResponse> merge(@PathVariable("id") Long id, @RequestBody @Valid MergeExpertRequest request) { + return ApiResponse.success(expertService.merge(id, request)); + } + + @GetMapping("/{id}/bank-cards") + @RequirePermission(value = "expert.read", dataScope = DataScopeType.TENANT, auditAction = "EXPERT_CARD_LIST") + public ApiResponse> cards(@PathVariable("id") Long id) { + return ApiResponse.success(expertService.listCards(id)); + } + + @GetMapping("/{id}/bank-cards/{cardId}") + @RequirePermission(value = "expert.read", dataScope = DataScopeType.TENANT, auditAction = "EXPERT_CARD_DETAIL") + public ApiResponse getCard(@PathVariable("id") Long id, @PathVariable("cardId") Long cardId) { + return ApiResponse.success(expertService.getCard(id, cardId)); + } + + @PostMapping("/{id}/bank-cards") + @RequirePermission(value = "expert.card.manage", dataScope = DataScopeType.TENANT, auditAction = "EXPERT_CARD_CREATE") + public ApiResponse addCard(@PathVariable("id") Long id, @RequestBody @Valid AddBankCardRequest request) { + return ApiResponse.success(expertService.addCard(id, request)); + } + + @PutMapping("/{id}/bank-cards/{cardId}") + @RequirePermission(value = "expert.card.manage", dataScope = DataScopeType.TENANT, auditAction = "EXPERT_CARD_UPDATE") + public ApiResponse updateCard(@PathVariable("id") Long id, @PathVariable("cardId") Long cardId, @RequestBody @Valid AddBankCardRequest request) { + return ApiResponse.success(expertService.updateCard(id, cardId, request)); + } +} diff --git a/backend/src/main/java/com/writeoff/module/expert/controller/PlatformExpertController.java b/backend/src/main/java/com/writeoff/module/expert/controller/PlatformExpertController.java new file mode 100644 index 0000000..4ca1dc2 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/expert/controller/PlatformExpertController.java @@ -0,0 +1,103 @@ +package com.writeoff.module.expert.controller; + +import com.writeoff.common.api.ApiResponse; +import com.writeoff.common.api.PageResult; +import com.writeoff.common.model.ImportResult; +import com.writeoff.module.expert.dto.AddBankCardRequest; +import com.writeoff.module.expert.dto.CreateExpertRequest; +import com.writeoff.module.expert.dto.ExpertAssetUploadSignRequest; +import com.writeoff.module.expert.dto.ImportExpertsRequest; +import com.writeoff.module.expert.dto.MergeExpertRequest; +import com.writeoff.module.expert.model.ExpertBankCardInfo; +import com.writeoff.module.expert.model.ExpertInfo; +import com.writeoff.module.expert.service.PlatformExpertService; +import com.writeoff.security.DataScopeType; +import com.writeoff.security.PermissionDomain; +import com.writeoff.security.RequirePermission; +import org.springframework.web.bind.annotation.*; + +import javax.validation.Valid; +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/platform/experts") +public class PlatformExpertController { + private final PlatformExpertService platformExpertService; + + public PlatformExpertController(PlatformExpertService platformExpertService) { + this.platformExpertService = platformExpertService; + } + + @GetMapping + @RequirePermission(value = "platform.expert.read", domain = PermissionDomain.PLATFORM, dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "PLATFORM_EXPERT_LIST") + public ApiResponse> list(@RequestParam(value = "keyword", required = false) String keyword) { + return ApiResponse.success(platformExpertService.list(keyword)); + } + + @GetMapping("/{id}") + @RequirePermission(value = "platform.expert.read", domain = PermissionDomain.PLATFORM, dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "PLATFORM_EXPERT_DETAIL") + public ApiResponse get(@PathVariable("id") Long id) { + return ApiResponse.success(platformExpertService.get(id)); + } + + @PostMapping + @RequirePermission(value = "platform.expert.manage", domain = PermissionDomain.PLATFORM, dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "PLATFORM_EXPERT_CREATE") + public ApiResponse create(@RequestBody @Valid CreateExpertRequest request) { + return ApiResponse.success(platformExpertService.create(request)); + } + + @PostMapping("/upload-sign") + @RequirePermission(value = "platform.expert.manage", domain = PermissionDomain.PLATFORM, dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "PLATFORM_EXPERT_UPLOAD_SIGN") + public ApiResponse> uploadSign(@RequestBody @Valid ExpertAssetUploadSignRequest request) { + return ApiResponse.success(platformExpertService.presignAssetUpload(request.getFileName(), request.getContentType())); + } + + @PutMapping("/{id}") + @RequirePermission(value = "platform.expert.manage", domain = PermissionDomain.PLATFORM, dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "PLATFORM_EXPERT_UPDATE") + public ApiResponse update(@PathVariable("id") Long id, @RequestBody @Valid CreateExpertRequest request) { + return ApiResponse.success(platformExpertService.update(id, request)); + } + + @PostMapping("/import") + @RequirePermission(value = "platform.expert.manage", domain = PermissionDomain.PLATFORM, dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "PLATFORM_EXPERT_IMPORT") + public ApiResponse importExperts(@RequestBody @Valid ImportExpertsRequest request) { + return ApiResponse.success(platformExpertService.importExperts(request.getExperts())); + } + + @GetMapping("/export") + @RequirePermission(value = "platform.expert.read", domain = PermissionDomain.PLATFORM, dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "PLATFORM_EXPERT_EXPORT") + public ApiResponse> exportExperts() { + return ApiResponse.success(platformExpertService.export()); + } + + @PostMapping("/{id}/merge") + @RequirePermission(value = "platform.expert.manage", domain = PermissionDomain.PLATFORM, dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "PLATFORM_EXPERT_MERGE") + public ApiResponse> merge(@PathVariable("id") Long id, @RequestBody @Valid MergeExpertRequest request) { + return ApiResponse.success(platformExpertService.merge(id, request)); + } + + @GetMapping("/{id}/bank-cards") + @RequirePermission(value = "platform.expert.read", domain = PermissionDomain.PLATFORM, dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "PLATFORM_EXPERT_CARD_LIST") + public ApiResponse> cards(@PathVariable("id") Long id) { + return ApiResponse.success(platformExpertService.listCards(id)); + } + + @GetMapping("/{id}/bank-cards/{cardId}") + @RequirePermission(value = "platform.expert.read", domain = PermissionDomain.PLATFORM, dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "PLATFORM_EXPERT_CARD_DETAIL") + public ApiResponse getCard(@PathVariable("id") Long id, @PathVariable("cardId") Long cardId) { + return ApiResponse.success(platformExpertService.getCard(id, cardId)); + } + + @PostMapping("/{id}/bank-cards") + @RequirePermission(value = "platform.expert.manage", domain = PermissionDomain.PLATFORM, dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "PLATFORM_EXPERT_CARD_CREATE") + public ApiResponse addCard(@PathVariable("id") Long id, @RequestBody @Valid AddBankCardRequest request) { + return ApiResponse.success(platformExpertService.addCard(id, request)); + } + + @PutMapping("/{id}/bank-cards/{cardId}") + @RequirePermission(value = "platform.expert.manage", domain = PermissionDomain.PLATFORM, dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "PLATFORM_EXPERT_CARD_UPDATE") + public ApiResponse updateCard(@PathVariable("id") Long id, @PathVariable("cardId") Long cardId, @RequestBody @Valid AddBankCardRequest request) { + return ApiResponse.success(platformExpertService.updateCard(id, cardId, request)); + } +} diff --git a/backend/src/main/java/com/writeoff/module/expert/dto/AddBankCardRequest.java b/backend/src/main/java/com/writeoff/module/expert/dto/AddBankCardRequest.java new file mode 100644 index 0000000..c07d11b --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/expert/dto/AddBankCardRequest.java @@ -0,0 +1,117 @@ +package com.writeoff.module.expert.dto; + +import javax.validation.constraints.NotBlank; + +public class AddBankCardRequest { + @NotBlank(message = "开户行不能为空") + private String bankName; + private String bankProvince; + private String bankCity; + private String bankBranchName; + @NotBlank(message = "银行卡号不能为空") + private String bankCardNo; + private String bankCardFrontOssKey; + private String bankCardBackOssKey; + @NotBlank(message = "账户名不能为空") + private String accountName; + private Boolean isDefault; + private String cardStatus; + private Boolean inconsistentNameApproved; + private String changeReason; + + public String getBankName() { + return bankName; + } + + public void setBankName(String bankName) { + this.bankName = bankName; + } + + public String getBankCardNo() { + return bankCardNo; + } + + public void setBankCardNo(String bankCardNo) { + this.bankCardNo = bankCardNo; + } + + public String getBankCardFrontOssKey() { + return bankCardFrontOssKey; + } + + public void setBankCardFrontOssKey(String bankCardFrontOssKey) { + this.bankCardFrontOssKey = bankCardFrontOssKey; + } + + public String getBankCardBackOssKey() { + return bankCardBackOssKey; + } + + public void setBankCardBackOssKey(String bankCardBackOssKey) { + this.bankCardBackOssKey = bankCardBackOssKey; + } + + public String getBankProvince() { + return bankProvince; + } + + public void setBankProvince(String bankProvince) { + this.bankProvince = bankProvince; + } + + public String getBankCity() { + return bankCity; + } + + public void setBankCity(String bankCity) { + this.bankCity = bankCity; + } + + public String getBankBranchName() { + return bankBranchName; + } + + public void setBankBranchName(String bankBranchName) { + this.bankBranchName = bankBranchName; + } + + public String getAccountName() { + return accountName; + } + + public void setAccountName(String accountName) { + this.accountName = accountName; + } + + public Boolean getIsDefault() { + return isDefault; + } + + public void setIsDefault(Boolean isDefault) { + this.isDefault = isDefault; + } + + public String getCardStatus() { + return cardStatus; + } + + public void setCardStatus(String cardStatus) { + this.cardStatus = cardStatus; + } + + public Boolean getInconsistentNameApproved() { + return inconsistentNameApproved; + } + + public void setInconsistentNameApproved(Boolean inconsistentNameApproved) { + this.inconsistentNameApproved = inconsistentNameApproved; + } + + public String getChangeReason() { + return changeReason; + } + + public void setChangeReason(String changeReason) { + this.changeReason = changeReason; + } +} diff --git a/backend/src/main/java/com/writeoff/module/expert/dto/CreateExpertRequest.java b/backend/src/main/java/com/writeoff/module/expert/dto/CreateExpertRequest.java new file mode 100644 index 0000000..cfc50c0 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/expert/dto/CreateExpertRequest.java @@ -0,0 +1,153 @@ +package com.writeoff.module.expert.dto; + +import javax.validation.constraints.NotBlank; + +public class CreateExpertRequest { + @NotBlank(message = "专家姓名不能为空") + private String expertName; + private String gender; + private String birthday; + @NotBlank(message = "身份证号不能为空") + private String idNo; + private String idCardValidUntil; + @NotBlank(message = "手机号不能为空") + private String phone; + /** + * 职称字典编码,来自平台字典 EXPERT_TITLE。 + */ + private String titleCode; + /** + * 职称名称快照,兼容历史入参。 + */ + private String title; + /** + * 医院字典编码,来自平台字典 EXPERT_HOSPITAL。 + */ + private String hospitalCode; + /** + * 医院名称快照,兼容历史入参。 + */ + private String organization; + /** + * 身份证正面图片 OSS Key。 + */ + private String idCardFrontOssKey; + /** + * 身份证反面图片 OSS Key。 + */ + private String idCardBackOssKey; + private String statusReason; + private Boolean exportRestricted; + + public String getExpertName() { + return expertName; + } + + public void setExpertName(String expertName) { + this.expertName = expertName; + } + + public String getIdNo() { + return idNo; + } + + public void setIdNo(String idNo) { + this.idNo = idNo; + } + + public String getGender() { + return gender; + } + + public void setGender(String gender) { + this.gender = gender; + } + + public String getBirthday() { + return birthday; + } + + public void setBirthday(String birthday) { + this.birthday = birthday; + } + + public String getIdCardValidUntil() { + return idCardValidUntil; + } + + public void setIdCardValidUntil(String idCardValidUntil) { + this.idCardValidUntil = idCardValidUntil; + } + + public String getPhone() { + return phone; + } + + public void setPhone(String phone) { + this.phone = phone; + } + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } + + public String getTitleCode() { + return titleCode; + } + + public void setTitleCode(String titleCode) { + this.titleCode = titleCode; + } + + public String getOrganization() { + return organization; + } + + public void setOrganization(String organization) { + this.organization = organization; + } + + public String getHospitalCode() { + return hospitalCode; + } + + public void setHospitalCode(String hospitalCode) { + this.hospitalCode = hospitalCode; + } + + public String getIdCardFrontOssKey() { + return idCardFrontOssKey; + } + + public void setIdCardFrontOssKey(String idCardFrontOssKey) { + this.idCardFrontOssKey = idCardFrontOssKey; + } + + public String getIdCardBackOssKey() { + return idCardBackOssKey; + } + + public void setIdCardBackOssKey(String idCardBackOssKey) { + this.idCardBackOssKey = idCardBackOssKey; + } + + public String getStatusReason() { + return statusReason; + } + + public void setStatusReason(String statusReason) { + this.statusReason = statusReason; + } + + public Boolean getExportRestricted() { + return exportRestricted; + } + + public void setExportRestricted(Boolean exportRestricted) { + this.exportRestricted = exportRestricted; + } +} diff --git a/backend/src/main/java/com/writeoff/module/expert/dto/ExpertAssetUploadSignRequest.java b/backend/src/main/java/com/writeoff/module/expert/dto/ExpertAssetUploadSignRequest.java new file mode 100644 index 0000000..0ae5bf8 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/expert/dto/ExpertAssetUploadSignRequest.java @@ -0,0 +1,26 @@ +package com.writeoff.module.expert.dto; + +import javax.validation.constraints.NotBlank; + +public class ExpertAssetUploadSignRequest { + @NotBlank(message = "文件名不能为空") + private String fileName; + + private String contentType; + + public String getFileName() { + return fileName; + } + + public void setFileName(String fileName) { + this.fileName = fileName; + } + + public String getContentType() { + return contentType; + } + + public void setContentType(String contentType) { + this.contentType = contentType; + } +} diff --git a/backend/src/main/java/com/writeoff/module/expert/dto/ImportExpertsRequest.java b/backend/src/main/java/com/writeoff/module/expert/dto/ImportExpertsRequest.java new file mode 100644 index 0000000..27d90cd --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/expert/dto/ImportExpertsRequest.java @@ -0,0 +1,19 @@ +package com.writeoff.module.expert.dto; + +import javax.validation.Valid; +import javax.validation.constraints.NotEmpty; +import java.util.List; + +public class ImportExpertsRequest { + @Valid + @NotEmpty(message = "导入列表不能为空") + private List experts; + + public List getExperts() { + return experts; + } + + public void setExperts(List experts) { + this.experts = experts; + } +} diff --git a/backend/src/main/java/com/writeoff/module/expert/dto/MergeExpertRequest.java b/backend/src/main/java/com/writeoff/module/expert/dto/MergeExpertRequest.java new file mode 100644 index 0000000..9509017 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/expert/dto/MergeExpertRequest.java @@ -0,0 +1,27 @@ +package com.writeoff.module.expert.dto; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; + +public class MergeExpertRequest { + @NotNull(message = "来源专家ID不能为空") + private Long sourceExpertId; + @NotBlank(message = "合并原因不能为空") + private String reason; + + public Long getSourceExpertId() { + return sourceExpertId; + } + + public void setSourceExpertId(Long sourceExpertId) { + this.sourceExpertId = sourceExpertId; + } + + public String getReason() { + return reason; + } + + public void setReason(String reason) { + this.reason = reason; + } +} diff --git a/backend/src/main/java/com/writeoff/module/expert/model/ExpertBankCardInfo.java b/backend/src/main/java/com/writeoff/module/expert/model/ExpertBankCardInfo.java new file mode 100644 index 0000000..1035105 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/expert/model/ExpertBankCardInfo.java @@ -0,0 +1,91 @@ +package com.writeoff.module.expert.model; + +public class ExpertBankCardInfo { + private Long id; + private Long expertId; + private String bankName; + private String bankProvince; + private String bankCity; + private String bankBranchName; + private String bankCardNo; + private String bankCardFrontOssKey; + private String bankCardBackOssKey; + private String accountName; + private String isDefault; + private String cardStatus; + private Boolean inconsistentNameApproved; + private String changeReason; + + public ExpertBankCardInfo(Long id, Long expertId, String bankName, String bankProvince, String bankCity, String bankBranchName, String bankCardNo, String bankCardFrontOssKey, String bankCardBackOssKey, String accountName, String isDefault, String cardStatus, Boolean inconsistentNameApproved, String changeReason) { + this.id = id; + this.expertId = expertId; + this.bankName = bankName; + this.bankProvince = bankProvince; + this.bankCity = bankCity; + this.bankBranchName = bankBranchName; + this.bankCardNo = bankCardNo; + this.bankCardFrontOssKey = bankCardFrontOssKey; + this.bankCardBackOssKey = bankCardBackOssKey; + this.accountName = accountName; + this.isDefault = isDefault; + this.cardStatus = cardStatus; + this.inconsistentNameApproved = inconsistentNameApproved; + this.changeReason = changeReason; + } + + public Long getId() { + return id; + } + + public Long getExpertId() { + return expertId; + } + + public String getBankName() { + return bankName; + } + + public String getBankProvince() { + return bankProvince; + } + + public String getBankCity() { + return bankCity; + } + + public String getBankBranchName() { + return bankBranchName; + } + + public String getBankCardNo() { + return bankCardNo; + } + + public String getBankCardFrontOssKey() { + return bankCardFrontOssKey; + } + + public String getBankCardBackOssKey() { + return bankCardBackOssKey; + } + + public String getAccountName() { + return accountName; + } + + public String getIsDefault() { + return isDefault; + } + + public String getCardStatus() { + return cardStatus; + } + + public Boolean getInconsistentNameApproved() { + return inconsistentNameApproved; + } + + public String getChangeReason() { + return changeReason; + } +} diff --git a/backend/src/main/java/com/writeoff/module/expert/model/ExpertInfo.java b/backend/src/main/java/com/writeoff/module/expert/model/ExpertInfo.java new file mode 100644 index 0000000..1365780 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/expert/model/ExpertInfo.java @@ -0,0 +1,119 @@ +package com.writeoff.module.expert.model; + +public class ExpertInfo { + /** 专家主键ID。 */ + private Long id; + /** 专家姓名。 */ + private String expertName; + private String gender; + private String birthday; + /** 身份证号(敏感字段)。 */ + private String idNo; + private String idCardValidUntil; + /** 身份证正面图片 OSS Key。 */ + private String idCardFrontOssKey; + /** 身份证反面图片 OSS Key。 */ + private String idCardBackOssKey; + /** 手机号(敏感字段)。 */ + private String phone; + /** 职称字典编码。 */ + private String titleCode; + /** 职称名称快照。 */ + private String title; + /** 医院字典编码。 */ + private String hospitalCode; + /** 医院名称快照(历史字段 organization)。 */ + private String organization; + private String status; + private String statusReason; + private String statusChangedAt; + private Boolean exportRestricted; + + public ExpertInfo(Long id, String expertName, String gender, String birthday, String idNo, String idCardValidUntil, String idCardFrontOssKey, String idCardBackOssKey, String phone, String titleCode, String title, String hospitalCode, String organization, String status, String statusReason, String statusChangedAt, Boolean exportRestricted) { + this.id = id; + this.expertName = expertName; + this.gender = gender; + this.birthday = birthday; + this.idNo = idNo; + this.idCardValidUntil = idCardValidUntil; + this.idCardFrontOssKey = idCardFrontOssKey; + this.idCardBackOssKey = idCardBackOssKey; + this.phone = phone; + this.titleCode = titleCode; + this.title = title; + this.hospitalCode = hospitalCode; + this.organization = organization; + this.status = status; + this.statusReason = statusReason; + this.statusChangedAt = statusChangedAt; + this.exportRestricted = exportRestricted; + } + + public Long getId() { + return id; + } + + public String getExpertName() { + return expertName; + } + + public String getGender() { + return gender; + } + + public String getBirthday() { + return birthday; + } + + public String getIdNo() { + return idNo; + } + + public String getIdCardValidUntil() { + return idCardValidUntil; + } + + public String getIdCardFrontOssKey() { + return idCardFrontOssKey; + } + + public String getIdCardBackOssKey() { + return idCardBackOssKey; + } + + public String getPhone() { + return phone; + } + + public String getTitle() { + return title; + } + + public String getTitleCode() { + return titleCode; + } + + public String getOrganization() { + return organization; + } + + public String getHospitalCode() { + return hospitalCode; + } + + public String getStatus() { + return status; + } + + public String getStatusReason() { + return statusReason; + } + + public String getStatusChangedAt() { + return statusChangedAt; + } + + public Boolean getExportRestricted() { + return exportRestricted; + } +} diff --git a/backend/src/main/java/com/writeoff/module/expert/service/ExpertService.java b/backend/src/main/java/com/writeoff/module/expert/service/ExpertService.java new file mode 100644 index 0000000..8468a64 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/expert/service/ExpertService.java @@ -0,0 +1,682 @@ +package com.writeoff.module.expert.service; + +import com.writeoff.common.api.PageResult; +import com.writeoff.common.exception.BusinessException; +import com.writeoff.common.model.ImportResult; +import com.writeoff.common.util.ImportValidationUtils; +import com.writeoff.module.expert.dto.AddBankCardRequest; +import com.writeoff.module.expert.dto.CreateExpertRequest; +import com.writeoff.module.expert.dto.MergeExpertRequest; +import com.writeoff.module.file.service.OssService; +import com.writeoff.module.expert.model.ExpertBankCardInfo; +import com.writeoff.module.expert.model.ExpertInfo; +import com.writeoff.module.system.service.DataPermissionService; +import com.writeoff.security.AuthContext; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.HashSet; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Collectors; + +@Service +public class ExpertService { + /** + * 专家主数据改为平台级共享,统一落在 tenant_id=0。 + */ + private static final long PLATFORM_TENANT_ID = 0L; + + private static final String DICT_TYPE_EXPERT_TITLE = "EXPERT_TITLE"; + private static final String DICT_TYPE_EXPERT_HOSPITAL = "EXPERT_HOSPITAL"; + + private final JdbcTemplate jdbcTemplate; + private final OssService ossService; + private final DataPermissionService dataPermissionService; + + private static final RowMapper EXPERT_ROW_MAPPER = (rs, n) -> new ExpertInfo( + rs.getLong("id"), + rs.getString("expert_name"), + rs.getString("gender"), + rs.getString("birthday"), + rs.getString("id_no"), + rs.getString("id_card_valid_until"), + rs.getString("id_card_front_oss_key"), + rs.getString("id_card_back_oss_key"), + rs.getString("phone"), + rs.getString("title_code"), + rs.getString("title_name"), + rs.getString("hospital_code"), + rs.getString("hospital_name"), + rs.getString("status"), + rs.getString("status_reason"), + rs.getString("status_changed_at"), + rs.getInt("export_restricted") == 1 + ); + + private static final RowMapper CARD_ROW_MAPPER = (rs, n) -> new ExpertBankCardInfo( + rs.getLong("id"), + rs.getLong("expert_id"), + rs.getString("bank_name"), + rs.getString("bank_province"), + rs.getString("bank_city"), + rs.getString("bank_branch_name"), + rs.getString("bank_card_no"), + rs.getString("bank_card_front_oss_key"), + rs.getString("bank_card_back_oss_key"), + rs.getString("account_name"), + rs.getString("is_default"), + rs.getString("card_status"), + rs.getInt("inconsistent_name_approved") == 1, + rs.getString("change_reason") + ); + + public ExpertService(JdbcTemplate jdbcTemplate, OssService ossService, DataPermissionService dataPermissionService) { + this.jdbcTemplate = jdbcTemplate; + this.ossService = ossService; + this.dataPermissionService = dataPermissionService; + } + + public Map presignAssetUpload(String fileName, String contentType) { + String name = fileName == null ? "" : fileName.trim(); + if (name.isEmpty()) { + throw new BusinessException(10001, "文件名不能为空"); + } + String ext = ""; + int idx = name.lastIndexOf('.'); + if (idx > 0 && idx < name.length() - 1) { + ext = "." + name.substring(idx + 1).toLowerCase(); + } + String normalizedType = normalizeContentType(contentType); + String objectKey = "expert/asset/" + PLATFORM_TENANT_ID + "/" + safeUserId() + "/" + UUID.randomUUID().toString().replace("-", "") + ext; + String uploadUrl = ossService.generateUploadUrl(objectKey, normalizedType); + Map data = new LinkedHashMap(); + data.put("objectKey", objectKey); + data.put("uploadUrl", uploadUrl); + data.put("contentType", normalizedType); + data.put("method", "PUT"); + return data; + } + + public PageResult list(String keyword, int pageNo, int pageSize) { + int safePage = Math.max(pageNo, 1); + int safeSize = Math.min(Math.max(pageSize, 1), 100); + int offset = (safePage - 1) * safeSize; + + StringBuilder whereClause = new StringBuilder( + "WHERE e.tenant_id=" + PLATFORM_TENANT_ID + " AND e.is_deleted=0" + ); + List countArgs = new ArrayList<>(); + if (keyword != null && !keyword.trim().isEmpty()) { + String kw = keyword.trim().replace("'", "''"); + whereClause.append(" AND (e.expert_name LIKE '%").append(kw).append("%' OR e.id_no LIKE '%").append(kw).append("%' OR e.phone LIKE '%").append(kw).append("%')"); + } + + Integer total = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM expert e " + whereClause, + Integer.class + ); + long totalCount = total == null ? 0 : total; + + StringBuilder sql = new StringBuilder( + "SELECT e.id, e.expert_name, e.gender, e.birthday, e.id_no, e.id_card_valid_until, e.id_card_front_oss_key, e.id_card_back_oss_key, e.phone, " + + "e.title_code, IFNULL(dt.dict_name, e.title) AS title_name, " + + "e.hospital_code, IFNULL(dh.dict_name, e.organization) AS hospital_name, " + + "e.status, e.status_reason, e.status_changed_at, e.export_restricted " + + "FROM expert e " + + "LEFT JOIN platform_dictionary_item dt ON dt.dict_type='EXPERT_TITLE' AND dt.dict_code=e.title_code AND dt.is_deleted=0 " + + "LEFT JOIN platform_dictionary_item dh ON dh.dict_type='EXPERT_HOSPITAL' AND dh.dict_code=e.hospital_code AND dh.is_deleted=0 " + ); + sql.append(whereClause); + sql.append(" ORDER BY e.id DESC LIMIT ").append(safeSize).append(" OFFSET ").append(offset); + List list = jdbcTemplate.query(sql.toString(), EXPERT_ROW_MAPPER); + list = filterByExpertScope(list); + List maskedList = new java.util.ArrayList(list.size()); + for (ExpertInfo item : list) { + maskedList.add(maskSensitiveFields(item)); + } + return new PageResult(maskedList, totalCount, safePage, safeSize); + } + + public ExpertInfo get(Long id) { + ExpertInfo expert = findById(id); + assertExpertAccessible(expert.getId()); + return expert; + } + + /** + * 按专家主键批量查询姓名与医院展示名(字典名优先,否则 organization),用于会议资料审核等只读展示。 + */ + public Map> mapExpertDisplayByIds(Collection expertIds) { + if (expertIds == null || expertIds.isEmpty()) { + return new LinkedHashMap<>(); + } + List idList = expertIds.stream() + .filter(Objects::nonNull) + .mapToLong(Long::longValue) + .filter(id -> id > 0) + .distinct() + .boxed() + .collect(Collectors.toList()); + if (idList.isEmpty()) { + return new LinkedHashMap<>(); + } + String placeholders = idList.stream().map(id -> "?").collect(Collectors.joining(",")); + String sql = "SELECT e.id, e.expert_name, IFNULL(dh.dict_name, e.organization) AS hospital_name " + + "FROM expert e " + + "LEFT JOIN platform_dictionary_item dh ON dh.dict_type='" + DICT_TYPE_EXPERT_HOSPITAL + "' " + + "AND dh.dict_code=e.hospital_code AND dh.is_deleted=0 " + + "WHERE e.tenant_id=? AND e.id IN (" + placeholders + ") AND e.is_deleted=0"; + List args = new ArrayList<>(); + args.add(PLATFORM_TENANT_ID); + args.addAll(idList); + return jdbcTemplate.query(sql, rs -> { + Map> m = new LinkedHashMap<>(); + while (rs.next()) { + long id = rs.getLong("id"); + Map row = new LinkedHashMap<>(); + row.put("expertId", id); + row.put("expertName", rs.getString("expert_name")); + String hospital = rs.getString("hospital_name"); + row.put("hospital", hospital != null ? hospital : ""); + m.put(id, row); + } + return m; + }, args.toArray()); + } + + @Transactional(rollbackFor = Exception.class) + public ExpertInfo create(CreateExpertRequest request) { + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM expert WHERE tenant_id=? AND id_no=? AND is_deleted=0", + Integer.class, + PLATFORM_TENANT_ID, + request.getIdNo() + ); + if (count != null && count > 0) { + throw new BusinessException(10001, "身份证号已存在"); + } + DictionaryItem titleItem = resolveDictionaryItem(DICT_TYPE_EXPERT_TITLE, request.getTitleCode(), request.getTitle(), "职称"); + DictionaryItem hospitalItem = resolveDictionaryItem(DICT_TYPE_EXPERT_HOSPITAL, request.getHospitalCode(), request.getOrganization(), "医院"); + jdbcTemplate.update( + "INSERT INTO expert (tenant_id, expert_name, gender, birthday, id_no, id_card_valid_until, id_card_front_oss_key, id_card_back_oss_key, phone, title_code, title, hospital_code, organization, status, status_reason, status_changed_by, status_changed_at, export_restricted, created_by, updated_by) " + + "VALUES (?, ?, ?, STR_TO_DATE(?, '%Y-%m-%d'), ?, STR_TO_DATE(?, '%Y-%m-%d'), ?, ?, ?, ?, ?, ?, ?, 'ENABLED', ?, ?, NOW(), ?, ?, ?)", + PLATFORM_TENANT_ID, + request.getExpertName(), + request.getGender(), + request.getBirthday(), + request.getIdNo(), + request.getIdCardValidUntil(), + request.getIdCardFrontOssKey(), + request.getIdCardBackOssKey(), + request.getPhone(), + titleItem.getDictCode(), + titleItem.getDictName(), + hospitalItem.getDictCode(), + hospitalItem.getDictName(), + request.getStatusReason(), + safeUserId(), + Boolean.TRUE.equals(request.getExportRestricted()) ? 1 : 0, + safeUserId(), + safeUserId() + ); + Long id = jdbcTemplate.queryForObject("SELECT IFNULL(MAX(id),0) FROM expert WHERE tenant_id=?", Long.class, PLATFORM_TENANT_ID); + return findById(id == null ? 0L : id); + } + + public ImportResult importExperts(List experts) { + ImportResult result = new ImportResult(); + result.setTotal(experts == null ? 0 : experts.size()); + if (experts == null) { + return result; + } + Set batchIdNos = new HashSet(); + Set batchPhones = new HashSet(); + for (int i = 0; i < experts.size(); i++) { + CreateExpertRequest item = experts.get(i); + int rowNo = i + 2; + try { + validateImportExpert(item, batchIdNos, batchPhones); + create(item); + result.markSuccess(); + } catch (Exception ex) { + result.addError(rowNo, buildExpertIdentifier(item), resolveImportMessage(ex)); + } + } + return result; + } + + @Transactional(rollbackFor = Exception.class) + public ExpertInfo update(Long id, CreateExpertRequest request) { + assertExpertExists(id); + DictionaryItem titleItem = resolveDictionaryItem(DICT_TYPE_EXPERT_TITLE, request.getTitleCode(), request.getTitle(), "职称"); + DictionaryItem hospitalItem = resolveDictionaryItem(DICT_TYPE_EXPERT_HOSPITAL, request.getHospitalCode(), request.getOrganization(), "医院"); + jdbcTemplate.update( + "UPDATE expert SET expert_name=?, gender=?, birthday=STR_TO_DATE(?, '%Y-%m-%d'), id_card_valid_until=STR_TO_DATE(?, '%Y-%m-%d'), " + + "id_card_front_oss_key=?, id_card_back_oss_key=?, phone=?, " + + "title_code=?, title=?, hospital_code=?, organization=?, status_reason=?, updated_by=?, updated_at=CURRENT_TIMESTAMP " + + "WHERE tenant_id=? AND id=? AND is_deleted=0", + request.getExpertName(), + request.getGender(), + request.getBirthday(), + request.getIdCardValidUntil(), + request.getIdCardFrontOssKey(), + request.getIdCardBackOssKey(), + request.getPhone(), + titleItem.getDictCode(), + titleItem.getDictName(), + hospitalItem.getDictCode(), + hospitalItem.getDictName(), + request.getStatusReason(), + safeUserId(), + PLATFORM_TENANT_ID, + id + ); + return findById(id); + } + + public List listCards(Long expertId) { + assertExpertExists(expertId); + assertExpertAccessible(expertId); + return jdbcTemplate.query( + "SELECT * FROM expert_bank_card WHERE tenant_id=? AND expert_id=? AND is_deleted=0 ORDER BY is_default DESC, id DESC", + CARD_ROW_MAPPER, + PLATFORM_TENANT_ID, + expertId + ); + } + + public ExpertBankCardInfo getCard(Long expertId, Long cardId) { + assertExpertExists(expertId); + assertExpertAccessible(expertId); + return findCardById(expertId, cardId); + } + + @Transactional(rollbackFor = Exception.class) + public ExpertBankCardInfo addCard(Long expertId, AddBankCardRequest request) { + assertExpertExists(expertId); + assertExpertAccessible(expertId); + if (Boolean.TRUE.equals(request.getIsDefault())) { + jdbcTemplate.update("UPDATE expert_bank_card SET is_default='N', updated_at=CURRENT_TIMESTAMP WHERE tenant_id=? AND expert_id=?", PLATFORM_TENANT_ID, expertId); + } + jdbcTemplate.update( + "INSERT INTO expert_bank_card (tenant_id, expert_id, bank_name, bank_province, bank_city, bank_branch_name, bank_card_no, bank_card_front_oss_key, bank_card_back_oss_key, account_name, is_default, card_status, inconsistent_name_approved, change_reason, created_by, updated_by) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + PLATFORM_TENANT_ID, + expertId, + request.getBankName(), + request.getBankProvince(), + request.getBankCity(), + request.getBankBranchName(), + request.getBankCardNo(), + request.getBankCardFrontOssKey(), + request.getBankCardBackOssKey(), + request.getAccountName(), + Boolean.TRUE.equals(request.getIsDefault()) ? "Y" : "N", + request.getCardStatus() == null || request.getCardStatus().trim().isEmpty() ? "ENABLED" : request.getCardStatus().trim().toUpperCase(), + Boolean.TRUE.equals(request.getInconsistentNameApproved()) ? 1 : 0, + request.getChangeReason(), + safeUserId(), + safeUserId() + ); + Long id = jdbcTemplate.queryForObject("SELECT IFNULL(MAX(id),0) FROM expert_bank_card WHERE tenant_id=? AND expert_id=?", Long.class, PLATFORM_TENANT_ID, expertId); + List list = jdbcTemplate.query( + "SELECT * FROM expert_bank_card WHERE tenant_id=? AND id=? LIMIT 1", + CARD_ROW_MAPPER, + PLATFORM_TENANT_ID, + id == null ? 0L : id + ); + if (list.isEmpty()) { + throw new BusinessException(10003, "银行卡不存在"); + } + return list.get(0); + } + + @Transactional(rollbackFor = Exception.class) + public ExpertBankCardInfo updateCard(Long expertId, Long cardId, AddBankCardRequest request) { + assertExpertExists(expertId); + assertExpertAccessible(expertId); + findCardById(expertId, cardId); + if (Boolean.TRUE.equals(request.getIsDefault())) { + jdbcTemplate.update( + "UPDATE expert_bank_card SET is_default='N', updated_at=CURRENT_TIMESTAMP WHERE tenant_id=? AND expert_id=?", + PLATFORM_TENANT_ID, + expertId + ); + } + jdbcTemplate.update( + "UPDATE expert_bank_card SET bank_name=?, bank_province=?, bank_city=?, bank_branch_name=?, bank_card_no=?, bank_card_front_oss_key=?, bank_card_back_oss_key=?, account_name=?, is_default=?, " + + "card_status=?, inconsistent_name_approved=?, change_reason=?, updated_by=?, updated_at=CURRENT_TIMESTAMP " + + "WHERE tenant_id=? AND expert_id=? AND id=? AND is_deleted=0", + request.getBankName(), + request.getBankProvince(), + request.getBankCity(), + request.getBankBranchName(), + request.getBankCardNo(), + request.getBankCardFrontOssKey(), + request.getBankCardBackOssKey(), + request.getAccountName(), + Boolean.TRUE.equals(request.getIsDefault()) ? "Y" : "N", + request.getCardStatus() == null || request.getCardStatus().trim().isEmpty() ? "ENABLED" : request.getCardStatus().trim().toUpperCase(), + Boolean.TRUE.equals(request.getInconsistentNameApproved()) ? 1 : 0, + request.getChangeReason(), + safeUserId(), + PLATFORM_TENANT_ID, + expertId, + cardId + ); + return findCardById(expertId, cardId); + } + + @Transactional(rollbackFor = Exception.class) + public Map merge(Long targetExpertId, MergeExpertRequest request) { + assertExpertExists(targetExpertId); + assertExpertExists(request.getSourceExpertId()); + assertExpertAccessible(targetExpertId); + assertExpertAccessible(request.getSourceExpertId()); + if (targetExpertId.equals(request.getSourceExpertId())) { + throw new BusinessException(10001, "来源专家不能与目标专家相同"); + } + List sourceCards = jdbcTemplate.query( + "SELECT * FROM expert_bank_card WHERE tenant_id=? AND expert_id=? AND is_deleted=0", + CARD_ROW_MAPPER, + PLATFORM_TENANT_ID, + request.getSourceExpertId() + ); + for (ExpertBankCardInfo card : sourceCards) { + Integer exists = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM expert_bank_card WHERE tenant_id=? AND expert_id=? AND bank_card_no=? AND is_deleted=0", + Integer.class, + PLATFORM_TENANT_ID, + targetExpertId, + card.getBankCardNo() + ); + if (exists == null || exists == 0) { + jdbcTemplate.update( + "INSERT INTO expert_bank_card (tenant_id, expert_id, bank_name, bank_province, bank_city, bank_branch_name, bank_card_no, bank_card_front_oss_key, bank_card_back_oss_key, account_name, is_default, card_status, inconsistent_name_approved, change_reason, created_by, updated_by) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'N', ?, ?, ?, ?, ?)", + PLATFORM_TENANT_ID, + targetExpertId, + card.getBankName(), + card.getBankProvince(), + card.getBankCity(), + card.getBankBranchName(), + card.getBankCardNo(), + card.getBankCardFrontOssKey(), + card.getBankCardBackOssKey(), + card.getAccountName(), + card.getCardStatus() == null ? "ENABLED" : card.getCardStatus(), + Boolean.TRUE.equals(card.getInconsistentNameApproved()) ? 1 : 0, + card.getChangeReason(), + safeUserId(), + safeUserId() + ); + } + } + jdbcTemplate.update( + "UPDATE expert SET is_deleted=1, updated_by=?, updated_at=CURRENT_TIMESTAMP WHERE tenant_id=? AND id=?", + safeUserId(), + PLATFORM_TENANT_ID, + request.getSourceExpertId() + ); + jdbcTemplate.update( + "INSERT INTO expert_merge_log (tenant_id, target_expert_id, source_expert_id, reason, created_by) VALUES (?, ?, ?, ?, ?)", + PLATFORM_TENANT_ID, + targetExpertId, + request.getSourceExpertId(), + request.getReason(), + safeUserId() + ); + Map data = new LinkedHashMap(); + data.put("targetExpertId", targetExpertId); + data.put("sourceExpertId", request.getSourceExpertId()); + data.put("status", "MERGED"); + return data; + } + + public Map export() { + List list = list(null, 1, 10000).getList(); + Map data = new LinkedHashMap(); + data.put("total", list.size()); + data.put("records", list); + return data; + } + + private List filterByExpertScope(List source) { + DataPermissionService.DataScope scope = dataPermissionService.resolveCurrentUserScope(); + if (scope.isExpertAll()) { + return source; + } + Set expertIds = new HashSet(); + for (ExpertInfo item : source) { + if (item != null && item.getId() != null) { + expertIds.add(item.getId()); + } + } + Map creatorMap = dataPermissionService.listExpertCreators(expertIds); + List filtered = new java.util.ArrayList(); + for (ExpertInfo item : source) { + if (item == null || item.getId() == null) { + continue; + } + if (dataPermissionService.canAccessExpert(item.getId(), creatorMap.get(item.getId()), scope)) { + filtered.add(item); + } + } + return filtered; + } + + private void assertExpertAccessible(Long expertId) { + DataPermissionService.DataScope scope = dataPermissionService.resolveCurrentUserScope(); + Map creatorMap = dataPermissionService.listExpertCreators(java.util.Collections.singleton(expertId)); + if (!dataPermissionService.canAccessExpert(expertId, creatorMap.get(expertId), scope)) { + throw new BusinessException(10003, "无权访问该专家"); + } + } + + private ExpertInfo maskSensitiveFields(ExpertInfo source) { + return new ExpertInfo( + source.getId(), + source.getExpertName(), + source.getGender(), + source.getBirthday(), + maskIdNo(source.getIdNo()), + source.getIdCardValidUntil(), + source.getIdCardFrontOssKey(), + source.getIdCardBackOssKey(), + maskPhone(source.getPhone()), + source.getTitleCode(), + source.getTitle(), + source.getHospitalCode(), + source.getOrganization(), + source.getStatus(), + source.getStatusReason(), + source.getStatusChangedAt(), + source.getExportRestricted() + ); + } + + private String maskIdNo(String idNo) { + if (idNo == null || idNo.trim().isEmpty()) { + return idNo; + } + String value = idNo.trim(); + if (value.length() <= 8) { + return value; + } + return value.substring(0, 4) + "********" + value.substring(value.length() - 4); + } + + private String maskPhone(String phone) { + if (phone == null || phone.trim().isEmpty()) { + return phone; + } + String value = phone.trim(); + if (value.length() <= 7) { + return value; + } + return value.substring(0, 3) + "****" + value.substring(value.length() - 4); + } + + private ExpertInfo findById(Long id) { + List list = jdbcTemplate.query( + "SELECT e.id, e.expert_name, e.gender, e.birthday, e.id_no, e.id_card_valid_until, e.id_card_front_oss_key, e.id_card_back_oss_key, e.phone, " + + "e.title_code, IFNULL(dt.dict_name, e.title) AS title_name, " + + "e.hospital_code, IFNULL(dh.dict_name, e.organization) AS hospital_name, " + + "e.status, e.status_reason, e.status_changed_at, e.export_restricted " + + "FROM expert e " + + "LEFT JOIN platform_dictionary_item dt ON dt.dict_type='EXPERT_TITLE' AND dt.dict_code=e.title_code AND dt.is_deleted=0 " + + "LEFT JOIN platform_dictionary_item dh ON dh.dict_type='EXPERT_HOSPITAL' AND dh.dict_code=e.hospital_code AND dh.is_deleted=0 " + + "WHERE e.tenant_id=? AND e.id=? AND e.is_deleted=0", + EXPERT_ROW_MAPPER, + PLATFORM_TENANT_ID, + id + ); + if (list.isEmpty()) { + throw new BusinessException(10003, "专家不存在"); + } + return list.get(0); + } + + private void assertExpertExists(Long expertId) { + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM expert WHERE tenant_id=? AND id=? AND is_deleted=0", + Integer.class, + PLATFORM_TENANT_ID, + expertId + ); + if (count == null || count == 0) { + throw new BusinessException(10003, "专家不存在"); + } + } + + private ExpertBankCardInfo findCardById(Long expertId, Long cardId) { + List list = jdbcTemplate.query( + "SELECT * FROM expert_bank_card WHERE tenant_id=? AND expert_id=? AND id=? AND is_deleted=0 LIMIT 1", + CARD_ROW_MAPPER, + PLATFORM_TENANT_ID, + expertId, + cardId + ); + if (list.isEmpty()) { + throw new BusinessException(10003, "银行卡不存在"); + } + return list.get(0); + } + + private Long safeUserId() { + Long userId = AuthContext.userId(); + return userId == null ? 0L : userId; + } + + private String normalizeContentType(String contentType) { + if (contentType == null || contentType.trim().isEmpty()) { + return "application/octet-stream"; + } + return contentType.trim(); + } + + /** + * 解析并校验平台字典项: + * 1) 优先按编码命中; + * 2) 兼容历史按名称命中; + * 3) 仅允许启用状态字典项。 + */ + private DictionaryItem resolveDictionaryItem(String dictType, String dictCode, String dictName, String label) { + String code = dictCode == null ? null : dictCode.trim(); + if (code != null && !code.isEmpty()) { + List byCode = jdbcTemplate.query( + "SELECT dict_code, dict_name FROM platform_dictionary_item " + + "WHERE dict_type=? AND dict_code=? AND status='ENABLED' AND is_deleted=0 LIMIT 1", + (rs, n) -> new DictionaryItem(rs.getString("dict_code"), rs.getString("dict_name")), + dictType, + code + ); + if (byCode.isEmpty()) { + throw new BusinessException(10001, label + "字典编码不存在或未启用"); + } + return byCode.get(0); + } + String name = dictName == null ? null : dictName.trim(); + if (name == null || name.isEmpty()) { + throw new BusinessException(10001, label + "不能为空"); + } + List byName = jdbcTemplate.query( + "SELECT dict_code, dict_name FROM platform_dictionary_item " + + "WHERE dict_type=? AND dict_name=? AND status='ENABLED' AND is_deleted=0 LIMIT 1", + (rs, n) -> new DictionaryItem(rs.getString("dict_code"), rs.getString("dict_name")), + dictType, + name + ); + if (byName.isEmpty()) { + throw new BusinessException(10001, label + "不在平台字典内,请先在平台字典中维护"); + } + return byName.get(0); + } + + private void validateImportExpert(CreateExpertRequest request, Set batchIdNos, Set batchPhones) { + if (request == null) { + throw new BusinessException(10001, "导入行不能为空"); + } + if (request.getExpertName() == null || request.getExpertName().trim().isEmpty()) { + throw new BusinessException(10001, "专家姓名不能为空"); + } + ImportValidationUtils.validateIdNo(request.getIdNo()); + ImportValidationUtils.validatePhone(request.getPhone()); + String idNo = ImportValidationUtils.trim(request.getIdNo()).toUpperCase(); + String phone = ImportValidationUtils.trim(request.getPhone()); + if (!batchIdNos.add(idNo)) { + throw new BusinessException(10001, "批次内身份证号重复"); + } + if (!batchPhones.add(phone)) { + throw new BusinessException(10001, "批次内手机号重复"); + } + } + + private String buildExpertIdentifier(CreateExpertRequest request) { + if (request == null) { + return ""; + } + String name = request.getExpertName() == null ? "" : request.getExpertName().trim(); + String idNo = request.getIdNo() == null ? "" : request.getIdNo().trim(); + if (!name.isEmpty() && !idNo.isEmpty()) { + return name + "/" + idNo; + } + return !name.isEmpty() ? name : idNo; + } + + private String resolveImportMessage(Exception ex) { + if (ex instanceof BusinessException) { + return ex.getMessage(); + } + return ex.getMessage() == null || ex.getMessage().trim().isEmpty() + ? "导入失败" + : ex.getMessage(); + } + + private static final class DictionaryItem { + private final String dictCode; + private final String dictName; + + private DictionaryItem(String dictCode, String dictName) { + this.dictCode = dictCode; + this.dictName = dictName; + } + + private String getDictCode() { + return dictCode; + } + + private String getDictName() { + return dictName; + } + } +} diff --git a/backend/src/main/java/com/writeoff/module/expert/service/ExpertSnapshotService.java b/backend/src/main/java/com/writeoff/module/expert/service/ExpertSnapshotService.java new file mode 100644 index 0000000..dd55209 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/expert/service/ExpertSnapshotService.java @@ -0,0 +1,131 @@ +package com.writeoff.module.expert.service; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.writeoff.security.AuthContext; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@Service +public class ExpertSnapshotService { + private final JdbcTemplate jdbcTemplate; + private final ObjectMapper objectMapper = new ObjectMapper(); + + public ExpertSnapshotService(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + @Transactional(rollbackFor = Exception.class) + public int snapshotOnMeetingSubmit(Long meetingId) { + List list = jdbcTemplate.queryForList( + "SELECT content_json FROM meeting_material WHERE tenant_id=? AND meeting_id=? AND module_code='EXPERT_PROFILE' AND is_deleted=0 LIMIT 1", + String.class, + tenantId(), + meetingId + ); + if (list.isEmpty() || list.get(0) == null || list.get(0).trim().isEmpty()) { + return 0; + } + List> profiles = new java.util.ArrayList>(); + try { + Map root = objectMapper.readValue(list.get(0), new TypeReference>() {}); + Object profileObj = root.get("profiles"); + if (!(profileObj instanceof List)) { + return 0; + } + List rawProfiles = (List) profileObj; + for (Object item : rawProfiles) { + if (item instanceof Map) { + Map profile = new LinkedHashMap(); + Map rawMap = (Map) item; + for (Map.Entry entry : rawMap.entrySet()) { + profile.put(String.valueOf(entry.getKey()), entry.getValue()); + } + profiles.add(profile); + } + } + } catch (Exception ex) { + return 0; + } + + jdbcTemplate.update( + "DELETE FROM meeting_expert_snapshot WHERE tenant_id=? AND meeting_id=?", + tenantId(), + meetingId + ); + + int count = 0; + for (Map profile : profiles) { + Long expertId = resolveExpertId(profile); + if (expertId == null) { + continue; + } + String snapshotJson; + try { + Map snapshot = new LinkedHashMap(profile); + snapshot.put("expertId", expertId); + snapshotJson = objectMapper.writeValueAsString(snapshot); + } catch (Exception ex) { + continue; + } + jdbcTemplate.update( + "INSERT INTO meeting_expert_snapshot (tenant_id, meeting_id, expert_id, snapshot_json, created_by) VALUES (?, ?, ?, ?, ?)", + tenantId(), + meetingId, + expertId, + snapshotJson, + safeUserId() + ); + count++; + } + return count; + } + + private Long resolveExpertId(Map profile) { + Object rawId = profile.get("expertId"); + if (rawId != null && String.valueOf(rawId).trim().matches("\\d+")) { + Long expertId = Long.valueOf(String.valueOf(rawId).trim()); + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM expert WHERE tenant_id=? AND id=? AND is_deleted=0", + Integer.class, + tenantId(), + expertId + ); + if (count != null && count > 0) { + return expertId; + } + } + String expertName = value(profile.get("expertName")); + String organization = value(profile.get("organization")); + if (expertName.isEmpty()) { + return null; + } + List ids = jdbcTemplate.queryForList( + "SELECT id FROM expert WHERE tenant_id=? AND expert_name=? AND (?='' OR organization=?) AND is_deleted=0 ORDER BY id DESC LIMIT 1", + Long.class, + tenantId(), + expertName, + organization, + organization + ); + return ids.isEmpty() ? null : ids.get(0); + } + + private String value(Object val) { + return val == null ? "" : String.valueOf(val).trim(); + } + + private Long safeUserId() { + Long userId = AuthContext.userId(); + return userId == null ? 0L : userId; + } + + private Long tenantId() { + return AuthContext.requireTenantId(); + } +} diff --git a/backend/src/main/java/com/writeoff/module/expert/service/PlatformExpertService.java b/backend/src/main/java/com/writeoff/module/expert/service/PlatformExpertService.java new file mode 100644 index 0000000..f208264 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/expert/service/PlatformExpertService.java @@ -0,0 +1,653 @@ +package com.writeoff.module.expert.service; + +import com.writeoff.common.api.PageResult; +import com.writeoff.common.exception.BusinessException; +import com.writeoff.common.model.ImportResult; +import com.writeoff.common.util.ImportValidationUtils; +import com.writeoff.module.expert.dto.AddBankCardRequest; +import com.writeoff.module.expert.dto.CreateExpertRequest; +import com.writeoff.module.expert.dto.MergeExpertRequest; +import com.writeoff.module.file.service.OssService; +import com.writeoff.module.expert.model.ExpertBankCardInfo; +import com.writeoff.module.expert.model.ExpertInfo; +import com.writeoff.security.AuthContext; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.HashSet; +import java.util.Set; +import java.util.UUID; +import java.util.regex.Pattern; + +@Service +public class PlatformExpertService { + private static final long PLATFORM_TENANT_ID = 0L; + private static final String DICT_TYPE_EXPERT_TITLE = "EXPERT_TITLE"; + private static final String DICT_TYPE_EXPERT_HOSPITAL = "EXPERT_HOSPITAL"; + private static final Pattern ID_NO_PATTERN = Pattern.compile("(^\\d{15}$)|(^\\d{17}[\\dXx]$)"); + + private final JdbcTemplate jdbcTemplate; + private final OssService ossService; + + private static final RowMapper EXPERT_ROW_MAPPER = (rs, n) -> new ExpertInfo( + rs.getLong("id"), + rs.getString("expert_name"), + rs.getString("gender"), + rs.getString("birthday"), + rs.getString("id_no"), + rs.getString("id_card_valid_until"), + rs.getString("id_card_front_oss_key"), + rs.getString("id_card_back_oss_key"), + rs.getString("phone"), + rs.getString("title_code"), + rs.getString("title_name"), + rs.getString("hospital_code"), + rs.getString("hospital_name"), + rs.getString("status"), + rs.getString("status_reason"), + rs.getString("status_changed_at"), + rs.getInt("export_restricted") == 1 + ); + + private static final RowMapper CARD_ROW_MAPPER = (rs, n) -> new ExpertBankCardInfo( + rs.getLong("id"), + rs.getLong("expert_id"), + rs.getString("bank_name"), + rs.getString("bank_province"), + rs.getString("bank_city"), + rs.getString("bank_branch_name"), + rs.getString("bank_card_no"), + rs.getString("bank_card_front_oss_key"), + rs.getString("bank_card_back_oss_key"), + rs.getString("account_name"), + rs.getString("is_default"), + rs.getString("card_status"), + rs.getInt("inconsistent_name_approved") == 1, + rs.getString("change_reason") + ); + + public PlatformExpertService(JdbcTemplate jdbcTemplate, OssService ossService) { + this.jdbcTemplate = jdbcTemplate; + this.ossService = ossService; + } + + public Map presignAssetUpload(String fileName, String contentType) { + String name = fileName == null ? "" : fileName.trim(); + if (name.isEmpty()) { + throw new BusinessException(10001, "文件名不能为空"); + } + String ext = ""; + int idx = name.lastIndexOf('.'); + if (idx > 0 && idx < name.length() - 1) { + ext = "." + name.substring(idx + 1).toLowerCase(); + } + String normalizedType = normalizeContentType(contentType); + String objectKey = "expert/asset/" + PLATFORM_TENANT_ID + "/" + safeUserId() + "/" + UUID.randomUUID().toString().replace("-", "") + ext; + String uploadUrl = ossService.generateUploadUrl(objectKey, normalizedType); + Map data = new LinkedHashMap(); + data.put("objectKey", objectKey); + data.put("uploadUrl", uploadUrl); + data.put("contentType", normalizedType); + data.put("method", "PUT"); + return data; + } + + public PageResult list(String keyword) { + StringBuilder sql = new StringBuilder( + "SELECT e.id, e.expert_name, e.gender, e.birthday, e.id_no, e.id_card_valid_until, e.id_card_front_oss_key, e.id_card_back_oss_key, e.phone, " + + "e.title_code, IFNULL(dt.dict_name, e.title) AS title_name, " + + "e.hospital_code, IFNULL(dh.dict_name, e.organization) AS hospital_name, " + + "e.status, e.status_reason, e.status_changed_at, e.export_restricted " + + "FROM expert e " + + "LEFT JOIN platform_dictionary_item dt ON dt.dict_type='EXPERT_TITLE' AND dt.dict_code=e.title_code AND dt.is_deleted=0 " + + "LEFT JOIN platform_dictionary_item dh ON dh.dict_type='EXPERT_HOSPITAL' AND dh.dict_code=e.hospital_code AND dh.is_deleted=0 " + + "WHERE e.tenant_id=" + PLATFORM_TENANT_ID + " AND e.is_deleted=0" + ); + String idNoKeyword = normalizeIdNoKeyword(keyword); + List params = new ArrayList(); + if (idNoKeyword != null) { + sql.append(" AND e.id_no = ?"); + params.add(idNoKeyword); + } + sql.append(" ORDER BY e.id DESC LIMIT 200"); + List list = params.isEmpty() + ? jdbcTemplate.query(sql.toString(), EXPERT_ROW_MAPPER) + : jdbcTemplate.query(sql.toString(), EXPERT_ROW_MAPPER, params.toArray()); + List maskedList = new ArrayList(list.size()); + for (ExpertInfo item : list) { + maskedList.add(maskSensitiveFields(item)); + } + return new PageResult(maskedList, maskedList.size(), 1, 200); + } + + public ExpertInfo get(Long id) { + return findById(id); + } + + public ExpertInfo findByExactIdNo(String idNo) { + String normalized = normalizeIdNoValue(idNo); + if (normalized == null) { + return null; + } + List list = jdbcTemplate.query( + "SELECT e.id, e.expert_name, e.gender, e.birthday, e.id_no, e.id_card_valid_until, e.id_card_front_oss_key, e.id_card_back_oss_key, e.phone, " + + "e.title_code, IFNULL(dt.dict_name, e.title) AS title_name, " + + "e.hospital_code, IFNULL(dh.dict_name, e.organization) AS hospital_name, " + + "e.status, e.status_reason, e.status_changed_at, e.export_restricted " + + "FROM expert e " + + "LEFT JOIN platform_dictionary_item dt ON dt.dict_type='EXPERT_TITLE' AND dt.dict_code=e.title_code AND dt.is_deleted=0 " + + "LEFT JOIN platform_dictionary_item dh ON dh.dict_type='EXPERT_HOSPITAL' AND dh.dict_code=e.hospital_code AND dh.is_deleted=0 " + + "WHERE e.tenant_id=? AND e.id_no=? AND e.is_deleted=0 LIMIT 1", + EXPERT_ROW_MAPPER, + PLATFORM_TENANT_ID, + normalized + ); + return list.isEmpty() ? null : list.get(0); + } + + @Transactional(rollbackFor = Exception.class) + public ExpertInfo create(CreateExpertRequest request) { + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM expert WHERE tenant_id=? AND id_no=? AND is_deleted=0", + Integer.class, + PLATFORM_TENANT_ID, + request.getIdNo() + ); + if (count != null && count > 0) { + throw new BusinessException(10001, "身份证号已存在"); + } + DictionaryItem titleItem = resolveOptionalDictionaryItem(DICT_TYPE_EXPERT_TITLE, request.getTitleCode(), request.getTitle(), "职称"); + DictionaryItem hospitalItem = resolveOptionalDictionaryItem(DICT_TYPE_EXPERT_HOSPITAL, request.getHospitalCode(), request.getOrganization(), "医院"); + jdbcTemplate.update( + "INSERT INTO expert (tenant_id, expert_name, gender, birthday, id_no, id_card_valid_until, id_card_front_oss_key, id_card_back_oss_key, phone, title_code, title, hospital_code, organization, status, status_reason, status_changed_by, status_changed_at, export_restricted, created_by, updated_by) " + + "VALUES (?, ?, ?, STR_TO_DATE(?, '%Y-%m-%d'), ?, STR_TO_DATE(?, '%Y-%m-%d'), ?, ?, ?, ?, ?, ?, ?, 'ENABLED', ?, ?, NOW(), ?, ?, ?)", + PLATFORM_TENANT_ID, + request.getExpertName(), + request.getGender(), + request.getBirthday(), + request.getIdNo(), + request.getIdCardValidUntil(), + request.getIdCardFrontOssKey(), + request.getIdCardBackOssKey(), + request.getPhone(), + titleItem == null ? null : titleItem.getDictCode(), + titleItem == null ? null : titleItem.getDictName(), + hospitalItem == null ? null : hospitalItem.getDictCode(), + hospitalItem == null ? null : hospitalItem.getDictName(), + request.getStatusReason(), + safeUserId(), + Boolean.TRUE.equals(request.getExportRestricted()) ? 1 : 0, + safeUserId(), + safeUserId() + ); + Long id = jdbcTemplate.queryForObject("SELECT IFNULL(MAX(id),0) FROM expert WHERE tenant_id=?", Long.class, PLATFORM_TENANT_ID); + return findById(id == null ? 0L : id); + } + + public ImportResult importExperts(List experts) { + ImportResult result = new ImportResult(); + result.setTotal(experts == null ? 0 : experts.size()); + if (experts == null) { + return result; + } + Set batchIdNos = new HashSet(); + Set batchPhones = new HashSet(); + for (int i = 0; i < experts.size(); i++) { + CreateExpertRequest item = experts.get(i); + int rowNo = i + 2; + try { + validateImportExpert(item, batchIdNos, batchPhones); + create(item); + result.markSuccess(); + } catch (Exception ex) { + result.addError(rowNo, buildExpertIdentifier(item), resolveImportMessage(ex)); + } + } + return result; + } + + @Transactional(rollbackFor = Exception.class) + public ExpertInfo update(Long id, CreateExpertRequest request) { + assertExpertExists(id); + DictionaryItem titleItem = resolveOptionalDictionaryItem(DICT_TYPE_EXPERT_TITLE, request.getTitleCode(), request.getTitle(), "职称"); + DictionaryItem hospitalItem = resolveOptionalDictionaryItem(DICT_TYPE_EXPERT_HOSPITAL, request.getHospitalCode(), request.getOrganization(), "医院"); + jdbcTemplate.update( + "UPDATE expert SET expert_name=?, gender=?, birthday=STR_TO_DATE(?, '%Y-%m-%d'), id_card_valid_until=STR_TO_DATE(?, '%Y-%m-%d'), " + + "id_card_front_oss_key=?, id_card_back_oss_key=?, phone=?, " + + "title_code=?, title=?, hospital_code=?, organization=?, status_reason=?, updated_by=?, updated_at=CURRENT_TIMESTAMP " + + "WHERE tenant_id=? AND id=? AND is_deleted=0", + request.getExpertName(), + request.getGender(), + request.getBirthday(), + request.getIdCardValidUntil(), + request.getIdCardFrontOssKey(), + request.getIdCardBackOssKey(), + request.getPhone(), + titleItem == null ? null : titleItem.getDictCode(), + titleItem == null ? null : titleItem.getDictName(), + hospitalItem == null ? null : hospitalItem.getDictCode(), + hospitalItem == null ? null : hospitalItem.getDictName(), + request.getStatusReason(), + safeUserId(), + PLATFORM_TENANT_ID, + id + ); + return findById(id); + } + + public List listCards(Long expertId) { + assertExpertExists(expertId); + return jdbcTemplate.query( + "SELECT * FROM expert_bank_card WHERE tenant_id=? AND expert_id=? AND is_deleted=0 ORDER BY is_default DESC, id DESC", + CARD_ROW_MAPPER, + PLATFORM_TENANT_ID, + expertId + ); + } + + public ExpertBankCardInfo getCard(Long expertId, Long cardId) { + assertExpertExists(expertId); + return findCardById(expertId, cardId); + } + + @Transactional(rollbackFor = Exception.class) + public ExpertBankCardInfo addCard(Long expertId, AddBankCardRequest request) { + assertExpertExists(expertId); + if (Boolean.TRUE.equals(request.getIsDefault())) { + jdbcTemplate.update( + "UPDATE expert_bank_card SET is_default='N', updated_at=CURRENT_TIMESTAMP WHERE tenant_id=? AND expert_id=?", + PLATFORM_TENANT_ID, + expertId + ); + } + jdbcTemplate.update( + "INSERT INTO expert_bank_card (tenant_id, expert_id, bank_name, bank_province, bank_city, bank_branch_name, bank_card_no, bank_card_front_oss_key, bank_card_back_oss_key, account_name, is_default, card_status, inconsistent_name_approved, change_reason, created_by, updated_by) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + PLATFORM_TENANT_ID, + expertId, + request.getBankName(), + request.getBankProvince(), + request.getBankCity(), + request.getBankBranchName(), + request.getBankCardNo(), + request.getBankCardFrontOssKey(), + request.getBankCardBackOssKey(), + request.getAccountName(), + Boolean.TRUE.equals(request.getIsDefault()) ? "Y" : "N", + request.getCardStatus() == null || request.getCardStatus().trim().isEmpty() ? "ENABLED" : request.getCardStatus().trim().toUpperCase(), + Boolean.TRUE.equals(request.getInconsistentNameApproved()) ? 1 : 0, + request.getChangeReason(), + safeUserId(), + safeUserId() + ); + Long id = jdbcTemplate.queryForObject("SELECT IFNULL(MAX(id),0) FROM expert_bank_card WHERE tenant_id=? AND expert_id=?", Long.class, PLATFORM_TENANT_ID, expertId); + List list = jdbcTemplate.query( + "SELECT * FROM expert_bank_card WHERE tenant_id=? AND id=? LIMIT 1", + CARD_ROW_MAPPER, + PLATFORM_TENANT_ID, + id == null ? 0L : id + ); + if (list.isEmpty()) { + throw new BusinessException(10003, "银行卡不存在"); + } + return list.get(0); + } + + @Transactional(rollbackFor = Exception.class) + public ExpertBankCardInfo updateCard(Long expertId, Long cardId, AddBankCardRequest request) { + assertExpertExists(expertId); + findCardById(expertId, cardId); + if (Boolean.TRUE.equals(request.getIsDefault())) { + jdbcTemplate.update( + "UPDATE expert_bank_card SET is_default='N', updated_at=CURRENT_TIMESTAMP WHERE tenant_id=? AND expert_id=?", + PLATFORM_TENANT_ID, + expertId + ); + } + jdbcTemplate.update( + "UPDATE expert_bank_card SET bank_name=?, bank_province=?, bank_city=?, bank_branch_name=?, bank_card_no=?, bank_card_front_oss_key=?, bank_card_back_oss_key=?, account_name=?, is_default=?, " + + "card_status=?, inconsistent_name_approved=?, change_reason=?, updated_by=?, updated_at=CURRENT_TIMESTAMP " + + "WHERE tenant_id=? AND expert_id=? AND id=? AND is_deleted=0", + request.getBankName(), + request.getBankProvince(), + request.getBankCity(), + request.getBankBranchName(), + request.getBankCardNo(), + request.getBankCardFrontOssKey(), + request.getBankCardBackOssKey(), + request.getAccountName(), + Boolean.TRUE.equals(request.getIsDefault()) ? "Y" : "N", + request.getCardStatus() == null || request.getCardStatus().trim().isEmpty() ? "ENABLED" : request.getCardStatus().trim().toUpperCase(), + Boolean.TRUE.equals(request.getInconsistentNameApproved()) ? 1 : 0, + request.getChangeReason(), + safeUserId(), + PLATFORM_TENANT_ID, + expertId, + cardId + ); + return findCardById(expertId, cardId); + } + + @Transactional(rollbackFor = Exception.class) + public ExpertBankCardInfo addOrUpdateDefaultCard(Long expertId, AddBankCardRequest request) { + assertExpertExists(expertId); + String cardNo = request.getBankCardNo() == null ? "" : request.getBankCardNo().trim(); + List cards = listCards(expertId); + for (ExpertBankCardInfo item : cards) { + String savedNo = item.getBankCardNo() == null ? "" : item.getBankCardNo().trim(); + if (!savedNo.isEmpty() && savedNo.equals(cardNo)) { + request.setIsDefault(true); + return updateCard(expertId, item.getId(), request); + } + } + request.setIsDefault(true); + return addCard(expertId, request); + } + + @Transactional(rollbackFor = Exception.class) + public Map merge(Long targetExpertId, MergeExpertRequest request) { + assertExpertExists(targetExpertId); + assertExpertExists(request.getSourceExpertId()); + if (targetExpertId.equals(request.getSourceExpertId())) { + throw new BusinessException(10001, "来源专家不能与目标专家相同"); + } + List sourceCards = jdbcTemplate.query( + "SELECT * FROM expert_bank_card WHERE tenant_id=? AND expert_id=? AND is_deleted=0", + CARD_ROW_MAPPER, + PLATFORM_TENANT_ID, + request.getSourceExpertId() + ); + for (ExpertBankCardInfo card : sourceCards) { + Integer exists = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM expert_bank_card WHERE tenant_id=? AND expert_id=? AND bank_card_no=? AND is_deleted=0", + Integer.class, + PLATFORM_TENANT_ID, + targetExpertId, + card.getBankCardNo() + ); + if (exists == null || exists == 0) { + jdbcTemplate.update( + "INSERT INTO expert_bank_card (tenant_id, expert_id, bank_name, bank_province, bank_city, bank_branch_name, bank_card_no, bank_card_front_oss_key, bank_card_back_oss_key, account_name, is_default, card_status, inconsistent_name_approved, change_reason, created_by, updated_by) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'N', ?, ?, ?, ?, ?)", + PLATFORM_TENANT_ID, + targetExpertId, + card.getBankName(), + card.getBankProvince(), + card.getBankCity(), + card.getBankBranchName(), + card.getBankCardNo(), + card.getBankCardFrontOssKey(), + card.getBankCardBackOssKey(), + card.getAccountName(), + card.getCardStatus() == null ? "ENABLED" : card.getCardStatus(), + Boolean.TRUE.equals(card.getInconsistentNameApproved()) ? 1 : 0, + card.getChangeReason(), + safeUserId(), + safeUserId() + ); + } + } + jdbcTemplate.update( + "UPDATE expert SET is_deleted=1, updated_by=?, updated_at=CURRENT_TIMESTAMP WHERE tenant_id=? AND id=?", + safeUserId(), + PLATFORM_TENANT_ID, + request.getSourceExpertId() + ); + jdbcTemplate.update( + "INSERT INTO expert_merge_log (tenant_id, target_expert_id, source_expert_id, reason, created_by) VALUES (?, ?, ?, ?, ?)", + PLATFORM_TENANT_ID, + targetExpertId, + request.getSourceExpertId(), + request.getReason(), + safeUserId() + ); + Map data = new LinkedHashMap(); + data.put("targetExpertId", targetExpertId); + data.put("sourceExpertId", request.getSourceExpertId()); + data.put("status", "MERGED"); + return data; + } + + public Map export() { + List list = list(null).getList(); + Map data = new LinkedHashMap(); + data.put("total", list.size()); + data.put("records", list); + return data; + } + + private ExpertInfo findById(Long id) { + List list = jdbcTemplate.query( + "SELECT e.id, e.expert_name, e.gender, e.birthday, e.id_no, e.id_card_valid_until, e.id_card_front_oss_key, e.id_card_back_oss_key, e.phone, " + + "e.title_code, IFNULL(dt.dict_name, e.title) AS title_name, " + + "e.hospital_code, IFNULL(dh.dict_name, e.organization) AS hospital_name, " + + "e.status, e.status_reason, e.status_changed_at, e.export_restricted " + + "FROM expert e " + + "LEFT JOIN platform_dictionary_item dt ON dt.dict_type='EXPERT_TITLE' AND dt.dict_code=e.title_code AND dt.is_deleted=0 " + + "LEFT JOIN platform_dictionary_item dh ON dh.dict_type='EXPERT_HOSPITAL' AND dh.dict_code=e.hospital_code AND dh.is_deleted=0 " + + "WHERE e.tenant_id=? AND e.id=? AND e.is_deleted=0", + EXPERT_ROW_MAPPER, + PLATFORM_TENANT_ID, + id + ); + if (list.isEmpty()) { + throw new BusinessException(10003, "专家不存在"); + } + return list.get(0); + } + + private void assertExpertExists(Long expertId) { + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM expert WHERE tenant_id=? AND id=? AND is_deleted=0", + Integer.class, + PLATFORM_TENANT_ID, + expertId + ); + if (count == null || count == 0) { + throw new BusinessException(10003, "专家不存在"); + } + } + + private ExpertBankCardInfo findCardById(Long expertId, Long cardId) { + List list = jdbcTemplate.query( + "SELECT * FROM expert_bank_card WHERE tenant_id=? AND expert_id=? AND id=? AND is_deleted=0 LIMIT 1", + CARD_ROW_MAPPER, + PLATFORM_TENANT_ID, + expertId, + cardId + ); + if (list.isEmpty()) { + throw new BusinessException(10003, "银行卡不存在"); + } + return list.get(0); + } + + private Long safeUserId() { + Long userId = AuthContext.userId(); + return userId == null ? 0L : userId; + } + + private String normalizeContentType(String contentType) { + if (contentType == null || contentType.trim().isEmpty()) { + return "application/octet-stream"; + } + return contentType.trim(); + } + + private String normalizeIdNoKeyword(String keyword) { + if (keyword == null || keyword.trim().isEmpty()) { + return null; + } + String trimmed = keyword.trim(); + if (!ID_NO_PATTERN.matcher(trimmed).matches()) { + throw new BusinessException(10001, "仅支持身份证号搜索"); + } + return trimmed.toUpperCase(); + } + + private String normalizeIdNoValue(String idNo) { + if (idNo == null || idNo.trim().isEmpty()) { + return null; + } + String normalized = idNo.trim().toUpperCase(); + if (!ID_NO_PATTERN.matcher(normalized).matches()) { + throw new BusinessException(10001, "身份证号格式不正确"); + } + return normalized; + } + + private ExpertInfo maskSensitiveFields(ExpertInfo source) { + return new ExpertInfo( + source.getId(), + source.getExpertName(), + source.getGender(), + source.getBirthday(), + maskIdNo(source.getIdNo()), + source.getIdCardValidUntil(), + source.getIdCardFrontOssKey(), + source.getIdCardBackOssKey(), + maskPhone(source.getPhone()), + source.getTitleCode(), + source.getTitle(), + source.getHospitalCode(), + source.getOrganization(), + source.getStatus(), + source.getStatusReason(), + source.getStatusChangedAt(), + source.getExportRestricted() + ); + } + + private String maskIdNo(String idNo) { + if (idNo == null || idNo.trim().isEmpty()) { + return idNo; + } + String value = idNo.trim(); + if (value.length() <= 8) { + return value; + } + return value.substring(0, 4) + "********" + value.substring(value.length() - 4); + } + + private String maskPhone(String phone) { + if (phone == null || phone.trim().isEmpty()) { + return phone; + } + String value = phone.trim(); + if (value.length() <= 7) { + return value; + } + return value.substring(0, 3) + "****" + value.substring(value.length() - 4); + } + + /** + * 解析并校验平台字典项: + * 1) 优先按编码命中; + * 2) 兼容历史按名称命中; + * 3) 仅允许启用状态字典项。 + */ + private DictionaryItem resolveDictionaryItem(String dictType, String dictCode, String dictName, String label) { + String code = dictCode == null ? null : dictCode.trim(); + if (code != null && !code.isEmpty()) { + List byCode = jdbcTemplate.query( + "SELECT dict_code, dict_name FROM platform_dictionary_item " + + "WHERE dict_type=? AND dict_code=? AND status='ENABLED' AND is_deleted=0 LIMIT 1", + (rs, n) -> new DictionaryItem(rs.getString("dict_code"), rs.getString("dict_name")), + dictType, + code + ); + if (byCode.isEmpty()) { + throw new BusinessException(10001, label + "字典编码不存在或未启用"); + } + return byCode.get(0); + } + String name = dictName == null ? null : dictName.trim(); + if (name == null || name.isEmpty()) { + throw new BusinessException(10001, label + "不能为空"); + } + List byName = jdbcTemplate.query( + "SELECT dict_code, dict_name FROM platform_dictionary_item " + + "WHERE dict_type=? AND dict_name=? AND status='ENABLED' AND is_deleted=0 LIMIT 1", + (rs, n) -> new DictionaryItem(rs.getString("dict_code"), rs.getString("dict_name")), + dictType, + name + ); + if (byName.isEmpty()) { + throw new BusinessException(10001, label + "不在平台字典内,请先在平台字典中维护"); + } + return byName.get(0); + } + + private DictionaryItem resolveOptionalDictionaryItem(String dictType, String dictCode, String dictName, String label) { + boolean hasCode = dictCode != null && !dictCode.trim().isEmpty(); + boolean hasName = dictName != null && !dictName.trim().isEmpty(); + if (!hasCode && !hasName) { + return null; + } + return resolveDictionaryItem(dictType, dictCode, dictName, label); + } + + private void validateImportExpert(CreateExpertRequest request, Set batchIdNos, Set batchPhones) { + if (request == null) { + throw new BusinessException(10001, "导入行不能为空"); + } + if (request.getExpertName() == null || request.getExpertName().trim().isEmpty()) { + throw new BusinessException(10001, "专家姓名不能为空"); + } + ImportValidationUtils.validateIdNo(request.getIdNo()); + ImportValidationUtils.validatePhone(request.getPhone()); + String idNo = ImportValidationUtils.trim(request.getIdNo()).toUpperCase(); + String phone = ImportValidationUtils.trim(request.getPhone()); + if (!batchIdNos.add(idNo)) { + throw new BusinessException(10001, "批次内身份证号重复"); + } + if (!batchPhones.add(phone)) { + throw new BusinessException(10001, "批次内手机号重复"); + } + } + + private String buildExpertIdentifier(CreateExpertRequest request) { + if (request == null) { + return ""; + } + String name = request.getExpertName() == null ? "" : request.getExpertName().trim(); + String idNo = request.getIdNo() == null ? "" : request.getIdNo().trim(); + if (!name.isEmpty() && !idNo.isEmpty()) { + return name + "/" + idNo; + } + return !name.isEmpty() ? name : idNo; + } + + private String resolveImportMessage(Exception ex) { + if (ex instanceof BusinessException) { + return ex.getMessage(); + } + return ex.getMessage() == null || ex.getMessage().trim().isEmpty() + ? "导入失败" + : ex.getMessage(); + } + + private static final class DictionaryItem { + private final String dictCode; + private final String dictName; + + private DictionaryItem(String dictCode, String dictName) { + this.dictCode = dictCode; + this.dictName = dictName; + } + + private String getDictCode() { + return dictCode; + } + + private String getDictName() { + return dictName; + } + } +} diff --git a/backend/src/main/java/com/writeoff/module/export/controller/ExportTaskController.java b/backend/src/main/java/com/writeoff/module/export/controller/ExportTaskController.java new file mode 100644 index 0000000..e03ad7e --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/export/controller/ExportTaskController.java @@ -0,0 +1,48 @@ +package com.writeoff.module.export.controller; + +import com.writeoff.common.api.ApiResponse; +import com.writeoff.common.api.PageResult; +import com.writeoff.module.export.dto.CreateExportTaskRequest; +import com.writeoff.module.export.model.ExportTaskInfo; +import com.writeoff.module.export.service.ExportTaskService; +import com.writeoff.security.DataScopeType; +import com.writeoff.security.RequirePermission; +import org.springframework.web.bind.annotation.*; + +import javax.validation.Valid; +import java.util.Map; + +@RestController +@RequestMapping("/api/export-tasks") +public class ExportTaskController { + private final ExportTaskService exportTaskService; + + public ExportTaskController(ExportTaskService exportTaskService) { + this.exportTaskService = exportTaskService; + } + + @GetMapping + @RequirePermission(value = "export.task.read", dataScope = DataScopeType.TENANT, auditAction = "EXPORT_TASK_LIST") + public ApiResponse> list() { + return ApiResponse.success(exportTaskService.list()); + } + + @PostMapping + @RequirePermission(value = "export.task.manage", dataScope = DataScopeType.TENANT, auditAction = "EXPORT_TASK_CREATE") + public ApiResponse> create(@RequestBody @Valid CreateExportTaskRequest request) { + return ApiResponse.success(exportTaskService.create(request)); + } + + @PostMapping("/{id}/refresh-token") + @RequirePermission(value = "export.task.manage", dataScope = DataScopeType.TENANT, auditAction = "EXPORT_TASK_REFRESH_TOKEN") + public ApiResponse> refreshToken(@PathVariable("id") Long id) { + return ApiResponse.success(exportTaskService.refreshDownloadToken(id)); + } + + @GetMapping("/{id}/download") + @RequirePermission(value = "export.task.download", dataScope = DataScopeType.TENANT, auditAction = "EXPORT_TASK_DOWNLOAD") + public ApiResponse> download(@PathVariable("id") Long id, + @RequestParam("token") String token) { + return ApiResponse.success(exportTaskService.download(id, token)); + } +} diff --git a/backend/src/main/java/com/writeoff/module/export/dto/CreateExportTaskRequest.java b/backend/src/main/java/com/writeoff/module/export/dto/CreateExportTaskRequest.java new file mode 100644 index 0000000..5e62c29 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/export/dto/CreateExportTaskRequest.java @@ -0,0 +1,63 @@ +package com.writeoff.module.export.dto; + +import javax.validation.constraints.NotBlank; + +public class CreateExportTaskRequest { + @NotBlank(message = "幂等键不能为空") + private String idempotencyKey; + @NotBlank(message = "任务编码不能为空") + private String taskCode; + @NotBlank(message = "业务类型不能为空") + private String bizType; + private String bizId; + private String filtersJson; + private String fileName; + + public String getIdempotencyKey() { + return idempotencyKey; + } + + public void setIdempotencyKey(String idempotencyKey) { + this.idempotencyKey = idempotencyKey; + } + + public String getTaskCode() { + return taskCode; + } + + public void setTaskCode(String taskCode) { + this.taskCode = taskCode; + } + + public String getBizType() { + return bizType; + } + + public void setBizType(String bizType) { + this.bizType = bizType; + } + + public String getBizId() { + return bizId; + } + + public void setBizId(String bizId) { + this.bizId = bizId; + } + + public String getFiltersJson() { + return filtersJson; + } + + public void setFiltersJson(String filtersJson) { + this.filtersJson = filtersJson; + } + + public String getFileName() { + return fileName; + } + + public void setFileName(String fileName) { + this.fileName = fileName; + } +} diff --git a/backend/src/main/java/com/writeoff/module/export/model/ExportTaskInfo.java b/backend/src/main/java/com/writeoff/module/export/model/ExportTaskInfo.java new file mode 100644 index 0000000..f90631c --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/export/model/ExportTaskInfo.java @@ -0,0 +1,85 @@ +package com.writeoff.module.export.model; + +public class ExportTaskInfo { + private Long id; + private String taskCode; + private String bizType; + private String bizId; + private String fileName; + private String fileOssKey; + private String status; + private Integer retryCount; + private Integer downloadCount; + private String tokenExpireAt; + private String errorMessage; + private String createdAt; + private String finishedAt; + + public ExportTaskInfo(Long id, String taskCode, String bizType, String bizId, String fileName, String fileOssKey, String status, Integer retryCount, Integer downloadCount, String tokenExpireAt, String errorMessage, String createdAt, String finishedAt) { + this.id = id; + this.taskCode = taskCode; + this.bizType = bizType; + this.bizId = bizId; + this.fileName = fileName; + this.fileOssKey = fileOssKey; + this.status = status; + this.retryCount = retryCount; + this.downloadCount = downloadCount; + this.tokenExpireAt = tokenExpireAt; + this.errorMessage = errorMessage; + this.createdAt = createdAt; + this.finishedAt = finishedAt; + } + + public Long getId() { + return id; + } + + public String getTaskCode() { + return taskCode; + } + + public String getBizType() { + return bizType; + } + + public String getBizId() { + return bizId; + } + + public String getFileName() { + return fileName; + } + + public String getFileOssKey() { + return fileOssKey; + } + + public String getStatus() { + return status; + } + + public Integer getRetryCount() { + return retryCount; + } + + public Integer getDownloadCount() { + return downloadCount; + } + + public String getTokenExpireAt() { + return tokenExpireAt; + } + + public String getErrorMessage() { + return errorMessage; + } + + public String getCreatedAt() { + return createdAt; + } + + public String getFinishedAt() { + return finishedAt; + } +} diff --git a/backend/src/main/java/com/writeoff/module/export/service/ExportTaskService.java b/backend/src/main/java/com/writeoff/module/export/service/ExportTaskService.java new file mode 100644 index 0000000..2b6af64 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/export/service/ExportTaskService.java @@ -0,0 +1,579 @@ +package com.writeoff.module.export.service; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.writeoff.common.api.PageResult; +import com.writeoff.common.exception.BusinessException; +import com.writeoff.common.exception.ErrorCodes; +import com.writeoff.module.file.service.OssService; +import com.writeoff.module.export.dto.CreateExportTaskRequest; +import com.writeoff.module.export.model.ExportTaskInfo; +import com.writeoff.module.meeting.service.MeetingMaterialExportService; +import com.writeoff.module.meeting.service.MeetingSummaryExportService; +import com.writeoff.module.scheduler.service.AsyncJobService; +import com.writeoff.security.AuthContext; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Locale; +import java.util.stream.Collectors; +import java.nio.charset.StandardCharsets; + +@Service +public class ExportTaskService { + private final JdbcTemplate jdbcTemplate; + private final AsyncJobService asyncJobService; + private final OssService ossService; + private final MeetingMaterialExportService meetingMaterialExportService; + private final MeetingSummaryExportService meetingSummaryExportService; + private final ObjectMapper objectMapper = new ObjectMapper(); + + private static final RowMapper ROW_MAPPER = (rs, n) -> new ExportTaskInfo( + rs.getLong("id"), + rs.getString("task_code"), + rs.getString("biz_type"), + rs.getString("biz_id"), + rs.getString("file_name"), + rs.getString("file_oss_key"), + rs.getString("status"), + rs.getInt("retry_count"), + rs.getInt("download_count"), + rs.getString("token_expire_at"), + rs.getString("error_message"), + rs.getString("created_at"), + rs.getString("finished_at") + ); + + public ExportTaskService(JdbcTemplate jdbcTemplate, + AsyncJobService asyncJobService, + OssService ossService, + MeetingMaterialExportService meetingMaterialExportService, + MeetingSummaryExportService meetingSummaryExportService) { + this.jdbcTemplate = jdbcTemplate; + this.asyncJobService = asyncJobService; + this.ossService = ossService; + this.meetingMaterialExportService = meetingMaterialExportService; + this.meetingSummaryExportService = meetingSummaryExportService; + } + + public PageResult list() { + List list = jdbcTemplate.query( + "SELECT id, task_code, biz_type, biz_id, file_name, file_oss_key, status, retry_count, IFNULL(download_count,0) AS download_count, " + + "DATE_FORMAT(download_token_expire_at, '%Y-%m-%d %H:%i:%s') AS token_expire_at, error_message, " + + "DATE_FORMAT(created_at, '%Y-%m-%d %H:%i:%s') AS created_at, DATE_FORMAT(finished_at, '%Y-%m-%d %H:%i:%s') AS finished_at " + + "FROM export_task WHERE tenant_id=? AND is_deleted=0 ORDER BY id DESC LIMIT 300", + ROW_MAPPER, + tenantId() + ); + return new PageResult(list, list.size(), 1, 300); + } + + @Transactional(rollbackFor = Exception.class) + public Map create(CreateExportTaskRequest request) { + String fileName = request.getFileName() == null || request.getFileName().trim().isEmpty() + ? (request.getTaskCode() + "-" + System.currentTimeMillis() + ".csv") + : request.getFileName().trim(); + jdbcTemplate.update( + "INSERT INTO export_task (tenant_id, task_code, biz_type, biz_id, filters_json, file_name, status, retry_count, max_retry, idempotency_key, requested_by, created_by, updated_by) " + + "VALUES (?, ?, ?, ?, ?, ?, 'PENDING', 0, 3, ?, ?, ?, ?)", + tenantId(), + request.getTaskCode(), + request.getBizType(), + request.getBizId(), + request.getFiltersJson(), + fileName, + request.getIdempotencyKey(), + safeUserId(), + safeUserId(), + safeUserId() + ); + Long taskId = jdbcTemplate.queryForObject("SELECT IFNULL(MAX(id),0) FROM export_task WHERE tenant_id=?", Long.class, tenantId()); + Long id = taskId == null ? 0L : taskId; + Map payload = new LinkedHashMap(); + payload.put("taskId", id); + try { + asyncJobService.enqueue("EXPORT_TASK", objectMapper.writeValueAsString(payload), "export-task-" + id); + } catch (Exception ex) { + throw new BusinessException(ErrorCodes.INTERNAL_ERROR, "导出任务入队失败"); + } + Map result = new LinkedHashMap(); + result.put("taskId", id); + result.put("status", "PENDING"); + return result; + } + + public void processTask(String payload) { + Long taskId = parseTaskId(payload); + Map task = findTaskDetail(taskId); + String status = String.valueOf(task.get("status")); + if ("SUCCESS".equalsIgnoreCase(status)) { + return; + } + int retryCount = ((Number) task.get("retry_count")).intValue(); + int maxRetry = ((Number) task.get("max_retry")).intValue(); + Long taskTenantId = toLong(task.get("tenant_id")); + Long requestedBy = toLong(task.get("requested_by")); + try { + ExportContent exportContent = buildExportContent(taskTenantId, task); + String fileOssKey = "exports/" + (taskTenantId == null ? tenantId() : taskTenantId) + "/" + taskId + "-" + System.currentTimeMillis() + exportContent.extension; + ossService.putObject(fileOssKey, exportContent.bytes, exportContent.contentType); + String token = UUID.randomUUID().toString().replace("-", ""); + jdbcTemplate.update( + "UPDATE export_task SET status='SUCCESS', file_oss_key=?, download_token=?, download_token_expire_at=DATE_ADD(NOW(), INTERVAL 7 DAY), error_message=NULL, finished_at=CURRENT_TIMESTAMP, updated_at=CURRENT_TIMESTAMP, updated_by=? WHERE tenant_id=? AND id=?", + fileOssKey, + token, + requestedBy == null ? 0L : requestedBy, + taskTenantId, + taskId + ); + } catch (Exception ex) { + int nextRetry = retryCount + 1; + String nextStatus = nextRetry >= maxRetry ? "FAILED" : "PENDING"; + jdbcTemplate.update( + "UPDATE export_task SET status=?, retry_count=?, error_message=?, updated_at=CURRENT_TIMESTAMP, updated_by=? WHERE tenant_id=? AND id=?", + nextStatus, + nextRetry, + truncateErrorMessage(ex), + requestedBy == null ? 0L : requestedBy, + taskTenantId, + taskId + ); + throw new IllegalStateException("导出任务处理失败: " + truncateErrorMessage(ex), ex); + } + } + + private ExportContent buildExportContent(Long taskTenantId, Map task) { + String taskCode = String.valueOf(task.get("task_code")); + String fileName = task.get("file_name") == null ? "" : String.valueOf(task.get("file_name")); + if ("MEETING_MATERIAL_EXPORT".equalsIgnoreCase(taskCode)) { + Map filters = parseJsonMap(task.get("filters_json")); + Long meetingId = toLong(filters.get("meetingId")); + if (meetingId == null || meetingId <= 0L) { + meetingId = toLong(task.get("biz_id")); + } + if (meetingId == null || meetingId <= 0L) { + throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "会议资料导出缺少会议ID"); + } + byte[] zipBytes = meetingMaterialExportService.buildZip(taskTenantId, meetingId); + return new ExportContent(zipBytes, "application/zip", resolveExtension(fileName, ".zip")); + } + if ("MEETING_SUMMARY_GENERATE".equalsIgnoreCase(taskCode)) { + Map filters = parseJsonMap(task.get("filters_json")); + Long meetingId = toLong(filters.get("meetingId")); + if (meetingId == null || meetingId <= 0L) { + meetingId = toLong(task.get("biz_id")); + } + if (meetingId == null || meetingId <= 0L) { + throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "会议总结导出缺少会议ID"); + } + byte[] docxBytes = meetingSummaryExportService.buildDocx(taskTenantId, meetingId); + return new ExportContent( + docxBytes, + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + resolveExtension(fileName, ".docx") + ); + } + String csvContent = buildCsvContent(taskTenantId, task); + return new ExportContent(csvContent.getBytes(StandardCharsets.UTF_8), "text/csv;charset=utf-8", resolveExtension(fileName, ".csv")); + } + + public Map refreshDownloadToken(Long taskId) { + Map task = findTaskWithOwner(taskId); + assertOwner(task); + String status = String.valueOf(task.get("status")); + if (!"SUCCESS".equalsIgnoreCase(status)) { + throw new BusinessException(ErrorCodes.INVALID_STATE, "导出任务未完成,不能刷新下载令牌"); + } + String token = UUID.randomUUID().toString().replace("-", ""); + jdbcTemplate.update( + "UPDATE export_task SET download_token=?, download_token_expire_at=DATE_ADD(NOW(), INTERVAL 7 DAY), updated_at=CURRENT_TIMESTAMP, updated_by=? WHERE tenant_id=? AND id=?", + token, + safeUserId(), + tenantId(), + taskId + ); + Map data = new LinkedHashMap(); + data.put("taskId", taskId); + data.put("downloadToken", token); + data.put("expireAt", jdbcTemplate.queryForObject("SELECT DATE_FORMAT(download_token_expire_at, '%Y-%m-%d %H:%i:%s') FROM export_task WHERE tenant_id=? AND id=?", String.class, tenantId(), taskId)); + return data; + } + + public Map download(Long taskId, String token) { + if (token == null || token.trim().isEmpty()) { + throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "下载令牌不能为空"); + } + Map task = findTaskWithOwner(taskId); + assertOwner(task); + String status = String.valueOf(task.get("status")); + if (!"SUCCESS".equalsIgnoreCase(status)) { + throw new BusinessException(ErrorCodes.INVALID_STATE, "导出任务未完成"); + } + String dbToken = task.get("download_token") == null ? "" : String.valueOf(task.get("download_token")); + if (!dbToken.equals(token)) { + throw new BusinessException(ErrorCodes.NO_DATA_PERMISSION, "下载令牌无效"); + } + Integer valid = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM export_task WHERE tenant_id=? AND id=? AND download_token_expire_at IS NOT NULL AND download_token_expire_at>=NOW()", + Integer.class, + tenantId(), + taskId + ); + if (valid == null || valid == 0) { + throw new BusinessException(ErrorCodes.NO_DATA_PERMISSION, "下载令牌已过期"); + } + jdbcTemplate.update( + "UPDATE export_task SET download_count=IFNULL(download_count,0)+1, updated_at=CURRENT_TIMESTAMP, updated_by=? WHERE tenant_id=? AND id=?", + safeUserId(), + tenantId(), + taskId + ); + Map data = new LinkedHashMap(); + data.put("taskId", taskId); + data.put("fileName", task.get("file_name")); + data.put("fileOssKey", task.get("file_oss_key")); + data.put("signedUrl", ossService.generateDownloadUrl(String.valueOf(task.get("file_oss_key")), String.valueOf(task.get("file_name")))); + data.put("expireAt", jdbcTemplate.queryForObject("SELECT DATE_FORMAT(download_token_expire_at, '%Y-%m-%d %H:%i:%s') FROM export_task WHERE tenant_id=? AND id=?", String.class, tenantId(), taskId)); + data.put("downloadCount", jdbcTemplate.queryForObject("SELECT IFNULL(download_count,0) FROM export_task WHERE tenant_id=? AND id=?", Integer.class, tenantId(), taskId)); + return data; + } + + private Long parseTaskId(String payload) { + try { + Map map = objectMapper.readValue(payload, new TypeReference>() {}); + Object val = map.get("taskId"); + if (val == null) { + throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "导出任务ID缺失"); + } + return Long.valueOf(String.valueOf(val)); + } catch (Exception ex) { + throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "导出任务参数非法"); + } + } + + private Map findTask(Long taskId) { + List> list = jdbcTemplate.queryForList( + "SELECT id, status, retry_count, max_retry FROM export_task WHERE tenant_id=? AND id=? AND is_deleted=0", + tenantId(), + taskId + ); + if (list.isEmpty()) { + throw new BusinessException(ErrorCodes.RESOURCE_NOT_FOUND, "导出任务不存在"); + } + return list.get(0); + } + + private Map findTaskDetail(Long taskId) { + List> list = jdbcTemplate.queryForList( + "SELECT id, tenant_id, status, retry_count, max_retry, requested_by, task_code, biz_type, biz_id, filters_json, file_name " + + "FROM export_task WHERE tenant_id=? AND id=? AND is_deleted=0", + tenantId(), + taskId + ); + if (list.isEmpty()) { + throw new BusinessException(ErrorCodes.RESOURCE_NOT_FOUND, "导出任务不存在"); + } + return list.get(0); + } + + private Map findTaskWithOwner(Long taskId) { + List> list = jdbcTemplate.queryForList( + "SELECT id, status, retry_count, max_retry, requested_by, file_name, file_oss_key, download_token FROM export_task WHERE tenant_id=? AND id=? AND is_deleted=0", + tenantId(), + taskId + ); + if (list.isEmpty()) { + throw new BusinessException(ErrorCodes.RESOURCE_NOT_FOUND, "导出任务不存在"); + } + return list.get(0); + } + + private void assertOwner(Map task) { + Long requestedBy = task.get("requested_by") == null ? 0L : ((Number) task.get("requested_by")).longValue(); + if (!requestedBy.equals(safeUserId())) { + throw new BusinessException(ErrorCodes.NO_DATA_PERMISSION, "仅任务创建人可下载导出结果"); + } + } + + private Long safeUserId() { + Long userId = AuthContext.userId(); + return userId == null ? 0L : userId; + } + + private Long tenantId() { + return AuthContext.requireTenantId(); + } + + private Long toLong(Object value) { + if (value == null) { + return null; + } + if (value instanceof Number) { + return ((Number) value).longValue(); + } + try { + return Long.valueOf(String.valueOf(value)); + } catch (Exception ex) { + return null; + } + } + + private String buildCsvContent(Long taskTenantId, Map task) { + String taskCode = String.valueOf(task.get("task_code")); + Map filters = parseJsonMap(task.get("filters_json")); + if ("MEETING_EXPORT".equalsIgnoreCase(taskCode)) { + return buildMeetingCsv(taskTenantId, filters); + } + if ("USER_EXPORT".equalsIgnoreCase(taskCode)) { + return buildUserCsv(taskTenantId, filters); + } + if ("PROJECT_EXPORT".equalsIgnoreCase(taskCode)) { + return buildProjectCsv(taskTenantId, filters); + } + if ("NOTIFICATION_TASK_EXPORT".equalsIgnoreCase(taskCode)) { + return buildNotificationTaskCsv(taskTenantId, filters); + } + throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "不支持的导出任务类型: " + taskCode); + } + + private Map parseJsonMap(Object raw) { + if (raw == null) { + return new LinkedHashMap(); + } + String text = String.valueOf(raw).trim(); + if (text.isEmpty()) { + return new LinkedHashMap(); + } + try { + return objectMapper.readValue(text, new TypeReference>() {}); + } catch (Exception ex) { + throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "导出筛选条件格式不正确"); + } + } + + private String buildMeetingCsv(Long tenantId, Map filters) { + StringBuilder sql = new StringBuilder(); + sql.append("SELECT m.id, p.project_name, m.topic, m.meeting_status, m.audit_status, "); + sql.append("DATE_FORMAT(m.start_time, '%Y-%m-%d %H:%i:%s') AS start_time, "); + sql.append("DATE_FORMAT(m.end_time, '%Y-%m-%d %H:%i:%s') AS end_time, "); + sql.append("m.budget_cent, m.is_deleted "); + sql.append("FROM meeting m LEFT JOIN project p ON m.tenant_id=p.tenant_id AND m.project_id=p.id "); + sql.append("WHERE m.tenant_id=? "); + List args = new ArrayList(); + args.add(tenantId); + appendDeletedFilter(sql, args, "m.is_deleted", filters); + appendLikeFilter(sql, args, "p.project_name", filters.get("projectName")); + appendLikeFilter(sql, args, "m.topic", filters.get("topic")); + appendEqualsFilter(sql, args, "m.project_id", filters.get("projectId")); + appendEqualsFilter(sql, args, "m.meeting_status", filters.get("meetingStatus")); + appendEqualsFilter(sql, args, "m.audit_status", filters.get("auditStatus")); + sql.append(" ORDER BY m.id DESC"); + List> rows = jdbcTemplate.queryForList(sql.toString(), args.toArray()); + return toCsv( + Arrays.asList("会议ID", "项目名称", "会议主题", "会议状态", "审核状态", "开始时间", "结束时间", "预算(元)", "是否已删除"), + rows.stream().map(row -> Arrays.asList( + row.get("id"), + row.get("project_name"), + row.get("topic"), + row.get("meeting_status"), + row.get("audit_status"), + row.get("start_time"), + row.get("end_time"), + toYuan(row.get("budget_cent")), + toDeletedText(row.get("is_deleted")) + )).collect(Collectors.>toList()) + ); + } + + private String buildUserCsv(Long tenantId, Map filters) { + StringBuilder sql = new StringBuilder(); + sql.append("SELECT u.id, u.user_name, u.phone, u.email, u.status, "); + sql.append("DATE_FORMAT(u.valid_from, '%Y-%m-%d %H:%i:%s') AS valid_from, "); + sql.append("DATE_FORMAT(u.valid_to, '%Y-%m-%d %H:%i:%s') AS valid_to, u.is_deleted, "); + sql.append("COALESCE(GROUP_CONCAT(DISTINCT r.role_name ORDER BY r.id SEPARATOR ','), '') AS role_names "); + sql.append("FROM sys_user u "); + sql.append("LEFT JOIN user_role ur ON u.tenant_id=ur.tenant_id AND u.id=ur.user_id "); + sql.append("LEFT JOIN role r ON ur.tenant_id=r.tenant_id AND ur.role_id=r.id AND r.is_deleted=0 "); + sql.append("WHERE u.tenant_id=? "); + List args = new ArrayList(); + args.add(tenantId); + appendDeletedFilter(sql, args, "u.is_deleted", filters); + sql.append(" GROUP BY u.id, u.user_name, u.phone, u.email, u.status, u.valid_from, u.valid_to, u.is_deleted "); + sql.append("ORDER BY u.id DESC"); + List> rows = jdbcTemplate.queryForList(sql.toString(), args.toArray()); + return toCsv( + Arrays.asList("用户ID", "姓名", "手机号", "邮箱", "状态", "角色", "生效时间", "失效时间", "是否已删除"), + rows.stream().map(row -> Arrays.asList( + row.get("id"), + row.get("user_name"), + row.get("phone"), + row.get("email"), + row.get("status"), + row.get("role_names"), + row.get("valid_from"), + row.get("valid_to"), + toDeletedText(row.get("is_deleted")) + )).collect(Collectors.>toList()) + ); + } + + private String buildProjectCsv(Long tenantId, Map filters) { + StringBuilder sql = new StringBuilder(); + sql.append("SELECT id, project_name, host_enterprise_name, budget_cent, meeting_total, status, "); + sql.append("DATE_FORMAT(start_date, '%Y-%m-%d') AS start_date, DATE_FORMAT(end_date, '%Y-%m-%d') AS end_date, is_deleted "); + sql.append("FROM project WHERE tenant_id=? "); + List args = new ArrayList(); + args.add(tenantId); + appendDeletedFilter(sql, args, "is_deleted", filters); + appendLikeFilter(sql, args, "project_name", filters.get("projectName")); + appendEqualsFilter(sql, args, "parent_project_id", filters.get("parentProjectId")); + sql.append(" ORDER BY id DESC"); + List> rows = jdbcTemplate.queryForList(sql.toString(), args.toArray()); + return toCsv( + Arrays.asList("项目ID", "项目名称", "主办单位", "预算(元)", "会议总期数", "状态", "开始日期", "结束日期", "是否已删除"), + rows.stream().map(row -> Arrays.asList( + row.get("id"), + row.get("project_name"), + row.get("host_enterprise_name"), + toYuan(row.get("budget_cent")), + row.get("meeting_total"), + row.get("status"), + row.get("start_date"), + row.get("end_date"), + toDeletedText(row.get("is_deleted")) + )).collect(Collectors.>toList()) + ); + } + + private String buildNotificationTaskCsv(Long tenantId, Map filters) { + StringBuilder sql = new StringBuilder(); + sql.append("SELECT id, event_code, channel, receiver_type, receiver_ref, status, retry_count, "); + sql.append("provider_message_id, receipt_code, receipt_message, error_message, "); + sql.append("DATE_FORMAT(created_at, '%Y-%m-%d %H:%i:%s') AS created_at "); + sql.append("FROM notification_task WHERE tenant_id=? "); + List args = new ArrayList(); + args.add(tenantId); + appendDeletedFilter(sql, args, "is_deleted", filters); + sql.append(" ORDER BY id DESC"); + List> rows = jdbcTemplate.queryForList(sql.toString(), args.toArray()); + return toCsv( + Arrays.asList("任务ID", "事件编码", "渠道", "接收人类型", "接收人", "状态", "重试次数", "供应商消息ID", "回执码", "回执信息", "错误信息", "创建时间"), + rows.stream().map(row -> Arrays.asList( + row.get("id"), + row.get("event_code"), + row.get("channel"), + row.get("receiver_type"), + row.get("receiver_ref"), + row.get("status"), + row.get("retry_count"), + row.get("provider_message_id"), + row.get("receipt_code"), + row.get("receipt_message"), + row.get("error_message"), + row.get("created_at") + )).collect(Collectors.>toList()) + ); + } + + private void appendDeletedFilter(StringBuilder sql, List args, String column, Map filters) { + boolean includeDeleted = filters != null && Boolean.TRUE.equals(parseBoolean(filters.get("includeDeleted"))); + if (!includeDeleted) { + sql.append(" AND ").append(column).append("=0"); + } + } + + private Boolean parseBoolean(Object value) { + if (value instanceof Boolean) { + return (Boolean) value; + } + if (value == null) { + return false; + } + return "true".equalsIgnoreCase(String.valueOf(value)); + } + + private void appendLikeFilter(StringBuilder sql, List args, String column, Object value) { + String text = value == null ? "" : String.valueOf(value).trim(); + if (!text.isEmpty()) { + sql.append(" AND ").append(column).append(" LIKE ?"); + args.add("%" + text + "%"); + } + } + + private void appendEqualsFilter(StringBuilder sql, List args, String column, Object value) { + if (value == null || String.valueOf(value).trim().isEmpty()) { + return; + } + sql.append(" AND ").append(column).append("=?"); + args.add(value); + } + + private String toCsv(List headers, List> rows) { + StringBuilder builder = new StringBuilder(); + builder.append('\uFEFF'); + builder.append(headers.stream().map(this::escapeCsv).collect(Collectors.joining(","))).append("\r\n"); + for (List row : rows) { + builder.append(row.stream() + .map(val -> escapeCsv(val == null ? "" : String.valueOf(val))) + .collect(Collectors.joining(","))); + builder.append("\r\n"); + } + return builder.toString(); + } + + private String escapeCsv(String value) { + String text = value == null ? "" : value; + String normalized = text.replace("\"", "\"\""); + if (normalized.contains(",") || normalized.contains("\"") || normalized.contains("\n") || normalized.contains("\r")) { + return "\"" + normalized + "\""; + } + return normalized; + } + + private String toDeletedText(Object flag) { + return toLong(flag) != null && toLong(flag) == 1L ? "是" : "否"; + } + + private String toYuan(Object cent) { + long value = toLong(cent) == null ? 0L : toLong(cent); + return String.format(Locale.ROOT, "%.2f", value / 100.0d); + } + + private String resolveExtension(String fileName, String defaultExtension) { + String text = fileName == null ? "" : fileName.trim(); + int idx = text.lastIndexOf('.'); + if (idx > 0 && idx < text.length() - 1) { + return text.substring(idx); + } + return defaultExtension; + } + + private String truncateErrorMessage(Exception ex) { + String message = ex == null ? "" : String.valueOf(ex.getMessage()); + if (message == null || message.trim().isEmpty()) { + message = ex == null ? "未知错误" : ex.getClass().getSimpleName(); + } + String safe = message.trim(); + return safe.length() > 500 ? safe.substring(0, 500) : safe; + } + + private static class ExportContent { + private final byte[] bytes; + private final String contentType; + private final String extension; + + private ExportContent(byte[] bytes, String contentType, String extension) { + this.bytes = bytes == null ? new byte[0] : bytes; + this.contentType = contentType; + this.extension = extension; + } + } +} diff --git a/backend/src/main/java/com/writeoff/module/file/controller/FileController.java b/backend/src/main/java/com/writeoff/module/file/controller/FileController.java new file mode 100644 index 0000000..b4e096f --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/file/controller/FileController.java @@ -0,0 +1,53 @@ +package com.writeoff.module.file.controller; + +import com.writeoff.common.api.ApiResponse; +import com.writeoff.common.exception.BusinessException; +import com.writeoff.module.file.service.OssService; +import com.writeoff.module.system.service.DataPermissionService; +import com.writeoff.security.AuthContext; +import com.writeoff.security.AuthScope; +import com.writeoff.security.PermissionService; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.LinkedHashMap; +import java.util.Map; + +@RestController +@RequestMapping("/api/files") +public class FileController { + private final OssService ossService; + private final DataPermissionService dataPermissionService; + private final PermissionService permissionService; + + public FileController(OssService ossService, DataPermissionService dataPermissionService, PermissionService permissionService) { + this.ossService = ossService; + this.dataPermissionService = dataPermissionService; + this.permissionService = permissionService; + } + + @GetMapping("/presign-download") + public ApiResponse> presignDownload(@RequestParam String objectKey) { + Long userId = AuthContext.userId(); + if (userId == null) { + throw new BusinessException(10002, "未登录"); + } + AuthScope scope = AuthContext.scope(); + if (scope == AuthScope.PLATFORM) { + if (!permissionService.hasPlatformPermission(userId, "file.download")) { + throw new BusinessException(10004, "无操作权限"); + } + } else { + if (!permissionService.hasPermission(userId, "file.download")) { + throw new BusinessException(10004, "无操作权限"); + } + } + String signedUrl = ossService.generateDownloadUrl(objectKey); + Map result = new LinkedHashMap<>(); + result.put("objectKey", objectKey); + result.put("signedUrl", signedUrl); + return ApiResponse.success(result); + } +} diff --git a/backend/src/main/java/com/writeoff/module/file/service/OssService.java b/backend/src/main/java/com/writeoff/module/file/service/OssService.java new file mode 100644 index 0000000..05d0e66 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/file/service/OssService.java @@ -0,0 +1,129 @@ +package com.writeoff.module.file.service; + +import com.aliyun.oss.OSS; +import com.aliyun.oss.OSSClientBuilder; +import com.aliyun.oss.model.OSSObject; +import com.aliyun.oss.model.ObjectMetadata; +import com.aliyun.oss.model.GeneratePresignedUrlRequest; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.net.URL; +import java.net.URLEncoder; +import java.util.Date; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import com.aliyun.oss.HttpMethod; + +@Service +public class OssService { + @Value("${app.oss.endpoint}") + private String endpoint; + @Value("${app.oss.bucket}") + private String bucket; + @Value("${app.oss.access-key-id}") + private String accessKeyId; + @Value("${app.oss.access-key-secret}") + private String accessKeySecret; + @Value("${app.oss.sign-expire-seconds:600}") + private int signExpireSeconds; + + public String generateDownloadUrl(String objectKey) { + return generateDownloadUrl(objectKey, null); + } + + public String generateDownloadUrl(String objectKey, String downloadFileName) { + OSS ossClient = new OSSClientBuilder().build(endpoint, accessKeyId, accessKeySecret); + try { + Date expiration = new Date(System.currentTimeMillis() + signExpireSeconds * 1000L); + GeneratePresignedUrlRequest req = new GeneratePresignedUrlRequest(bucket, objectKey, HttpMethod.GET); + req.setExpiration(expiration); + String responseDisposition = buildAttachmentDisposition(downloadFileName); + if (responseDisposition != null && !responseDisposition.isEmpty()) { + req.addQueryParameter("response-content-disposition", responseDisposition); + } + URL url = ossClient.generatePresignedUrl(req); + return url.toString(); + } finally { + ossClient.shutdown(); + } + } + + public String generateUploadUrl(String objectKey) { + return generateUploadUrl(objectKey, "application/octet-stream"); + } + + public String generateUploadUrl(String objectKey, String contentType) { + OSS ossClient = new OSSClientBuilder().build(endpoint, accessKeyId, accessKeySecret); + try { + Date expiration = new Date(System.currentTimeMillis() + signExpireSeconds * 1000L); + GeneratePresignedUrlRequest req = new GeneratePresignedUrlRequest(bucket, objectKey, HttpMethod.PUT); + req.setExpiration(expiration); + req.setContentType(contentType == null || contentType.trim().isEmpty() ? "application/octet-stream" : contentType.trim()); + URL url = ossClient.generatePresignedUrl(req); + return url.toString(); + } finally { + ossClient.shutdown(); + } + } + + public void putTextObject(String objectKey, String content, String contentType) { + byte[] bytes = content == null ? new byte[0] : content.getBytes(StandardCharsets.UTF_8); + putObject(objectKey, bytes, contentType == null || contentType.trim().isEmpty() ? "text/plain;charset=utf-8" : contentType.trim()); + } + + public void putObject(String objectKey, byte[] bytes, String contentType) { + OSS ossClient = new OSSClientBuilder().build(endpoint, accessKeyId, accessKeySecret); + try { + byte[] safeBytes = bytes == null ? new byte[0] : bytes; + ObjectMetadata metadata = new ObjectMetadata(); + metadata.setContentLength(safeBytes.length); + metadata.setContentType(contentType == null || contentType.trim().isEmpty() ? "application/octet-stream" : contentType.trim()); + ossClient.putObject(bucket, objectKey, new ByteArrayInputStream(safeBytes), metadata); + } finally { + ossClient.shutdown(); + } + } + + public byte[] getObjectBytes(String objectKey) { + OSS ossClient = new OSSClientBuilder().build(endpoint, accessKeyId, accessKeySecret); + try (OSSObject object = ossClient.getObject(bucket, objectKey); + InputStream inputStream = object.getObjectContent()) { + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + byte[] buffer = new byte[8192]; + int len; + while ((len = inputStream.read(buffer)) >= 0) { + outputStream.write(buffer, 0, len); + } + return outputStream.toByteArray(); + } catch (IOException ex) { + throw new IllegalStateException("Failed to read OSS object: " + objectKey, ex); + } finally { + ossClient.shutdown(); + } + } + + private String buildAttachmentDisposition(String downloadFileName) { + String fileName = downloadFileName == null ? "" : downloadFileName.trim(); + if (fileName.isEmpty()) { + return ""; + } + String asciiName = fileName.replaceAll("[^\\x20-\\x7E]", "_").replace("\"", "_"); + if (asciiName.trim().isEmpty()) { + asciiName = "download"; + } + String encoded = urlEncodeUtf8(fileName); + return "attachment; filename=\"" + asciiName + "\"; filename*=UTF-8''" + encoded; + } + + private String urlEncodeUtf8(String value) { + try { + return URLEncoder.encode(value, "UTF-8").replace("+", "%20"); + } catch (Exception ex) { + return value == null ? "" : value; + } + } +} diff --git a/backend/src/main/java/com/writeoff/module/finance/controller/FinanceController.java b/backend/src/main/java/com/writeoff/module/finance/controller/FinanceController.java new file mode 100644 index 0000000..0487e51 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/finance/controller/FinanceController.java @@ -0,0 +1,80 @@ +package com.writeoff.module.finance.controller; + +import com.writeoff.common.api.ApiResponse; +import com.writeoff.common.api.PageResult; +import com.writeoff.module.finance.dto.ConfirmPaymentRequest; +import com.writeoff.module.finance.dto.FinanceLockRequest; +import com.writeoff.module.finance.dto.FinanceReconciliationRequest; +import com.writeoff.module.finance.dto.UpsertFinanceMeetingBillRequest; +import com.writeoff.module.finance.model.FinanceMeetingBillInfo; +import com.writeoff.module.finance.model.Payment; +import com.writeoff.module.finance.service.FinanceService; +import com.writeoff.security.DataScopeType; +import com.writeoff.security.RequirePermission; +import javax.validation.Valid; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; + +@RestController +@RequestMapping("/api/finance") +public class FinanceController { + private final FinanceService financeService; + + public FinanceController(FinanceService financeService) { + this.financeService = financeService; + } + + @GetMapping("/projects") + public ApiResponse> projects() { + return ApiResponse.success(financeService.listProjects()); + } + + @PostMapping("/payments") + @RequirePermission(value = "finance.payment.confirm", dataScope = DataScopeType.PROJECT, auditAction = "FINANCE_PAYMENT_CONFIRM") + public ApiResponse> confirmPayment(@RequestBody @Valid ConfirmPaymentRequest request) { + return ApiResponse.success(financeService.confirmPayment(request)); + } + + @GetMapping("/ledger/export") + @RequirePermission(value = "finance.ledger.export", dataScope = DataScopeType.PROJECT, auditAction = "FINANCE_LEDGER_EXPORT") + public ApiResponse> exportLedger() { + return ApiResponse.success(financeService.exportLedger()); + } + + @PostMapping("/reconciliation") + @RequirePermission(value = "finance.reconciliation", dataScope = DataScopeType.PROJECT, auditAction = "FINANCE_RECONCILIATION") + public ApiResponse> reconciliation(@RequestBody @Valid FinanceReconciliationRequest request) { + return ApiResponse.success(financeService.reconciliation(request)); + } + + @PostMapping("/lock") + @RequirePermission(value = "finance.lock", dataScope = DataScopeType.PROJECT, auditAction = "FINANCE_LOCK") + public ApiResponse> lock(@RequestBody @Valid FinanceLockRequest request) { + return ApiResponse.success(financeService.lock(request)); + } + + @PostMapping("/unlock") + @RequirePermission(value = "finance.unlock", dataScope = DataScopeType.PROJECT, auditAction = "FINANCE_UNLOCK") + public ApiResponse> unlock(@RequestBody @Valid FinanceLockRequest request) { + return ApiResponse.success(financeService.unlock(request)); + } + + @GetMapping("/reconciliation/list") + @RequirePermission(value = "finance.reconciliation", dataScope = DataScopeType.PROJECT, auditAction = "FINANCE_RECONCILIATION_LIST") + public ApiResponse> reconciliationList(@RequestParam(value = "projectId", required = false) Long projectId) { + return ApiResponse.success(financeService.reconciliationList(projectId)); + } + + @GetMapping("/meeting-bills") + @RequirePermission(value = "finance.reconciliation", dataScope = DataScopeType.PROJECT, auditAction = "FINANCE_MEETING_BILL_LIST") + public ApiResponse> meetingBills(@RequestParam(value = "projectId", required = false) Long projectId) { + return ApiResponse.success(financeService.listMeetingBills(projectId)); + } + + @PostMapping("/meeting-bills") + @RequirePermission(value = "finance.reconciliation", dataScope = DataScopeType.PROJECT, auditAction = "FINANCE_MEETING_BILL_UPSERT") + public ApiResponse upsertMeetingBill(@RequestBody @Valid UpsertFinanceMeetingBillRequest request) { + return ApiResponse.success(financeService.upsertMeetingBill(request)); + } +} diff --git a/backend/src/main/java/com/writeoff/module/finance/dto/ConfirmPaymentRequest.java b/backend/src/main/java/com/writeoff/module/finance/dto/ConfirmPaymentRequest.java new file mode 100644 index 0000000..b1f1526 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/finance/dto/ConfirmPaymentRequest.java @@ -0,0 +1,59 @@ +package com.writeoff.module.finance.dto; + +import javax.validation.constraints.Min; +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; + +public class ConfirmPaymentRequest { + @NotBlank(message = "幂等键不能为空") + private String idempotencyKey; + @NotNull(message = "项目ID不能为空") + private Long projectId; + @NotNull(message = "会议ID不能为空") + private Long meetingId; + @NotNull(message = "支付金额不能为空") + @Min(value = 1, message = "支付金额必须大于0") + private Long amountCent; + @NotBlank(message = "支付凭证不能为空") + private String paymentVoucherOssKey; + + public String getIdempotencyKey() { + return idempotencyKey; + } + + public void setIdempotencyKey(String idempotencyKey) { + this.idempotencyKey = idempotencyKey; + } + + public Long getProjectId() { + return projectId; + } + + public void setProjectId(Long projectId) { + this.projectId = projectId; + } + + public Long getMeetingId() { + return meetingId; + } + + public void setMeetingId(Long meetingId) { + this.meetingId = meetingId; + } + + public Long getAmountCent() { + return amountCent; + } + + public void setAmountCent(Long amountCent) { + this.amountCent = amountCent; + } + + public String getPaymentVoucherOssKey() { + return paymentVoucherOssKey; + } + + public void setPaymentVoucherOssKey(String paymentVoucherOssKey) { + this.paymentVoucherOssKey = paymentVoucherOssKey; + } +} diff --git a/backend/src/main/java/com/writeoff/module/finance/dto/FinanceLockRequest.java b/backend/src/main/java/com/writeoff/module/finance/dto/FinanceLockRequest.java new file mode 100644 index 0000000..0b9ddbc --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/finance/dto/FinanceLockRequest.java @@ -0,0 +1,37 @@ +package com.writeoff.module.finance.dto; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; + +public class FinanceLockRequest { + @NotBlank(message = "幂等键不能为空") + private String idempotencyKey; + @NotNull(message = "项目ID不能为空") + private Long projectId; + @NotBlank(message = "原因不能为空") + private String reason; + + public String getIdempotencyKey() { + return idempotencyKey; + } + + public void setIdempotencyKey(String idempotencyKey) { + this.idempotencyKey = idempotencyKey; + } + + public Long getProjectId() { + return projectId; + } + + public void setProjectId(Long projectId) { + this.projectId = projectId; + } + + public String getReason() { + return reason; + } + + public void setReason(String reason) { + this.reason = reason; + } +} diff --git a/backend/src/main/java/com/writeoff/module/finance/dto/FinanceReconciliationRequest.java b/backend/src/main/java/com/writeoff/module/finance/dto/FinanceReconciliationRequest.java new file mode 100644 index 0000000..cabb850 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/finance/dto/FinanceReconciliationRequest.java @@ -0,0 +1,37 @@ +package com.writeoff.module.finance.dto; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; + +public class FinanceReconciliationRequest { + @NotBlank(message = "幂等键不能为空") + private String idempotencyKey; + @NotNull(message = "项目ID不能为空") + private Long projectId; + @NotNull(message = "应收金额不能为空") + private Long expectedAmountCent; + + public String getIdempotencyKey() { + return idempotencyKey; + } + + public void setIdempotencyKey(String idempotencyKey) { + this.idempotencyKey = idempotencyKey; + } + + public Long getProjectId() { + return projectId; + } + + public void setProjectId(Long projectId) { + this.projectId = projectId; + } + + public Long getExpectedAmountCent() { + return expectedAmountCent; + } + + public void setExpectedAmountCent(Long expectedAmountCent) { + this.expectedAmountCent = expectedAmountCent; + } +} diff --git a/backend/src/main/java/com/writeoff/module/finance/dto/UpsertFinanceMeetingBillRequest.java b/backend/src/main/java/com/writeoff/module/finance/dto/UpsertFinanceMeetingBillRequest.java new file mode 100644 index 0000000..ba32d21 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/finance/dto/UpsertFinanceMeetingBillRequest.java @@ -0,0 +1,95 @@ +package com.writeoff.module.finance.dto; + +import javax.validation.constraints.Min; +import javax.validation.constraints.NotNull; + +public class UpsertFinanceMeetingBillRequest { + @NotNull(message = "项目ID不能为空") + private Long projectId; + @NotNull(message = "会议ID不能为空") + private Long meetingId; + @Min(value = 0, message = "会场费用不能小于0") + private Long venueAmountCent; + @Min(value = 0, message = "会议搭建费用不能小于0") + private Long buildAmountCent; + @Min(value = 0, message = "住宿费用不能小于0") + private Long hotelAmountCent; + @Min(value = 0, message = "餐饮费用不能小于0") + private Long cateringAmountCent; + @Min(value = 0, message = "小交通费用不能小于0") + private Long localTrafficAmountCent; + @Min(value = 0, message = "大交通费用不能小于0") + private Long longDistanceTrafficAmountCent; + @Min(value = 0, message = "物料费用不能小于0") + private Long materialAmountCent; + @Min(value = 0, message = "设计稿费用不能小于0") + private Long designAmountCent; + @Min(value = 0, message = "劳务应付费用不能小于0") + private Long laborPayableAmountCent; + @Min(value = 0, message = "劳务实发费用不能小于0") + private Long laborActualAmountCent; + @Min(value = 0, message = "财务审核费不能小于0") + private Long financeReviewFeeCent; + @Min(value = 0, message = "管理费不能小于0") + private Long managementFeeCent; + @Min(value = 0, message = "税费不能小于0") + private Long taxFeeCent; + private String customFeeJson; + @Min(value = 0, message = "已支付金额不能小于0") + private Long paidAmountCent; + @Min(value = 0, message = "待支付金额不能小于0") + private Long unpaidAmountCent; + private String reconciliationResult; + @Min(value = 0, message = "对账差异金额不能小于0") + private Long reconciliationDiffAmountCent; + private String reconciliationDiffReason; + private String settlementNo; + private String status; + + public Long getProjectId() { return projectId; } + public void setProjectId(Long projectId) { this.projectId = projectId; } + public Long getMeetingId() { return meetingId; } + public void setMeetingId(Long meetingId) { this.meetingId = meetingId; } + public Long getVenueAmountCent() { return venueAmountCent; } + public void setVenueAmountCent(Long venueAmountCent) { this.venueAmountCent = venueAmountCent; } + public Long getBuildAmountCent() { return buildAmountCent; } + public void setBuildAmountCent(Long buildAmountCent) { this.buildAmountCent = buildAmountCent; } + public Long getHotelAmountCent() { return hotelAmountCent; } + public void setHotelAmountCent(Long hotelAmountCent) { this.hotelAmountCent = hotelAmountCent; } + public Long getCateringAmountCent() { return cateringAmountCent; } + public void setCateringAmountCent(Long cateringAmountCent) { this.cateringAmountCent = cateringAmountCent; } + public Long getLocalTrafficAmountCent() { return localTrafficAmountCent; } + public void setLocalTrafficAmountCent(Long localTrafficAmountCent) { this.localTrafficAmountCent = localTrafficAmountCent; } + public Long getLongDistanceTrafficAmountCent() { return longDistanceTrafficAmountCent; } + public void setLongDistanceTrafficAmountCent(Long longDistanceTrafficAmountCent) { this.longDistanceTrafficAmountCent = longDistanceTrafficAmountCent; } + public Long getMaterialAmountCent() { return materialAmountCent; } + public void setMaterialAmountCent(Long materialAmountCent) { this.materialAmountCent = materialAmountCent; } + public Long getDesignAmountCent() { return designAmountCent; } + public void setDesignAmountCent(Long designAmountCent) { this.designAmountCent = designAmountCent; } + public Long getLaborPayableAmountCent() { return laborPayableAmountCent; } + public void setLaborPayableAmountCent(Long laborPayableAmountCent) { this.laborPayableAmountCent = laborPayableAmountCent; } + public Long getLaborActualAmountCent() { return laborActualAmountCent; } + public void setLaborActualAmountCent(Long laborActualAmountCent) { this.laborActualAmountCent = laborActualAmountCent; } + public Long getFinanceReviewFeeCent() { return financeReviewFeeCent; } + public void setFinanceReviewFeeCent(Long financeReviewFeeCent) { this.financeReviewFeeCent = financeReviewFeeCent; } + public Long getManagementFeeCent() { return managementFeeCent; } + public void setManagementFeeCent(Long managementFeeCent) { this.managementFeeCent = managementFeeCent; } + public Long getTaxFeeCent() { return taxFeeCent; } + public void setTaxFeeCent(Long taxFeeCent) { this.taxFeeCent = taxFeeCent; } + public String getCustomFeeJson() { return customFeeJson; } + public void setCustomFeeJson(String customFeeJson) { this.customFeeJson = customFeeJson; } + public Long getPaidAmountCent() { return paidAmountCent; } + public void setPaidAmountCent(Long paidAmountCent) { this.paidAmountCent = paidAmountCent; } + public Long getUnpaidAmountCent() { return unpaidAmountCent; } + public void setUnpaidAmountCent(Long unpaidAmountCent) { this.unpaidAmountCent = unpaidAmountCent; } + public String getReconciliationResult() { return reconciliationResult; } + public void setReconciliationResult(String reconciliationResult) { this.reconciliationResult = reconciliationResult; } + public Long getReconciliationDiffAmountCent() { return reconciliationDiffAmountCent; } + public void setReconciliationDiffAmountCent(Long reconciliationDiffAmountCent) { this.reconciliationDiffAmountCent = reconciliationDiffAmountCent; } + public String getReconciliationDiffReason() { return reconciliationDiffReason; } + public void setReconciliationDiffReason(String reconciliationDiffReason) { this.reconciliationDiffReason = reconciliationDiffReason; } + public String getSettlementNo() { return settlementNo; } + public void setSettlementNo(String settlementNo) { this.settlementNo = settlementNo; } + public String getStatus() { return status; } + public void setStatus(String status) { this.status = status; } +} diff --git a/backend/src/main/java/com/writeoff/module/finance/model/FinanceMeetingBillInfo.java b/backend/src/main/java/com/writeoff/module/finance/model/FinanceMeetingBillInfo.java new file mode 100644 index 0000000..5ada75f --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/finance/model/FinanceMeetingBillInfo.java @@ -0,0 +1,88 @@ +package com.writeoff.module.finance.model; + +public class FinanceMeetingBillInfo { + private Long id; + private Long projectId; + private Long meetingId; + private Long venueAmountCent; + private Long buildAmountCent; + private Long hotelAmountCent; + private Long cateringAmountCent; + private Long localTrafficAmountCent; + private Long longDistanceTrafficAmountCent; + private Long materialAmountCent; + private Long designAmountCent; + private Long laborPayableAmountCent; + private Long laborActualAmountCent; + private Long financeReviewFeeCent; + private Long managementFeeCent; + private Long taxFeeCent; + private String customFeeJson; + private Long paidAmountCent; + private Long unpaidAmountCent; + private String reconciliationResult; + private Long reconciliationDiffAmountCent; + private String reconciliationDiffReason; + private String settlementNo; + private String status; + private String updatedAt; + + public FinanceMeetingBillInfo(Long id, Long projectId, Long meetingId, Long venueAmountCent, Long buildAmountCent, Long hotelAmountCent, + Long cateringAmountCent, Long localTrafficAmountCent, Long longDistanceTrafficAmountCent, Long materialAmountCent, + Long designAmountCent, Long laborPayableAmountCent, Long laborActualAmountCent, Long financeReviewFeeCent, + Long managementFeeCent, Long taxFeeCent, String customFeeJson, Long paidAmountCent, Long unpaidAmountCent, + String reconciliationResult, Long reconciliationDiffAmountCent, String reconciliationDiffReason, String settlementNo, + String status, String updatedAt) { + this.id = id; + this.projectId = projectId; + this.meetingId = meetingId; + this.venueAmountCent = venueAmountCent; + this.buildAmountCent = buildAmountCent; + this.hotelAmountCent = hotelAmountCent; + this.cateringAmountCent = cateringAmountCent; + this.localTrafficAmountCent = localTrafficAmountCent; + this.longDistanceTrafficAmountCent = longDistanceTrafficAmountCent; + this.materialAmountCent = materialAmountCent; + this.designAmountCent = designAmountCent; + this.laborPayableAmountCent = laborPayableAmountCent; + this.laborActualAmountCent = laborActualAmountCent; + this.financeReviewFeeCent = financeReviewFeeCent; + this.managementFeeCent = managementFeeCent; + this.taxFeeCent = taxFeeCent; + this.customFeeJson = customFeeJson; + this.paidAmountCent = paidAmountCent; + this.unpaidAmountCent = unpaidAmountCent; + this.reconciliationResult = reconciliationResult; + this.reconciliationDiffAmountCent = reconciliationDiffAmountCent; + this.reconciliationDiffReason = reconciliationDiffReason; + this.settlementNo = settlementNo; + this.status = status; + this.updatedAt = updatedAt; + } + + public Long getId() { return id; } + public Long getProjectId() { return projectId; } + public Long getMeetingId() { return meetingId; } + public Long getVenueAmountCent() { return venueAmountCent; } + public Long getBuildAmountCent() { return buildAmountCent; } + public Long getHotelAmountCent() { return hotelAmountCent; } + public Long getCateringAmountCent() { return cateringAmountCent; } + public Long getLocalTrafficAmountCent() { return localTrafficAmountCent; } + public Long getLongDistanceTrafficAmountCent() { return longDistanceTrafficAmountCent; } + public Long getMaterialAmountCent() { return materialAmountCent; } + public Long getDesignAmountCent() { return designAmountCent; } + public Long getLaborPayableAmountCent() { return laborPayableAmountCent; } + public Long getLaborActualAmountCent() { return laborActualAmountCent; } + public Long getFinanceReviewFeeCent() { return financeReviewFeeCent; } + public Long getManagementFeeCent() { return managementFeeCent; } + public Long getTaxFeeCent() { return taxFeeCent; } + public String getCustomFeeJson() { return customFeeJson; } + public Long getPaidAmountCent() { return paidAmountCent; } + public Long getUnpaidAmountCent() { return unpaidAmountCent; } + public String getReconciliationResult() { return reconciliationResult; } + public Long getReconciliationDiffAmountCent() { return reconciliationDiffAmountCent; } + public String getReconciliationDiffReason() { return reconciliationDiffReason; } + public String getSettlementNo() { return settlementNo; } + public String getStatus() { return status; } + public String getUpdatedAt() { return updatedAt; } +} diff --git a/backend/src/main/java/com/writeoff/module/finance/model/Payment.java b/backend/src/main/java/com/writeoff/module/finance/model/Payment.java new file mode 100644 index 0000000..eb15322 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/finance/model/Payment.java @@ -0,0 +1,37 @@ +package com.writeoff.module.finance.model; + +public class Payment { + private Long id; + private Long projectId; + private Long meetingId; + private long amountCent; + private PaymentStatus status; + + public Payment(Long id, Long projectId, Long meetingId, long amountCent, PaymentStatus status) { + this.id = id; + this.projectId = projectId; + this.meetingId = meetingId; + this.amountCent = amountCent; + this.status = status; + } + + public Long getId() { + return id; + } + + public Long getProjectId() { + return projectId; + } + + public Long getMeetingId() { + return meetingId; + } + + public long getAmountCent() { + return amountCent; + } + + public PaymentStatus getStatus() { + return status; + } +} diff --git a/backend/src/main/java/com/writeoff/module/finance/model/PaymentStatus.java b/backend/src/main/java/com/writeoff/module/finance/model/PaymentStatus.java new file mode 100644 index 0000000..f3f3cb4 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/finance/model/PaymentStatus.java @@ -0,0 +1,8 @@ +package com.writeoff.module.finance.model; + +public enum PaymentStatus { + SUBMITTED, + CONFIRMED, + PARTIAL, + SETTLED +} diff --git a/backend/src/main/java/com/writeoff/module/finance/repository/InMemoryPaymentRepository.java b/backend/src/main/java/com/writeoff/module/finance/repository/InMemoryPaymentRepository.java new file mode 100644 index 0000000..1c595ed --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/finance/repository/InMemoryPaymentRepository.java @@ -0,0 +1,112 @@ +package com.writeoff.module.finance.repository; + +import com.writeoff.module.finance.model.Payment; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Repository; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; + +@Repository +@ConditionalOnProperty(prefix = "app.repository", name = "mode", havingValue = "in-memory") +public class InMemoryPaymentRepository implements PaymentRepository { + private final ConcurrentHashMap store = new ConcurrentHashMap<>(); + private final ConcurrentHashMap projectLockStore = new ConcurrentHashMap<>(); + private final List> reconciliationStore = new ArrayList<>(); + private final List> lockLogStore = new ArrayList<>(); + private final AtomicLong idGenerator = new AtomicLong(9000); + + @Override + public Payment save(Payment payment) { + if (payment.getId() == null) { + Payment newPayment = new Payment( + idGenerator.incrementAndGet(), + payment.getProjectId(), + payment.getMeetingId(), + payment.getAmountCent(), + payment.getStatus() + ); + store.put(newPayment.getId(), newPayment); + return newPayment; + } + store.put(payment.getId(), payment); + return payment; + } + + @Override + public List findAll() { + return new ArrayList<>(store.values()); + } + + @Override + public boolean isProjectLocked(Long projectId) { + return Boolean.TRUE.equals(projectLockStore.get(projectId)); + } + + @Override + public void lockProject(Long projectId, String reason, Long operatorUserId) { + projectLockStore.put(projectId, true); + Map row = new LinkedHashMap<>(); + row.put("id", lockLogStore.size() + 1L); + row.put("projectId", projectId); + row.put("lockStatus", "LOCKED"); + row.put("reason", reason); + row.put("createdAt", String.valueOf(System.currentTimeMillis())); + lockLogStore.add(0, row); + } + + @Override + public void unlockProject(Long projectId, String reason, Long operatorUserId) { + projectLockStore.put(projectId, false); + Map row = new LinkedHashMap<>(); + row.put("id", lockLogStore.size() + 1L); + row.put("projectId", projectId); + row.put("lockStatus", "UNLOCKED"); + row.put("reason", reason); + row.put("createdAt", String.valueOf(System.currentTimeMillis())); + lockLogStore.add(0, row); + } + + @Override + public Map createReconciliation(Long projectId, Long expectedAmountCent, Long operatorUserId) { + long actual = store.values().stream() + .filter(x -> x.getProjectId().equals(projectId)) + .mapToLong(Payment::getAmountCent) + .sum(); + long expected = expectedAmountCent == null ? actual : expectedAmountCent; + long diff = actual - expected; + Map row = new LinkedHashMap<>(); + row.put("id", reconciliationStore.size() + 1L); + row.put("projectId", projectId); + row.put("expectedAmountCent", expected); + row.put("actualAmountCent", actual); + row.put("diffAmountCent", diff); + row.put("resultStatus", diff == 0 ? "MATCH" : "DIFF"); + row.put("createdAt", String.valueOf(System.currentTimeMillis())); + reconciliationStore.add(0, row); + return row; + } + + @Override + public List> listReconciliations() { + return new ArrayList<>(reconciliationStore); + } + + @Override + public List> listLockLogs(Long projectId) { + if (projectId == null) { + return new ArrayList<>(lockLogStore); + } + List> list = new ArrayList<>(); + for (Map row : lockLogStore) { + if (projectId.equals(row.get("projectId"))) { + list.add(row); + } + } + return list; + } +} diff --git a/backend/src/main/java/com/writeoff/module/finance/repository/JdbcPaymentRepository.java b/backend/src/main/java/com/writeoff/module/finance/repository/JdbcPaymentRepository.java new file mode 100644 index 0000000..91f754d --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/finance/repository/JdbcPaymentRepository.java @@ -0,0 +1,187 @@ +package com.writeoff.module.finance.repository; + +import com.writeoff.module.finance.model.Payment; +import com.writeoff.module.finance.model.PaymentStatus; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.jdbc.support.GeneratedKeyHolder; +import org.springframework.jdbc.support.KeyHolder; +import org.springframework.stereotype.Repository; +import com.writeoff.security.AuthContext; + +import java.sql.PreparedStatement; +import java.sql.Statement; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@Repository +@ConditionalOnProperty(prefix = "app.repository", name = "mode", havingValue = "jdbc", matchIfMissing = true) +public class JdbcPaymentRepository implements PaymentRepository { + private final JdbcTemplate jdbcTemplate; + private static final RowMapper ROW_MAPPER = (rs, n) -> new Payment( + rs.getLong("id"), + rs.getLong("project_id"), + rs.getLong("meeting_id"), + rs.getLong("amount_cent"), + PaymentStatus.valueOf(rs.getString("payment_status")) + ); + + public JdbcPaymentRepository(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + @Override + public Payment save(Payment payment) { + if (payment.getId() == null) { + KeyHolder keyHolder = new GeneratedKeyHolder(); + jdbcTemplate.update(connection -> { + PreparedStatement ps = connection.prepareStatement( + "INSERT INTO finance_payment (tenant_id, project_id, meeting_id, amount_cent, payment_status, created_by, updated_by) VALUES (?, ?, ?, ?, ?, 0, 0)", + Statement.RETURN_GENERATED_KEYS + ); + ps.setLong(1, tenantId()); + ps.setLong(2, payment.getProjectId()); + ps.setLong(3, payment.getMeetingId()); + ps.setLong(4, payment.getAmountCent()); + ps.setString(5, payment.getStatus().name()); + return ps; + }, keyHolder); + Number key = keyHolder.getKey(); + Long id = key == null ? null : key.longValue(); + return new Payment(id, payment.getProjectId(), payment.getMeetingId(), payment.getAmountCent(), payment.getStatus()); + } + return payment; + } + + @Override + public List findAll() { + return jdbcTemplate.query("SELECT * FROM finance_payment WHERE tenant_id=? AND is_deleted=0 ORDER BY id DESC", ROW_MAPPER, tenantId()); + } + + @Override + public boolean isProjectLocked(Long projectId) { + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM finance_lock_log WHERE tenant_id=? AND project_id=? AND lock_status='LOCKED' AND is_deleted=0", + Integer.class, + tenantId(), + projectId + ); + return count != null && count > 0; + } + + @Override + public void lockProject(Long projectId, String reason, Long operatorUserId) { + jdbcTemplate.update( + "UPDATE finance_lock_log SET is_deleted=1, updated_by=?, updated_at=CURRENT_TIMESTAMP WHERE tenant_id=? AND project_id=? AND lock_status='LOCKED' AND is_deleted=0", + operatorUserId == null ? 0L : operatorUserId, + tenantId(), + projectId + ); + jdbcTemplate.update( + "INSERT INTO finance_lock_log (tenant_id, project_id, lock_status, reason, created_by, updated_by) VALUES (?, ?, 'LOCKED', ?, ?, ?)", + tenantId(), + projectId, + reason, + operatorUserId == null ? 0L : operatorUserId, + operatorUserId == null ? 0L : operatorUserId + ); + } + + @Override + public void unlockProject(Long projectId, String reason, Long operatorUserId) { + jdbcTemplate.update( + "UPDATE finance_lock_log SET is_deleted=1, updated_by=?, updated_at=CURRENT_TIMESTAMP WHERE tenant_id=? AND project_id=? AND lock_status='LOCKED' AND is_deleted=0", + operatorUserId == null ? 0L : operatorUserId, + tenantId(), + projectId + ); + jdbcTemplate.update( + "INSERT INTO finance_lock_log (tenant_id, project_id, lock_status, reason, created_by, updated_by) VALUES (?, ?, 'UNLOCKED', ?, ?, ?)", + tenantId(), + projectId, + reason, + operatorUserId == null ? 0L : operatorUserId, + operatorUserId == null ? 0L : operatorUserId + ); + } + + @Override + public Map createReconciliation(Long projectId, Long expectedAmountCent, Long operatorUserId) { + Long actualAmountCent = jdbcTemplate.queryForObject( + "SELECT IFNULL(SUM(amount_cent),0) FROM finance_payment WHERE tenant_id=? AND project_id=? AND is_deleted=0", + Long.class, + tenantId(), + projectId + ); + long actual = actualAmountCent == null ? 0L : actualAmountCent; + long expected = expectedAmountCent == null ? actual : expectedAmountCent; + long diff = actual - expected; + String resultStatus = diff == 0 ? "MATCH" : "DIFF"; + jdbcTemplate.update( + "INSERT INTO finance_reconciliation (tenant_id, project_id, expected_amount_cent, actual_amount_cent, diff_amount_cent, result_status, created_by, updated_by) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + tenantId(), + projectId, + expected, + actual, + diff, + resultStatus, + operatorUserId == null ? 0L : operatorUserId, + operatorUserId == null ? 0L : operatorUserId + ); + Map data = new LinkedHashMap<>(); + data.put("projectId", projectId); + data.put("expectedAmountCent", expected); + data.put("actualAmountCent", actual); + data.put("diffAmountCent", diff); + data.put("resultStatus", resultStatus); + return data; + } + + @Override + public List> listReconciliations() { + return jdbcTemplate.query( + "SELECT id, project_id, expected_amount_cent, actual_amount_cent, diff_amount_cent, result_status, " + + "DATE_FORMAT(created_at, '%Y-%m-%d %H:%i:%s') AS created_at " + + "FROM finance_reconciliation WHERE tenant_id=? AND is_deleted=0 ORDER BY id DESC", + (rs, n) -> { + Map row = new LinkedHashMap<>(); + row.put("id", rs.getLong("id")); + row.put("projectId", rs.getLong("project_id")); + row.put("expectedAmountCent", rs.getLong("expected_amount_cent")); + row.put("actualAmountCent", rs.getLong("actual_amount_cent")); + row.put("diffAmountCent", rs.getLong("diff_amount_cent")); + row.put("resultStatus", rs.getString("result_status")); + row.put("createdAt", rs.getString("created_at")); + return row; + }, + tenantId() + ); + } + + @Override + public List> listLockLogs(Long projectId) { + return jdbcTemplate.query( + "SELECT id, project_id, lock_status, reason, DATE_FORMAT(created_at, '%Y-%m-%d %H:%i:%s') AS created_at " + + "FROM finance_lock_log WHERE tenant_id=? AND (? IS NULL OR project_id=?) ORDER BY id DESC", + (rs, n) -> { + Map row = new LinkedHashMap<>(); + row.put("id", rs.getLong("id")); + row.put("projectId", rs.getLong("project_id")); + row.put("lockStatus", rs.getString("lock_status")); + row.put("reason", rs.getString("reason")); + row.put("createdAt", rs.getString("created_at")); + return row; + }, + tenantId(), + projectId, + projectId + ); + } + + private Long tenantId() { + return AuthContext.requireTenantId(); + } +} diff --git a/backend/src/main/java/com/writeoff/module/finance/repository/PaymentRepository.java b/backend/src/main/java/com/writeoff/module/finance/repository/PaymentRepository.java new file mode 100644 index 0000000..2f5db26 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/finance/repository/PaymentRepository.java @@ -0,0 +1,24 @@ +package com.writeoff.module.finance.repository; + +import com.writeoff.module.finance.model.Payment; + +import java.util.Map; +import java.util.List; + +public interface PaymentRepository { + Payment save(Payment payment); + + List findAll(); + + boolean isProjectLocked(Long projectId); + + void lockProject(Long projectId, String reason, Long operatorUserId); + + void unlockProject(Long projectId, String reason, Long operatorUserId); + + Map createReconciliation(Long projectId, Long expectedAmountCent, Long operatorUserId); + + List> listReconciliations(); + + List> listLockLogs(Long projectId); +} diff --git a/backend/src/main/java/com/writeoff/module/finance/service/FinanceService.java b/backend/src/main/java/com/writeoff/module/finance/service/FinanceService.java new file mode 100644 index 0000000..4d47644 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/finance/service/FinanceService.java @@ -0,0 +1,267 @@ +package com.writeoff.module.finance.service; + +import com.writeoff.common.api.PageResult; +import com.writeoff.common.exception.BusinessException; +import com.writeoff.common.exception.ErrorCodes; +import com.writeoff.module.finance.dto.ConfirmPaymentRequest; +import com.writeoff.module.finance.dto.FinanceLockRequest; +import com.writeoff.module.finance.dto.FinanceReconciliationRequest; +import com.writeoff.module.finance.dto.UpsertFinanceMeetingBillRequest; +import com.writeoff.module.finance.model.FinanceMeetingBillInfo; +import com.writeoff.module.finance.model.Payment; +import com.writeoff.module.finance.model.PaymentStatus; +import com.writeoff.module.finance.repository.PaymentRepository; +import com.writeoff.module.meeting.model.Meeting; +import com.writeoff.module.meeting.model.MeetingAuditStatus; +import com.writeoff.module.meeting.service.MeetingService; +import com.writeoff.module.system.service.DataPermissionService; +import com.writeoff.security.AuthContext; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Service; + +import java.util.LinkedHashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; + +@Service +public class FinanceService { + private final PaymentRepository paymentRepository; + private final MeetingService meetingService; + private final DataPermissionService dataPermissionService; + private final JdbcTemplate jdbcTemplate; + private final Map paymentIdempotency = new ConcurrentHashMap<>(); + private static final RowMapper BILL_ROW_MAPPER = (rs, n) -> new FinanceMeetingBillInfo( + rs.getLong("id"), + rs.getLong("project_id"), + rs.getLong("meeting_id"), + rs.getLong("venue_amount_cent"), + rs.getLong("build_amount_cent"), + rs.getLong("hotel_amount_cent"), + rs.getLong("catering_amount_cent"), + rs.getLong("local_traffic_amount_cent"), + rs.getLong("long_distance_traffic_amount_cent"), + rs.getLong("material_amount_cent"), + rs.getLong("design_amount_cent"), + rs.getLong("labor_payable_amount_cent"), + rs.getLong("labor_actual_amount_cent"), + rs.getLong("finance_review_fee_cent"), + rs.getLong("management_fee_cent"), + rs.getLong("tax_fee_cent"), + rs.getString("custom_fee_json"), + rs.getLong("paid_amount_cent"), + rs.getLong("unpaid_amount_cent"), + rs.getString("reconciliation_result"), + rs.getLong("reconciliation_diff_amount_cent"), + rs.getString("reconciliation_diff_reason"), + rs.getString("settlement_no"), + rs.getString("status"), + rs.getString("updated_at") + ); + + @Autowired + public FinanceService(PaymentRepository paymentRepository, MeetingService meetingService, DataPermissionService dataPermissionService, JdbcTemplate jdbcTemplate) { + this.paymentRepository = paymentRepository; + this.meetingService = meetingService; + this.dataPermissionService = dataPermissionService; + this.jdbcTemplate = jdbcTemplate; + } + + public FinanceService(PaymentRepository paymentRepository, MeetingService meetingService, JdbcTemplate jdbcTemplate) { + this(paymentRepository, meetingService, null, jdbcTemplate); + } + + public FinanceService(PaymentRepository paymentRepository, MeetingService meetingService) { + this(paymentRepository, meetingService, null, null); + } + + public PageResult listProjects() { + List list = paymentRepository.findAll(); + if (dataPermissionService != null) { + DataPermissionService.DataScope scope = dataPermissionService.resolveCurrentUserScope(); + Set meetingIds = list.stream().map(Payment::getMeetingId).collect(Collectors.toCollection(HashSet::new)); + Map meetingCreatorMap = dataPermissionService.listMeetingCreators(meetingIds); + Map meetingProjectMap = dataPermissionService.listMeetingProjectIds(meetingIds); + Set projectIds = new HashSet<>(meetingProjectMap.values()); + Map projectCreatorMap = dataPermissionService.listProjectCreators(projectIds); + list = list.stream() + .filter(payment -> { + Long projectId = payment.getProjectId(); + if (projectId == null) { + projectId = meetingProjectMap.get(payment.getMeetingId()); + } + Long meetingCreatedBy = meetingCreatorMap.get(payment.getMeetingId()); + Long projectCreatedBy = projectId == null ? null : projectCreatorMap.get(projectId); + return dataPermissionService.canAccessMeeting(payment.getMeetingId(), projectId, meetingCreatedBy, projectCreatedBy, scope); + }) + .collect(Collectors.toList()); + } + return new PageResult<>(list, list.size(), 1, 20); + } + + public Map confirmPayment(ConfirmPaymentRequest request) { + if (paymentIdempotency.containsKey(request.getIdempotencyKey())) { + throw new BusinessException(ErrorCodes.IDEMPOTENCY_CONFLICT, "请求幂等冲突"); + } + paymentIdempotency.put(request.getIdempotencyKey(), request.getMeetingId()); + + Meeting meeting = meetingService.getById(request.getMeetingId()); + if (meeting.getAuditStatus() != MeetingAuditStatus.APPROVED) { + throw new BusinessException(ErrorCodes.PAYMENT_STATE_INVALID, "当前会议未终审通过,不能支付确认"); + } + if (paymentRepository.isProjectLocked(request.getProjectId())) { + throw new BusinessException(ErrorCodes.PAYMENT_LOCKED, "项目已锁账,暂不允许支付确认"); + } + + Payment payment = paymentRepository.save(new Payment( + null, + request.getProjectId(), + request.getMeetingId(), + request.getAmountCent(), + PaymentStatus.CONFIRMED + )); + + Map result = new LinkedHashMap<>(); + result.put("paymentId", payment.getId()); + result.put("paymentStatus", payment.getStatus().name()); + return result; + } + + public Map exportLedger() { + if (dataPermissionService != null && !dataPermissionService.canExportCurrentUser()) { + throw new BusinessException(ErrorCodes.NO_PERMISSION, "当前账号无导出权限"); + } + List list = listProjects().getList(); + Map result = new LinkedHashMap<>(); + result.put("total", list.size()); + result.put("records", list); + return result; + } + + public Map reconciliation(FinanceReconciliationRequest request) { + checkIdempotency(request.getIdempotencyKey(), request.getProjectId()); + return paymentRepository.createReconciliation(request.getProjectId(), request.getExpectedAmountCent(), 0L); + } + + public Map lock(FinanceLockRequest request) { + checkIdempotency(request.getIdempotencyKey(), request.getProjectId()); + paymentRepository.lockProject(request.getProjectId(), request.getReason(), 0L); + Map result = new LinkedHashMap<>(); + result.put("projectId", request.getProjectId()); + result.put("lockStatus", "LOCKED"); + return result; + } + + public Map unlock(FinanceLockRequest request) { + checkIdempotency(request.getIdempotencyKey(), request.getProjectId()); + paymentRepository.unlockProject(request.getProjectId(), request.getReason(), 0L); + Map result = new LinkedHashMap<>(); + result.put("projectId", request.getProjectId()); + result.put("lockStatus", "UNLOCKED"); + return result; + } + + public Map reconciliationList(Long projectId) { + Map result = new LinkedHashMap<>(); + result.put("reconciliation", paymentRepository.listReconciliations()); + result.put("lockLogs", paymentRepository.listLockLogs(projectId)); + return result; + } + + public PageResult listMeetingBills(Long projectId) { + List list = jdbcTemplate.query( + "SELECT id, project_id, meeting_id, venue_amount_cent, build_amount_cent, hotel_amount_cent, catering_amount_cent, local_traffic_amount_cent, " + + "long_distance_traffic_amount_cent, material_amount_cent, design_amount_cent, labor_payable_amount_cent, labor_actual_amount_cent, finance_review_fee_cent, " + + "management_fee_cent, tax_fee_cent, custom_fee_json, paid_amount_cent, unpaid_amount_cent, reconciliation_result, reconciliation_diff_amount_cent, " + + "reconciliation_diff_reason, settlement_no, status, DATE_FORMAT(updated_at, '%Y-%m-%d %H:%i:%s') AS updated_at " + + "FROM finance_meeting_bill WHERE tenant_id=? AND is_deleted=0 AND (? IS NULL OR project_id=?) ORDER BY id DESC", + BILL_ROW_MAPPER, + tenantId(), + projectId, + projectId + ); + return new PageResult<>(list, list.size(), 1, 50); + } + + public FinanceMeetingBillInfo upsertMeetingBill(UpsertFinanceMeetingBillRequest request) { + jdbcTemplate.update( + "INSERT INTO finance_meeting_bill (tenant_id, project_id, meeting_id, venue_amount_cent, build_amount_cent, hotel_amount_cent, catering_amount_cent, local_traffic_amount_cent, " + + "long_distance_traffic_amount_cent, material_amount_cent, design_amount_cent, labor_payable_amount_cent, labor_actual_amount_cent, finance_review_fee_cent, management_fee_cent, " + + "tax_fee_cent, custom_fee_json, paid_amount_cent, unpaid_amount_cent, reconciliation_result, reconciliation_diff_amount_cent, reconciliation_diff_reason, settlement_no, status, created_by, updated_by) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) " + + "ON DUPLICATE KEY UPDATE project_id=VALUES(project_id), venue_amount_cent=VALUES(venue_amount_cent), build_amount_cent=VALUES(build_amount_cent), " + + "hotel_amount_cent=VALUES(hotel_amount_cent), catering_amount_cent=VALUES(catering_amount_cent), local_traffic_amount_cent=VALUES(local_traffic_amount_cent), " + + "long_distance_traffic_amount_cent=VALUES(long_distance_traffic_amount_cent), material_amount_cent=VALUES(material_amount_cent), design_amount_cent=VALUES(design_amount_cent), " + + "labor_payable_amount_cent=VALUES(labor_payable_amount_cent), labor_actual_amount_cent=VALUES(labor_actual_amount_cent), finance_review_fee_cent=VALUES(finance_review_fee_cent), " + + "management_fee_cent=VALUES(management_fee_cent), tax_fee_cent=VALUES(tax_fee_cent), custom_fee_json=VALUES(custom_fee_json), paid_amount_cent=VALUES(paid_amount_cent), " + + "unpaid_amount_cent=VALUES(unpaid_amount_cent), reconciliation_result=VALUES(reconciliation_result), reconciliation_diff_amount_cent=VALUES(reconciliation_diff_amount_cent), " + + "reconciliation_diff_reason=VALUES(reconciliation_diff_reason), settlement_no=VALUES(settlement_no), status=VALUES(status), updated_at=CURRENT_TIMESTAMP, updated_by=VALUES(updated_by)", + tenantId(), + request.getProjectId(), + request.getMeetingId(), + nvl(request.getVenueAmountCent()), + nvl(request.getBuildAmountCent()), + nvl(request.getHotelAmountCent()), + nvl(request.getCateringAmountCent()), + nvl(request.getLocalTrafficAmountCent()), + nvl(request.getLongDistanceTrafficAmountCent()), + nvl(request.getMaterialAmountCent()), + nvl(request.getDesignAmountCent()), + nvl(request.getLaborPayableAmountCent()), + nvl(request.getLaborActualAmountCent()), + nvl(request.getFinanceReviewFeeCent()), + nvl(request.getManagementFeeCent()), + nvl(request.getTaxFeeCent()), + request.getCustomFeeJson(), + nvl(request.getPaidAmountCent()), + nvl(request.getUnpaidAmountCent()), + request.getReconciliationResult(), + nvl(request.getReconciliationDiffAmountCent()), + request.getReconciliationDiffReason(), + request.getSettlementNo(), + request.getStatus() == null || request.getStatus().trim().isEmpty() ? "DRAFT" : request.getStatus().trim().toUpperCase(), + safeUserId(), + safeUserId() + ); + + List list = jdbcTemplate.query( + "SELECT id, project_id, meeting_id, venue_amount_cent, build_amount_cent, hotel_amount_cent, catering_amount_cent, local_traffic_amount_cent, " + + "long_distance_traffic_amount_cent, material_amount_cent, design_amount_cent, labor_payable_amount_cent, labor_actual_amount_cent, finance_review_fee_cent, " + + "management_fee_cent, tax_fee_cent, custom_fee_json, paid_amount_cent, unpaid_amount_cent, reconciliation_result, reconciliation_diff_amount_cent, " + + "reconciliation_diff_reason, settlement_no, status, DATE_FORMAT(updated_at, '%Y-%m-%d %H:%i:%s') AS updated_at " + + "FROM finance_meeting_bill WHERE tenant_id=? AND meeting_id=? AND is_deleted=0 LIMIT 1", + BILL_ROW_MAPPER, + tenantId(), + request.getMeetingId() + ); + if (list.isEmpty()) { + throw new BusinessException(ErrorCodes.RESOURCE_NOT_FOUND, "会议账单不存在"); + } + return list.get(0); + } + + private void checkIdempotency(String key, Long marker) { + if (paymentIdempotency.containsKey(key)) { + throw new BusinessException(ErrorCodes.IDEMPOTENCY_CONFLICT, "请求幂等冲突"); + } + paymentIdempotency.put(key, marker == null ? 0L : marker); + } + + private long nvl(Long v) { + return v == null ? 0L : v; + } + + private Long tenantId() { + return AuthContext.requireTenantId(); + } + + private Long safeUserId() { + Long userId = AuthContext.userId(); + return userId == null ? 0L : userId; + } +} diff --git a/backend/src/main/java/com/writeoff/module/meeting/controller/MeetingController.java b/backend/src/main/java/com/writeoff/module/meeting/controller/MeetingController.java new file mode 100644 index 0000000..ae1aa54 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/meeting/controller/MeetingController.java @@ -0,0 +1,294 @@ +package com.writeoff.module.meeting.controller; + +import com.writeoff.common.api.ApiResponse; +import com.writeoff.common.api.PageResult; +import com.writeoff.module.meeting.dto.CreateMeetingRequest; +import com.writeoff.module.meeting.dto.MeetingQueryRequest; +import com.writeoff.module.meeting.dto.CreateMeetingMaterialsExportRequest; +import com.writeoff.module.meeting.dto.GenerateMeetingSummaryRequest; +import com.writeoff.module.meeting.dto.BindMeetingExpertsRequest; +import com.writeoff.module.meeting.dto.MeetingMaterialUploadSignRequest; +import com.writeoff.module.export.dto.CreateExportTaskRequest; +import com.writeoff.module.export.service.ExportTaskService; +import com.writeoff.module.expert.dto.AddBankCardRequest; +import com.writeoff.module.expert.dto.CreateExpertRequest; +import com.writeoff.module.meeting.dto.MeetingInvoiceConfigRequest; +import com.writeoff.module.meeting.dto.MeetingLaborAgreementExtractApplyRequest; +import com.writeoff.module.meeting.dto.MeetingLaborAgreementExtractQueryRequest; +import com.writeoff.module.meeting.dto.MeetingLaborAgreementExtractSubmitRequest; +import com.writeoff.module.meeting.dto.SaveMeetingMaterialRequest; +import com.writeoff.module.meeting.dto.SubmitMeetingRequest; +import com.writeoff.module.meeting.dto.SubmitMeetingMaterialRequest; +import com.writeoff.module.meeting.dto.WithdrawMeetingRequest; +import com.writeoff.module.meeting.model.Meeting; +import com.writeoff.module.meeting.model.MeetingExpertBinding; +import com.writeoff.module.meeting.model.MeetingLaborAgreementExtractResult; +import com.writeoff.module.meeting.model.MeetingMaterial; +import com.writeoff.module.meeting.model.MeetingMaterialHistory; +import com.writeoff.module.expert.model.ExpertBankCardInfo; +import com.writeoff.module.expert.model.ExpertInfo; +import com.writeoff.module.expert.service.PlatformExpertService; +import com.writeoff.module.meeting.service.MeetingExpertBindingService; +import com.writeoff.module.meeting.service.MeetingLaborAgreementExtractService; +import com.writeoff.module.meeting.service.MeetingMaterialService; +import com.writeoff.module.meeting.service.MeetingService; +import com.writeoff.module.template.model.TemplateInfo; +import com.writeoff.module.template.service.TemplateService; +import com.writeoff.module.ocr.dto.DocumentExtractTaskSubmitResponse; +import com.writeoff.security.DataScopeType; +import com.writeoff.security.RequirePermission; +import javax.validation.Valid; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; +import java.util.List; + +@RestController +@RequestMapping("/api/meetings") +public class MeetingController { + private final MeetingService meetingService; + private final MeetingMaterialService meetingMaterialService; + private final TemplateService templateService; + private final PlatformExpertService platformExpertService; + private final MeetingExpertBindingService meetingExpertBindingService; + private final MeetingLaborAgreementExtractService meetingLaborAgreementExtractService; + private final ExportTaskService exportTaskService; + + public MeetingController(MeetingService meetingService, + MeetingMaterialService meetingMaterialService, + TemplateService templateService, + PlatformExpertService platformExpertService, + MeetingExpertBindingService meetingExpertBindingService, + MeetingLaborAgreementExtractService meetingLaborAgreementExtractService, + ExportTaskService exportTaskService) { + this.meetingService = meetingService; + this.meetingMaterialService = meetingMaterialService; + this.templateService = templateService; + this.platformExpertService = platformExpertService; + this.meetingExpertBindingService = meetingExpertBindingService; + this.meetingLaborAgreementExtractService = meetingLaborAgreementExtractService; + this.exportTaskService = exportTaskService; + } + + @GetMapping + @RequirePermission(value = "meeting.read", dataScope = DataScopeType.TENANT, auditAction = "MEETING_LIST") + public ApiResponse> list(MeetingQueryRequest query) { + return ApiResponse.success(meetingService.list(query)); + } + + @GetMapping("/tenant-experts") + @RequirePermission(value = "meeting.material.read", dataScope = DataScopeType.TENANT, auditAction = "MEETING_TENANT_EXPERT_LIST") + public ApiResponse> tenantExperts(@RequestParam(value = "keyword", required = false) String keyword) { + return ApiResponse.success(platformExpertService.list(keyword)); + } + + @PostMapping("/tenant-experts") + @RequirePermission(value = "meeting.material.save", dataScope = DataScopeType.TENANT, auditAction = "MEETING_TENANT_EXPERT_CREATE") + public ApiResponse createTenantContextExpert(@RequestBody @Valid CreateExpertRequest request) { + return ApiResponse.success(platformExpertService.create(request)); + } + + @PostMapping("/tenant-experts/{expertId}/bank-cards") + @RequirePermission(value = "meeting.material.save", dataScope = DataScopeType.TENANT, auditAction = "MEETING_TENANT_EXPERT_BANK_CARD_CREATE") + public ApiResponse addTenantContextExpertBankCard(@PathVariable("expertId") Long expertId, + @RequestBody @Valid AddBankCardRequest request) { + return ApiResponse.success(platformExpertService.addCard(expertId, request)); + } + + @GetMapping("/{id}/experts") + @RequirePermission(value = "meeting.material.read", dataScope = DataScopeType.MEETING, auditAction = "MEETING_EXPERT_BINDING_LIST") + public ApiResponse> listExperts(@PathVariable("id") Long id) { + return ApiResponse.success(meetingExpertBindingService.listByMeetingId(id)); + } + + @PostMapping("/{id}/experts/bind") + @RequirePermission(value = "meeting.material.save", dataScope = DataScopeType.MEETING, auditAction = "MEETING_EXPERT_BINDING_SAVE") + public ApiResponse> bindExperts(@PathVariable("id") Long id, + @RequestBody @Valid BindMeetingExpertsRequest request) { + return ApiResponse.success(meetingExpertBindingService.bind(id, request)); + } + + @DeleteMapping("/{id}/experts/{expertId}") + @RequirePermission(value = "meeting.material.save", dataScope = DataScopeType.MEETING, auditAction = "MEETING_EXPERT_BINDING_DELETE") + public ApiResponse> unbindExpert(@PathVariable("id") Long id, + @PathVariable("expertId") Long expertId) { + return ApiResponse.success(meetingExpertBindingService.unbindOne(id, expertId)); + } + + @PostMapping("/{id}/labor-agreement-extract/task") + @RequirePermission(value = "meeting.material.save", dataScope = DataScopeType.MEETING, auditAction = "MEETING_LABOR_AGREEMENT_EXTRACT_SUBMIT") + public ApiResponse submitLaborAgreementExtractTask(@PathVariable("id") Long id, + @RequestBody @Valid MeetingLaborAgreementExtractSubmitRequest request) { + return ApiResponse.success(meetingLaborAgreementExtractService.submit(id, request)); + } + + @PostMapping("/{id}/labor-agreement-extract/query") + @RequirePermission(value = "meeting.material.save", dataScope = DataScopeType.MEETING, auditAction = "MEETING_LABOR_AGREEMENT_EXTRACT_QUERY") + public ApiResponse queryLaborAgreementExtract(@PathVariable("id") Long id, + @RequestBody @Valid MeetingLaborAgreementExtractQueryRequest request) { + return ApiResponse.success(meetingLaborAgreementExtractService.query(id, request.getTaskId())); + } + + @PostMapping("/{id}/labor-agreement-extract/apply") + @RequirePermission(value = "meeting.material.save", dataScope = DataScopeType.MEETING, auditAction = "MEETING_LABOR_AGREEMENT_EXTRACT_APPLY") + public ApiResponse> applyLaborAgreementExtract(@PathVariable("id") Long id, + @RequestBody @Valid MeetingLaborAgreementExtractApplyRequest request) { + return ApiResponse.success(meetingLaborAgreementExtractService.apply(id, request)); + } + + @PostMapping + @RequirePermission(value = "meeting.create", dataScope = DataScopeType.PROJECT, auditAction = "MEETING_CREATE") + public ApiResponse create(@RequestBody @Valid CreateMeetingRequest request) { + return ApiResponse.success(meetingService.create(request)); + } + + @GetMapping("/{id}") + @RequirePermission(value = "meeting.read", dataScope = DataScopeType.MEETING, auditAction = "MEETING_DETAIL") + public ApiResponse detail(@PathVariable("id") Long id) { + return ApiResponse.success(meetingService.getById(id)); + } + + @GetMapping("/{id}/change-logs") + @RequirePermission(value = "meeting.change-log.read", dataScope = DataScopeType.MEETING, auditAction = "MEETING_CHANGE_LOG_LIST") + public ApiResponse>> changeLogs(@PathVariable("id") Long id) { + return ApiResponse.success(meetingService.listChangeLogs(id)); + } + + @PutMapping("/{id}") + @RequirePermission(value = "meeting.create", dataScope = DataScopeType.MEETING, auditAction = "MEETING_UPDATE") + public ApiResponse update(@PathVariable("id") Long id, + @RequestBody @Valid CreateMeetingRequest request) { + return ApiResponse.success(meetingService.update(id, request)); + } + + @PutMapping("/{id}/invoice-config") + @RequirePermission(value = "meeting.invoice.config", dataScope = DataScopeType.MEETING, auditAction = "MEETING_INVOICE_CONFIG_UPDATE") + public ApiResponse updateInvoiceConfig(@PathVariable("id") Long id, + @RequestBody @Valid MeetingInvoiceConfigRequest request) { + return ApiResponse.success(meetingService.updateInvoiceConfig(id, request)); + } + + @PostMapping("/{id}/submit") + @RequirePermission(value = "meeting.submit", dataScope = DataScopeType.MEETING, auditAction = "MEETING_SUBMIT") + public ApiResponse> submit(@PathVariable("id") Long id, + @RequestBody @Valid SubmitMeetingRequest request) { + return ApiResponse.success(meetingService.submit(id, request)); + } + + @PostMapping("/{id}/withdraw") + @RequirePermission(value = "meeting.withdraw", dataScope = DataScopeType.MEETING, auditAction = "MEETING_WITHDRAW") + public ApiResponse> withdraw(@PathVariable("id") Long id, + @RequestBody @Valid WithdrawMeetingRequest request) { + return ApiResponse.success(meetingService.withdraw(id, request)); + } + + @PostMapping("/{id}/delete") + @RequirePermission(value = "meeting.delete", dataScope = DataScopeType.MEETING, auditAction = "MEETING_DELETE") + public ApiResponse deleteDraft(@PathVariable("id") Long id) { + meetingService.deleteDraft(id); + return ApiResponse.success("ok"); + } + + @PostMapping("/{id}/cancel") + @RequirePermission(value = "meeting.cancel", dataScope = DataScopeType.MEETING, auditAction = "MEETING_CANCEL") + public ApiResponse> cancel(@PathVariable("id") Long id, + @RequestBody Map body) { + String reason = body == null ? null : body.get("reason"); + return ApiResponse.success(meetingService.cancel(id, reason)); + } + + @GetMapping("/{id}/materials") + @RequirePermission(value = "meeting.material.read", dataScope = DataScopeType.MEETING_MODULE, auditAction = "MEETING_MATERIAL_LIST") + public ApiResponse> materials(@PathVariable("id") Long id) { + return ApiResponse.success(meetingMaterialService.list(id)); + } + + @PostMapping("/{id}/materials/{moduleCode}/save") + @RequirePermission(value = "meeting.material.save", dataScope = DataScopeType.MEETING_MODULE, auditAction = "MEETING_MATERIAL_SAVE") + public ApiResponse saveMaterial(@PathVariable("id") Long id, + @PathVariable("moduleCode") String moduleCode, + @RequestBody @Valid SaveMeetingMaterialRequest request) { + return ApiResponse.success(meetingMaterialService.save(id, moduleCode, request)); + } + + @PostMapping("/{id}/materials/{moduleCode}/submit") + @RequirePermission(value = "meeting.material.submit", dataScope = DataScopeType.MEETING_MODULE, auditAction = "MEETING_MATERIAL_SUBMIT") + public ApiResponse submitMaterial(@PathVariable("id") Long id, + @PathVariable("moduleCode") String moduleCode, + @RequestBody @Valid SubmitMeetingMaterialRequest request) { + return ApiResponse.success(meetingMaterialService.submit(id, moduleCode, request)); + } + + @PostMapping("/{id}/materials/{moduleCode}/upload-sign") + @RequirePermission(value = "meeting.material.save", dataScope = DataScopeType.MEETING_MODULE, auditAction = "MEETING_MATERIAL_UPLOAD_SIGN") + public ApiResponse> uploadMaterialSign(@PathVariable("id") Long id, + @PathVariable("moduleCode") String moduleCode, + @RequestBody @Valid MeetingMaterialUploadSignRequest request) { + return ApiResponse.success(meetingMaterialService.presignMaterialUpload(id, moduleCode, request.getFileName(), request.getContentType())); + } + + @GetMapping("/{id}/materials/{moduleCode}/current") + @RequirePermission(value = "meeting.material.read", dataScope = DataScopeType.MEETING_MODULE, auditAction = "MEETING_MATERIAL_CURRENT") + public ApiResponse currentMaterial(@PathVariable("id") Long id, + @PathVariable("moduleCode") String moduleCode) { + return ApiResponse.success(meetingMaterialService.current(id, moduleCode)); + } + + @GetMapping("/{id}/materials/{moduleCode}/history") + @RequirePermission(value = "meeting.material.history.read", dataScope = DataScopeType.MEETING_MODULE, auditAction = "MEETING_MATERIAL_HISTORY") + public ApiResponse> materialHistory(@PathVariable("id") Long id, + @PathVariable("moduleCode") String moduleCode) { + return ApiResponse.success(meetingMaterialService.history(id, moduleCode)); + } + + @GetMapping("/{id}/matched-templates") + @RequirePermission(value = "meeting.material.read", dataScope = DataScopeType.MEETING, auditAction = "MEETING_MATCHED_TEMPLATES") + public ApiResponse> matchedTemplates(@PathVariable("id") Long id) { + Meeting meeting = meetingService.getById(id); + return ApiResponse.success(templateService.listMatchedForMeeting(id, meeting.getProjectId())); + } + + @PostMapping("/{id}/materials/export") + @RequirePermission(value = "meeting.material.export", dataScope = DataScopeType.MEETING, auditAction = "MEETING_MATERIAL_EXPORT") + public ApiResponse> exportMaterials(@PathVariable("id") Long id, + @RequestBody @Valid CreateMeetingMaterialsExportRequest request) { + return ApiResponse.success(meetingMaterialService.createMaterialsExportTask(id, request.getIdempotencyKey(), request.getFileName())); + } + + @PostMapping("/{id}/summary/generate") + @RequirePermission(value = "meeting.material.export", dataScope = DataScopeType.MEETING, auditAction = "MEETING_SUMMARY_GENERATE") + public ApiResponse> generateSummary(@PathVariable("id") Long id, + @RequestBody @Valid GenerateMeetingSummaryRequest request) { + return ApiResponse.success(meetingMaterialService.generateSummaryTask(id, request.getIdempotencyKey(), request.getFileName())); + } + + @GetMapping("/{id}/summary/task-status") + @RequirePermission(value = "meeting.material.export", dataScope = DataScopeType.MEETING, auditAction = "MEETING_SUMMARY_TASK_STATUS") + public ApiResponse> summaryTaskStatus(@PathVariable("id") Long id, + @RequestParam(value = "taskId", required = false) Long taskId) { + return ApiResponse.success(meetingMaterialService.getSummaryTaskStatus(id, taskId)); + } + + @PostMapping("/{id}/summary/refresh-token") + @RequirePermission(value = "meeting.material.export", dataScope = DataScopeType.MEETING, auditAction = "MEETING_SUMMARY_REFRESH_TOKEN") + public ApiResponse> refreshSummaryToken(@PathVariable("id") Long id, + @RequestParam("taskId") Long taskId) { + return ApiResponse.success(meetingMaterialService.refreshSummaryToken(id, taskId)); + } + + @GetMapping("/{id}/summary/download") + @RequirePermission(value = "meeting.material.export", dataScope = DataScopeType.MEETING, auditAction = "MEETING_SUMMARY_DOWNLOAD") + public ApiResponse> downloadSummary(@PathVariable("id") Long id, + @RequestParam("taskId") Long taskId, + @RequestParam("token") String token) { + return ApiResponse.success(meetingMaterialService.downloadSummary(id, taskId, token)); + } + + @PostMapping("/export") + @RequirePermission(value = "meeting.read", dataScope = DataScopeType.TENANT, auditAction = "MEETING_EXPORT") + public ApiResponse> exportMeetings(@RequestBody @Valid CreateExportTaskRequest request) { + request.setTaskCode("MEETING_EXPORT"); + request.setBizType("MEETING"); + return ApiResponse.success(exportTaskService.create(request)); + } +} diff --git a/backend/src/main/java/com/writeoff/module/meeting/dto/BindMeetingExpertsRequest.java b/backend/src/main/java/com/writeoff/module/meeting/dto/BindMeetingExpertsRequest.java new file mode 100644 index 0000000..7b571de --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/meeting/dto/BindMeetingExpertsRequest.java @@ -0,0 +1,18 @@ +package com.writeoff.module.meeting.dto; + +import javax.validation.constraints.NotNull; +import java.util.ArrayList; +import java.util.List; + +public class BindMeetingExpertsRequest { + @NotNull(message = "专家ID列表不能为空") + private List expertIds = new ArrayList(); + + public List getExpertIds() { + return expertIds; + } + + public void setExpertIds(List expertIds) { + this.expertIds = expertIds; + } +} diff --git a/backend/src/main/java/com/writeoff/module/meeting/dto/CreateMeetingMaterialsExportRequest.java b/backend/src/main/java/com/writeoff/module/meeting/dto/CreateMeetingMaterialsExportRequest.java new file mode 100644 index 0000000..350cc22 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/meeting/dto/CreateMeetingMaterialsExportRequest.java @@ -0,0 +1,25 @@ +package com.writeoff.module.meeting.dto; + +import javax.validation.constraints.NotBlank; + +public class CreateMeetingMaterialsExportRequest { + @NotBlank(message = "幂等键不能为空") + private String idempotencyKey; + private String fileName; + + public String getIdempotencyKey() { + return idempotencyKey; + } + + public void setIdempotencyKey(String idempotencyKey) { + this.idempotencyKey = idempotencyKey; + } + + public String getFileName() { + return fileName; + } + + public void setFileName(String fileName) { + this.fileName = fileName; + } +} diff --git a/backend/src/main/java/com/writeoff/module/meeting/dto/CreateMeetingRequest.java b/backend/src/main/java/com/writeoff/module/meeting/dto/CreateMeetingRequest.java new file mode 100644 index 0000000..594b04a --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/meeting/dto/CreateMeetingRequest.java @@ -0,0 +1,125 @@ +package com.writeoff.module.meeting.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import javax.validation.constraints.DecimalMax; +import javax.validation.constraints.DecimalMin; +import javax.validation.constraints.Min; +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; + +@Schema(description = "创建会议请求") +public class CreateMeetingRequest { + @Schema(description = "项目ID", requiredMode = Schema.RequiredMode.REQUIRED) + @NotNull(message = "项目ID不能为空") + private Long projectId; + @Schema(description = "会议主题", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank(message = "会议主题不能为空") + private String topic; + @Schema(description = "会议预算,单位:分", requiredMode = Schema.RequiredMode.REQUIRED) + @NotNull(message = "会议预算不能为空") + @Min(value = 1, message = "会议预算必须大于0") + private Long budgetCent; + @Schema(description = "会议类别", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank(message = "会议类别不能为空") + private String meetingCategory; + @Schema(description = "会议形式", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank(message = "会议形式不能为空") + private String meetingForm; + @Schema(description = "会议地点", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank(message = "会议地点不能为空") + private String location; + @Schema(description = "会议开始时间,格式:yyyy-MM-dd HH:mm:ss", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank(message = "会议开始时间不能为空") + private String startTime; + @Schema(description = "会议结束时间,格式:yyyy-MM-dd HH:mm:ss", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank(message = "会议结束时间不能为空") + private String endTime; + @Schema(description = "劳务费用占比,范围:[0,1]") + @DecimalMin(value = "0.0", inclusive = true, message = "劳务费用占比不能小于0") + @DecimalMax(value = "1.0", inclusive = true, message = "劳务费用占比不能大于1") + private Double laborRatio; + @Schema(description = "餐费占比,范围:[0,1]") + @DecimalMin(value = "0.0", inclusive = true, message = "餐费占比不能小于0") + @DecimalMax(value = "1.0", inclusive = true, message = "餐费占比不能大于1") + private Double cateringRatio; + + public Long getProjectId() { + return projectId; + } + + public void setProjectId(Long projectId) { + this.projectId = projectId; + } + + public String getTopic() { + return topic; + } + + public void setTopic(String topic) { + this.topic = topic; + } + + public Long getBudgetCent() { + return budgetCent; + } + + public void setBudgetCent(Long budgetCent) { + this.budgetCent = budgetCent; + } + + public String getMeetingCategory() { + return meetingCategory; + } + + public void setMeetingCategory(String meetingCategory) { + this.meetingCategory = meetingCategory; + } + + public String getMeetingForm() { + return meetingForm; + } + + public void setMeetingForm(String meetingForm) { + this.meetingForm = meetingForm; + } + + public String getLocation() { + return location; + } + + public void setLocation(String location) { + this.location = location; + } + + public String getStartTime() { + return startTime; + } + + public void setStartTime(String startTime) { + this.startTime = startTime; + } + + public String getEndTime() { + return endTime; + } + + public void setEndTime(String endTime) { + this.endTime = endTime; + } + + public Double getLaborRatio() { + return laborRatio; + } + + public void setLaborRatio(Double laborRatio) { + this.laborRatio = laborRatio; + } + + public Double getCateringRatio() { + return cateringRatio; + } + + public void setCateringRatio(Double cateringRatio) { + this.cateringRatio = cateringRatio; + } +} diff --git a/backend/src/main/java/com/writeoff/module/meeting/dto/GenerateMeetingSummaryRequest.java b/backend/src/main/java/com/writeoff/module/meeting/dto/GenerateMeetingSummaryRequest.java new file mode 100644 index 0000000..f909975 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/meeting/dto/GenerateMeetingSummaryRequest.java @@ -0,0 +1,25 @@ +package com.writeoff.module.meeting.dto; + +import javax.validation.constraints.NotBlank; + +public class GenerateMeetingSummaryRequest { + @NotBlank(message = "幂等键不能为空") + private String idempotencyKey; + private String fileName; + + public String getIdempotencyKey() { + return idempotencyKey; + } + + public void setIdempotencyKey(String idempotencyKey) { + this.idempotencyKey = idempotencyKey; + } + + public String getFileName() { + return fileName; + } + + public void setFileName(String fileName) { + this.fileName = fileName; + } +} diff --git a/backend/src/main/java/com/writeoff/module/meeting/dto/MeetingInvoiceConfigRequest.java b/backend/src/main/java/com/writeoff/module/meeting/dto/MeetingInvoiceConfigRequest.java new file mode 100644 index 0000000..ec4b3c6 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/meeting/dto/MeetingInvoiceConfigRequest.java @@ -0,0 +1,19 @@ +package com.writeoff.module.meeting.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; + +@Schema(description = "会议发票配置更新请求") +public class MeetingInvoiceConfigRequest { + + @Schema(description = "配置启用的发票项目code列表") + private List invoiceModules; + + public List getInvoiceModules() { + return invoiceModules; + } + + public void setInvoiceModules(List invoiceModules) { + this.invoiceModules = invoiceModules; + } +} diff --git a/backend/src/main/java/com/writeoff/module/meeting/dto/MeetingLaborAgreementExtractApplyRequest.java b/backend/src/main/java/com/writeoff/module/meeting/dto/MeetingLaborAgreementExtractApplyRequest.java new file mode 100644 index 0000000..1ac1b2c --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/meeting/dto/MeetingLaborAgreementExtractApplyRequest.java @@ -0,0 +1,52 @@ +package com.writeoff.module.meeting.dto; + +import javax.validation.constraints.NotBlank; + +public class MeetingLaborAgreementExtractApplyRequest { + @NotBlank(message = "taskId不能为空") + private String taskId; + private Long existingExpertId; + private Boolean updateExistingExpert; + private String objectKey; + private String fileName; + + public String getTaskId() { + return taskId; + } + + public void setTaskId(String taskId) { + this.taskId = taskId; + } + + public Long getExistingExpertId() { + return existingExpertId; + } + + public void setExistingExpertId(Long existingExpertId) { + this.existingExpertId = existingExpertId; + } + + public Boolean getUpdateExistingExpert() { + return updateExistingExpert; + } + + public void setUpdateExistingExpert(Boolean updateExistingExpert) { + this.updateExistingExpert = updateExistingExpert; + } + + public String getObjectKey() { + return objectKey; + } + + public void setObjectKey(String objectKey) { + this.objectKey = objectKey; + } + + public String getFileName() { + return fileName; + } + + public void setFileName(String fileName) { + this.fileName = fileName; + } +} diff --git a/backend/src/main/java/com/writeoff/module/meeting/dto/MeetingLaborAgreementExtractQueryRequest.java b/backend/src/main/java/com/writeoff/module/meeting/dto/MeetingLaborAgreementExtractQueryRequest.java new file mode 100644 index 0000000..55aa832 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/meeting/dto/MeetingLaborAgreementExtractQueryRequest.java @@ -0,0 +1,16 @@ +package com.writeoff.module.meeting.dto; + +import javax.validation.constraints.NotBlank; + +public class MeetingLaborAgreementExtractQueryRequest { + @NotBlank(message = "taskId不能为空") + private String taskId; + + public String getTaskId() { + return taskId; + } + + public void setTaskId(String taskId) { + this.taskId = taskId; + } +} diff --git a/backend/src/main/java/com/writeoff/module/meeting/dto/MeetingLaborAgreementExtractSubmitRequest.java b/backend/src/main/java/com/writeoff/module/meeting/dto/MeetingLaborAgreementExtractSubmitRequest.java new file mode 100644 index 0000000..ee6928f --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/meeting/dto/MeetingLaborAgreementExtractSubmitRequest.java @@ -0,0 +1,26 @@ +package com.writeoff.module.meeting.dto; + +import javax.validation.constraints.NotBlank; + +public class MeetingLaborAgreementExtractSubmitRequest { + @NotBlank(message = "objectKey不能为空") + private String objectKey; + @NotBlank(message = "fileName不能为空") + private String fileName; + + public String getObjectKey() { + return objectKey; + } + + public void setObjectKey(String objectKey) { + this.objectKey = objectKey; + } + + public String getFileName() { + return fileName; + } + + public void setFileName(String fileName) { + this.fileName = fileName; + } +} diff --git a/backend/src/main/java/com/writeoff/module/meeting/dto/MeetingMaterialUploadSignRequest.java b/backend/src/main/java/com/writeoff/module/meeting/dto/MeetingMaterialUploadSignRequest.java new file mode 100644 index 0000000..8be79d7 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/meeting/dto/MeetingMaterialUploadSignRequest.java @@ -0,0 +1,25 @@ +package com.writeoff.module.meeting.dto; + +import javax.validation.constraints.NotBlank; + +public class MeetingMaterialUploadSignRequest { + @NotBlank(message = "文件名不能为空") + private String fileName; + private String contentType; + + public String getFileName() { + return fileName; + } + + public void setFileName(String fileName) { + this.fileName = fileName; + } + + public String getContentType() { + return contentType; + } + + public void setContentType(String contentType) { + this.contentType = contentType; + } +} diff --git a/backend/src/main/java/com/writeoff/module/meeting/dto/MeetingQueryRequest.java b/backend/src/main/java/com/writeoff/module/meeting/dto/MeetingQueryRequest.java new file mode 100644 index 0000000..dc3f246 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/meeting/dto/MeetingQueryRequest.java @@ -0,0 +1,127 @@ +package com.writeoff.module.meeting.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "会议列表筛选参数") +public class MeetingQueryRequest { + @Schema(description = "项目ID") + private Long projectId; + @Schema(description = "项目名称(模糊匹配)") + private String projectName; + @Schema(description = "会议主题(模糊匹配)") + private String topic; + @Schema(description = "会议状态(NOT_STARTED/IN_PROGRESS/COMPLETED/CANCELED/DELAYED/FROZEN)") + private String meetingStatus; + @Schema(description = "会议审核状态(PENDING/IN_REVIEW/APPROVED/REJECTED)") + private String auditStatus; + @Schema(description = "当前审核节点") + private String currentAuditNode; + @Schema(description = "当前审核人用户ID") + private Long currentAuditorUserId; + @Schema(description = "会议开始时间范围-起,格式:yyyy-MM-dd HH:mm:ss") + private String meetingStartFrom; + @Schema(description = "会议开始时间范围-止,格式:yyyy-MM-dd HH:mm:ss") + private String meetingStartTo; + @Schema(description = "最后提交时间范围-起,格式:yyyy-MM-ddTHH:mm:ss") + private String lastSubmitFrom; + @Schema(description = "最后提交时间范围-止,格式:yyyy-MM-ddTHH:mm:ss") + private String lastSubmitTo; + @Schema(description = "是否包含已删除会议") + private Boolean includeDeleted; + + public Long getProjectId() { + return projectId; + } + + public void setProjectId(Long projectId) { + this.projectId = projectId; + } + + public String getProjectName() { + return projectName; + } + + public void setProjectName(String projectName) { + this.projectName = projectName; + } + + public String getTopic() { + return topic; + } + + public void setTopic(String topic) { + this.topic = topic; + } + + public String getMeetingStatus() { + return meetingStatus; + } + + public void setMeetingStatus(String meetingStatus) { + this.meetingStatus = meetingStatus; + } + + public String getAuditStatus() { + return auditStatus; + } + + public void setAuditStatus(String auditStatus) { + this.auditStatus = auditStatus; + } + + public String getCurrentAuditNode() { + return currentAuditNode; + } + + public void setCurrentAuditNode(String currentAuditNode) { + this.currentAuditNode = currentAuditNode; + } + + public Long getCurrentAuditorUserId() { + return currentAuditorUserId; + } + + public void setCurrentAuditorUserId(Long currentAuditorUserId) { + this.currentAuditorUserId = currentAuditorUserId; + } + + public String getMeetingStartFrom() { + return meetingStartFrom; + } + + public void setMeetingStartFrom(String meetingStartFrom) { + this.meetingStartFrom = meetingStartFrom; + } + + public String getMeetingStartTo() { + return meetingStartTo; + } + + public void setMeetingStartTo(String meetingStartTo) { + this.meetingStartTo = meetingStartTo; + } + + public String getLastSubmitFrom() { + return lastSubmitFrom; + } + + public void setLastSubmitFrom(String lastSubmitFrom) { + this.lastSubmitFrom = lastSubmitFrom; + } + + public String getLastSubmitTo() { + return lastSubmitTo; + } + + public void setLastSubmitTo(String lastSubmitTo) { + this.lastSubmitTo = lastSubmitTo; + } + + public Boolean getIncludeDeleted() { + return includeDeleted; + } + + public void setIncludeDeleted(Boolean includeDeleted) { + this.includeDeleted = includeDeleted; + } +} diff --git a/backend/src/main/java/com/writeoff/module/meeting/dto/SaveMeetingMaterialRequest.java b/backend/src/main/java/com/writeoff/module/meeting/dto/SaveMeetingMaterialRequest.java new file mode 100644 index 0000000..e92f5bc --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/meeting/dto/SaveMeetingMaterialRequest.java @@ -0,0 +1,25 @@ +package com.writeoff.module.meeting.dto; + +import javax.validation.constraints.NotBlank; + +public class SaveMeetingMaterialRequest { + @NotBlank(message = "资料内容不能为空") + private String contentJson; + private String remark; + + public String getContentJson() { + return contentJson; + } + + public void setContentJson(String contentJson) { + this.contentJson = contentJson; + } + + public String getRemark() { + return remark; + } + + public void setRemark(String remark) { + this.remark = remark; + } +} diff --git a/backend/src/main/java/com/writeoff/module/meeting/dto/SubmitMeetingMaterialRequest.java b/backend/src/main/java/com/writeoff/module/meeting/dto/SubmitMeetingMaterialRequest.java new file mode 100644 index 0000000..8658a00 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/meeting/dto/SubmitMeetingMaterialRequest.java @@ -0,0 +1,25 @@ +package com.writeoff.module.meeting.dto; + +import javax.validation.constraints.NotBlank; + +public class SubmitMeetingMaterialRequest { + @NotBlank(message = "资料内容不能为空") + private String contentJson; + private String remark; + + public String getContentJson() { + return contentJson; + } + + public void setContentJson(String contentJson) { + this.contentJson = contentJson; + } + + public String getRemark() { + return remark; + } + + public void setRemark(String remark) { + this.remark = remark; + } +} diff --git a/backend/src/main/java/com/writeoff/module/meeting/dto/SubmitMeetingRequest.java b/backend/src/main/java/com/writeoff/module/meeting/dto/SubmitMeetingRequest.java new file mode 100644 index 0000000..e25c44e --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/meeting/dto/SubmitMeetingRequest.java @@ -0,0 +1,25 @@ +package com.writeoff.module.meeting.dto; + +import javax.validation.constraints.NotBlank; + +public class SubmitMeetingRequest { + @NotBlank(message = "幂等键不能为空") + private String idempotencyKey; + private String remark; + + public String getIdempotencyKey() { + return idempotencyKey; + } + + public void setIdempotencyKey(String idempotencyKey) { + this.idempotencyKey = idempotencyKey; + } + + public String getRemark() { + return remark; + } + + public void setRemark(String remark) { + this.remark = remark; + } +} diff --git a/backend/src/main/java/com/writeoff/module/meeting/dto/WithdrawMeetingRequest.java b/backend/src/main/java/com/writeoff/module/meeting/dto/WithdrawMeetingRequest.java new file mode 100644 index 0000000..59053f0 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/meeting/dto/WithdrawMeetingRequest.java @@ -0,0 +1,26 @@ +package com.writeoff.module.meeting.dto; + +import javax.validation.constraints.NotBlank; + +public class WithdrawMeetingRequest { + @NotBlank(message = "幂等键不能为空") + private String idempotencyKey; + @NotBlank(message = "撤回原因不能为空") + private String reason; + + public String getIdempotencyKey() { + return idempotencyKey; + } + + public void setIdempotencyKey(String idempotencyKey) { + this.idempotencyKey = idempotencyKey; + } + + public String getReason() { + return reason; + } + + public void setReason(String reason) { + this.reason = reason; + } +} diff --git a/backend/src/main/java/com/writeoff/module/meeting/model/Meeting.java b/backend/src/main/java/com/writeoff/module/meeting/model/Meeting.java new file mode 100644 index 0000000..de3da35 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/meeting/model/Meeting.java @@ -0,0 +1,413 @@ +package com.writeoff.module.meeting.model; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "会议实体") +public class Meeting { + @Schema(description = "会议ID") + private Long id; + @Schema(description = "项目ID") + private Long projectId; + @Schema(description = "项目名称(展示字段)") + private String projectName; + @Schema(description = "会议主题") + private String topic; + @Schema(description = "会议类别") + private String meetingCategory; + @Schema(description = "会议形式") + private String meetingForm; + @Schema(description = "会议地点") + private String location; + @Schema(description = "会议开始时间,格式:yyyy-MM-dd HH:mm:ss") + private String startTime; + @Schema(description = "会议结束时间,格式:yyyy-MM-dd HH:mm:ss") + private String endTime; + @Schema(description = "会议预算,单位:分") + private long budgetCent; + @Schema(description = "劳务费用占比,范围:[0,1]") + private double laborRatio; + @Schema(description = "餐费占比,范围:[0,1]") + private double cateringRatio; + @Schema(description = "会议状态") + private MeetingStatus status; + @Schema(description = "会议审核状态") + private MeetingAuditStatus auditStatus; + @Schema(description = "当前审核节点") + private String currentAuditNode; + @Schema(description = "最后提交时间,格式:yyyy-MM-ddTHH:mm:ss") + private String lastSubmitAt; + @Schema(description = "最后驳回原因摘要") + private String lastRejectReason; + @Schema(description = "逾期天数") + private int overdueDays; + @Schema(description = "风险标记JSON") + private String riskFlagsJson; + @Schema(description = "是否冻结") + private boolean frozen; + @Schema(description = "冻结原因") + private String freezeReason; + @Schema(description = "当前审核人用户ID") + private Long currentAuditorUserId; + @Schema(description = "当前节点SLA截止时间,格式:yyyy-MM-ddTHH:mm:ss") + private String nodeDeadlineAt; + @Schema(description = "累计驳回次数") + private int rejectCount; + @Schema(description = "最后一次流程动作时间,格式:yyyy-MM-ddTHH:mm:ss") + private String lastActionAt; + @Schema(description = "会议状态最近变更时间,格式:yyyy-MM-ddTHH:mm:ss") + private String statusChangedAt; + @Schema(description = "会议状态最近变更人用户ID") + private Long statusChangedBy; + @Schema(description = "取消原因") + private String cancelReason; + @Schema(description = "延期原因") + private String postponeReason; + @Schema(description = "撤回原因") + private String withdrawReason; + @Schema(description = "字段锁版本号(并发控制)") + private int lockVersion; + @Schema(description = "字段锁定时间,格式:yyyy-MM-ddTHH:mm:ss") + private String lockAt; + @Schema(description = "字段锁定操作人用户ID") + private Long lockedBy; + @Schema(description = "动态会议发票模块配置JSON") + private String invoiceConfigJson; + @Schema(description = "是否已软删除") + private boolean deleted; + + public Meeting(Long id, Long projectId, String topic, long budgetCent, MeetingStatus status, MeetingAuditStatus auditStatus) { + this( + id, projectId, topic, null, null, null, null, null, budgetCent, 0d, 0d, status, auditStatus, null, + null, null, 0, null, false, null, null, null, 0, null, null, null, null, null, null, 0, null, null, null + ); + } + + public Meeting(Long id, + Long projectId, + String topic, + String meetingCategory, + String meetingForm, + String location, + String startTime, + String endTime, + long budgetCent, + double laborRatio, + double cateringRatio, + MeetingStatus status, + MeetingAuditStatus auditStatus, + String currentAuditNode, + String lastSubmitAt, + String lastRejectReason, + int overdueDays, + String riskFlagsJson, + boolean frozen, + String freezeReason, + Long currentAuditorUserId, + String nodeDeadlineAt, + int rejectCount, + String lastActionAt, + String statusChangedAt, + Long statusChangedBy, + String cancelReason, + String postponeReason, + String withdrawReason, + int lockVersion, + String lockAt, + Long lockedBy, + String invoiceConfigJson) { + this(id, projectId, topic, meetingCategory, meetingForm, location, startTime, endTime, budgetCent, laborRatio, cateringRatio, + status, auditStatus, currentAuditNode, lastSubmitAt, lastRejectReason, overdueDays, riskFlagsJson, frozen, freezeReason, + currentAuditorUserId, nodeDeadlineAt, rejectCount, lastActionAt, statusChangedAt, statusChangedBy, cancelReason, + postponeReason, withdrawReason, lockVersion, lockAt, lockedBy, invoiceConfigJson, false); + } + + public Meeting(Long id, + Long projectId, + String topic, + String meetingCategory, + String meetingForm, + String location, + String startTime, + String endTime, + long budgetCent, + double laborRatio, + double cateringRatio, + MeetingStatus status, + MeetingAuditStatus auditStatus, + String currentAuditNode, + String lastSubmitAt, + String lastRejectReason, + int overdueDays, + String riskFlagsJson, + boolean frozen, + String freezeReason, + Long currentAuditorUserId, + String nodeDeadlineAt, + int rejectCount, + String lastActionAt, + String statusChangedAt, + Long statusChangedBy, + String cancelReason, + String postponeReason, + String withdrawReason, + int lockVersion, + String lockAt, + Long lockedBy, + String invoiceConfigJson, + boolean deleted) { + this.id = id; + this.projectId = projectId; + this.topic = topic; + this.meetingCategory = meetingCategory; + this.meetingForm = meetingForm; + this.location = location; + this.startTime = startTime; + this.endTime = endTime; + this.budgetCent = budgetCent; + this.laborRatio = laborRatio; + this.cateringRatio = cateringRatio; + this.status = status; + this.auditStatus = auditStatus; + this.currentAuditNode = currentAuditNode; + this.lastSubmitAt = lastSubmitAt; + this.lastRejectReason = lastRejectReason; + this.overdueDays = overdueDays; + this.riskFlagsJson = riskFlagsJson; + this.frozen = frozen; + this.freezeReason = freezeReason; + this.currentAuditorUserId = currentAuditorUserId; + this.nodeDeadlineAt = nodeDeadlineAt; + this.rejectCount = rejectCount; + this.lastActionAt = lastActionAt; + this.statusChangedAt = statusChangedAt; + this.statusChangedBy = statusChangedBy; + this.cancelReason = cancelReason; + this.postponeReason = postponeReason; + this.withdrawReason = withdrawReason; + this.lockVersion = lockVersion; + this.lockAt = lockAt; + this.lockedBy = lockedBy; + this.invoiceConfigJson = invoiceConfigJson; + this.deleted = deleted; + } + + public Long getId() { + return id; + } + + public Long getProjectId() { + return projectId; + } + + public String getProjectName() { + return projectName; + } + + public String getTopic() { + return topic; + } + + public String getMeetingCategory() { + return meetingCategory; + } + + public String getMeetingForm() { + return meetingForm; + } + + public String getLocation() { + return location; + } + + public String getStartTime() { + return startTime; + } + + public String getEndTime() { + return endTime; + } + + public long getBudgetCent() { + return budgetCent; + } + + public double getLaborRatio() { + return laborRatio; + } + + public double getCateringRatio() { + return cateringRatio; + } + + public MeetingStatus getStatus() { + return status; + } + + public MeetingAuditStatus getAuditStatus() { + return auditStatus; + } + + public String getCurrentAuditNode() { + return currentAuditNode; + } + + public String getLastSubmitAt() { + return lastSubmitAt; + } + + public String getLastRejectReason() { + return lastRejectReason; + } + + public int getOverdueDays() { + return overdueDays; + } + + public String getRiskFlagsJson() { + return riskFlagsJson; + } + + public boolean isFrozen() { + return frozen; + } + + public String getFreezeReason() { + return freezeReason; + } + + public Long getCurrentAuditorUserId() { + return currentAuditorUserId; + } + + public String getNodeDeadlineAt() { + return nodeDeadlineAt; + } + + public int getRejectCount() { + return rejectCount; + } + + public String getLastActionAt() { + return lastActionAt; + } + + public String getStatusChangedAt() { + return statusChangedAt; + } + + public Long getStatusChangedBy() { + return statusChangedBy; + } + + public String getCancelReason() { + return cancelReason; + } + + public String getPostponeReason() { + return postponeReason; + } + + public String getWithdrawReason() { + return withdrawReason; + } + + public int getLockVersion() { + return lockVersion; + } + + public String getLockAt() { + return lockAt; + } + + public Long getLockedBy() { + return lockedBy; + } + + public void setStatus(MeetingStatus status) { + this.status = status; + } + + public void setAuditStatus(MeetingAuditStatus auditStatus) { + this.auditStatus = auditStatus; + } + + public void setCurrentAuditNode(String currentAuditNode) { + this.currentAuditNode = currentAuditNode; + } + + public void setLastSubmitAt(String lastSubmitAt) { + this.lastSubmitAt = lastSubmitAt; + } + + public void setLastRejectReason(String lastRejectReason) { + this.lastRejectReason = lastRejectReason; + } + + public void setProjectName(String projectName) { + this.projectName = projectName; + } + + public void setCurrentAuditorUserId(Long currentAuditorUserId) { + this.currentAuditorUserId = currentAuditorUserId; + } + + public void setNodeDeadlineAt(String nodeDeadlineAt) { + this.nodeDeadlineAt = nodeDeadlineAt; + } + + public void setRejectCount(int rejectCount) { + this.rejectCount = rejectCount; + } + + public void setLastActionAt(String lastActionAt) { + this.lastActionAt = lastActionAt; + } + + public void setStatusChangedAt(String statusChangedAt) { + this.statusChangedAt = statusChangedAt; + } + + public void setStatusChangedBy(Long statusChangedBy) { + this.statusChangedBy = statusChangedBy; + } + + public void setCancelReason(String cancelReason) { + this.cancelReason = cancelReason; + } + + public void setPostponeReason(String postponeReason) { + this.postponeReason = postponeReason; + } + + public void setWithdrawReason(String withdrawReason) { + this.withdrawReason = withdrawReason; + } + + public void setLockVersion(int lockVersion) { + this.lockVersion = lockVersion; + } + + public void setLockAt(String lockAt) { + this.lockAt = lockAt; + } + + public void setLockedBy(Long lockedBy) { + this.lockedBy = lockedBy; + } + + public String getInvoiceConfigJson() { + return invoiceConfigJson; + } + + public void setInvoiceConfigJson(String invoiceConfigJson) { + this.invoiceConfigJson = invoiceConfigJson; + } + + public boolean isDeleted() { + return deleted; + } + + public void setDeleted(boolean deleted) { + this.deleted = deleted; + } +} diff --git a/backend/src/main/java/com/writeoff/module/meeting/model/MeetingAuditStatus.java b/backend/src/main/java/com/writeoff/module/meeting/model/MeetingAuditStatus.java new file mode 100644 index 0000000..94e1fcf --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/meeting/model/MeetingAuditStatus.java @@ -0,0 +1,8 @@ +package com.writeoff.module.meeting.model; + +public enum MeetingAuditStatus { + PENDING, + IN_REVIEW, + APPROVED, + REJECTED +} diff --git a/backend/src/main/java/com/writeoff/module/meeting/model/MeetingExpertBinding.java b/backend/src/main/java/com/writeoff/module/meeting/model/MeetingExpertBinding.java new file mode 100644 index 0000000..01f1b80 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/meeting/model/MeetingExpertBinding.java @@ -0,0 +1,49 @@ +package com.writeoff.module.meeting.model; + +public class MeetingExpertBinding { + private Long id; + private Long meetingId; + private Long expertId; + private String expertName; + private String phone; + private String title; + private String organization; + + public MeetingExpertBinding(Long id, Long meetingId, Long expertId, String expertName, String phone, String title, String organization) { + this.id = id; + this.meetingId = meetingId; + this.expertId = expertId; + this.expertName = expertName; + this.phone = phone; + this.title = title; + this.organization = organization; + } + + public Long getId() { + return id; + } + + public Long getMeetingId() { + return meetingId; + } + + public Long getExpertId() { + return expertId; + } + + public String getExpertName() { + return expertName; + } + + public String getPhone() { + return phone; + } + + public String getTitle() { + return title; + } + + public String getOrganization() { + return organization; + } +} diff --git a/backend/src/main/java/com/writeoff/module/meeting/model/MeetingLaborAgreementExtractResult.java b/backend/src/main/java/com/writeoff/module/meeting/model/MeetingLaborAgreementExtractResult.java new file mode 100644 index 0000000..34a6eab --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/meeting/model/MeetingLaborAgreementExtractResult.java @@ -0,0 +1,253 @@ +package com.writeoff.module.meeting.model; + +public class MeetingLaborAgreementExtractResult { + private String taskId; + private String status; + private String reason; + private String createdAt; + private String startedAt; + private String finishedAt; + private Long duration; + private String logId; + private ParsedExpert parsedExpert; + private ExistingExpert existingExpert; + private Boolean needsConfirm; + private Boolean nameMismatchFlag; + + public String getTaskId() { + return taskId; + } + + public void setTaskId(String taskId) { + this.taskId = taskId; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public String getReason() { + return reason; + } + + public void setReason(String reason) { + this.reason = reason; + } + + public String getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(String createdAt) { + this.createdAt = createdAt; + } + + public String getStartedAt() { + return startedAt; + } + + public void setStartedAt(String startedAt) { + this.startedAt = startedAt; + } + + public String getFinishedAt() { + return finishedAt; + } + + public void setFinishedAt(String finishedAt) { + this.finishedAt = finishedAt; + } + + public Long getDuration() { + return duration; + } + + public void setDuration(Long duration) { + this.duration = duration; + } + + public String getLogId() { + return logId; + } + + public void setLogId(String logId) { + this.logId = logId; + } + + public ParsedExpert getParsedExpert() { + return parsedExpert; + } + + public void setParsedExpert(ParsedExpert parsedExpert) { + this.parsedExpert = parsedExpert; + } + + public ExistingExpert getExistingExpert() { + return existingExpert; + } + + public void setExistingExpert(ExistingExpert existingExpert) { + this.existingExpert = existingExpert; + } + + public Boolean getNeedsConfirm() { + return needsConfirm; + } + + public void setNeedsConfirm(Boolean needsConfirm) { + this.needsConfirm = needsConfirm; + } + + public Boolean getNameMismatchFlag() { + return nameMismatchFlag; + } + + public void setNameMismatchFlag(Boolean nameMismatchFlag) { + this.nameMismatchFlag = nameMismatchFlag; + } + + public static class ParsedExpert { + private String expertName; + private String hospitalName; + private String phone; + private String laborFeeText; + private Long laborFeeCent; + private String bankName; + private String bankCardNo; + private String accountName; + private String idNo; + private Boolean nameMismatchFlag; + + public String getExpertName() { + return expertName; + } + + public void setExpertName(String expertName) { + this.expertName = expertName; + } + + public String getHospitalName() { + return hospitalName; + } + + public void setHospitalName(String hospitalName) { + this.hospitalName = hospitalName; + } + + public String getPhone() { + return phone; + } + + public void setPhone(String phone) { + this.phone = phone; + } + + public String getLaborFeeText() { + return laborFeeText; + } + + public void setLaborFeeText(String laborFeeText) { + this.laborFeeText = laborFeeText; + } + + public Long getLaborFeeCent() { + return laborFeeCent; + } + + public void setLaborFeeCent(Long laborFeeCent) { + this.laborFeeCent = laborFeeCent; + } + + public String getBankName() { + return bankName; + } + + public void setBankName(String bankName) { + this.bankName = bankName; + } + + public String getBankCardNo() { + return bankCardNo; + } + + public void setBankCardNo(String bankCardNo) { + this.bankCardNo = bankCardNo; + } + + public String getAccountName() { + return accountName; + } + + public void setAccountName(String accountName) { + this.accountName = accountName; + } + + public String getIdNo() { + return idNo; + } + + public void setIdNo(String idNo) { + this.idNo = idNo; + } + + public Boolean getNameMismatchFlag() { + return nameMismatchFlag; + } + + public void setNameMismatchFlag(Boolean nameMismatchFlag) { + this.nameMismatchFlag = nameMismatchFlag; + } + } + + public static class ExistingExpert { + private Long expertId; + private String expertName; + private String phoneMasked; + private String idNoMasked; + private String hospitalName; + + public Long getExpertId() { + return expertId; + } + + public void setExpertId(Long expertId) { + this.expertId = expertId; + } + + public String getExpertName() { + return expertName; + } + + public void setExpertName(String expertName) { + this.expertName = expertName; + } + + public String getPhoneMasked() { + return phoneMasked; + } + + public void setPhoneMasked(String phoneMasked) { + this.phoneMasked = phoneMasked; + } + + public String getIdNoMasked() { + return idNoMasked; + } + + public void setIdNoMasked(String idNoMasked) { + this.idNoMasked = idNoMasked; + } + + public String getHospitalName() { + return hospitalName; + } + + public void setHospitalName(String hospitalName) { + this.hospitalName = hospitalName; + } + } +} diff --git a/backend/src/main/java/com/writeoff/module/meeting/model/MeetingMaterial.java b/backend/src/main/java/com/writeoff/module/meeting/model/MeetingMaterial.java new file mode 100644 index 0000000..17da495 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/meeting/model/MeetingMaterial.java @@ -0,0 +1,104 @@ +package com.writeoff.module.meeting.model; + +public class MeetingMaterial { + private Long id; + private Long meetingId; + private String moduleCode; + private String contentJson; + private String status; + private String auditNodeStatus; + private String auditAggregateStatus; + private String submitRemark; + private Integer rejectCount; + private String lastRejectReason; + private String resubmitAt; + private Integer versionNo; + private Boolean latestVersion; + private String updatedAt; + + public MeetingMaterial(Long id, + Long meetingId, + String moduleCode, + String contentJson, + String status, + String auditNodeStatus, + String auditAggregateStatus, + String submitRemark, + Integer rejectCount, + String lastRejectReason, + String resubmitAt, + Integer versionNo, + Boolean latestVersion, + String updatedAt) { + this.id = id; + this.meetingId = meetingId; + this.moduleCode = moduleCode; + this.contentJson = contentJson; + this.status = status; + this.auditNodeStatus = auditNodeStatus; + this.auditAggregateStatus = auditAggregateStatus; + this.submitRemark = submitRemark; + this.rejectCount = rejectCount; + this.lastRejectReason = lastRejectReason; + this.resubmitAt = resubmitAt; + this.versionNo = versionNo; + this.latestVersion = latestVersion; + this.updatedAt = updatedAt; + } + + public Long getId() { + return id; + } + + public Long getMeetingId() { + return meetingId; + } + + public String getModuleCode() { + return moduleCode; + } + + public String getContentJson() { + return contentJson; + } + + public String getStatus() { + return status; + } + + public String getAuditNodeStatus() { + return auditNodeStatus; + } + + public String getAuditAggregateStatus() { + return auditAggregateStatus; + } + + public String getSubmitRemark() { + return submitRemark; + } + + public Integer getRejectCount() { + return rejectCount; + } + + public String getLastRejectReason() { + return lastRejectReason; + } + + public String getResubmitAt() { + return resubmitAt; + } + + public Integer getVersionNo() { + return versionNo; + } + + public Boolean getLatestVersion() { + return latestVersion; + } + + public String getUpdatedAt() { + return updatedAt; + } +} diff --git a/backend/src/main/java/com/writeoff/module/meeting/model/MeetingMaterialHistory.java b/backend/src/main/java/com/writeoff/module/meeting/model/MeetingMaterialHistory.java new file mode 100644 index 0000000..b415bb9 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/meeting/model/MeetingMaterialHistory.java @@ -0,0 +1,55 @@ +package com.writeoff.module.meeting.model; + +public class MeetingMaterialHistory { + private Long id; + private Long meetingId; + private String moduleCode; + private Integer versionNo; + private String actionType; + private String contentJson; + private String remark; + private String createdAt; + + public MeetingMaterialHistory(Long id, Long meetingId, String moduleCode, Integer versionNo, String actionType, String contentJson, String remark, String createdAt) { + this.id = id; + this.meetingId = meetingId; + this.moduleCode = moduleCode; + this.versionNo = versionNo; + this.actionType = actionType; + this.contentJson = contentJson; + this.remark = remark; + this.createdAt = createdAt; + } + + public Long getId() { + return id; + } + + public Long getMeetingId() { + return meetingId; + } + + public String getModuleCode() { + return moduleCode; + } + + public Integer getVersionNo() { + return versionNo; + } + + public String getActionType() { + return actionType; + } + + public String getContentJson() { + return contentJson; + } + + public String getRemark() { + return remark; + } + + public String getCreatedAt() { + return createdAt; + } +} diff --git a/backend/src/main/java/com/writeoff/module/meeting/model/MeetingStatus.java b/backend/src/main/java/com/writeoff/module/meeting/model/MeetingStatus.java new file mode 100644 index 0000000..b73ed5c --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/meeting/model/MeetingStatus.java @@ -0,0 +1,10 @@ +package com.writeoff.module.meeting.model; + +public enum MeetingStatus { + NOT_STARTED, + IN_PROGRESS, + COMPLETED, + CANCELED, + DELAYED, + FROZEN +} diff --git a/backend/src/main/java/com/writeoff/module/meeting/repository/InMemoryMeetingRepository.java b/backend/src/main/java/com/writeoff/module/meeting/repository/InMemoryMeetingRepository.java new file mode 100644 index 0000000..9ff932c --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/meeting/repository/InMemoryMeetingRepository.java @@ -0,0 +1,82 @@ +package com.writeoff.module.meeting.repository; + +import com.writeoff.module.meeting.model.Meeting; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Repository; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; + +@Repository +@ConditionalOnProperty(prefix = "app.repository", name = "mode", havingValue = "in-memory") +public class InMemoryMeetingRepository implements MeetingRepository { + private final ConcurrentHashMap store = new ConcurrentHashMap<>(); + private final AtomicLong idGenerator = new AtomicLong(2000); + + @Override + public Meeting save(Meeting meeting) { + if (meeting.getId() == null) { + Meeting newMeeting = new Meeting( + idGenerator.incrementAndGet(), + meeting.getProjectId(), + meeting.getTopic(), + meeting.getMeetingCategory(), + meeting.getMeetingForm(), + meeting.getLocation(), + meeting.getStartTime(), + meeting.getEndTime(), + meeting.getBudgetCent(), + meeting.getLaborRatio(), + meeting.getCateringRatio(), + meeting.getStatus(), + meeting.getAuditStatus(), + meeting.getCurrentAuditNode(), + meeting.getLastSubmitAt(), + meeting.getLastRejectReason(), + meeting.getOverdueDays(), + meeting.getRiskFlagsJson(), + meeting.isFrozen(), + meeting.getFreezeReason(), + meeting.getCurrentAuditorUserId(), + meeting.getNodeDeadlineAt(), + meeting.getRejectCount(), + meeting.getLastActionAt(), + meeting.getStatusChangedAt(), + meeting.getStatusChangedBy(), + meeting.getCancelReason(), + meeting.getPostponeReason(), + meeting.getWithdrawReason(), + meeting.getLockVersion(), + meeting.getLockAt(), + meeting.getLockedBy(), + meeting.getInvoiceConfigJson() + ); + store.put(newMeeting.getId(), newMeeting); + return newMeeting; + } + store.put(meeting.getId(), meeting); + return meeting; + } + + @Override + public Optional findById(Long id) { + return Optional.ofNullable(store.get(id)); + } + + @Override + public List findAll(boolean includeDeleted) { + return new ArrayList<>(store.values()); + } + + @Override + public void softDelete(Long id) { + Meeting meeting = store.get(id); + if (meeting != null) { + meeting.setDeleted(true); + store.put(id, meeting); + } + } +} diff --git a/backend/src/main/java/com/writeoff/module/meeting/repository/JdbcMeetingRepository.java b/backend/src/main/java/com/writeoff/module/meeting/repository/JdbcMeetingRepository.java new file mode 100644 index 0000000..7bcb566 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/meeting/repository/JdbcMeetingRepository.java @@ -0,0 +1,252 @@ +package com.writeoff.module.meeting.repository; + +import com.writeoff.module.meeting.model.Meeting; +import com.writeoff.module.meeting.model.MeetingAuditStatus; +import com.writeoff.module.meeting.model.MeetingStatus; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.jdbc.support.GeneratedKeyHolder; +import org.springframework.jdbc.support.KeyHolder; +import org.springframework.stereotype.Repository; +import com.writeoff.security.AuthContext; + +import java.sql.PreparedStatement; +import java.sql.Statement; +import java.util.List; +import java.util.Optional; + +@Repository +@ConditionalOnProperty(prefix = "app.repository", name = "mode", havingValue = "jdbc", matchIfMissing = true) +public class JdbcMeetingRepository implements MeetingRepository { + private final JdbcTemplate jdbcTemplate; + private static final RowMapper ROW_MAPPER = (rs, n) -> { + Meeting meeting = new Meeting( + rs.getLong("id"), + rs.getLong("project_id"), + rs.getString("topic"), + rs.getString("meeting_category"), + rs.getString("meeting_form"), + rs.getString("location"), + rs.getString("start_time"), + rs.getString("end_time"), + rs.getLong("budget_cent"), + rs.getBigDecimal("labor_ratio") == null ? 0d : rs.getBigDecimal("labor_ratio").doubleValue(), + rs.getBigDecimal("catering_ratio") == null ? 0d : rs.getBigDecimal("catering_ratio").doubleValue(), + MeetingStatus.valueOf(rs.getString("meeting_status")), + MeetingAuditStatus.valueOf(rs.getString("audit_status")), + rs.getString("current_audit_node"), + rs.getString("last_submit_at"), + rs.getString("last_reject_reason"), + rs.getInt("overdue_days"), + rs.getString("risk_flags_json"), + rs.getInt("is_frozen") == 1, + rs.getString("freeze_reason"), + rs.getObject("current_auditor_user_id") == null ? null : rs.getLong("current_auditor_user_id"), + rs.getString("node_deadline_at"), + rs.getInt("reject_count"), + rs.getString("last_action_at"), + rs.getString("status_changed_at"), + rs.getObject("status_changed_by") == null ? null : rs.getLong("status_changed_by"), + rs.getString("cancel_reason"), + rs.getString("postpone_reason"), + rs.getString("withdraw_reason"), + rs.getInt("lock_version"), + rs.getString("lock_at"), + rs.getObject("locked_by") == null ? null : rs.getLong("locked_by"), + rs.getString("invoice_config_json") + ); + meeting.setProjectName(rs.getString("project_name")); + meeting.setDeleted(rs.getInt("is_deleted") == 1); + return meeting; + }; + + public JdbcMeetingRepository(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + @Override + public Meeting save(Meeting meeting) { + if (meeting.getId() == null) { + KeyHolder keyHolder = new GeneratedKeyHolder(); + jdbcTemplate.update(connection -> { + PreparedStatement ps = connection.prepareStatement( + "INSERT INTO meeting (tenant_id, project_id, topic, meeting_category, meeting_form, location, start_time, end_time, budget_cent, labor_ratio, catering_ratio, meeting_status, audit_status, current_audit_node, last_submit_at, last_reject_reason, overdue_days, risk_flags_json, is_frozen, freeze_reason, current_auditor_user_id, node_deadline_at, reject_count, last_action_at, status_changed_at, status_changed_by, cancel_reason, postpone_reason, withdraw_reason, lock_version, lock_at, locked_by, invoice_config_json, created_by, updated_by) " + + "VALUES (?, ?, ?, ?, ?, ?, STR_TO_DATE(?, '%Y-%m-%d %H:%i:%s'), STR_TO_DATE(?, '%Y-%m-%d %H:%i:%s'), ?, ?, ?, ?, ?, ?, STR_TO_DATE(?, '%Y-%m-%dT%H:%i:%s'), ?, ?, ?, ?, ?, ?, STR_TO_DATE(?, '%Y-%m-%dT%H:%i:%s'), ?, STR_TO_DATE(?, '%Y-%m-%dT%H:%i:%s'), STR_TO_DATE(?, '%Y-%m-%dT%H:%i:%s'), ?, ?, ?, ?, ?, STR_TO_DATE(?, '%Y-%m-%dT%H:%i:%s'), ?, ?, ?, ?)", + Statement.RETURN_GENERATED_KEYS + ); + Long operator = safeUserId(); + ps.setLong(1, tenantId()); + ps.setLong(2, meeting.getProjectId()); + ps.setString(3, meeting.getTopic()); + ps.setString(4, meeting.getMeetingCategory()); + ps.setString(5, meeting.getMeetingForm()); + ps.setString(6, meeting.getLocation()); + ps.setString(7, meeting.getStartTime()); + ps.setString(8, meeting.getEndTime()); + ps.setLong(9, meeting.getBudgetCent()); + ps.setBigDecimal(10, java.math.BigDecimal.valueOf(meeting.getLaborRatio())); + ps.setBigDecimal(11, java.math.BigDecimal.valueOf(meeting.getCateringRatio())); + ps.setString(12, meeting.getStatus().name()); + ps.setString(13, meeting.getAuditStatus().name()); + ps.setString(14, meeting.getCurrentAuditNode()); + ps.setString(15, meeting.getLastSubmitAt()); + ps.setString(16, meeting.getLastRejectReason()); + ps.setInt(17, meeting.getOverdueDays()); + ps.setString(18, meeting.getRiskFlagsJson()); + ps.setInt(19, meeting.isFrozen() ? 1 : 0); + ps.setString(20, meeting.getFreezeReason()); + ps.setObject(21, meeting.getCurrentAuditorUserId()); + ps.setString(22, meeting.getNodeDeadlineAt()); + ps.setInt(23, meeting.getRejectCount()); + ps.setString(24, meeting.getLastActionAt()); + ps.setString(25, meeting.getStatusChangedAt()); + ps.setObject(26, meeting.getStatusChangedBy()); + ps.setString(27, meeting.getCancelReason()); + ps.setString(28, meeting.getPostponeReason()); + ps.setString(29, meeting.getWithdrawReason()); + ps.setInt(30, meeting.getLockVersion()); + ps.setString(31, meeting.getLockAt()); + ps.setObject(32, meeting.getLockedBy()); + ps.setString(33, meeting.getInvoiceConfigJson()); + ps.setLong(34, operator); + ps.setLong(35, operator); + return ps; + }, keyHolder); + Number key = keyHolder.getKey(); + Long id = key == null ? null : key.longValue(); + return new Meeting( + id, + meeting.getProjectId(), + meeting.getTopic(), + meeting.getMeetingCategory(), + meeting.getMeetingForm(), + meeting.getLocation(), + meeting.getStartTime(), + meeting.getEndTime(), + meeting.getBudgetCent(), + meeting.getLaborRatio(), + meeting.getCateringRatio(), + meeting.getStatus(), + meeting.getAuditStatus(), + meeting.getCurrentAuditNode(), + meeting.getLastSubmitAt(), + meeting.getLastRejectReason(), + meeting.getOverdueDays(), + meeting.getRiskFlagsJson(), + meeting.isFrozen(), + meeting.getFreezeReason(), + meeting.getCurrentAuditorUserId(), + meeting.getNodeDeadlineAt(), + meeting.getRejectCount(), + meeting.getLastActionAt(), + meeting.getStatusChangedAt(), + meeting.getStatusChangedBy(), + meeting.getCancelReason(), + meeting.getPostponeReason(), + meeting.getWithdrawReason(), + meeting.getLockVersion(), + meeting.getLockAt(), + meeting.getLockedBy(), + meeting.getInvoiceConfigJson() + ); + } + jdbcTemplate.update( + "UPDATE meeting SET topic=?, meeting_category=?, meeting_form=?, location=?, " + + "start_time=STR_TO_DATE(?, '%Y-%m-%d %H:%i:%s'), end_time=STR_TO_DATE(?, '%Y-%m-%d %H:%i:%s'), budget_cent=?, labor_ratio=?, catering_ratio=?, " + + "meeting_status=?, audit_status=?, current_audit_node=?, last_submit_at=STR_TO_DATE(?, '%Y-%m-%dT%H:%i:%s'), last_reject_reason=?, overdue_days=?, " + + "risk_flags_json=?, is_frozen=?, freeze_reason=?, current_auditor_user_id=?, node_deadline_at=STR_TO_DATE(?, '%Y-%m-%dT%H:%i:%s'), reject_count=?, last_action_at=STR_TO_DATE(?, '%Y-%m-%dT%H:%i:%s'), " + + "status_changed_at=STR_TO_DATE(?, '%Y-%m-%dT%H:%i:%s'), status_changed_by=?, cancel_reason=?, postpone_reason=?, withdraw_reason=?, lock_version=?, lock_at=STR_TO_DATE(?, '%Y-%m-%dT%H:%i:%s'), locked_by=?, invoice_config_json=?, updated_by=? WHERE tenant_id=? AND id=?", + meeting.getTopic(), + meeting.getMeetingCategory(), + meeting.getMeetingForm(), + meeting.getLocation(), + meeting.getStartTime(), + meeting.getEndTime(), + meeting.getBudgetCent(), + java.math.BigDecimal.valueOf(meeting.getLaborRatio()), + java.math.BigDecimal.valueOf(meeting.getCateringRatio()), + meeting.getStatus().name(), + meeting.getAuditStatus().name(), + meeting.getCurrentAuditNode(), + meeting.getLastSubmitAt(), + meeting.getLastRejectReason(), + meeting.getOverdueDays(), + meeting.getRiskFlagsJson(), + meeting.isFrozen() ? 1 : 0, + meeting.getFreezeReason(), + meeting.getCurrentAuditorUserId(), + meeting.getNodeDeadlineAt(), + meeting.getRejectCount(), + meeting.getLastActionAt(), + meeting.getStatusChangedAt(), + meeting.getStatusChangedBy(), + meeting.getCancelReason(), + meeting.getPostponeReason(), + meeting.getWithdrawReason(), + meeting.getLockVersion(), + meeting.getLockAt(), + meeting.getLockedBy(), + meeting.getInvoiceConfigJson(), + safeUserId(), + tenantId(), + meeting.getId() + ); + return meeting; + } + + @Override + public Optional findById(Long id) { + List list = jdbcTemplate.query( + "SELECT m.id, m.project_id, p.project_name, m.topic, m.meeting_category, m.meeting_form, m.location, " + + "DATE_FORMAT(m.start_time, '%Y-%m-%d %H:%i:%s') AS start_time, DATE_FORMAT(m.end_time, '%Y-%m-%d %H:%i:%s') AS end_time, " + + "m.budget_cent, m.labor_ratio, m.catering_ratio, m.meeting_status, m.audit_status, m.current_audit_node, " + + "DATE_FORMAT(m.last_submit_at, '%Y-%m-%dT%H:%i:%s') AS last_submit_at, m.last_reject_reason, m.overdue_days, m.risk_flags_json, m.is_frozen, m.freeze_reason, " + + "m.current_auditor_user_id, DATE_FORMAT(m.node_deadline_at, '%Y-%m-%dT%H:%i:%s') AS node_deadline_at, m.reject_count, DATE_FORMAT(m.last_action_at, '%Y-%m-%dT%H:%i:%s') AS last_action_at, " + + "DATE_FORMAT(m.status_changed_at, '%Y-%m-%dT%H:%i:%s') AS status_changed_at, m.status_changed_by, m.cancel_reason, m.postpone_reason, m.withdraw_reason, m.lock_version, DATE_FORMAT(m.lock_at, '%Y-%m-%dT%H:%i:%s') AS lock_at, m.locked_by, m.invoice_config_json, m.is_deleted " + + "FROM meeting m LEFT JOIN project p ON m.tenant_id=p.tenant_id AND m.project_id=p.id " + + "WHERE m.tenant_id=? AND m.id=? AND m.is_deleted=0", + ROW_MAPPER, + tenantId(), + id + ); + return list.stream().findFirst(); + } + + @Override + public List findAll(boolean includeDeleted) { + String whereSql = includeDeleted ? "WHERE m.tenant_id=? " : "WHERE m.tenant_id=? AND m.is_deleted=0 "; + return jdbcTemplate.query( + "SELECT m.id, m.project_id, p.project_name, m.topic, m.meeting_category, m.meeting_form, m.location, " + + "DATE_FORMAT(m.start_time, '%Y-%m-%d %H:%i:%s') AS start_time, DATE_FORMAT(m.end_time, '%Y-%m-%d %H:%i:%s') AS end_time, " + + "m.budget_cent, m.labor_ratio, m.catering_ratio, m.meeting_status, m.audit_status, m.current_audit_node, " + + "DATE_FORMAT(m.last_submit_at, '%Y-%m-%dT%H:%i:%s') AS last_submit_at, m.last_reject_reason, m.overdue_days, m.risk_flags_json, m.is_frozen, m.freeze_reason, " + + "m.current_auditor_user_id, DATE_FORMAT(m.node_deadline_at, '%Y-%m-%dT%H:%i:%s') AS node_deadline_at, m.reject_count, DATE_FORMAT(m.last_action_at, '%Y-%m-%dT%H:%i:%s') AS last_action_at, " + + "DATE_FORMAT(m.status_changed_at, '%Y-%m-%dT%H:%i:%s') AS status_changed_at, m.status_changed_by, m.cancel_reason, m.postpone_reason, m.withdraw_reason, m.lock_version, DATE_FORMAT(m.lock_at, '%Y-%m-%dT%H:%i:%s') AS lock_at, m.locked_by, m.invoice_config_json, m.is_deleted " + + "FROM meeting m LEFT JOIN project p ON m.tenant_id=p.tenant_id AND m.project_id=p.id " + + whereSql + + "ORDER BY m.id DESC", + ROW_MAPPER, + tenantId() + ); + } + + private Long tenantId() { + return AuthContext.requireTenantId(); + } + + private Long safeUserId() { + Long userId = AuthContext.userId(); + return userId == null ? 0L : userId; + } + + @Override + public void softDelete(Long id) { + jdbcTemplate.update( + "UPDATE meeting SET is_deleted=1, updated_by=?, updated_at=CURRENT_TIMESTAMP WHERE tenant_id=? AND id=?", + safeUserId(), + tenantId(), + id + ); + } +} diff --git a/backend/src/main/java/com/writeoff/module/meeting/repository/MeetingRepository.java b/backend/src/main/java/com/writeoff/module/meeting/repository/MeetingRepository.java new file mode 100644 index 0000000..8a998f0 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/meeting/repository/MeetingRepository.java @@ -0,0 +1,16 @@ +package com.writeoff.module.meeting.repository; + +import com.writeoff.module.meeting.model.Meeting; + +import java.util.List; +import java.util.Optional; + +public interface MeetingRepository { + Meeting save(Meeting meeting); + + Optional findById(Long id); + + List findAll(boolean includeDeleted); + + void softDelete(Long id); +} diff --git a/backend/src/main/java/com/writeoff/module/meeting/service/MeetingExpertBindingService.java b/backend/src/main/java/com/writeoff/module/meeting/service/MeetingExpertBindingService.java new file mode 100644 index 0000000..e81f754 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/meeting/service/MeetingExpertBindingService.java @@ -0,0 +1,460 @@ +package com.writeoff.module.meeting.service; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.writeoff.common.exception.BusinessException; +import com.writeoff.common.exception.ErrorCodes; +import com.writeoff.module.meeting.dto.BindMeetingExpertsRequest; +import com.writeoff.module.meeting.model.MeetingExpertBinding; +import com.writeoff.module.system.service.BizChangeLogService; +import com.writeoff.security.AuthContext; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +@Service +public class MeetingExpertBindingService { + private static final Long PLATFORM_TENANT_ID = 0L; + private static final String EXPERT_LIST_MODULE_CODE = "EXPERT_LIST"; + private static final String UNBIND_BLOCKED_MESSAGE = "该专家在会议资料-专家列表中已有已保存资料,请先删除相关信息后再解绑"; + + private final JdbcTemplate jdbcTemplate; + private final MeetingService meetingService; + private final BizChangeLogService bizChangeLogService; + private final ObjectMapper objectMapper = new ObjectMapper(); + + private static final RowMapper ROW_MAPPER = (rs, n) -> new MeetingExpertBinding( + rs.getLong("id"), + rs.getLong("meeting_id"), + rs.getLong("expert_id"), + rs.getString("expert_name"), + maskPhone(rs.getString("phone")), + rs.getString("title"), + rs.getString("organization") + ); + + public MeetingExpertBindingService(JdbcTemplate jdbcTemplate, MeetingService meetingService, BizChangeLogService bizChangeLogService) { + this.jdbcTemplate = jdbcTemplate; + this.meetingService = meetingService; + this.bizChangeLogService = bizChangeLogService; + } + + public List listByMeetingId(Long meetingId) { + meetingService.getById(meetingId); + return jdbcTemplate.query( + "SELECT id, meeting_id, expert_id, expert_name, phone, title, organization " + + "FROM meeting_expert_binding WHERE tenant_id=? AND meeting_id=? ORDER BY id DESC", + ROW_MAPPER, + tenantId(), + meetingId + ); + } + + @Transactional(rollbackFor = Exception.class) + public List bind(Long meetingId, BindMeetingExpertsRequest request) { + meetingService.getById(meetingId); + List beforeBindings = listByMeetingId(meetingId); + List rawIds = request.getExpertIds() == null ? new ArrayList() : request.getExpertIds(); + Set idSet = new HashSet(); + for (Long id : rawIds) { + if (id != null && id > 0) { + idSet.add(id); + } + } + List expertIds = new ArrayList(idSet); + validateRemovedExpertCanUnbind(meetingId, beforeBindings, expertIds); + + jdbcTemplate.update( + "DELETE FROM meeting_expert_binding WHERE tenant_id=? AND meeting_id=?", + tenantId(), + meetingId + ); + if (expertIds.isEmpty()) { + return listByMeetingId(meetingId); + } + + StringBuilder sql = new StringBuilder("SELECT id, expert_name, phone, title, organization FROM expert WHERE tenant_id=? AND is_deleted=0 AND id IN ("); + for (int i = 0; i < expertIds.size(); i++) { + if (i > 0) { + sql.append(","); + } + sql.append("?"); + } + sql.append(")"); + + List args = new ArrayList(); + args.add(PLATFORM_TENANT_ID); + args.addAll(expertIds); + List> experts = jdbcTemplate.queryForList(sql.toString(), args.toArray()); + Map> expertMap = new LinkedHashMap>(); + for (Map item : experts) { + Object idVal = item.get("id"); + if (idVal instanceof Number) { + expertMap.put(((Number) idVal).longValue(), item); + } + } + if (expertMap.isEmpty()) { + throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "未匹配到平台专家"); + } + logBindingDiff(meetingId, beforeBindings, expertIds, expertMap); + Long operator = safeUserId(); + for (Long expertId : expertIds) { + Map expert = expertMap.get(expertId); + if (expert == null) { + continue; + } + jdbcTemplate.update( + "INSERT INTO meeting_expert_binding (tenant_id, meeting_id, expert_id, expert_name, phone, title, organization, created_by) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + tenantId(), + meetingId, + expertId, + String.valueOf(expert.get("expert_name") == null ? "" : expert.get("expert_name")), + String.valueOf(expert.get("phone") == null ? "" : expert.get("phone")), + String.valueOf(expert.get("title") == null ? "" : expert.get("title")), + String.valueOf(expert.get("organization") == null ? "" : expert.get("organization")), + operator + ); + } + return listByMeetingId(meetingId); + } + + @Transactional(rollbackFor = Exception.class) + public List unbindOne(Long meetingId, Long expertId) { + meetingService.getById(meetingId); + validateExpertMaterialCanUnbind(meetingId, expertId); + MeetingExpertBinding beforeBinding = findBinding(meetingId, expertId); + jdbcTemplate.update( + "DELETE FROM meeting_expert_binding WHERE tenant_id=? AND meeting_id=? AND expert_id=?", + tenantId(), + meetingId, + expertId + ); + if (beforeBinding != null && bizChangeLogService != null) { + bizChangeLogService.logRelationRemove("MEETING", meetingId, "MEETING_EXPERT_UNBIND", "expertBinding", "绑定专家", beforeBinding.getExpertId(), beforeBinding.getExpertName(), null, null); + } + return listByMeetingId(meetingId); + } + + private Long safeUserId() { + Long userId = AuthContext.userId(); + return userId == null ? 0L : userId; + } + + private Long tenantId() { + return AuthContext.requireTenantId(); + } + + private static String maskPhone(String phone) { + if (phone == null || phone.trim().isEmpty()) { + return phone; + } + String value = phone.trim(); + if (value.length() <= 7) { + return value; + } + return value.substring(0, 3) + "****" + value.substring(value.length() - 4); + } + + private void validateExpertMaterialCanUnbind(Long meetingId, Long expertId) { + long id = expertId == null ? 0L : expertId; + if (id <= 0L) { + return; + } + String contentJson = loadExpertListContentJson(meetingId); + if (!hasSavedExpertMaterial(contentJson, id)) { + return; + } + throw new BusinessException( + ErrorCodes.INVALID_STATE, + UNBIND_BLOCKED_MESSAGE + ); + } + + private void validateRemovedExpertCanUnbind(Long meetingId, List beforeBindings, List nextExpertIds) { + if (beforeBindings == null || beforeBindings.isEmpty()) { + return; + } + Set nextIdSet = new HashSet(nextExpertIds == null ? new ArrayList() : nextExpertIds); + for (MeetingExpertBinding binding : beforeBindings) { + if (binding == null || binding.getExpertId() == null) { + continue; + } + Long expertId = binding.getExpertId(); + if (nextIdSet.contains(expertId)) { + continue; + } + validateExpertMaterialCanUnbind(meetingId, expertId); + } + } + + private String loadExpertListContentJson(Long meetingId) { + List rows = jdbcTemplate.query( + "SELECT content_json FROM meeting_material WHERE tenant_id=? AND meeting_id=? AND module_code=? AND is_deleted=0 LIMIT 1", + (rs, n) -> rs.getString("content_json"), + tenantId(), + meetingId, + EXPERT_LIST_MODULE_CODE + ); + if (rows.isEmpty()) { + return ""; + } + return rows.get(0) == null ? "" : rows.get(0); + } + + private boolean hasSavedExpertMaterial(String contentJson, long expertId) { + if (contentJson == null || contentJson.trim().isEmpty()) { + return false; + } + try { + Map root = objectMapper.readValue(contentJson, new TypeReference>() {}); + return hasExpertOnsitePhoto(root, expertId) + || hasExpertOnsiteSummary(root, expertId) + || hasExpertLaborDetail(root, expertId) + || hasExpertInvoiceDetail(root, expertId); + } catch (Exception ignore) { + return false; + } + } + + private boolean hasExpertOnsitePhoto(Map root, long expertId) { + Map onsitePhoto = asMap(root.get("onsitePhoto")); + Object photosObj = onsitePhoto.get("photos"); + if (!(photosObj instanceof Collection)) { + return false; + } + for (Object obj : (Collection) photosObj) { + if (!(obj instanceof Map)) { + continue; + } + Map photo = (Map) obj; + if (!matchesExpert(photo.get("expertId"), expertId)) { + continue; + } + if (hasText(photo.get("ossKey")) || hasText(firstNonNull(photo.get("fileName"), photo.get("name")))) { + return true; + } + } + return false; + } + + private boolean hasExpertOnsiteSummary(Map root, long expertId) { + Map onsitePhoto = asMap(root.get("onsitePhoto")); + Object expertSummariesObj = onsitePhoto.get("expertSummaries"); + if (expertSummariesObj instanceof Collection) { + for (Object obj : (Collection) expertSummariesObj) { + if (!(obj instanceof Map)) { + continue; + } + Map summary = (Map) obj; + if (matchesExpert(summary.get("expertId"), expertId) && hasText(summary.get("summary"))) { + return true; + } + } + } + Object globalSummary = onsitePhoto.get("summary"); + return hasText(globalSummary); + } + + private boolean hasExpertLaborDetail(Map root, long expertId) { + Map laborProtocol = asMap(root.get("laborProtocol")); + Object detailsObj = laborProtocol.get("details"); + if (!(detailsObj instanceof Collection)) { + return false; + } + for (Object obj : (Collection) detailsObj) { + if (!(obj instanceof Map)) { + continue; + } + Map detail = (Map) obj; + if (!matchesExpert(detail.get("expertId"), expertId)) { + continue; + } + if (hasExpertLaborContent(detail)) { + return true; + } + } + return false; + } + + private boolean hasExpertInvoiceDetail(Map root, long expertId) { + Map invoiceDetail = asMap(root.get("invoiceDetail")); + Object invoicesObj = firstNonNull(invoiceDetail.get("invoices"), invoiceDetail.get("items")); + if (!(invoicesObj instanceof Collection)) { + return false; + } + for (Object obj : (Collection) invoicesObj) { + if (!(obj instanceof Map)) { + continue; + } + Map invoice = (Map) obj; + if (!matchesExpert(invoice.get("expertId"), expertId)) { + continue; + } + if (hasText(invoice.get("invoiceNo")) + || hasText(invoice.get("vendorName")) + || hasText(invoice.get("remark")) + || hasPositiveNumber(firstNonNull(invoice.get("invoiceAmountCent"), invoice.get("amountCent"))) + || hasInvoiceFiles(invoice)) { + return true; + } + } + return false; + } + + private boolean hasExpertLaborContent(Map detail) { + Map protocolFile = asMap(detail.get("protocolFile")); + Map invoiceFile = asMap(detail.get("invoiceFile")); + Object invoiceFilesObj = detail.get("invoiceFiles"); + return hasText(protocolFile.get("ossKey")) + || hasText(firstNonNull(protocolFile.get("fileName"), protocolFile.get("name"))) + || hasText(invoiceFile.get("ossKey")) + || hasText(firstNonNull(invoiceFile.get("fileName"), invoiceFile.get("name"))) + || hasInvoiceFiles(invoiceFilesObj) + || hasPositiveNumber(detail.get("amountCent")) + || hasText(detail.get("remark")) + || hasNonIdleInvoiceOcr(detail.get("invoiceOcr")); + } + + private boolean hasInvoiceFiles(Map invoice) { + return hasInvoiceFiles(firstNonNull(invoice.get("files"), invoice.get("file"))); + } + + private boolean hasInvoiceFiles(Object filesObj) { + if (filesObj instanceof Collection) { + for (Object obj : (Collection) filesObj) { + if (!(obj instanceof Map)) { + continue; + } + Map file = (Map) obj; + if (hasText(file.get("ossKey")) || hasText(firstNonNull(file.get("fileName"), file.get("name")))) { + return true; + } + } + return false; + } + if (filesObj instanceof Map) { + Map file = (Map) filesObj; + return hasText(file.get("ossKey")) || hasText(firstNonNull(file.get("fileName"), file.get("name"))); + } + return false; + } + + private boolean hasNonIdleInvoiceOcr(Object invoiceOcrObj) { + if (!(invoiceOcrObj instanceof Map)) { + return false; + } + Map invoiceOcr = (Map) invoiceOcrObj; + String status = stringValue(invoiceOcr.get("status")); + if (status != null && !status.isEmpty() && !"idle".equalsIgnoreCase(status)) { + return true; + } + return hasText(invoiceOcr.get("message")) || !asMap(invoiceOcr.get("normalized")).isEmpty(); + } + + private boolean matchesExpert(Object value, long expertId) { + if (value == null) { + return false; + } + if (value instanceof Number) { + return ((Number) value).longValue() == expertId; + } + try { + return Long.parseLong(String.valueOf(value).trim()) == expertId; + } catch (Exception ignore) { + return false; + } + } + + private boolean hasPositiveNumber(Object value) { + if (value == null) { + return false; + } + try { + return Long.parseLong(String.valueOf(value).trim()) > 0L; + } catch (Exception ignore) { + return false; + } + } + + private boolean hasText(Object value) { + return value != null && !String.valueOf(value).trim().isEmpty(); + } + + private String stringValue(Object value) { + return value == null ? null : String.valueOf(value).trim(); + } + + private Object firstNonNull(Object first, Object second) { + return first != null ? first : second; + } + + private void logBindingDiff(Long meetingId, + List beforeBindings, + List afterExpertIds, + Map> expertMap) { + if (bizChangeLogService == null) { + return; + } + Map beforeMap = new LinkedHashMap(); + for (MeetingExpertBinding item : beforeBindings) { + if (item != null && item.getExpertId() != null) { + beforeMap.put(item.getExpertId(), item.getExpertName()); + } + } + Map afterMap = new LinkedHashMap(); + for (Long expertId : afterExpertIds) { + Map expert = expertMap.get(expertId); + if (expert == null) { + continue; + } + afterMap.put(expertId, String.valueOf(expert.get("expert_name") == null ? "" : expert.get("expert_name")).trim()); + } + Set allIds = new HashSet(); + allIds.addAll(beforeMap.keySet()); + allIds.addAll(afterMap.keySet()); + String batchId = bizChangeLogService.newBatchId(); + for (Long expertId : allIds) { + boolean beforeExists = beforeMap.containsKey(expertId); + boolean afterExists = afterMap.containsKey(expertId); + String expertName = beforeExists ? beforeMap.get(expertId) : afterMap.get(expertId); + if (!beforeExists && afterExists) { + bizChangeLogService.logRelationAdd("MEETING", meetingId, "MEETING_EXPERT_BIND_ADD", "expertBinding", "绑定专家", expertId, expertName, batchId, null); + } else if (beforeExists && !afterExists) { + bizChangeLogService.logRelationRemove("MEETING", meetingId, "MEETING_EXPERT_BIND_REMOVE", "expertBinding", "绑定专家", expertId, expertName, batchId, null); + } + } + } + + private MeetingExpertBinding findBinding(Long meetingId, Long expertId) { + if (expertId == null || expertId <= 0L) { + return null; + } + List list = jdbcTemplate.query( + "SELECT id, meeting_id, expert_id, expert_name, phone, title, organization " + + "FROM meeting_expert_binding WHERE tenant_id=? AND meeting_id=? AND expert_id=? LIMIT 1", + ROW_MAPPER, + tenantId(), + meetingId, + expertId + ); + return list.isEmpty() ? null : list.get(0); + } + + @SuppressWarnings("unchecked") + private Map asMap(Object value) { + if (value instanceof Map) { + return (Map) value; + } + return new LinkedHashMap(); + } +} diff --git a/backend/src/main/java/com/writeoff/module/meeting/service/MeetingLaborAgreementExtractService.java b/backend/src/main/java/com/writeoff/module/meeting/service/MeetingLaborAgreementExtractService.java new file mode 100644 index 0000000..270ae8e --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/meeting/service/MeetingLaborAgreementExtractService.java @@ -0,0 +1,541 @@ +package com.writeoff.module.meeting.service; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.writeoff.common.exception.BusinessException; +import com.writeoff.common.exception.ErrorCodes; +import com.writeoff.module.expert.dto.AddBankCardRequest; +import com.writeoff.module.expert.dto.CreateExpertRequest; +import com.writeoff.module.expert.model.ExpertInfo; +import com.writeoff.module.expert.service.PlatformExpertService; +import com.writeoff.module.meeting.dto.BindMeetingExpertsRequest; +import com.writeoff.module.meeting.dto.MeetingLaborAgreementExtractApplyRequest; +import com.writeoff.module.meeting.dto.MeetingLaborAgreementExtractSubmitRequest; +import com.writeoff.module.meeting.model.Meeting; +import com.writeoff.module.meeting.model.MeetingAuditStatus; +import com.writeoff.module.meeting.model.MeetingLaborAgreementExtractResult; +import com.writeoff.module.ocr.dto.DocumentExtractTaskQueryResponse; +import com.writeoff.module.ocr.dto.DocumentExtractTaskSubmitRequest; +import com.writeoff.module.ocr.dto.DocumentExtractTaskSubmitResponse; +import com.writeoff.module.ocr.service.BaiduDocumentExtractService; +import com.writeoff.module.system.model.PlatformDictionaryItem; +import com.writeoff.module.system.service.PlatformDictionaryService; +import com.writeoff.security.AuthContext; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +@Service +public class MeetingLaborAgreementExtractService { + private static final String MODULE_CODE = "EXPERT_LIST"; + private static final String HOSPITAL_DICT_TYPE = "EXPERT_HOSPITAL"; + private static final List OCR_KEYS_NAME = asList("涔欐柟"); + private static final List OCR_KEYS_HOSPITAL = asList("宸ヤ綔鍗曚綅"); + private static final List OCR_KEYS_PHONE = asList("鑱旂郴鐢佃瘽"); + private static final List OCR_KEYS_FEE = asList("鍔冲姟璐?); + private static final List OCR_KEYS_BANK_NAME = asList("寮€鎴烽摱琛?); + private static final List OCR_KEYS_BANK_CARD = asList("寮€鎴峰笎鍙?, "寮€鎴疯处鍙?); + private static final List OCR_KEYS_ID_NO = asList("韬唤璇佸彿鐮?, "韬唤璇佸彿"); + private static final List OCR_KEYS_ACCOUNT_NAME = asList("璐︽埛鍚?); + + private final MeetingService meetingService; + private final BaiduDocumentExtractService documentExtractService; + private final PlatformExpertService platformExpertService; + private final PlatformDictionaryService platformDictionaryService; + private final MeetingExpertBindingService meetingExpertBindingService; + private final MeetingMaterialService meetingMaterialService; + private final ObjectMapper objectMapper = new ObjectMapper(); + + public MeetingLaborAgreementExtractService(MeetingService meetingService, + BaiduDocumentExtractService documentExtractService, + PlatformExpertService platformExpertService, + PlatformDictionaryService platformDictionaryService, + MeetingExpertBindingService meetingExpertBindingService, + MeetingMaterialService meetingMaterialService) { + this.meetingService = meetingService; + this.documentExtractService = documentExtractService; + this.platformExpertService = platformExpertService; + this.platformDictionaryService = platformDictionaryService; + this.meetingExpertBindingService = meetingExpertBindingService; + this.meetingMaterialService = meetingMaterialService; + } + + public DocumentExtractTaskSubmitResponse submit(Long meetingId, MeetingLaborAgreementExtractSubmitRequest request) { + assertMeetingEditable(meetingId); + DocumentExtractTaskSubmitRequest submitRequest = new DocumentExtractTaskSubmitRequest(); + submitRequest.setObjectKey(trimToNull(request.getObjectKey())); + submitRequest.setFileName(trimToNull(request.getFileName())); + submitRequest.setManifest(buildManifest()); + submitRequest.setRemoveDuplicates(Boolean.TRUE); + submitRequest.setEraseWatermark(Boolean.TRUE); + return documentExtractService.submitTask(submitRequest); + } + + public MeetingLaborAgreementExtractResult query(Long meetingId, String taskId) { + assertMeetingEditable(meetingId); + return buildResult(documentExtractService.queryTask(taskId)); + } + + @Transactional(rollbackFor = Exception.class) + public Map apply(Long meetingId, MeetingLaborAgreementExtractApplyRequest request) { + assertMeetingEditable(meetingId); + MeetingLaborAgreementExtractResult result = buildResult(documentExtractService.queryTask(request.getTaskId())); + if (!"Success".equalsIgnoreCase(trimToEmpty(result.getStatus()))) { + throw new BusinessException(ErrorCodes.INVALID_STATE, "OCR浠诲姟鏈畬鎴愶紝涓嶈兘搴旂敤"); + } + MeetingLaborAgreementExtractResult.ParsedExpert parsed = result.getParsedExpert(); + if (parsed == null) { + throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "鏈В鏋愬埌涓撳淇℃伅"); + } + String idNo = trimToNull(parsed.getIdNo()); + if (idNo == null) { + throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "鏈瘑鍒埌韬唤璇佸彿鐮?); + } + + ExpertInfo existing = platformExpertService.findByExactIdNo(idNo); + Long requestedExpertId = request.getExistingExpertId(); + boolean updateExisting = Boolean.TRUE.equals(request.getUpdateExistingExpert()); + ExpertInfo targetExpert; + boolean created = false; + boolean updated = false; + + if (existing != null) { + if (!updateExisting) { + throw new BusinessException(ErrorCodes.INVALID_STATE, "宸插瓨鍦ㄥ悓韬唤璇佷笓瀹讹紝璇风‘璁ゆ槸鍚﹀鐢ㄥ苟鏇存柊"); + } + if (requestedExpertId == null || !existing.getId().equals(requestedExpertId)) { + throw new BusinessException(ErrorCodes.INVALID_STATE, "纭涓撳涓庣郴缁熷尮閰嶇粨鏋滀笉涓€鑷?); + } + targetExpert = updateExistingExpert(existing, parsed); + updated = true; + } else { + targetExpert = createExpert(parsed); + created = true; + } + + upsertBankCard(targetExpert.getId(), parsed); + bindExpertToMeeting(meetingId, targetExpert.getId()); + saveLaborToMeeting(meetingId, targetExpert, parsed, request.getObjectKey(), request.getFileName()); + + Map data = new LinkedHashMap(); + data.put("taskId", request.getTaskId()); + data.put("expertId", targetExpert.getId()); + data.put("createdExpert", created); + data.put("updatedExpert", updated); + data.put("boundMeeting", true); + data.put("nameMismatchFlag", Boolean.TRUE.equals(parsed.getNameMismatchFlag())); + return data; + } + + private MeetingLaborAgreementExtractResult buildResult(DocumentExtractTaskQueryResponse response) { + MeetingLaborAgreementExtractResult result = new MeetingLaborAgreementExtractResult(); + result.setTaskId(response.getTaskId()); + result.setStatus(response.getStatus()); + result.setReason(response.getReason()); + result.setCreatedAt(response.getCreatedAt()); + result.setStartedAt(response.getStartedAt()); + result.setFinishedAt(response.getFinishedAt()); + result.setDuration(response.getDuration()); + result.setLogId(response.getLogId()); + if (!"Success".equalsIgnoreCase(trimToEmpty(response.getStatus()))) { + return result; + } + MeetingLaborAgreementExtractResult.ParsedExpert parsedExpert = parseExtractedExpert(response.getRaw()); + result.setParsedExpert(parsedExpert); + ExpertInfo existing = parsedExpert == null ? null : platformExpertService.findByExactIdNo(parsedExpert.getIdNo()); + if (existing != null) { + MeetingLaborAgreementExtractResult.ExistingExpert existingExpert = new MeetingLaborAgreementExtractResult.ExistingExpert(); + existingExpert.setExpertId(existing.getId()); + existingExpert.setExpertName(existing.getExpertName()); + existingExpert.setPhoneMasked(existing.getPhone()); + existingExpert.setIdNoMasked(existing.getIdNo()); + existingExpert.setHospitalName(existing.getOrganization()); + result.setExistingExpert(existingExpert); + result.setNeedsConfirm(Boolean.TRUE); + } else { + result.setNeedsConfirm(Boolean.FALSE); + } + result.setNameMismatchFlag(parsedExpert != null && Boolean.TRUE.equals(parsedExpert.getNameMismatchFlag())); + return result; + } + + private MeetingLaborAgreementExtractResult.ParsedExpert parseExtractedExpert(Map raw) { + Map resultMap = asMap(raw == null ? null : raw.get("result")); + List> extractResults = listOfMap(resultMap.get("extractResult")); + if (extractResults.isEmpty()) { + return null; + } + Map first = extractResults.get(0); + Map data = asMap(first.get("data")); + Map singleKey = asMap(data.get("singleKey")); + MeetingLaborAgreementExtractResult.ParsedExpert expert = new MeetingLaborAgreementExtractResult.ParsedExpert(); + expert.setExpertName(firstWord(singleKey, OCR_KEYS_NAME)); + expert.setHospitalName(firstWord(singleKey, OCR_KEYS_HOSPITAL)); + expert.setPhone(normalizePhone(firstWord(singleKey, OCR_KEYS_PHONE))); + String laborFeeText = firstWord(singleKey, OCR_KEYS_FEE); + expert.setLaborFeeText(laborFeeText); + expert.setLaborFeeCent(parseAmountCent(laborFeeText)); + expert.setBankName(firstWord(singleKey, OCR_KEYS_BANK_NAME)); + expert.setBankCardNo(normalizeBankCardNo(firstWord(singleKey, OCR_KEYS_BANK_CARD))); + expert.setAccountName(firstWord(singleKey, OCR_KEYS_ACCOUNT_NAME)); + expert.setIdNo(normalizeIdNo(firstWord(singleKey, OCR_KEYS_ID_NO))); + boolean mismatch = hasText(expert.getExpertName()) + && hasText(expert.getAccountName()) + && !trimToEmpty(expert.getExpertName()).equals(trimToEmpty(expert.getAccountName())); + expert.setNameMismatchFlag(mismatch); + return expert; + } + + private ExpertInfo createExpert(MeetingLaborAgreementExtractResult.ParsedExpert parsed) { + PlatformDictionaryItem hospitalItem = ensureHospitalDictionary(parsed.getHospitalName()); + CreateExpertRequest request = new CreateExpertRequest(); + request.setExpertName(trimToEmpty(parsed.getExpertName())); + request.setIdNo(trimToEmpty(parsed.getIdNo())); + request.setPhone(trimToEmpty(parsed.getPhone())); + request.setHospitalCode(hospitalItem == null ? null : hospitalItem.getDictCode()); + request.setOrganization(hospitalItem == null ? trimToEmpty(parsed.getHospitalName()) : hospitalItem.getDictName()); + return platformExpertService.create(request); + } + + private ExpertInfo updateExistingExpert(ExpertInfo existing, MeetingLaborAgreementExtractResult.ParsedExpert parsed) { + PlatformDictionaryItem hospitalItem = ensureHospitalDictionary(parsed.getHospitalName()); + CreateExpertRequest request = new CreateExpertRequest(); + request.setExpertName(hasText(parsed.getExpertName()) ? parsed.getExpertName().trim() : existing.getExpertName()); + request.setIdNo(existing.getIdNo()); + request.setPhone(hasText(parsed.getPhone()) ? parsed.getPhone().trim() : existing.getPhone()); + request.setGender(existing.getGender()); + request.setBirthday(existing.getBirthday()); + request.setIdCardValidUntil(existing.getIdCardValidUntil()); + request.setIdCardFrontOssKey(existing.getIdCardFrontOssKey()); + request.setIdCardBackOssKey(existing.getIdCardBackOssKey()); + request.setTitleCode(existing.getTitleCode()); + request.setTitle(existing.getTitle()); + request.setHospitalCode(hospitalItem == null ? existing.getHospitalCode() : hospitalItem.getDictCode()); + request.setOrganization(hospitalItem == null + ? trimToEmpty(existing.getOrganization()) + : hospitalItem.getDictName()); + request.setExportRestricted(existing.getExportRestricted()); + return platformExpertService.update(existing.getId(), request); + } + + private void upsertBankCard(Long expertId, MeetingLaborAgreementExtractResult.ParsedExpert parsed) { + if (expertId == null || expertId <= 0L) { + return; + } + AddBankCardRequest request = new AddBankCardRequest(); + request.setBankName(trimToEmpty(parsed.getBankName())); + request.setBankCardNo(trimToEmpty(parsed.getBankCardNo())); + request.setAccountName(hasText(parsed.getAccountName()) ? parsed.getAccountName().trim() : trimToEmpty(parsed.getExpertName())); + request.setIsDefault(Boolean.TRUE); + boolean mismatch = Boolean.TRUE.equals(parsed.getNameMismatchFlag()); + request.setInconsistentNameApproved(mismatch ? Boolean.TRUE : Boolean.FALSE); + request.setChangeReason(mismatch ? "鍔冲姟鍗忚OCR璇嗗埆鍒拌处鎴峰悕涓庝箼鏂逛笉涓€鑷达紝宸叉墦鏍囦繚瀛? : "鍔冲姟鍗忚OCR鑷姩瀵煎叆"); + platformExpertService.addOrUpdateDefaultCard(expertId, request); + } + + private void bindExpertToMeeting(Long meetingId, Long expertId) { + List ids = new ArrayList(); + List existing = meetingExpertBindingService.listByMeetingId(meetingId); + for (Object obj : existing) { + if (!(obj instanceof com.writeoff.module.meeting.model.MeetingExpertBinding)) { + continue; + } + com.writeoff.module.meeting.model.MeetingExpertBinding row = (com.writeoff.module.meeting.model.MeetingExpertBinding) obj; + if (row.getExpertId() != null && row.getExpertId() > 0L) { + ids.add(row.getExpertId()); + } + } + ids.add(expertId); + Set unique = new LinkedHashSet(ids); + BindMeetingExpertsRequest bindRequest = new BindMeetingExpertsRequest(); + bindRequest.setExpertIds(new ArrayList(unique)); + meetingExpertBindingService.bind(meetingId, bindRequest); + } + + private void saveLaborToMeeting(Long meetingId, ExpertInfo expert, MeetingLaborAgreementExtractResult.ParsedExpert parsed, String protocolObjectKey, String protocolFileName) { + String contentJson = meetingMaterialService.currentContentJsonOrEmpty(meetingId, MODULE_CODE); + Map root = parseJsonObject(contentJson); + Map onsitePhoto = ensureMap(root, "onsitePhoto"); + if (!(onsitePhoto.get("photos") instanceof List)) { + onsitePhoto.put("photos", new ArrayList()); + } + Map laborProtocol = ensureMap(root, "laborProtocol"); + List> details = ensureListOfMap(laborProtocol, "details"); + Map invoiceDetail = ensureMap(root, "invoiceDetail"); + if (!(invoiceDetail.get("invoices") instanceof List)) { + invoiceDetail.put("invoices", new ArrayList()); + } + + long expertId = expert.getId() == null ? 0L : expert.getId(); + Map targetRow = null; + for (Map row : details) { + if (longValue(row.get("expertId")) == expertId) { + targetRow = row; + break; + } + } + if (targetRow == null) { + targetRow = new LinkedHashMap(); + details.add(targetRow); + } + targetRow.put("expertId", expertId); + targetRow.put("expertName", trimToEmpty(expert.getExpertName())); + Map protocolFile = ensureMap(targetRow, "protocolFile"); + String protocolOssKey = trimToEmpty(protocolObjectKey); + String protocolName = trimToEmpty(protocolFileName); + if (hasText(protocolOssKey)) { + protocolFile.put("name", protocolName); + protocolFile.put("fileName", protocolName); + protocolFile.put("ossKey", protocolOssKey); + } else if (!hasText(protocolFile.get("ossKey"))) { + protocolFile.put("name", ""); + protocolFile.put("fileName", ""); + protocolFile.put("ossKey", ""); + } + Map invoiceFile = ensureMap(targetRow, "invoiceFile"); + if (!hasText(invoiceFile.get("ossKey"))) { + invoiceFile.put("name", ""); + invoiceFile.put("ossKey", ""); + } + if (!(targetRow.get("invoiceFiles") instanceof List)) { + targetRow.put("invoiceFiles", new ArrayList()); + } + targetRow.put("amountCent", parsed.getLaborFeeCent() == null ? 0L : parsed.getLaborFeeCent()); + String remark = Boolean.TRUE.equals(parsed.getNameMismatchFlag()) + ? "OCR璇嗗埆鎻愮ず锛氫箼鏂逛笌璐︽埛鍚嶄笉涓€鑷达紝璇蜂汉宸ュ鏍? + : ""; + targetRow.put("remark", remark); + meetingMaterialService.saveRawContent(meetingId, MODULE_CODE, toJson(root), "鍔冲姟鍗忚OCR鑷姩瀵煎叆"); + } + + private PlatformDictionaryItem ensureHospitalDictionary(String hospitalName) { + String name = trimToNull(hospitalName); + if (name == null) { + return null; + } + PlatformDictionaryItem existing = platformDictionaryService.findEnabledItemByName(HOSPITAL_DICT_TYPE, name); + if (existing != null) { + return existing; + } + return platformDictionaryService.createEnabledItem(HOSPITAL_DICT_TYPE, name, "AUTO_HOSPITAL", "鍔冲姟鍗忚OCR鑷姩鍒涘缓"); + } + + private void assertMeetingEditable(Long meetingId) { + Meeting meeting = meetingService.getById(meetingId); + MeetingAuditStatus auditStatus = meeting.getAuditStatus(); + if (auditStatus == MeetingAuditStatus.IN_REVIEW || auditStatus == MeetingAuditStatus.APPROVED) { + throw new BusinessException(ErrorCodes.INVALID_STATE, "璇ヤ細璁祫鏂欏鏍镐腑鎴栧凡瀹℃牳閫氳繃锛屼笉鍏佽鍐嶄慨鏀?); + } + } + + private List buildManifest() { + List list = new ArrayList(); + list.add(manifestField("涔欐柟")); + list.add(manifestField("宸ヤ綔鍗曚綅")); + list.add(manifestField("鑱旂郴鐢佃瘽")); + list.add(manifestField("鍔冲姟璐?)); + list.add(manifestField("寮€鎴烽摱琛?)); + list.add(manifestField("寮€鎴峰笎鍙?)); + list.add(manifestField("璐︽埛鍚?)); + list.add(manifestField("韬唤璇佸彿鐮?)); + return list; + } + + private DocumentExtractTaskSubmitRequest.ManifestField manifestField(String key) { + DocumentExtractTaskSubmitRequest.ManifestField field = new DocumentExtractTaskSubmitRequest.ManifestField(); + field.setKey(key); + field.setParentKey("root"); + field.setDescription(""); + return field; + } + + private static List asList(String... values) { + List list = new ArrayList(); + if (values != null) { + for (String value : values) { + list.add(value); + } + } + return list; + } + + private String firstWord(Map singleKey, List keys) { + for (String key : keys) { + Object rowsObj = singleKey.get(key); + if (!(rowsObj instanceof Collection)) { + continue; + } + for (Object one : (Collection) rowsObj) { + if (!(one instanceof Map)) { + continue; + } + String word = trimToNull(((Map) one).get("word")); + if (word != null) { + return word; + } + } + } + return ""; + } + + private Long parseAmountCent(String text) { + String raw = trimToNull(text); + if (raw == null) { + return 0L; + } + String normalized = raw.replace(",", "").replace("锛?, "").replace("鍏?, "").replace("浜烘皯甯?, "").trim(); + if (normalized.isEmpty()) { + return 0L; + } + StringBuilder builder = new StringBuilder(); + boolean dotSeen = false; + for (int i = 0; i < normalized.length(); i++) { + char ch = normalized.charAt(i); + if (Character.isDigit(ch)) { + builder.append(ch); + } else if (ch == '.' && !dotSeen) { + builder.append(ch); + dotSeen = true; + } + } + if (builder.length() == 0) { + return 0L; + } + try { + BigDecimal yuan = new BigDecimal(builder.toString()); + return yuan.movePointRight(2).setScale(0, BigDecimal.ROUND_HALF_UP).longValue(); + } catch (Exception ex) { + return 0L; + } + } + + private Map parseJsonObject(String json) { + if (!hasText(json)) { + return new LinkedHashMap(); + } + try { + return objectMapper.readValue(json, new TypeReference>() {}); + } catch (Exception ex) { + return new LinkedHashMap(); + } + } + + private String toJson(Object value) { + try { + return objectMapper.writeValueAsString(value); + } catch (Exception ex) { + throw new BusinessException(ErrorCodes.INTERNAL_ERROR, "JSON搴忓垪鍖栧け璐?); + } + } + + private Map ensureMap(Map parent, String key) { + Map value = asMap(parent.get(key)); + if (value.isEmpty() && !(parent.get(key) instanceof Map)) { + value = new LinkedHashMap(); + parent.put(key, value); + } else if (!(parent.get(key) instanceof Map)) { + parent.put(key, value); + } + return value; + } + + private List> ensureListOfMap(Map parent, String key) { + Object value = parent.get(key); + if (!(value instanceof List)) { + List> list = new ArrayList>(); + parent.put(key, list); + return list; + } + List> list = new ArrayList>(); + for (Object one : (List) value) { + if (one instanceof Map) { + list.add(new LinkedHashMap((Map) one)); + } + } + parent.put(key, list); + return list; + } + + private Map asMap(Object value) { + if (!(value instanceof Map)) { + return new LinkedHashMap(); + } + return new LinkedHashMap((Map) value); + } + + private List> listOfMap(Object value) { + List> list = new ArrayList>(); + if (!(value instanceof Collection)) { + return list; + } + for (Object one : (Collection) value) { + if (one instanceof Map) { + list.add(new LinkedHashMap((Map) one)); + } + } + return list; + } + + private long longValue(Object value) { + if (value instanceof Number) { + return ((Number) value).longValue(); + } + try { + return Long.parseLong(trimToEmpty(value)); + } catch (Exception ex) { + return 0L; + } + } + + private String normalizeBankCardNo(String value) { + String raw = trimToNull(value); + if (raw == null) { + return ""; + } + return raw.replaceAll("\\s+", ""); + } + + private String normalizePhone(String value) { + String raw = trimToNull(value); + if (raw == null) { + return ""; + } + return raw.replaceAll("[^0-9]", ""); + } + + private String normalizeIdNo(String value) { + String raw = trimToNull(value); + if (raw == null) { + return ""; + } + return raw.replace(" ", "").toUpperCase(Locale.ROOT); + } + + private boolean hasText(Object value) { + return trimToNull(value) != null; + } + + private String trimToEmpty(Object value) { + String result = trimToNull(value); + return result == null ? "" : result; + } + + private String trimToNull(Object value) { + if (value == null) { + return null; + } + String text = String.valueOf(value).trim(); + return text.isEmpty() ? null : text; + } +} + + diff --git a/backend/src/main/java/com/writeoff/module/meeting/service/MeetingMaterialExportService.java b/backend/src/main/java/com/writeoff/module/meeting/service/MeetingMaterialExportService.java new file mode 100644 index 0000000..a0f1563 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/meeting/service/MeetingMaterialExportService.java @@ -0,0 +1,643 @@ +package com.writeoff.module.meeting.service; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.writeoff.common.exception.BusinessException; +import com.writeoff.common.exception.ErrorCodes; +import com.writeoff.module.file.service.OssService; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.regex.Pattern; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +@Service +public class MeetingMaterialExportService { + private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + private static final Pattern ILLEGAL_FILE_CHARS = Pattern.compile("[\\\\/:*?\"<>|]"); + private static final List MODULE_SPECS = Arrays.asList( + new ModuleSpec("BASIC_INFO", "01_会议基本信息模块", "basic-info.json"), + new ModuleSpec("WRITE_OFF_DOCS", "02_核销材料模块", "write-off-docs.json"), + new ModuleSpec("EXPERT_LIST", "03_专家列表模块", "expert-list.json"), + new ModuleSpec("MEETING_INVOICE", "04_会议发票模块", "meeting-invoice.json") + ); + private static final Map INVOICE_SECTION_TITLES = new LinkedHashMap() {{ + put("SETTLEMENT_DETAIL", "会议结算单明细"); + put("VENUE_CONFIRMATION", "会场确认函、会场协议"); + put("CONSTRUCTION_DETAIL", "会议搭建明细"); + put("ACCOMMODATION_DETAIL", "住宿明细"); + put("CATERING_DETAIL", "餐饮明细"); + put("LOCAL_TRANSPORT_DETAIL", "小交通明细"); + put("INTERCITY_TRANSPORT_DETAIL", "大交通明细"); + put("MATERIAL_DETAIL", "物料明细"); + put("DESIGN_DRAFT_DETAIL", "设计稿明细"); + put("OTHER", "其他"); + }}; + + private final JdbcTemplate jdbcTemplate; + private final OssService ossService; + private final ObjectMapper objectMapper = new ObjectMapper(); + + public MeetingMaterialExportService(JdbcTemplate jdbcTemplate, OssService ossService) { + this.jdbcTemplate = jdbcTemplate; + this.ossService = ossService; + } + + public byte[] buildZip(Long tenantId, Long meetingId) { + Map meeting = findMeeting(tenantId, meetingId); + String meetingTopic = stringValue(meeting.get("topic")); + String exportedAt = DATE_TIME_FORMATTER.format(LocalDateTime.now()); + String rootFolder = "会议资料包/" + sanitizeSegment((meetingTopic.isEmpty() ? "会议" : meetingTopic) + "-" + meetingId) + "/"; + List> materialRows = jdbcTemplate.queryForList( + "SELECT module_code, content_json FROM meeting_material WHERE tenant_id=? AND meeting_id=? AND is_deleted=0 ORDER BY id ASC", + tenantId, + meetingId + ); + Map materialJsonByCode = new LinkedHashMap(); + for (Map row : materialRows) { + String moduleCode = stringValue(row.get("module_code")); + if (!moduleCode.isEmpty()) { + materialJsonByCode.put(moduleCode, stringValue(row.get("content_json"))); + } + } + + List> manifestModules = new ArrayList>(); + List> manifestFiles = new ArrayList>(); + List failureLines = new ArrayList(); + Set usedEntries = new LinkedHashSet(); + int attemptedAttachmentCount = 0; + int successAttachmentCount = 0; + int failedAttachmentCount = 0; + + try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + ZipOutputStream zipOutputStream = new ZipOutputStream(outputStream, StandardCharsets.UTF_8)) { + for (ModuleSpec moduleSpec : MODULE_SPECS) { + String contentJson = materialJsonByCode.get(moduleSpec.moduleCode); + if (contentJson == null || contentJson.trim().isEmpty()) { + continue; + } + Map moduleManifest = new LinkedHashMap(); + moduleManifest.put("moduleCode", moduleSpec.moduleCode); + moduleManifest.put("moduleTitle", moduleSpec.folderName.substring(3)); + + String moduleFolder = rootFolder + moduleSpec.folderName + "/"; + String jsonEntryPath = uniqueEntryPath(moduleFolder, moduleSpec.jsonFileName, usedEntries); + writeTextEntry(zipOutputStream, jsonEntryPath, prettyJson(contentJson)); + manifestFiles.add(buildManifestFile(moduleSpec.moduleCode, moduleSpec.folderName.substring(3), "JSON", moduleSpec.jsonFileName, null, jsonEntryPath, "SUCCESS", null)); + + Map parsedJson = parseJsonObject(contentJson, moduleSpec.moduleCode, failureLines); + List attachments = extractAttachments(moduleSpec.moduleCode, parsedJson); + moduleManifest.put("jsonEntryPath", jsonEntryPath); + moduleManifest.put("attachmentCount", attachments.size()); + + for (ExportAttachment attachment : attachments) { + attemptedAttachmentCount++; + String displayName = resolveDisplayName(attachment.fileName, attachment.objectKey, attachment.fallbackName); + String outputFileName = withOptionalPrefix(attachment.fileNamePrefix, displayName); + String folderPath = moduleFolder + sanitizeSegment(attachment.subFolder) + "/"; + String zipEntryPath = uniqueEntryPath(folderPath, outputFileName, usedEntries); + try { + byte[] fileBytes = ossService.getObjectBytes(attachment.objectKey); + writeBinaryEntry(zipOutputStream, zipEntryPath, fileBytes); + manifestFiles.add(buildManifestFile(moduleSpec.moduleCode, moduleSpec.folderName.substring(3), attachment.subFolder, outputFileName, attachment.objectKey, zipEntryPath, "SUCCESS", null)); + successAttachmentCount++; + } catch (Exception ex) { + String errorMessage = ex.getMessage() == null ? "下载失败" : ex.getMessage(); + failureLines.add(moduleSpec.folderName.substring(3) + "/" + attachment.subFolder + "/" + outputFileName + ":" + errorMessage); + manifestFiles.add(buildManifestFile(moduleSpec.moduleCode, moduleSpec.folderName.substring(3), attachment.subFolder, outputFileName, attachment.objectKey, zipEntryPath, "FAILED", errorMessage)); + failedAttachmentCount++; + } + } + moduleManifest.put("successAttachmentCount", successAttachmentCount); + moduleManifest.put("failedAttachmentCount", failedAttachmentCount); + manifestModules.add(moduleManifest); + } + + if (attemptedAttachmentCount > 0 && successAttachmentCount == 0) { + throw new BusinessException(ErrorCodes.INTERNAL_ERROR, "会议资料附件全部导出失败"); + } + + writeTextEntry(zipOutputStream, rootFolder + "00_导出说明.txt", buildReadme(meetingId, meetingTopic, exportedAt, attemptedAttachmentCount, successAttachmentCount, failedAttachmentCount, failureLines)); + + Map manifest = new LinkedHashMap(); + manifest.put("meetingId", meetingId); + manifest.put("meetingTopic", meetingTopic); + manifest.put("exportedAt", exportedAt); + manifest.put("attemptedAttachmentCount", attemptedAttachmentCount); + manifest.put("successAttachmentCount", successAttachmentCount); + manifest.put("failedAttachmentCount", failedAttachmentCount); + manifest.put("modules", manifestModules); + manifest.put("files", manifestFiles); + writeTextEntry(zipOutputStream, rootFolder + "00_manifest.json", objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(manifest)); + + zipOutputStream.finish(); + return outputStream.toByteArray(); + } catch (BusinessException ex) { + throw ex; + } catch (Exception ex) { + throw new BusinessException(ErrorCodes.INTERNAL_ERROR, "会议资料打包失败"); + } + } + + private Map findMeeting(Long tenantId, Long meetingId) { + List> rows = jdbcTemplate.queryForList( + "SELECT id, topic FROM meeting WHERE tenant_id=? AND id=? AND is_deleted=0 LIMIT 1", + tenantId, + meetingId + ); + if (rows.isEmpty()) { + throw new BusinessException(ErrorCodes.RESOURCE_NOT_FOUND, "会议不存在"); + } + return rows.get(0); + } + + private Map parseJsonObject(String contentJson, String moduleCode, List failureLines) { + try { + return objectMapper.readValue(contentJson, new TypeReference>() {}); + } catch (Exception ex) { + failureLines.add(moduleCode + " 模块数据解析失败:" + ex.getMessage()); + return new LinkedHashMap(); + } + } + + private List extractAttachments(String moduleCode, Map content) { + if ("WRITE_OFF_DOCS".equalsIgnoreCase(moduleCode)) { + return extractWriteOffDocs(content); + } + if ("EXPERT_LIST".equalsIgnoreCase(moduleCode)) { + return extractExpertList(content); + } + if ("MEETING_INVOICE".equalsIgnoreCase(moduleCode)) { + return extractMeetingInvoices(content); + } + return Collections.emptyList(); + } + + private List extractWriteOffDocs(Map content) { + List attachments = new ArrayList(); + List> agendas = listOfMap(content.get("agenda")); + for (int i = 0; i < agendas.size(); i++) { + Map agenda = agendas.get(i); + attachments.add(new ExportAttachment( + "会议议程", + stringValue(agenda.get("name")), + stringValue(agenda.get("ossKey")), + "会议议程" + (i + 1), + null + )); + } + + Map signInSheet = mapValue(content.get("signInSheet")); + if (!signInSheet.isEmpty()) { + attachments.add(new ExportAttachment( + "签到表", + stringValue(signInSheet.get("name")), + stringValue(signInSheet.get("ossKey")), + "签到表", + null + )); + } + + Map themePhoto = mapValue(content.get("themePhoto")); + if (!stringValue(themePhoto.get("ossKey")).isEmpty()) { + attachments.add(new ExportAttachment( + "主题照片", + stringValue(firstNonEmpty(themePhoto.get("name"), themePhoto.get("fileName"))), + stringValue(themePhoto.get("ossKey")), + "主题照片", + null + )); + } + + List> invitations = listOfMap(content.get("invitation")); + for (int i = 0; i < invitations.size(); i++) { + Map invitation = invitations.get(i); + attachments.add(new ExportAttachment( + "邀请函", + stringValue(invitation.get("name")), + stringValue(invitation.get("ossKey")), + "邀请函" + (i + 1), + null + )); + } + + Map profileFile = mapValue(content.get("profileFile")); + if (!profileFile.isEmpty()) { + attachments.add(new ExportAttachment( + "专家简介串场稿", + stringValue(profileFile.get("name")), + stringValue(profileFile.get("ossKey")), + "专家简介串场稿", + null + )); + } + return filterValidAttachments(attachments); + } + + private List extractExpertList(Map content) { + List attachments = new ArrayList(); + Map onsitePhoto = mapValue(content.get("onsitePhoto")); + List> photos = listOfMap(onsitePhoto.get("photos")); + for (int i = 0; i < photos.size(); i++) { + Map photo = photos.get(i); + attachments.add(new ExportAttachment( + "现场照片/" + resolveExpertFolder(photo, i + 1), + stringValue(photo.get("name")), + stringValue(photo.get("ossKey")), + "现场照片" + (i + 1), + null + )); + } + + Map laborProtocol = mapValue(content.get("laborProtocol")); + List> details = listOfMap(laborProtocol.get("details")); + for (int i = 0; i < details.size(); i++) { + Map detail = details.get(i); + String expertFolder = resolveExpertFolder(detail, i + 1); + Map protocolFile = mapValue(detail.get("protocolFile")); + if (!protocolFile.isEmpty()) { + attachments.add(new ExportAttachment( + "劳务协议/" + expertFolder, + stringValue(firstNonEmpty(protocolFile.get("fileName"), protocolFile.get("name"))), + stringValue(protocolFile.get("ossKey")), + "劳务协议" + (i + 1), + null + )); + } + Map invoiceFile = mapValue(detail.get("invoiceFile")); + if (!invoiceFile.isEmpty()) { + attachments.add(new ExportAttachment( + "劳务协议/" + expertFolder, + stringValue(firstNonEmpty(invoiceFile.get("fileName"), invoiceFile.get("name"))), + stringValue(invoiceFile.get("ossKey")), + "劳务发票" + (i + 1), + null + )); + } + } + + Map invoiceDetail = mapValue(content.get("invoiceDetail")); + List> invoices = listOfMap(invoiceDetail.get("invoices")); + for (int i = 0; i < invoices.size(); i++) { + Map invoice = invoices.get(i); + String expertFolder = resolveExpertFolder(invoice, i + 1); + String prefix = invoicePrefix(invoice); + List> files = listOfMap(invoice.get("files")); + if (files.isEmpty()) { + Map singleFile = mapValue(invoice.get("file")); + if (!singleFile.isEmpty()) { + files = Collections.singletonList(singleFile); + } + } + for (int fileIndex = 0; fileIndex < files.size(); fileIndex++) { + Map file = files.get(fileIndex); + attachments.add(new ExportAttachment( + "专家发票/" + expertFolder, + stringValue(firstNonEmpty(file.get("fileName"), file.get("name"))), + stringValue(file.get("ossKey")), + "专家发票" + (i + 1) + "-" + (fileIndex + 1), + prefix + )); + } + } + return filterValidAttachments(attachments); + } + + private List extractMeetingInvoices(Map content) { + List attachments = new ArrayList(); + List> sections = listOfMap(content.get("sections")); + for (int i = 0; i < sections.size(); i++) { + Map section = sections.get(i); + String sectionCode = stringValue(section.get("sectionCode")); + String sectionTitle = stringValue(firstNonEmpty(section.get("sectionTitle"), INVOICE_SECTION_TITLES.get(sectionCode))); + if (sectionTitle.isEmpty()) { + sectionTitle = "发票分组" + (i + 1); + } + List> files = listOfMap(section.get("files")); + for (int fileIndex = 0; fileIndex < files.size(); fileIndex++) { + Map file = files.get(fileIndex); + attachments.add(new ExportAttachment( + sectionTitle, + stringValue(file.get("fileName")), + stringValue(file.get("ossKey")), + sectionTitle + (fileIndex + 1), + stringValue(file.get("label")) + )); + } + } + return filterValidAttachments(attachments); + } + + private List filterValidAttachments(List attachments) { + List valid = new ArrayList(); + for (ExportAttachment attachment : attachments) { + if (attachment == null) { + continue; + } + String subFolder = sanitizeNestedFolder(attachment.subFolder); + valid.add(new ExportAttachment(subFolder, attachment.fileName, attachment.objectKey, attachment.fallbackName, attachment.fileNamePrefix)); + } + return valid; + } + + private String resolveExpertFolder(Map row, int fallbackIndex) { + String expertName = stringValue(firstNonEmpty(row.get("expertName"), row.get("name"))); + if (!expertName.isEmpty()) { + return sanitizeSegment(expertName); + } + Long expertId = longValue(row.get("expertId")); + if (expertId != null && expertId > 0L) { + return "专家-" + expertId; + } + return "专家-" + fallbackIndex; + } + + private String invoicePrefix(Map invoice) { + List parts = new ArrayList(); + String expenseType = stringValue(invoice.get("expenseType")); + if (!expenseType.isEmpty()) { + parts.add(expenseType); + } + String invoiceNo = stringValue(invoice.get("invoiceNo")); + if (!invoiceNo.isEmpty()) { + parts.add(invoiceNo); + } + return parts.isEmpty() ? null : String.join("-", parts); + } + + private Map buildManifestFile(String moduleCode, + String moduleTitle, + String subFolder, + String fileName, + String objectKey, + String zipEntryPath, + String status, + String errorMessage) { + Map file = new LinkedHashMap(); + file.put("moduleCode", moduleCode); + file.put("moduleTitle", moduleTitle); + file.put("subFolder", subFolder); + file.put("displayName", fileName); + file.put("objectKey", objectKey); + file.put("zipEntryPath", zipEntryPath); + file.put("status", status); + file.put("errorMessage", errorMessage); + return file; + } + + private void writeTextEntry(ZipOutputStream zipOutputStream, String entryPath, String content) throws IOException { + writeBinaryEntry(zipOutputStream, entryPath, content == null ? new byte[0] : content.getBytes(StandardCharsets.UTF_8)); + } + + private void writeBinaryEntry(ZipOutputStream zipOutputStream, String entryPath, byte[] bytes) throws IOException { + ZipEntry entry = new ZipEntry(entryPath); + zipOutputStream.putNextEntry(entry); + zipOutputStream.write(bytes == null ? new byte[0] : bytes); + zipOutputStream.closeEntry(); + } + + private String buildReadme(Long meetingId, + String meetingTopic, + String exportedAt, + int attemptedAttachmentCount, + int successAttachmentCount, + int failedAttachmentCount, + List failureLines) { + StringBuilder builder = new StringBuilder(); + builder.append("会议资料包导出说明").append("\r\n"); + builder.append("会议ID:").append(meetingId).append("\r\n"); + builder.append("会议主题:").append(meetingTopic == null || meetingTopic.trim().isEmpty() ? "-" : meetingTopic.trim()).append("\r\n"); + builder.append("导出时间:").append(exportedAt).append("\r\n"); + builder.append("附件总数:").append(attemptedAttachmentCount).append("\r\n"); + builder.append("成功数量:").append(successAttachmentCount).append("\r\n"); + builder.append("失败数量:").append(failedAttachmentCount).append("\r\n"); + builder.append("\r\n"); + builder.append("失败明细:").append("\r\n"); + if (failureLines.isEmpty()) { + builder.append("无").append("\r\n"); + } else { + for (String failureLine : failureLines) { + builder.append("- ").append(failureLine).append("\r\n"); + } + } + return builder.toString(); + } + + private String prettyJson(String contentJson) { + try { + Object parsed = objectMapper.readValue(contentJson, Object.class); + return objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(parsed); + } catch (Exception ex) { + return contentJson == null ? "" : contentJson; + } + } + + private String resolveDisplayName(String fileName, String objectKey, String fallbackName) { + String displayName = sanitizeFileName(fileName); + if (!displayName.isEmpty()) { + return appendExtensionIfMissing(displayName, objectKey); + } + String keyName = sanitizeFileName(fileNameFromObjectKey(objectKey)); + if (!keyName.isEmpty()) { + return keyName; + } + return appendExtensionIfMissing(sanitizeFileName(fallbackName), objectKey); + } + + private String appendExtensionIfMissing(String fileName, String objectKey) { + String safeFileName = sanitizeFileName(fileName); + if (safeFileName.isEmpty() || hasFileExtension(safeFileName)) { + return safeFileName; + } + String objectFileName = sanitizeFileName(fileNameFromObjectKey(objectKey)); + String extension = fileExtension(objectFileName); + if (extension.isEmpty()) { + return safeFileName; + } + return safeFileName + extension; + } + + private String uniqueEntryPath(String directory, String fileName, Set usedEntries) { + String safeDirectory = directory; + String safeFileName = sanitizeFileName(fileName); + if (safeFileName.isEmpty()) { + safeFileName = "附件"; + } + String extension = ""; + String baseName = safeFileName; + int dotIndex = safeFileName.lastIndexOf('.'); + if (dotIndex > 0 && dotIndex < safeFileName.length() - 1) { + extension = safeFileName.substring(dotIndex); + baseName = safeFileName.substring(0, dotIndex); + } + String candidate = safeDirectory + safeFileName; + int suffix = 2; + while (!usedEntries.add(candidate)) { + candidate = safeDirectory + baseName + "(" + suffix + ")" + extension; + suffix++; + } + return candidate; + } + + private String sanitizeNestedFolder(String rawFolder) { + String text = rawFolder == null ? "" : rawFolder.trim(); + if (text.isEmpty()) { + return "未分类"; + } + String[] parts = text.split("/"); + List safeParts = new ArrayList(); + for (String part : parts) { + String safe = sanitizeSegment(part); + if (!safe.isEmpty()) { + safeParts.add(safe); + } + } + return safeParts.isEmpty() ? "未分类" : String.join("/", safeParts); + } + + private String sanitizeSegment(String value) { + String text = value == null ? "" : value.trim(); + if (text.isEmpty()) { + return ""; + } + String safe = ILLEGAL_FILE_CHARS.matcher(text).replaceAll("_"); + safe = safe.replaceAll("\\s+", " ").trim(); + return safe.isEmpty() ? "" : safe; + } + + private String sanitizeFileName(String value) { + String safe = sanitizeSegment(value); + if (safe.isEmpty()) { + return ""; + } + if (".".equals(safe) || "..".equals(safe)) { + return "附件"; + } + return safe; + } + + private String fileNameFromObjectKey(String objectKey) { + String key = stringValue(objectKey); + if (key.isEmpty()) { + return ""; + } + int index = key.lastIndexOf('/'); + return index >= 0 ? key.substring(index + 1) : key; + } + + private boolean hasFileExtension(String fileName) { + return !fileExtension(fileName).isEmpty(); + } + + private String fileExtension(String fileName) { + String safeFileName = sanitizeFileName(fileName); + if (safeFileName.isEmpty()) { + return ""; + } + int dotIndex = safeFileName.lastIndexOf('.'); + if (dotIndex <= 0 || dotIndex >= safeFileName.length() - 1) { + return ""; + } + return safeFileName.substring(dotIndex); + } + + private String withOptionalPrefix(String prefix, String fileName) { + String safeFileName = sanitizeFileName(fileName); + String safePrefix = sanitizeSegment(prefix); + if (safePrefix.isEmpty()) { + return safeFileName; + } + if (safeFileName.isEmpty()) { + return safePrefix; + } + return safePrefix + "-" + safeFileName; + } + + private Object firstNonEmpty(Object first, Object second) { + String firstText = stringValue(first); + if (!firstText.isEmpty()) { + return first; + } + return second; + } + + private List> listOfMap(Object value) { + if (!(value instanceof List)) { + return Collections.emptyList(); + } + List list = (List) value; + List> result = new ArrayList>(); + for (Object item : list) { + if (item instanceof Map) { + result.add((Map) item); + } + } + return result; + } + + private Map mapValue(Object value) { + if (value instanceof Map) { + return (Map) value; + } + return Collections.emptyMap(); + } + + private String stringValue(Object value) { + return value == null ? "" : String.valueOf(value).trim(); + } + + private Long longValue(Object value) { + if (value instanceof Number) { + return ((Number) value).longValue(); + } + try { + String text = stringValue(value); + return text.isEmpty() ? null : Long.valueOf(text); + } catch (Exception ex) { + return null; + } + } + + private static class ModuleSpec { + private final String moduleCode; + private final String folderName; + private final String jsonFileName; + + private ModuleSpec(String moduleCode, String folderName, String jsonFileName) { + this.moduleCode = moduleCode; + this.folderName = folderName; + this.jsonFileName = jsonFileName; + } + } + + private static class ExportAttachment { + private final String subFolder; + private final String fileName; + private final String objectKey; + private final String fallbackName; + private final String fileNamePrefix; + + private ExportAttachment(String subFolder, String fileName, String objectKey, String fallbackName, String fileNamePrefix) { + this.subFolder = subFolder; + this.fileName = fileName; + this.objectKey = objectKey; + this.fallbackName = fallbackName; + this.fileNamePrefix = fileNamePrefix; + } + } +} diff --git a/backend/src/main/java/com/writeoff/module/meeting/service/MeetingMaterialService.java b/backend/src/main/java/com/writeoff/module/meeting/service/MeetingMaterialService.java new file mode 100644 index 0000000..ba0dec4 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/meeting/service/MeetingMaterialService.java @@ -0,0 +1,1479 @@ +package com.writeoff.module.meeting.service; + +import com.writeoff.common.api.PageResult; +import com.writeoff.common.exception.BusinessException; +import com.writeoff.module.export.dto.CreateExportTaskRequest; +import com.writeoff.module.export.service.ExportTaskService; +import com.writeoff.module.file.service.OssService; +import com.writeoff.module.meeting.dto.SaveMeetingMaterialRequest; +import com.writeoff.module.meeting.dto.SubmitMeetingMaterialRequest; +import com.writeoff.module.meeting.model.Meeting; +import com.writeoff.module.meeting.model.MeetingMaterial; +import com.writeoff.module.meeting.model.MeetingMaterialHistory; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.writeoff.security.AuthContext; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Arrays; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.math.BigDecimal; +import java.math.RoundingMode; + +@Service +public class MeetingMaterialService { + private static final Set SUPPORTED_MODULES = new HashSet<>(Arrays.asList("BASIC_INFO", "WRITE_OFF_DOCS", "EXPERT_PROFILE", "EXPERT_LIST", "MEETING_INVOICE")); + private static final Set MEETING_INVOICE_AMOUNT_SECTION_CODES = new HashSet<>(Arrays.asList( + "VENUE_CONFIRMATION", + "CONSTRUCTION_DETAIL", + "ACCOMMODATION_DETAIL", + "CATERING_DETAIL", + "LOCAL_TRANSPORT_DETAIL", + "INTERCITY_TRANSPORT_DETAIL", + "MATERIAL_DETAIL", + "DESIGN_DRAFT_DETAIL" + )); + private static final List MEETING_INVOICE_SECTION_CODES = Arrays.asList( + "SETTLEMENT_DETAIL", + "VENUE_CONFIRMATION", + "CONSTRUCTION_DETAIL", + "ACCOMMODATION_DETAIL", + "CATERING_DETAIL", + "LOCAL_TRANSPORT_DETAIL", + "INTERCITY_TRANSPORT_DETAIL", + "MATERIAL_DETAIL", + "DESIGN_DRAFT_DETAIL", + "OTHER" + ); + private static final Map> MEETING_INVOICE_SECTION_FIELD_KEYS = new LinkedHashMap>(); + static { + MEETING_INVOICE_SECTION_FIELD_KEYS.put("SETTLEMENT_DETAIL", Arrays.asList("settlementFile")); + MEETING_INVOICE_SECTION_FIELD_KEYS.put("VENUE_CONFIRMATION", Arrays.asList("invoiceFile", "detailFile")); + MEETING_INVOICE_SECTION_FIELD_KEYS.put("CONSTRUCTION_DETAIL", Arrays.asList("invoiceFile", "detailFile", "equipmentFile")); + MEETING_INVOICE_SECTION_FIELD_KEYS.put("ACCOMMODATION_DETAIL", Arrays.asList("invoiceFile", "detailFile")); + MEETING_INVOICE_SECTION_FIELD_KEYS.put("CATERING_DETAIL", Arrays.asList("invoiceFile", "detailFile")); + MEETING_INVOICE_SECTION_FIELD_KEYS.put("LOCAL_TRANSPORT_DETAIL", Arrays.asList("invoiceFile", "detailFile")); + MEETING_INVOICE_SECTION_FIELD_KEYS.put("INTERCITY_TRANSPORT_DETAIL", Arrays.asList("invoiceFile", "detailFile")); + MEETING_INVOICE_SECTION_FIELD_KEYS.put("MATERIAL_DETAIL", Arrays.asList("invoiceFile", "detailFile", "materialFile")); + MEETING_INVOICE_SECTION_FIELD_KEYS.put("DESIGN_DRAFT_DETAIL", Arrays.asList("invoiceFile", "detailFile", "designDraftFile")); + MEETING_INVOICE_SECTION_FIELD_KEYS.put("OTHER", Arrays.asList("invoiceFile", "detailFile", "equipmentFile", "materialFile", "designDraftFile")); + } + private final JdbcTemplate jdbcTemplate; + private final MeetingService meetingService; + private final ExportTaskService exportTaskService; + private final OssService ossService; + private final ObjectMapper objectMapper = new ObjectMapper(); + + private static final RowMapper MATERIAL_ROW_MAPPER = (rs, n) -> new MeetingMaterial( + rs.getLong("id"), + rs.getLong("meeting_id"), + rs.getString("module_code"), + rs.getString("content_json"), + rs.getString("status"), + rs.getString("audit_node_status"), + rs.getString("audit_aggregate_status"), + rs.getString("submit_remark"), + rs.getInt("reject_count"), + rs.getString("last_reject_reason"), + rs.getString("resubmit_at"), + rs.getInt("version_no"), + rs.getInt("is_latest_version") == 1, + rs.getString("updated_at") + ); + + private static final RowMapper HISTORY_ROW_MAPPER = (rs, n) -> new MeetingMaterialHistory( + rs.getLong("id"), + rs.getLong("meeting_id"), + rs.getString("module_code"), + rs.getInt("version_no"), + rs.getString("action_type"), + rs.getString("content_json"), + rs.getString("remark"), + rs.getString("created_at") + ); + + public MeetingMaterialService(JdbcTemplate jdbcTemplate, MeetingService meetingService, ExportTaskService exportTaskService, OssService ossService) { + this.jdbcTemplate = jdbcTemplate; + this.meetingService = meetingService; + this.exportTaskService = exportTaskService; + this.ossService = ossService; + } + + public PageResult list(Long meetingId) { + meetingService.getById(meetingId); + List list = jdbcTemplate.query( + "SELECT id, meeting_id, module_code, content_json, status, " + + "audit_node_status, audit_aggregate_status, submit_remark, reject_count, last_reject_reason, " + + "DATE_FORMAT(resubmit_at, '%Y-%m-%d %H:%i:%s') AS resubmit_at, version_no, is_latest_version, " + + "DATE_FORMAT(updated_at, '%Y-%m-%d %H:%i:%s') AS updated_at " + + "FROM meeting_material WHERE tenant_id=? AND meeting_id=? AND is_deleted=0 ORDER BY id ASC", + MATERIAL_ROW_MAPPER, + tenantId(), + meetingId + ); + return new PageResult<>(list, list.size(), 1, 20); + } + + public MeetingMaterial current(Long meetingId, String moduleCode) { + meetingService.getById(meetingId); + validateModule(moduleCode); + String storageModuleCode = resolveStorageModuleCode(moduleCode); + List list = jdbcTemplate.query( + "SELECT id, meeting_id, module_code, content_json, status, " + + "audit_node_status, audit_aggregate_status, submit_remark, reject_count, last_reject_reason, " + + "DATE_FORMAT(resubmit_at, '%Y-%m-%d %H:%i:%s') AS resubmit_at, version_no, is_latest_version, " + + "DATE_FORMAT(updated_at, '%Y-%m-%d %H:%i:%s') AS updated_at " + + "FROM meeting_material WHERE tenant_id=? AND meeting_id=? AND module_code=? AND is_deleted=0 LIMIT 1", + MATERIAL_ROW_MAPPER, + tenantId(), + meetingId, + storageModuleCode + ); + return list.isEmpty() ? null : list.get(0); + } + + public String currentContentJsonOrEmpty(Long meetingId, String moduleCode) { + MeetingMaterial material = current(meetingId, moduleCode); + if (material == null || material.getContentJson() == null) { + return ""; + } + return material.getContentJson(); + } + + @Transactional + public MeetingMaterial save(Long meetingId, String moduleCode, SaveMeetingMaterialRequest request) { + validateMeetingBudgetLimit(meetingId, moduleCode, request.getContentJson()); + return upsert(meetingId, moduleCode, request.getContentJson(), request.getRemark(), "SAVE", "DRAFT"); + } + + @Transactional + public MeetingMaterial saveRawContent(Long meetingId, String moduleCode, String contentJson, String remark) { + validateMeetingBudgetLimit(meetingId, moduleCode, contentJson); + return upsert(meetingId, moduleCode, contentJson, remark, "SAVE", "DRAFT"); + } + + @Transactional + public MeetingMaterial submit(Long meetingId, String moduleCode, SubmitMeetingMaterialRequest request) { + validateSubmitContent(moduleCode, request.getContentJson()); + validateMeetingBudgetLimit(meetingId, moduleCode, request.getContentJson()); + return upsert(meetingId, moduleCode, request.getContentJson(), request.getRemark(), "SUBMIT", "SUBMITTED"); + } + + public PageResult history(Long meetingId, String moduleCode) { + meetingService.getById(meetingId); + validateModule(moduleCode); + String storageModuleCode = resolveStorageModuleCode(moduleCode); + List list = jdbcTemplate.query( + "SELECT id, meeting_id, module_code, version_no, action_type, content_json, remark, " + + "DATE_FORMAT(created_at, '%Y-%m-%d %H:%i:%s') AS created_at " + + "FROM meeting_material_history WHERE tenant_id=? AND meeting_id=? AND module_code=? ORDER BY version_no DESC", + HISTORY_ROW_MAPPER, + tenantId(), + meetingId, + storageModuleCode + ); + return new PageResult<>(list, list.size(), 1, 20); + } + + public List> listMaterialReviewItems(Long meetingId, String moduleCode) { + MeetingMaterial material = current(meetingId, moduleCode); + if (material == null || material.getContentJson() == null || material.getContentJson().trim().isEmpty()) { + return Collections.emptyList(); + } + return buildReviewItems(moduleCode, material.getContentJson()); + } + + public List> listMaterialItemReviews(Long taskId, String reviewNode, String moduleCode) { + validateModule(moduleCode); + return jdbcTemplate.query( + "SELECT item_key, item_label, review_result, review_reason, reviewer_user_id, " + + "DATE_FORMAT(updated_at, '%Y-%m-%d %H:%i:%s') AS updated_at " + + "FROM audit_material_item_review " + + "WHERE tenant_id=? AND task_id=? AND review_node=? AND module_code=? ORDER BY item_key ASC", + (rs, n) -> { + Map row = new LinkedHashMap<>(); + row.put("itemKey", rs.getString("item_key")); + row.put("itemLabel", rs.getString("item_label")); + row.put("reviewResult", rs.getString("review_result")); + row.put("reviewReason", rs.getString("review_reason")); + row.put("reviewerUserId", rs.getLong("reviewer_user_id")); + row.put("updatedAt", rs.getString("updated_at")); + return row; + }, + tenantId(), + taskId, + reviewNode, + moduleCode + ); + } + + @Transactional + public int saveMaterialItemReviewRecords(Long meetingId, + Long taskId, + String reviewNode, + String moduleCode, + List> items, + String reviewResult, + String reviewReason, + Long reviewerUserId) { + validateModule(moduleCode); + if (items == null || items.isEmpty()) { + return 0; + } + int affected = 0; + for (Map item : items) { + String itemKey = stringValue(item.get("itemKey")); + String itemLabel = stringValue(item.get("itemLabel")); + if (itemKey == null || itemKey.isEmpty() || itemLabel == null || itemLabel.isEmpty()) { + continue; + } + affected += jdbcTemplate.update( + "INSERT INTO audit_material_item_review (tenant_id, meeting_id, task_id, review_node, module_code, item_key, item_label, review_result, review_reason, reviewer_user_id) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) " + + "ON DUPLICATE KEY UPDATE item_label=VALUES(item_label), review_result=VALUES(review_result), review_reason=VALUES(review_reason), reviewer_user_id=VALUES(reviewer_user_id), updated_at=CURRENT_TIMESTAMP", + tenantId(), + meetingId, + taskId, + reviewNode, + moduleCode, + itemKey, + itemLabel, + reviewResult, + reviewReason, + reviewerUserId == null ? 0L : reviewerUserId + ); + } + return affected; + } + + public Map createMaterialsExportTask(Long meetingId, String idempotencyKey, String fileName) { + Meeting meeting = meetingService.getById(meetingId); + CreateExportTaskRequest request = new CreateExportTaskRequest(); + request.setIdempotencyKey(idempotencyKey); + request.setTaskCode("MEETING_MATERIAL_EXPORT"); + request.setBizType("MEETING_MATERIAL"); + request.setBizId(String.valueOf(meetingId)); + request.setFiltersJson("{\"meetingId\":" + meetingId + "}"); + request.setFileName(resolveMeetingExportFileName(meeting, fileName, "会议资料包", ".zip", "meeting-material-")); + return exportTaskService.create(request); + } + + public Map generateSummaryTask(Long meetingId, String idempotencyKey, String fileName) { + Meeting meeting = meetingService.getById(meetingId); + CreateExportTaskRequest request = new CreateExportTaskRequest(); + request.setIdempotencyKey(idempotencyKey); + request.setTaskCode("MEETING_SUMMARY_GENERATE"); + request.setBizType("MEETING_SUMMARY"); + request.setBizId(String.valueOf(meetingId)); + request.setFiltersJson("{\"meetingId\":" + meetingId + "}"); + request.setFileName(resolveMeetingExportFileName(meeting, fileName, "会议总结", ".docx", "meeting-summary-")); + return exportTaskService.create(request); + } + + private String resolveMeetingExportFileName(Meeting meeting, + String requestedFileName, + String suffixLabel, + String extension, + String genericPrefix) { + String normalizedExtension = normalizeExtension(extension); + if (isMeaningfulExportFileName(requestedFileName, normalizedExtension, genericPrefix)) { + return ensureFileExtension(sanitizeFileName(requestedFileName), normalizedExtension); + } + String meetingTopic = meeting == null ? "" : stringValue(meeting.getTopic()); + String baseName = sanitizeFileName(firstNonEmptyText(meetingTopic, "会议")); + return ensureFileExtension(baseName + "-" + suffixLabel, normalizedExtension); + } + + private boolean isMeaningfulExportFileName(String fileName, String extension, String genericPrefix) { + String text = sanitizeFileName(fileName); + if (text.isEmpty()) { + return false; + } + String lower = text.toLowerCase(Locale.ROOT); + String normalizedExtension = normalizeExtension(extension).toLowerCase(Locale.ROOT); + if (!lower.endsWith(normalizedExtension)) { + return false; + } + String prefix = genericPrefix == null ? "" : genericPrefix.trim().toLowerCase(Locale.ROOT); + return prefix.isEmpty() || !lower.startsWith(prefix); + } + + private String ensureFileExtension(String fileName, String extension) { + String safeName = sanitizeFileName(fileName); + String normalizedExtension = normalizeExtension(extension); + if (safeName.isEmpty()) { + return "文件" + normalizedExtension; + } + if (safeName.toLowerCase(Locale.ROOT).endsWith(normalizedExtension.toLowerCase(Locale.ROOT))) { + return safeName; + } + return safeName + normalizedExtension; + } + + private String normalizeExtension(String extension) { + String text = stringValue(extension); + if (text.isEmpty()) { + return ""; + } + return text.startsWith(".") ? text : "." + text; + } + + private String sanitizeFileName(String value) { + String text = stringValue(value).replaceAll("[\\\\/:*?\"<>|]", "_"); + text = text.replaceAll("\\s+", " ").trim(); + if (text.isEmpty()) { + return ""; + } + return text.length() > 120 ? text.substring(0, 120).trim() : text; + } + + private String firstNonEmptyText(String... values) { + for (String value : values) { + if (value != null && !value.trim().isEmpty()) { + return value.trim(); + } + } + return ""; + } + + private String stringValue(Object value) { + return value == null ? "" : String.valueOf(value).trim(); + } + + public Map getSummaryTaskStatus(Long meetingId, Long taskId) { + meetingService.getById(meetingId); + List> rows; + List args = new ArrayList(); + StringBuilder sql = new StringBuilder(); + sql.append("SELECT id, file_name, status, file_oss_key, download_token, retry_count, max_retry, "); + sql.append("DATE_FORMAT(download_token_expire_at, '%Y-%m-%d %H:%i:%s') AS expire_at, "); + sql.append("error_message, DATE_FORMAT(created_at, '%Y-%m-%d %H:%i:%s') AS created_at, "); + sql.append("DATE_FORMAT(finished_at, '%Y-%m-%d %H:%i:%s') AS finished_at "); + sql.append("FROM export_task "); + sql.append("WHERE tenant_id=? AND biz_type='MEETING_SUMMARY' AND biz_id=? AND requested_by=? AND is_deleted=0 "); + args.add(tenantId()); + args.add(String.valueOf(meetingId)); + args.add(safeUserId()); + if (taskId != null && taskId > 0L) { + sql.append("AND id=? "); + args.add(taskId); + } + sql.append("ORDER BY id DESC LIMIT 1"); + rows = jdbcTemplate.queryForList(sql.toString(), args.toArray()); + if (rows.isEmpty()) { + throw new BusinessException(10003, "会议总结任务不存在"); + } + Map row = rows.get(0); + row = reconcileFailedSummaryTask(row); + Map data = new LinkedHashMap(); + data.put("taskId", row.get("id")); + data.put("fileName", row.get("file_name")); + data.put("status", row.get("status")); + data.put("fileOssKey", row.get("file_oss_key")); + data.put("downloadToken", row.get("download_token")); + data.put("expireAt", row.get("expire_at")); + data.put("errorMessage", row.get("error_message")); + data.put("createdAt", row.get("created_at")); + data.put("finishedAt", row.get("finished_at")); + return data; + } + + public Map refreshSummaryToken(Long meetingId, Long taskId) { + meetingService.getById(meetingId); + if (taskId == null || taskId <= 0L) { + throw new BusinessException(10001, "会议总结任务ID不能为空"); + } + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM export_task WHERE tenant_id=? AND id=? AND biz_type='MEETING_SUMMARY' AND biz_id=? AND requested_by=? AND is_deleted=0", + Integer.class, + tenantId(), + taskId, + String.valueOf(meetingId), + safeUserId() + ); + if (count == null || count == 0) { + throw new BusinessException(10003, "会议总结任务不存在"); + } + return exportTaskService.refreshDownloadToken(taskId); + } + + public Map downloadSummary(Long meetingId, Long taskId, String token) { + meetingService.getById(meetingId); + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM export_task WHERE tenant_id=? AND id=? AND biz_type='MEETING_SUMMARY' AND biz_id=? AND is_deleted=0", + Integer.class, + tenantId(), + taskId, + String.valueOf(meetingId) + ); + if (count == null || count == 0) { + throw new BusinessException(10003, "会议总结任务不存在"); + } + return exportTaskService.download(taskId, token); + } + + private Long safeUserId() { + Long userId = AuthContext.userId(); + return userId == null ? 0L : userId; + } + + private Map reconcileFailedSummaryTask(Map row) { + String status = textValue(row.get("status")).toUpperCase(); + Long taskId = toLong(row.get("id")); + if (taskId == null || taskId <= 0L || !"PENDING".equals(status)) { + return row; + } + List> jobs = jdbcTemplate.queryForList( + "SELECT status, retry_count FROM async_job WHERE tenant_id=? AND job_type='EXPORT_TASK' AND payload=? ORDER BY id DESC LIMIT 1", + tenantId(), + "{\"taskId\":" + taskId + "}" + ); + if (jobs.isEmpty()) { + return row; + } + String jobStatus = textValue(jobs.get(0).get("status")).toUpperCase(); + if (!"FAILED".equals(jobStatus)) { + return row; + } + String errorMessage = firstNonEmptyText( + textValue(row.get("error_message")), + "异步导出任务已失败,请重新生成会议总结" + ); + jdbcTemplate.update( + "UPDATE export_task SET status='FAILED', retry_count=max_retry, error_message=?, updated_at=CURRENT_TIMESTAMP, updated_by=? WHERE tenant_id=? AND id=? AND status='PENDING'", + errorMessage, + safeUserId(), + tenantId(), + taskId + ); + row.put("status", "FAILED"); + row.put("retry_count", row.get("max_retry")); + row.put("error_message", errorMessage); + return row; + } + + + private String textValue(Object value) { + return value == null ? "" : String.valueOf(value).trim(); + } + + private Long toLong(Object value) { + if (value == null) { + return null; + } + if (value instanceof Number) { + return ((Number) value).longValue(); + } + try { + String text = String.valueOf(value).trim(); + return text.isEmpty() ? null : Long.valueOf(text); + } catch (Exception ex) { + return null; + } + } + + public Map presignMaterialUpload(Long meetingId, String moduleCode, String fileName, String contentType) { + meetingService.getById(meetingId); + validateModule(moduleCode); + String name = fileName == null ? "" : fileName.trim(); + if (name.isEmpty()) { + throw new BusinessException(10001, "文件名不能为空"); + } + String ext = ""; + int idx = name.lastIndexOf('.'); + if (idx > 0 && idx < name.length() - 1) { + ext = "." + name.substring(idx + 1).toLowerCase(); + } + String normalizedType = contentType == null || contentType.trim().isEmpty() ? "application/octet-stream" : contentType.trim(); + String objectKey = "meeting/material/" + tenantId() + "/" + meetingId + "/" + moduleCode.toLowerCase() + "/" + + UUID.randomUUID().toString().replace("-", "") + ext; + String uploadUrl = ossService.generateUploadUrl(objectKey, normalizedType); + Map result = new LinkedHashMap(); + result.put("objectKey", objectKey); + result.put("uploadUrl", uploadUrl); + result.put("contentType", normalizedType); + result.put("method", "PUT"); + return result; + } + + private MeetingMaterial upsert(Long meetingId, String moduleCode, String contentJson, String remark, String actionType, String status) { + meetingService.getById(meetingId); + validateModule(moduleCode); + + List existingList = jdbcTemplate.query( + "SELECT id, meeting_id, module_code, content_json, status, " + + "audit_node_status, audit_aggregate_status, submit_remark, reject_count, last_reject_reason, " + + "DATE_FORMAT(resubmit_at, '%Y-%m-%d %H:%i:%s') AS resubmit_at, version_no, is_latest_version, " + + "DATE_FORMAT(updated_at, '%Y-%m-%d %H:%i:%s') AS updated_at " + + "FROM meeting_material WHERE tenant_id=? AND meeting_id=? AND module_code=? AND is_deleted=0 LIMIT 1", + MATERIAL_ROW_MAPPER, + tenantId(), + meetingId, + moduleCode + ); + int nextVersion = 1; + Long materialId; + if (existingList.isEmpty()) { + jdbcTemplate.update( + "INSERT INTO meeting_material (tenant_id, meeting_id, module_code, content_json, status, audit_node_status, audit_aggregate_status, submit_remark, reject_count, version_no, is_latest_version, created_by, updated_by) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, 1, 1, 0, 0)", + tenantId(), + meetingId, + moduleCode, + contentJson, + status, + "SUBMIT".equals(actionType) ? "PENDING" : null, + "SUBMIT".equals(actionType) ? "PENDING" : null, + remark + ); + materialId = jdbcTemplate.queryForObject( + "SELECT id FROM meeting_material WHERE tenant_id=? AND meeting_id=? AND module_code=? LIMIT 1", + Long.class, + tenantId(), + meetingId, + moduleCode + ); + } else { + MeetingMaterial existing = existingList.get(0); + nextVersion = existing.getVersionNo() + 1; + materialId = existing.getId(); + jdbcTemplate.update( + "UPDATE meeting_material SET content_json=?, status=?, audit_node_status=?, audit_aggregate_status=?, submit_remark=?, " + + "version_no=?, is_latest_version=1, updated_at=CURRENT_TIMESTAMP, updated_by=0 WHERE id=?", + contentJson, + status, + "SUBMIT".equals(actionType) ? "PENDING" : existing.getAuditNodeStatus(), + "SUBMIT".equals(actionType) ? "PENDING" : existing.getAuditAggregateStatus(), + remark, + nextVersion, + materialId + ); + } + + jdbcTemplate.update( + "INSERT INTO meeting_material_history (tenant_id, meeting_id, module_code, version_no, action_type, content_json, remark, created_by) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, 0)", + tenantId(), + meetingId, + moduleCode, + nextVersion, + actionType, + contentJson, + remark + ); + + if ("EXPERT_LIST".equals(moduleCode)) { + syncInvoiceStructuredData(meetingId, materialId, contentJson); + } + + List result = jdbcTemplate.query( + "SELECT id, meeting_id, module_code, content_json, status, " + + "audit_node_status, audit_aggregate_status, submit_remark, reject_count, last_reject_reason, " + + "DATE_FORMAT(resubmit_at, '%Y-%m-%d %H:%i:%s') AS resubmit_at, version_no, is_latest_version, " + + "DATE_FORMAT(updated_at, '%Y-%m-%d %H:%i:%s') AS updated_at " + + "FROM meeting_material WHERE id=?", + MATERIAL_ROW_MAPPER, + materialId + ); + if (result.isEmpty()) { + throw new BusinessException(10003, "资料不存在"); + } + return result.get(0); + } + + private void validateModule(String moduleCode) { + if (moduleCode == null || !SUPPORTED_MODULES.contains(moduleCode)) { + throw new BusinessException(10001, "不支持的资料模块"); + } + } + + private String resolveStorageModuleCode(String moduleCode) { + if ("EXPERT_PROFILE".equals(moduleCode)) { + return "WRITE_OFF_DOCS"; + } + return moduleCode; + } + + private void validateSubmitContent(String moduleCode, String contentJson) { + try { + Map map = objectMapper.readValue(contentJson, new TypeReference>() {}); + if ("BASIC_INFO".equals(moduleCode)) { + requireField(map, "chairmanExpertIds"); + requireField(map, "speakerExpertIds"); + requireField(map, "hostExpertIds"); + requireField(map, "discussionGuestExpertIds"); + requireField(map, "guestCount"); + requireField(map, "attendeeCount"); + requireField(map, "attendeeActualCount"); + requireField(map, "targetAudience"); + requireField(map, "mainAgenda"); + requireField(map, "improvementSuggestion"); + requireField(map, "meetingEffect"); + requireIdList(map.get("chairmanExpertIds"), "chairmanExpertIds"); + requireIdList(map.get("speakerExpertIds"), "speakerExpertIds"); + requireIdList(map.get("hostExpertIds"), "hostExpertIds"); + requireIdList(map.get("discussionGuestExpertIds"), "discussionGuestExpertIds"); + } else if ("WRITE_OFF_DOCS".equals(moduleCode)) { + requireField(map, "agenda"); + requireField(map, "signInSheet"); + requireField(map, "invitation"); + requireAgendaField(map.get("agenda")); + requireNestedField(map, "signInSheet", "ossKey"); + requireOptionalNestedField(map, "themePhoto", "ossKey"); + requireInvitationList(map.get("invitation")); + } else if ("EXPERT_LIST".equals(moduleCode)) { + requireField(map, "onsitePhoto"); + requireField(map, "laborProtocol"); + requireField(map, "invoiceDetail"); + Map onsitePhoto = asObjectMap(map.get("onsitePhoto"), "onsitePhoto"); + Map laborProtocol = asObjectMap(map.get("laborProtocol"), "laborProtocol"); + Map invoiceDetail = asObjectMap(map.get("invoiceDetail"), "invoiceDetail"); + requireField(onsitePhoto, "photos"); + requirePhotoList(onsitePhoto.get("photos")); + requireField(laborProtocol, "details"); + requireLaborDetailList(laborProtocol.get("details")); + requireField(invoiceDetail, "invoices"); + requireInvoiceList(invoiceDetail.get("invoices")); + } else if ("MEETING_INVOICE".equals(moduleCode)) { + if (map.get("sections") instanceof Collection) { + requireMeetingInvoiceSections(map.get("sections")); + } else { + requireField(map, "attachments"); + requireMeetingInvoiceAttachmentList(map.get("attachments")); + } + } + } catch (Exception e) { + throw new BusinessException(10001, "资料内容格式错误或缺少必填字段"); + } + } + + private List> buildReviewItems(String moduleCode, String contentJson) { + List> items = new ArrayList<>(); + Map root; + try { + root = objectMapper.readValue(contentJson, new TypeReference>() {}); + } catch (Exception e) { + return items; + } + if ("BASIC_INFO".equals(moduleCode)) { + addReviewItem(items, "discussionGuestExpertIds", "讨论嘉宾"); + addReviewItem(items, "chairmanExpertIds", "大会主席"); + addReviewItem(items, "speakerExpertIds", "会议讲者"); + addReviewItem(items, "hostExpertIds", "会议主持"); + addReviewItem(items, "guestCount", "嘉宾人数"); + addReviewItem(items, "attendeeCount", "计划参会人数"); + addReviewItem(items, "attendeeActualCount", "实到人数"); + addReviewItem(items, "targetAudience", "主要参会对象"); + addReviewItem(items, "mainAgenda", "主要议程"); + addReviewItem(items, "improvementSuggestion", "不足/改进建议"); + addReviewItem(items, "meetingEffect", "会议效果"); + } else if ("WRITE_OFF_DOCS".equals(moduleCode)) { + Object agendaObj = root.get("agenda"); + if (agendaObj instanceof Collection) { + int idx = 1; + for (Object ignored : (Collection) agendaObj) { + addReviewItem(items, "agenda:" + idx, "会议日程#" + idx); + idx++; + } + } else { + addReviewItem(items, "agenda", "会议日程"); + } + addReviewItem(items, "signInSheet", "签到表"); + Map themePhoto = asObjectMapOrEmpty(root.get("themePhoto")); + if (!stringValue(themePhoto.get("ossKey")).isEmpty()) { + addReviewItem(items, "themePhoto", "主题照片"); + } + Object invitationObj = root.get("invitation"); + if (invitationObj instanceof Collection) { + int idx = 1; + for (Object invitationItem : (Collection) invitationObj) { + if (invitationItem == null) { + // keep index continuity for malformed rows + } + addReviewItem(items, "invitation:" + idx, "邀请函/通知#" + idx); + idx++; + } + } + } else if ("EXPERT_PROFILE".equals(moduleCode)) { + Map profileFile = asObjectMapOrEmpty(root.get("profileFile")); + String ossKey = stringValue(firstNonNull(profileFile.get("ossKey"), root.get("ossKey"))); + if (ossKey != null && !ossKey.isEmpty()) { + addReviewItem(items, "expert_profile_file", "涓撳绠€浠?涓插満鏂囦欢"); + } + } else if ("EXPERT_LIST".equals(moduleCode)) { + Map onsiteRoot = asObjectMapOrEmpty(root.get("onsitePhoto")); + Map laborRoot = asObjectMapOrEmpty(root.get("laborProtocol")); + Map invoiceRoot = asObjectMapOrEmpty(root.get("invoiceDetail")); + Object photosObj = onsiteRoot.get("photos"); + if (photosObj instanceof Collection) { + int idx = 1; + for (Object one : (Collection) photosObj) { + if (one instanceof Map) { + String ossKey = stringValue(((Map) one).get("ossKey")); + addReviewItem(items, "photo:" + (ossKey == null || ossKey.isEmpty() ? idx : ossKey), "现场照片#" + idx); + } else { + addReviewItem(items, "photo:" + idx, "现场照片#" + idx); + } + idx++; + } + } + Object expertSummariesObj = onsiteRoot.get("expertSummaries"); + if (expertSummariesObj instanceof Collection) { + for (Object one : (Collection) expertSummariesObj) { + if (one instanceof Map) { + Map summaryMap = (Map) one; + String expertId = stringValue(summaryMap.get("expertId")); + String expertName = stringValue(summaryMap.get("expertName")); + if (expertId != null && !expertId.isEmpty()) { + String key = "onsite_summary:" + expertId; + String label = "现场说明-" + (expertName == null || expertName.isEmpty() ? "讲者" : expertName); + addReviewItem(items, key, label); + } + } + } + } else { + addReviewItem(items, "onsite_summary", "现场说明"); + } + Object detailsObj = laborRoot.get("details"); + if (detailsObj instanceof Collection) { + int idx = 1; + for (Object one : (Collection) detailsObj) { + if (one instanceof Map) { + Map detail = (Map) one; + String expertId = stringValue(detail.get("expertId")); + String expertName = stringValue(detail.get("expertName")); + String key; + if (expertId == null || expertId.isEmpty()) { + key = "labor:" + idx; + } else { + key = "labor:" + expertId; + } + String label = "劳务协议-" + (expertName == null || expertName.isEmpty() ? ("条目#" + idx) : expertName); + addReviewItem(items, key, label); + } else { + addReviewItem(items, "labor:" + idx, "劳务协议-条目#" + idx); + } + idx++; + } + } + Object invoicesObj = invoiceRoot.get("invoices"); + if (invoicesObj instanceof Collection) { + int idx = 1; + for (Object one : (Collection) invoicesObj) { + if (one instanceof Map) { + Map invoice = (Map) one; + String invoiceNo = stringValue(invoice.get("invoiceNo")); + String expertName = stringValue(invoice.get("expertName")); + String key = "invoice:" + (invoiceNo == null || invoiceNo.isEmpty() ? idx : invoiceNo); + String label = "发票明细-" + + (expertName == null || expertName.isEmpty() ? "条目" : expertName) + + "#" + (invoiceNo == null || invoiceNo.isEmpty() ? idx : invoiceNo); + addReviewItem(items, key, label); + } else { + addReviewItem(items, "invoice:" + idx, "发票明细-条目#" + idx); + } + idx++; + } + } + addReviewItem(items, "invoice_summary", "发票明细汇总说明"); + } else if ("MEETING_INVOICE".equals(moduleCode) && root.get("sections") instanceof Collection) { + Object sectionsObj = root.get("sections"); + for (Object one : (Collection) sectionsObj) { + if (!(one instanceof Map)) { + continue; + } + Map section = asObjectMapOrEmpty(one); + String sectionCode = stringValue(firstNonNull(section.get("sectionCode"), section.get("code"))); + if (sectionCode == null || sectionCode.isEmpty() || !MEETING_INVOICE_SECTION_CODES.contains(sectionCode)) { + continue; + } + List fieldKeys = MEETING_INVOICE_SECTION_FIELD_KEYS.get(sectionCode); + if (fieldKeys != null) { + for (String fieldKey : fieldKeys) { + if (hasNonEmptyMeetingInvoiceFieldFile(section, fieldKey)) { + addReviewItem(items, buildMeetingInvoiceFieldReviewKey(sectionCode, fieldKey), buildMeetingInvoiceFieldReviewKey(sectionCode, fieldKey)); + } + } + } + if (MEETING_INVOICE_AMOUNT_SECTION_CODES.contains(sectionCode)) { + Object amountObj = firstNonNull(section.get("totalAmountCent"), section.get("totalAmountYuan")); + if (amountObj != null && !String.valueOf(amountObj).trim().isEmpty()) { + addReviewItem(items, buildMeetingInvoiceAmountReviewKey(sectionCode), buildMeetingInvoiceAmountReviewKey(sectionCode)); + } + } + } + } else if ("MEETING_INVOICE".equals(moduleCode)) { + Object attachmentsObj = root.get("attachments"); + if (attachmentsObj instanceof Collection) { + int idx = 1; + for (Object one : (Collection) attachmentsObj) { + if (one instanceof Map) { + Map item = (Map) one; + String attachmentName = stringValue(item.get("attachmentName")); + String ossKey = stringValue(item.get("ossKey")); + String key = "meeting_invoice_file:" + (ossKey == null || ossKey.isEmpty() ? idx : ossKey); + String label = "会议发票附件-" + (attachmentName == null || attachmentName.isEmpty() ? ("条目#" + idx) : attachmentName); + addReviewItem(items, key, label); + } else { + addReviewItem(items, "meeting_invoice_file:" + idx, "会议发票附件-条目#" + idx); + } + idx++; + } + } + addReviewItem(items, "meeting_invoice_summary", "会议发票说明"); + } + return items; + } + + private void addReviewItem(List> items, String itemKey, String itemLabel) { + Map row = new LinkedHashMap<>(); + row.put("itemKey", itemKey); + row.put("itemLabel", itemLabel); + items.add(row); + } + + private String buildMeetingInvoiceFieldReviewKey(String sectionCode, String fieldKey) { + return "meeting_invoice:" + sectionCode + ":" + fieldKey; + } + + private String buildMeetingInvoiceAmountReviewKey(String sectionCode) { + return "meeting_invoice:" + sectionCode + ":amount"; + } + + private boolean hasNonEmptyMeetingInvoiceFieldFile(Map section, String fieldKey) { + if (section == null || fieldKey == null || fieldKey.trim().isEmpty()) { + return false; + } + Object filesObj = section.get("files"); + if (filesObj instanceof Collection) { + for (Object fileObj : (Collection) filesObj) { + if (!(fileObj instanceof Map)) { + continue; + } + Map fileMap = (Map) fileObj; + String currentFieldKey = stringValue(firstNonNull(fileMap.get("fieldKey"), fileMap.get("key"))); + String ossKey = stringValue(firstNonNull(fileMap.get("ossKey"), fileMap.get("objectKey"))); + if (fieldKey.equals(currentFieldKey) && ossKey != null && !ossKey.isEmpty()) { + return true; + } + } + } + Object legacyObj = section.get(fieldKey); + if (legacyObj instanceof Collection) { + for (Object fileObj : (Collection) legacyObj) { + if (!(fileObj instanceof Map)) { + continue; + } + String ossKey = stringValue(firstNonNull(((Map) fileObj).get("ossKey"), ((Map) fileObj).get("objectKey"))); + if (ossKey != null && !ossKey.isEmpty()) { + return true; + } + } + return false; + } + if (legacyObj instanceof Map) { + String ossKey = stringValue(firstNonNull(((Map) legacyObj).get("ossKey"), ((Map) legacyObj).get("objectKey"))); + return ossKey != null && !ossKey.isEmpty(); + } + return false; + } + + private void requireField(Map map, String key) { + Object value = map.get(key); + if (value == null || String.valueOf(value).trim().isEmpty()) { + throw new BusinessException(10001, "提交失败,缺少必填字段: " + key); + } + } + + private void requireNestedField(Map map, String key, String nestedKey) { + Object target = map.get(key); + if (!(target instanceof Map)) { + throw new BusinessException(10001, "提交失败,字段格式错误: " + key); + } + Map nested = (Map) target; + Object nestedValue = nested.get(nestedKey); + if (nestedValue == null || String.valueOf(nestedValue).trim().isEmpty()) { + throw new BusinessException(10001, "提交失败,缺少必填字段: " + key + "." + nestedKey); + } + } + + private void requireOptionalNestedField(Map map, String key, String nestedKey) { + Object target = map.get(key); + if (target == null) { + return; + } + if (!(target instanceof Map)) { + throw new BusinessException(10001, "invalid nested field " + key); + } + Map nested = (Map) target; + Object nestedValue = nested.get(nestedKey); + if (nestedValue == null || String.valueOf(nestedValue).trim().isEmpty()) { + return; + } + } + + private void requireAgendaField(Object value) { + if (value instanceof Map) { + Map map = (Map) value; + Object ossKey = map.get("ossKey"); + if (ossKey == null || String.valueOf(ossKey).trim().isEmpty()) { + throw new BusinessException(10001, "提交失败,缺少必填字段: agenda.ossKey"); + } + return; + } + if (value instanceof Collection) { + Collection list = (Collection) value; + if (list.isEmpty()) { + throw new BusinessException(10001, "提交失败,缺少必填字段: agenda"); + } + for (Object item : list) { + if (!(item instanceof Map)) { + throw new BusinessException(10001, "提交失败,字段格式错误: agenda"); + } + Map row = (Map) item; + Object ossKey = row.get("ossKey"); + if (ossKey == null || String.valueOf(ossKey).trim().isEmpty()) { + throw new BusinessException(10001, "提交失败,缺少必填字段: agenda.ossKey"); + } + } + return; + } + throw new BusinessException(10001, "提交失败,字段格式错误: agenda"); + } + + private Map asObjectMap(Object value, String fieldName) { + if (!(value instanceof Map)) { + throw new BusinessException(10001, "提交失败,字段格式错误: " + fieldName); + } + @SuppressWarnings("unchecked") + Map target = (Map) value; + return target; + } + + private Map asObjectMapOrEmpty(Object value) { + if (!(value instanceof Map)) { + return new LinkedHashMap<>(); + } + @SuppressWarnings("unchecked") + Map target = (Map) value; + return target; + } + + private void requireInvitationList(Object value) { + if (!(value instanceof Collection)) { + throw new BusinessException(10001, "提交失败,字段格式错误: invitation"); + } + Collection list = (Collection) value; + if (list.isEmpty()) { + throw new BusinessException(10001, "提交失败,缺少必填字段: invitation"); + } + for (Object item : list) { + if (!(item instanceof Map)) { + throw new BusinessException(10001, "提交失败,字段格式错误: invitation"); + } + Map invitation = (Map) item; + Object ossKey = invitation.get("ossKey"); + if (ossKey == null || String.valueOf(ossKey).trim().isEmpty()) { + throw new BusinessException(10001, "提交失败,缺少必填字段: invitation.ossKey"); + } + } + } + + private void requirePhotoList(Object value) { + if (!(value instanceof Collection)) { + throw new BusinessException(10001, "提交失败,字段格式错误: photos"); + } + Collection list = (Collection) value; + if (list.isEmpty()) { + throw new BusinessException(10001, "提交失败,缺少必填字段: photos"); + } + for (Object item : list) { + if (!(item instanceof Map)) { + throw new BusinessException(10001, "提交失败,字段格式错误: photos"); + } + Map photo = (Map) item; + Object ossKey = photo.get("ossKey"); + if (ossKey == null || String.valueOf(ossKey).trim().isEmpty()) { + throw new BusinessException(10001, "提交失败,缺少必填字段: photos.ossKey"); + } + } + } + + private void requireLaborDetailList(Object value) { + if (!(value instanceof Collection)) { + throw new BusinessException(10001, "提交失败,字段格式错误: details"); + } + Collection list = (Collection) value; + if (list.isEmpty()) { + throw new BusinessException(10001, "提交失败,缺少必填字段: details"); + } + for (Object item : list) { + if (!(item instanceof Map)) { + throw new BusinessException(10001, "提交失败,字段格式错误: details"); + } + Map detail = (Map) item; + Object expertName = detail.get("expertName"); + Object amountCent = detail.get("amountCent"); + if (expertName == null || String.valueOf(expertName).trim().isEmpty()) { + throw new BusinessException(10001, "提交失败,缺少必填字段: details.expertName"); + } + if (amountCent == null || String.valueOf(amountCent).trim().isEmpty()) { + throw new BusinessException(10001, "提交失败,缺少必填字段: details.amountCent"); + } + } + } + + private void requireInvoiceList(Object value) { + if (!(value instanceof Collection)) { + throw new BusinessException(10001, "提交失败,字段格式错误: invoices"); + } + Collection list = (Collection) value; + if (list.isEmpty()) { + throw new BusinessException(10001, "提交失败,缺少必填字段: invoices"); + } + for (Object item : list) { + if (!(item instanceof Map)) { + throw new BusinessException(10001, "提交失败,字段格式错误: invoices"); + } + Map invoice = (Map) item; + Object expenseType = invoice.get("expenseType"); + Object invoiceNo = invoice.get("invoiceNo"); + Object amountCent = firstNonNull(invoice.get("invoiceAmountCent"), invoice.get("amountCent")); + Object taxCent = firstNonNull(invoice.get("taxAmountCent"), invoice.get("taxCent")); + Object detailCent = firstNonNull(invoice.get("detailAmountCent"), invoice.get("amountCent")); + Object files = firstNonNull(invoice.get("files"), invoice.get("file")); + if (expenseType == null || String.valueOf(expenseType).trim().isEmpty()) { + throw new BusinessException(10001, "提交失败,缺少必填字段: invoices.expenseType"); + } + if (invoiceNo == null || String.valueOf(invoiceNo).trim().isEmpty()) { + throw new BusinessException(10001, "提交失败,缺少必填字段: invoices.invoiceNo"); + } + if (amountCent == null || String.valueOf(amountCent).trim().isEmpty()) { + throw new BusinessException(10001, "提交失败,缺少必填字段: invoices.amountCent"); + } + if (taxCent == null || String.valueOf(taxCent).trim().isEmpty()) { + throw new BusinessException(10001, "提交失败,缺少必填字段: invoices.taxCent"); + } + if (detailCent == null || String.valueOf(detailCent).trim().isEmpty()) { + throw new BusinessException(10001, "提交失败,缺少必填字段: invoices.detailAmountCent"); + } + + long invoiceAmount = safeLong(amountCent, "invoices.invoiceAmountCent"); + long taxAmount = safeLong(taxCent, "invoices.taxAmountCent"); + long detailAmount = safeLong(detailCent, "invoices.detailAmountCent"); + if (invoiceAmount < 0 || taxAmount < 0 || detailAmount < 0) { + throw new BusinessException(10001, "提交失败,金额字段必须大于等于0"); + } + + if (files instanceof Collection) { + requireInvoiceFileList((Collection) files); + } else if (files instanceof Map) { + requireSingleInvoiceFile((Map) files, "invoices.file"); + } else { + throw new BusinessException(10001, "提交失败,字段格式错误: invoices.files"); + } + } + } + + private void requireInvoiceFileList(Collection files) { + if (files.isEmpty()) { + throw new BusinessException(10001, "提交失败,缺少必填字段: invoices.files"); + } + for (Object file : files) { + if (!(file instanceof Map)) { + throw new BusinessException(10001, "提交失败,字段格式错误: invoices.files"); + } + requireSingleInvoiceFile((Map) file, "invoices.files"); + } + } + + private void requireMeetingInvoiceAttachmentList(Object value) { + if (!(value instanceof Collection)) { + throw new BusinessException(10001, "提交失败,字段格式错误: attachments"); + } + Collection list = (Collection) value; + if (list.isEmpty()) { + throw new BusinessException(10001, "提交失败,缺少必填字段: attachments"); + } + for (Object item : list) { + if (!(item instanceof Map)) { + throw new BusinessException(10001, "提交失败,字段格式错误: attachments"); + } + Map file = (Map) item; + Object attachmentName = file.get("attachmentName"); + Object ossKey = file.get("ossKey"); + if (attachmentName == null || String.valueOf(attachmentName).trim().isEmpty()) { + throw new BusinessException(10001, "提交失败,缺少必填字段: attachments.attachmentName"); + } + if (ossKey == null || String.valueOf(ossKey).trim().isEmpty()) { + throw new BusinessException(10001, "提交失败,缺少必填字段: attachments.ossKey"); + } + } + } + + private void requireSingleInvoiceFile(Map file, String path) { + Object fileType = file.get("fileType"); + Object fileName = file.get("fileName"); + Object ossKey = file.get("ossKey"); + if (fileType == null || String.valueOf(fileType).trim().isEmpty()) { + throw new BusinessException(10001, "提交失败,缺少必填字段: " + path + ".fileType"); + } + if (fileName == null || String.valueOf(fileName).trim().isEmpty()) { + throw new BusinessException(10001, "提交失败,缺少必填字段: " + path + ".fileName"); + } + if (ossKey == null || String.valueOf(ossKey).trim().isEmpty()) { + throw new BusinessException(10001, "提交失败,缺少必填字段: " + path + ".ossKey"); + } + } + + private void requireMeetingInvoiceSections(Object value) { + if (!(value instanceof Collection)) { + throw new BusinessException(10001, "提交失败,字段格式错误: sections"); + } + Collection sections = (Collection) value; + if (sections.isEmpty()) { + throw new BusinessException(10001, "提交失败,缺少必填字段: sections"); + } + for (Object sectionObj : sections) { + if (!(sectionObj instanceof Map)) { + throw new BusinessException(10001, "提交失败,字段格式错误: sections"); + } + Map section = (Map) sectionObj; + String sectionCode = stringValue(firstNonNull(section.get("sectionCode"), section.get("code"))); + if (sectionCode == null || sectionCode.isEmpty()) { + throw new BusinessException(10001, "提交失败,缺少必填字段: sections.sectionCode"); + } + Object filesObj = section.get("files"); + if (!(filesObj instanceof Collection)) { + throw new BusinessException(10001, "提交失败,字段格式错误: sections.files"); + } + Collection files = (Collection) filesObj; + if (!"OTHER".equals(sectionCode) && files.isEmpty()) { + throw new BusinessException(10001, "提交失败,缺少必填字段: sections.files"); + } + for (Object fileObj : files) { + if (!(fileObj instanceof Map)) { + throw new BusinessException(10001, "提交失败,字段格式错误: sections.files"); + } + Map fileMap = (Map) fileObj; + Object ossKey = fileMap.get("ossKey"); + if (ossKey == null || String.valueOf(ossKey).trim().isEmpty()) { + throw new BusinessException(10001, "提交失败,缺少必填字段: sections.files.ossKey"); + } + } + if (MEETING_INVOICE_AMOUNT_SECTION_CODES.contains(sectionCode)) { + Object amountObj = firstNonNull(section.get("totalAmountCent"), section.get("totalAmountYuan")); + if (amountObj == null || String.valueOf(amountObj).trim().isEmpty()) { + throw new BusinessException(10001, "提交失败,缺少必填字段: sections.totalAmount"); + } + } + } + } + + private void validateMeetingBudgetLimit(Long meetingId, String moduleCode, String currentContentJson) { + validateModule(moduleCode); + Meeting meeting = meetingService.getById(meetingId); + long budgetCent = Math.max(0L, meeting.getBudgetCent()); + String expertListContentJson = "EXPERT_LIST".equals(moduleCode) + ? currentContentJson + : getMaterialContentJsonOrEmpty(meetingId, "EXPERT_LIST"); + String meetingInvoiceContentJson = "MEETING_INVOICE".equals(moduleCode) + ? currentContentJson + : getMaterialContentJsonOrEmpty(meetingId, "MEETING_INVOICE"); + long laborTotalCent = extractLaborTotalCent(expertListContentJson); + long expertInvoiceTotalCent = extractExpertInvoiceTotalCent(expertListContentJson); + long meetingInvoiceTotalCent = extractMeetingInvoiceTotalCent(meetingInvoiceContentJson); + long usedTotalCent = laborTotalCent + expertInvoiceTotalCent + meetingInvoiceTotalCent; + if (usedTotalCent <= budgetCent) { + return; + } + long overCent = usedTotalCent - budgetCent; + throw new BusinessException(10001, "预算校验不通过:当前会议预算" + formatYuan(budgetCent) + + "元,已用" + formatYuan(usedTotalCent) + "元,超预算" + formatYuan(overCent) + "元"); + } + + private String getMaterialContentJsonOrEmpty(Long meetingId, String moduleCode) { + List rows = jdbcTemplate.query( + "SELECT content_json FROM meeting_material WHERE tenant_id=? AND meeting_id=? AND module_code=? AND is_deleted=0 LIMIT 1", + (rs, n) -> rs.getString("content_json"), + tenantId(), + meetingId, + moduleCode + ); + if (rows.isEmpty()) { + return ""; + } + return rows.get(0) == null ? "" : rows.get(0); + } + + private long extractLaborTotalCent(String contentJson) { + if (contentJson == null || contentJson.trim().isEmpty()) { + return 0L; + } + try { + Map root = objectMapper.readValue(contentJson, new TypeReference>() {}); + Map laborProtocol = asObjectMapOrEmpty(root.get("laborProtocol")); + Object detailObj = laborProtocol.get("details"); + if (!(detailObj instanceof Collection)) { + return 0L; + } + long total = 0L; + for (Object one : (Collection) detailObj) { + if (!(one instanceof Map)) { + continue; + } + Map row = (Map) one; + total += Math.max(0L, parseCentValue(row.get("amountCent"))); + } + return total; + } catch (Exception e) { + return 0L; + } + } + + private long extractExpertInvoiceTotalCent(String contentJson) { + if (contentJson == null || contentJson.trim().isEmpty()) { + return 0L; + } + try { + Map root = objectMapper.readValue(contentJson, new TypeReference>() {}); + Map invoiceDetail = asObjectMapOrEmpty(root.get("invoiceDetail")); + Object invoiceObj = invoiceDetail.get("invoices"); + if (!(invoiceObj instanceof Collection)) { + return 0L; + } + long total = 0L; + for (Object one : (Collection) invoiceObj) { + if (!(one instanceof Map)) { + continue; + } + Map row = (Map) one; + Object amountObj = firstNonNull(row.get("invoiceAmountCent"), row.get("amountCent")); + total += Math.max(0L, parseCentValue(amountObj)); + } + return total; + } catch (Exception e) { + return 0L; + } + } + + private long extractMeetingInvoiceTotalCent(String contentJson) { + if (contentJson == null || contentJson.trim().isEmpty()) { + return 0L; + } + try { + Map root = objectMapper.readValue(contentJson, new TypeReference>() {}); + Object sectionsObj = root.get("sections"); + if (!(sectionsObj instanceof Collection)) { + return 0L; + } + long total = 0L; + for (Object one : (Collection) sectionsObj) { + if (!(one instanceof Map)) { + continue; + } + Map section = (Map) one; + String sectionCode = stringValue(firstNonNull(section.get("sectionCode"), section.get("code"))); + if (sectionCode == null || !MEETING_INVOICE_AMOUNT_SECTION_CODES.contains(sectionCode)) { + continue; + } + Object centObj = firstNonNull(section.get("totalAmountCent"), section.get("totalFeeCent")); + if (centObj != null && !String.valueOf(centObj).trim().isEmpty()) { + total += Math.max(0L, parseCentValue(centObj)); + continue; + } + Object yuanObj = firstNonNull(section.get("totalAmountYuan"), section.get("totalFeeYuan")); + total += Math.max(0L, parseYuanToCent(yuanObj)); + } + return total; + } catch (Exception e) { + return 0L; + } + } + + private long parseCentValue(Object value) { + if (value == null || String.valueOf(value).trim().isEmpty()) { + return 0L; + } + try { + BigDecimal decimal = new BigDecimal(String.valueOf(value).trim()); + return decimal.setScale(0, RoundingMode.HALF_UP).longValue(); + } catch (Exception e) { + throw new BusinessException(10001, "提交失败,金额字段不是合法数字"); + } + } + + private long parseYuanToCent(Object value) { + if (value == null || String.valueOf(value).trim().isEmpty()) { + return 0L; + } + try { + BigDecimal decimal = new BigDecimal(String.valueOf(value).trim()); + return decimal.multiply(new BigDecimal("100")).setScale(0, RoundingMode.HALF_UP).longValue(); + } catch (Exception e) { + throw new BusinessException(10001, "提交失败,金额字段不是合法数字"); + } + } + + private String formatYuan(long cent) { + BigDecimal yuan = new BigDecimal(cent).divide(new BigDecimal("100"), 2, RoundingMode.HALF_UP); + return yuan.toPlainString(); + } + + private void requireIdList(Object value, String key) { + if (!(value instanceof Collection)) { + throw new BusinessException(10001, "提交失败,字段格式错误: " + key); + } + Collection list = (Collection) value; + if (list.isEmpty()) { + throw new BusinessException(10001, "提交失败,缺少必填字段: " + key); + } + for (Object item : list) { + if (!(item instanceof Number) && !String.valueOf(item).matches("^\\d+$")) { + throw new BusinessException(10001, "提交失败,字段格式错误: " + key); + } + } + } + + private Long tenantId() { + return AuthContext.requireTenantId(); + } + + private void syncInvoiceStructuredData(Long meetingId, Long materialId, String contentJson) { + try { + Map root = objectMapper.readValue(contentJson, new TypeReference>() {}); + Map invoiceDetail = asObjectMapOrEmpty(root.get("invoiceDetail")); + Object invoiceNode = firstNonNull(invoiceDetail.get("invoices"), invoiceDetail.get("items")); + if (!(invoiceNode instanceof Collection)) { + return; + } + + Collection invoices = (Collection) invoiceNode; + jdbcTemplate.update("DELETE FROM meeting_material_invoice_file WHERE tenant_id=? AND meeting_id=?", tenantId(), meetingId); + jdbcTemplate.update("DELETE FROM meeting_material_invoice_item WHERE tenant_id=? AND meeting_id=?", tenantId(), meetingId); + + Map categoryAmount = new LinkedHashMap<>(); + long total = 0L; + Meeting meeting = meetingService.getById(meetingId); + + for (Object obj : invoices) { + if (!(obj instanceof Map)) { + continue; + } + Map invoice = (Map) obj; + String expenseType = stringValue(invoice.get("expenseType")); + String invoiceNo = stringValue(invoice.get("invoiceNo")); + long invoiceAmountCent = safeLong(firstNonNull(invoice.get("invoiceAmountCent"), invoice.get("amountCent")), "invoiceAmountCent"); + long taxAmountCent = safeLong(firstNonNull(invoice.get("taxAmountCent"), invoice.get("taxCent")), "taxAmountCent"); + long detailAmountCent = safeLong(firstNonNull(invoice.get("detailAmountCent"), invoice.get("amountCent")), "detailAmountCent"); + String vendorName = stringValue(invoice.get("vendorName")); + String occurDate = stringValue(invoice.get("occurDate")); + String itemRemark = stringValue(invoice.get("remark")); + + jdbcTemplate.update( + "INSERT INTO meeting_material_invoice_item (tenant_id, meeting_id, material_id, expense_type, invoice_no, invoice_amount_cent, tax_amount_cent, detail_amount_cent, vendor_name, occur_date, remark, created_by, updated_by) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, STR_TO_DATE(?, '%Y-%m-%d'), ?, 0, 0)", + tenantId(), + meetingId, + materialId, + expenseType, + invoiceNo, + invoiceAmountCent, + taxAmountCent, + detailAmountCent, + vendorName, + occurDate, + itemRemark + ); + Long invoiceItemId = jdbcTemplate.queryForObject( + "SELECT LAST_INSERT_ID()", + Long.class + ); + + Object fileNode = firstNonNull(invoice.get("files"), invoice.get("file")); + if (fileNode instanceof Collection) { + for (Object fileObj : (Collection) fileNode) { + if (fileObj instanceof Map) { + insertInvoiceFile(meetingId, invoiceItemId, (Map) fileObj); + } + } + } else if (fileNode instanceof Map) { + insertInvoiceFile(meetingId, invoiceItemId, (Map) fileNode); + } + + categoryAmount.put(expenseType, categoryAmount.getOrDefault(expenseType, 0L) + detailAmountCent); + total += detailAmountCent; + } + + String categoryAmountJson = objectMapper.writeValueAsString(categoryAmount); + int isOverBudget = total > meeting.getBudgetCent() ? 1 : 0; + + jdbcTemplate.update( + "INSERT INTO meeting_invoice_summary (tenant_id, meeting_id, category_amount_cent_json, meeting_total_amount_cent, is_over_budget, created_by, updated_by) " + + "VALUES (?, ?, ?, ?, ?, 0, 0) " + + "ON DUPLICATE KEY UPDATE category_amount_cent_json=VALUES(category_amount_cent_json), meeting_total_amount_cent=VALUES(meeting_total_amount_cent), " + + "is_over_budget=VALUES(is_over_budget), updated_at=CURRENT_TIMESTAMP, updated_by=0", + tenantId(), + meetingId, + categoryAmountJson, + total, + isOverBudget + ); + } catch (BusinessException e) { + throw e; + } catch (Exception e) { + throw new BusinessException(10001, "发票结构化数据处理失败"); + } + } + + private void insertInvoiceFile(Long meetingId, Long invoiceItemId, Map file) { + String fileType = stringValue(file.get("fileType")); + String fileName = stringValue(file.get("fileName")); + String ossKey = stringValue(file.get("ossKey")); + String contentType = stringValue(file.get("contentType")); + long size = safeLong(file.get("size"), "files.size"); + if (size < 0) { + throw new BusinessException(10001, "提交失败,文件大小必须大于等于0"); + } + jdbcTemplate.update( + "INSERT INTO meeting_material_invoice_file (tenant_id, meeting_id, invoice_item_id, file_type, file_name, oss_key, content_type, size, created_by) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0)", + tenantId(), + meetingId, + invoiceItemId, + fileType, + fileName, + ossKey, + contentType, + size + ); + } + + private Object firstNonNull(Object first, Object second) { + return first != null ? first : second; + } + + + private long safeLong(Object value, String fieldName) { + if (value == null || String.valueOf(value).trim().isEmpty()) { + return 0L; + } + try { + return Long.parseLong(String.valueOf(value).trim()); + } catch (Exception e) { + throw new BusinessException(10001, "提交失败,字段不是合法数字: " + fieldName); + } + } +} diff --git a/backend/src/main/java/com/writeoff/module/meeting/service/MeetingService.java b/backend/src/main/java/com/writeoff/module/meeting/service/MeetingService.java new file mode 100644 index 0000000..916863e --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/meeting/service/MeetingService.java @@ -0,0 +1,744 @@ +package com.writeoff.module.meeting.service; + +import com.writeoff.common.api.PageResult; +import com.writeoff.common.exception.BusinessException; +import com.writeoff.common.exception.ErrorCodes; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.writeoff.module.audit.model.AuditNode; +import com.writeoff.module.audit.model.AuditTask; +import com.writeoff.module.audit.model.AuditTaskStatus; +import com.writeoff.module.audit.repository.AuditTaskRepository; +import com.writeoff.module.audit.service.AuditFlowConfigService; +import com.writeoff.module.expert.service.ExpertSnapshotService; +import com.writeoff.module.meeting.dto.CreateMeetingRequest; +import com.writeoff.module.meeting.dto.MeetingQueryRequest; +import com.writeoff.module.meeting.dto.SubmitMeetingRequest; +import com.writeoff.module.meeting.dto.WithdrawMeetingRequest; +import com.writeoff.module.meeting.model.Meeting; +import com.writeoff.module.meeting.model.MeetingAuditStatus; +import com.writeoff.module.meeting.model.MeetingStatus; +import com.writeoff.module.meeting.repository.MeetingRepository; +import com.writeoff.module.project.model.Project; +import com.writeoff.module.project.service.ProjectService; +import com.writeoff.module.scheduler.service.AsyncJobService; +import com.writeoff.module.system.model.BizChangeLogInfo; +import com.writeoff.module.system.service.BizChangeLogService; +import com.writeoff.module.system.service.DataPermissionService; +import com.writeoff.security.AuthContext; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.time.LocalDate; +import java.util.List; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.Map; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.ArrayList; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; + +@Service +public class MeetingService { + private static final DateTimeFormatter SQL_ISO_SECOND_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss"); + private static final Set LOCATION_OPTIONS = new HashSet(); + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + static { + LOCATION_OPTIONS.add("线上"); + LOCATION_OPTIONS.add("线下"); + LOCATION_OPTIONS.add("线上+线下"); + } + private final MeetingRepository meetingRepository; + private final ProjectService projectService; + private final AuditTaskRepository auditTaskRepository; + private final AsyncJobService asyncJobService; + private final AuditFlowConfigService auditFlowConfigService; + private final DataPermissionService dataPermissionService; + private final ExpertSnapshotService expertSnapshotService; + private final BizChangeLogService bizChangeLogService; + private final Map submitIdempotency = new ConcurrentHashMap<>(); + private final Map withdrawIdempotency = new ConcurrentHashMap<>(); + + @Autowired + public MeetingService(MeetingRepository meetingRepository, ProjectService projectService, AuditTaskRepository auditTaskRepository, AsyncJobService asyncJobService, AuditFlowConfigService auditFlowConfigService, DataPermissionService dataPermissionService, ExpertSnapshotService expertSnapshotService, BizChangeLogService bizChangeLogService) { + this.meetingRepository = meetingRepository; + this.projectService = projectService; + this.auditTaskRepository = auditTaskRepository; + this.asyncJobService = asyncJobService; + this.auditFlowConfigService = auditFlowConfigService; + this.dataPermissionService = dataPermissionService; + this.expertSnapshotService = expertSnapshotService; + this.bizChangeLogService = bizChangeLogService; + } + + public MeetingService(MeetingRepository meetingRepository, ProjectService projectService, AuditTaskRepository auditTaskRepository, AsyncJobService asyncJobService) { + this(meetingRepository, projectService, auditTaskRepository, asyncJobService, null, null, null, null); + } + + public PageResult list(MeetingQueryRequest query) { + boolean includeDeleted = query != null && Boolean.TRUE.equals(query.getIncludeDeleted()); + List list = meetingRepository.findAll(includeDeleted); + if (dataPermissionService != null) { + DataPermissionService.DataScope scope = dataPermissionService.resolveCurrentUserScope(); + Set meetingIds = list.stream().map(Meeting::getId).collect(Collectors.toCollection(HashSet::new)); + Map meetingCreatorMap = dataPermissionService.listMeetingCreators(meetingIds); + Map meetingProjectMap = dataPermissionService.listMeetingProjectIds(meetingIds); + Set projectIds = new HashSet<>(meetingProjectMap.values()); + Map projectCreatorMap = dataPermissionService.listProjectCreators(projectIds); + list = list.stream() + .filter(meeting -> { + Long projectId = meetingProjectMap.get(meeting.getId()); + Long meetingCreatedBy = meetingCreatorMap.get(meeting.getId()); + Long projectCreatedBy = projectId == null ? null : projectCreatorMap.get(projectId); + return dataPermissionService.canAccessMeeting(meeting.getId(), meeting.getProjectId(), meetingCreatedBy, projectCreatedBy, scope); + }) + .collect(Collectors.toList()); + } + list.forEach(this::applyEffectiveStatus); + list = applyFilters(list, query); + return new PageResult<>(list, list.size(), 1, 20); + } + + private List applyFilters(List source, MeetingQueryRequest query) { + if (query == null) { + return source; + } + final String projectName = normalize(query.getProjectName()); + final String topic = normalize(query.getTopic()); + final String meetingStatus = normalize(query.getMeetingStatus()); + final String auditStatus = normalize(query.getAuditStatus()); + final String currentNode = normalize(query.getCurrentAuditNode()); + final LocalDateTime meetingStartFrom = parseDateTime(query.getMeetingStartFrom()); + final LocalDateTime meetingStartTo = parseDateTime(query.getMeetingStartTo()); + final LocalDateTime lastSubmitFrom = parseDateTime(query.getLastSubmitFrom()); + final LocalDateTime lastSubmitTo = parseDateTime(query.getLastSubmitTo()); + + return source.stream() + .filter(m -> query.getProjectId() == null || query.getProjectId().equals(m.getProjectId())) + .filter(m -> projectName == null || containsIgnoreCase(m.getProjectName(), projectName)) + .filter(m -> topic == null || containsIgnoreCase(m.getTopic(), topic)) + .filter(m -> meetingStatus == null || (m.getStatus() != null && m.getStatus().name().equalsIgnoreCase(meetingStatus))) + .filter(m -> auditStatus == null || (m.getAuditStatus() != null && m.getAuditStatus().name().equalsIgnoreCase(auditStatus))) + .filter(m -> currentNode == null || containsIgnoreCase(m.getCurrentAuditNode(), currentNode)) + .filter(m -> query.getCurrentAuditorUserId() == null || query.getCurrentAuditorUserId().equals(m.getCurrentAuditorUserId())) + .filter(m -> inRange(parseDateTime(m.getStartTime()), meetingStartFrom, meetingStartTo)) + .filter(m -> inRange(parseDateTime(m.getLastSubmitAt()), lastSubmitFrom, lastSubmitTo)) + .collect(Collectors.toList()); + } + + private boolean inRange(LocalDateTime value, LocalDateTime from, LocalDateTime to) { + if (from == null && to == null) { + return true; + } + if (value == null) { + return false; + } + if (from != null && value.isBefore(from)) { + return false; + } + return to == null || !value.isAfter(to); + } + + private String normalize(String val) { + if (val == null) { + return null; + } + String trimmed = val.trim(); + return trimmed.isEmpty() ? null : trimmed; + } + + private boolean containsIgnoreCase(String origin, String keyword) { + if (origin == null) { + return false; + } + return origin.toLowerCase().contains(keyword.toLowerCase()); + } + + private LocalDateTime parseDateTime(String value) { + if (value == null || value.trim().isEmpty()) { + return null; + } + String trimmed = value.trim(); + DateTimeFormatter[] patterns = new DateTimeFormatter[] { + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"), + DateTimeFormatter.ISO_LOCAL_DATE_TIME + }; + for (DateTimeFormatter pattern : patterns) { + try { + return LocalDateTime.parse(trimmed, pattern); + } catch (DateTimeParseException ignore) { + } + } + return null; + } + + public Meeting create(CreateMeetingRequest request) { + Project project = projectService.getById(request.getProjectId()); + validateProjectForMeetingCreate(project); + validateMeetingTimeInProjectCycle(project, request); + int existingMeetingCount = countMeetingsByProjectId(project.getId()); + if (existingMeetingCount >= project.getMeetingTotal()) { + throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "项目可创建的会议数量已达上限"); + } + validateLocation(request.getLocation()); + long defaultBudgetCent = calculateDefaultMeetingBudgetCent(project); + if (defaultBudgetCent <= 0L) { + throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "默认会议预算必须大于 0"); + } + String now = nowIsoSeconds(); + Meeting meeting = new Meeting( + null, + request.getProjectId(), + request.getTopic(), + request.getMeetingCategory(), + request.getMeetingForm(), + request.getLocation(), + request.getStartTime(), + request.getEndTime(), + defaultBudgetCent, + request.getLaborRatio() == null ? 0d : request.getLaborRatio(), + request.getCateringRatio() == null ? 0d : request.getCateringRatio(), + MeetingStatus.NOT_STARTED, + MeetingAuditStatus.PENDING, + null, + null, + null, + 0, + null, + false, + null, + null, + null, + 0, + null, + now, + safeUserId(), + null, + null, + null, + 0, + null, + null, + null + ); + Meeting saved = meetingRepository.save(meeting); + projectService.markInProgress(request.getProjectId()); + logMeetingCreate(saved); + return saved; + } + + private void validateProjectForMeetingCreate(Project project) { + if (project.getMeetingTotal() <= 0) { + throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "创建会议前请先配置项目会议场次"); + } + LocalDate startDate = project.getStartDate(); + LocalDate endDate = project.getEndDate(); + if (startDate == null || endDate == null || endDate.isBefore(startDate)) { + throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "创建会议前请先配置有效的项目起止日期"); + } + } + + private int countMeetingsByProjectId(Long projectId) { + return (int) meetingRepository.findAll(false).stream() + .filter(item -> !item.isDeleted()) + .filter(item -> projectId.equals(item.getProjectId())) + .count(); + } + + private long calculateDefaultMeetingBudgetCent(Project project) { + long projectBudgetCent = Math.max(0L, project.getBudgetCent()); + ProjectFeeSummary feeSummary = parseProjectFeeSummary(project.getProjectFeeJson()); + long distributableBudgetCent = projectBudgetCent - feeSummary.managementFeeCent - feeSummary.taxFeeCent - feeSummary.customFeeTotalCent; + if (project.getMeetingTotal() <= 0) { + return 0L; + } + return distributableBudgetCent / project.getMeetingTotal(); + } + + private ProjectFeeSummary parseProjectFeeSummary(String projectFeeJson) { + if (projectFeeJson == null || projectFeeJson.trim().isEmpty()) { + return new ProjectFeeSummary(0L, 0L, 0L); + } + try { + JsonNode root = OBJECT_MAPPER.readTree(projectFeeJson); + long managementFeeCent = readNonNegativeLong(root, "managementFeeCent"); + long taxFeeCent = readNonNegativeLong(root, "taxFeeCent"); + long customFeeTotalCent = 0L; + JsonNode customFees = root.path("customFees"); + if (customFees.isArray()) { + for (JsonNode feeNode : customFees) { + if (feeNode == null || !feeNode.isObject()) { + continue; + } + customFeeTotalCent += readNonNegativeLong(feeNode, "amountCent"); + } + } + return new ProjectFeeSummary(managementFeeCent, taxFeeCent, customFeeTotalCent); + } catch (BusinessException ex) { + throw ex; + } catch (Exception ex) { + throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "项目费用配置格式不正确"); + } + } + + private long readNonNegativeLong(JsonNode node, String fieldName) { + if (node == null || node.isNull()) { + return 0L; + } + JsonNode valueNode = node.get(fieldName); + if (valueNode == null || valueNode.isNull()) { + return 0L; + } + if (!valueNode.isNumber()) { + throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "项目费用字段格式不正确:" + fieldName); + } + long value = valueNode.asLong(); + if (value < 0L) { + throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "项目费用字段不能为负数:" + fieldName); + } + return value; + } + + private static class ProjectFeeSummary { + private final long managementFeeCent; + private final long taxFeeCent; + private final long customFeeTotalCent; + + private ProjectFeeSummary(long managementFeeCent, long taxFeeCent, long customFeeTotalCent) { + this.managementFeeCent = managementFeeCent; + this.taxFeeCent = taxFeeCent; + this.customFeeTotalCent = customFeeTotalCent; + } + } + + public Meeting update(Long meetingId, CreateMeetingRequest request) { + Meeting existing = getById(meetingId); + if (request.getProjectId() != null && !existing.getProjectId().equals(request.getProjectId())) { + throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "编辑会议时不能修改所属项目"); + } + Project project = projectService.getById(existing.getProjectId()); + validateProjectForMeetingCreate(project); + validateMeetingTimeInProjectCycle(project, request); + validateLocation(request.getLocation()); + Meeting updated = new Meeting( + existing.getId(), + existing.getProjectId(), + request.getTopic(), + request.getMeetingCategory(), + request.getMeetingForm(), + request.getLocation(), + request.getStartTime(), + request.getEndTime(), + request.getBudgetCent(), + request.getLaborRatio() == null ? 0d : request.getLaborRatio(), + request.getCateringRatio() == null ? 0d : request.getCateringRatio(), + existing.getStatus(), + existing.getAuditStatus(), + existing.getCurrentAuditNode(), + existing.getLastSubmitAt(), + existing.getLastRejectReason(), + existing.getOverdueDays(), + existing.getRiskFlagsJson(), + existing.isFrozen(), + existing.getFreezeReason(), + existing.getCurrentAuditorUserId(), + existing.getNodeDeadlineAt(), + existing.getRejectCount(), + nowIsoSeconds(), + nowIsoSeconds(), + safeUserId(), + existing.getCancelReason(), + existing.getPostponeReason(), + existing.getWithdrawReason(), + existing.getLockVersion() + 1, + nowIsoSeconds(), + safeUserId(), + existing.getInvoiceConfigJson() + ); + updated.setProjectName(existing.getProjectName()); + Meeting saved = meetingRepository.save(updated); + logMeetingUpdate(existing, saved); + return saved; + } + + private void validateLocation(String location) { + if (location == null || location.trim().isEmpty()) { + throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "\u4f1a\u8bae\u5730\u70b9\u4e0d\u80fd\u4e3a\u7a7a"); + } + } + + private void validateLocationLegacy(String location) { + validateLocation(location); + } + + + private void validateMeetingTimeInProjectCycle(Project project, CreateMeetingRequest request) { + LocalDateTime startTime = parseDateTime(request.getStartTime()); + LocalDateTime endTime = parseDateTime(request.getEndTime()); + if (startTime == null || endTime == null) { + throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "会议时间格式必须为 yyyy-MM-dd HH:mm:ss"); + } + if (endTime.isBefore(startTime)) { + throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "会议结束时间不能早于开始时间"); + } + LocalDate projectStartDate = project.getStartDate(); + LocalDate projectEndDate = project.getEndDate(); + if (projectStartDate == null || projectEndDate == null) { + throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "项目日期范围配置不完整"); + } + LocalDate meetingStartDate = startTime.toLocalDate(); + LocalDate meetingEndDate = endTime.toLocalDate(); + if (meetingStartDate.isBefore(projectStartDate) || meetingEndDate.isAfter(projectEndDate)) { + throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "会议时间必须在项目周期内"); + } + } + + public Map submit(Long meetingId, SubmitMeetingRequest request) { + Meeting meeting = meetingRepository.findById(meetingId) + .orElseThrow(() -> new BusinessException(ErrorCodes.RESOURCE_NOT_FOUND, "会议不存在")); + + if (submitIdempotency.containsKey(request.getIdempotencyKey())) { + throw new BusinessException(ErrorCodes.IDEMPOTENCY_CONFLICT, "请求重复,请勿重复提交"); + } + submitIdempotency.put(request.getIdempotencyKey(), meetingId); + + MeetingStatus effectiveStatus = resolveEffectiveStatus(meeting); + if (effectiveStatus != MeetingStatus.COMPLETED) { + throw new BusinessException(ErrorCodes.INVALID_STATE, "会议未完成,不能提交审核"); + } + + if (meeting.getAuditStatus() == MeetingAuditStatus.APPROVED) { + throw new BusinessException(ErrorCodes.INVALID_STATE, "会议已终审通过,不能重复提交"); + } + if (meeting.getAuditStatus() == MeetingAuditStatus.IN_REVIEW) { + throw new BusinessException(ErrorCodes.INVALID_STATE, "会议正在审核中"); + } + if (expertSnapshotService != null) { + expertSnapshotService.snapshotOnMeetingSubmit(meetingId); + } + meeting.setAuditStatus(MeetingAuditStatus.IN_REVIEW); + meetingRepository.save(meeting); + + Long tenantId = tenantId(); + AuditNode firstNode = auditFlowConfigService == null ? AuditNode.INIT_REVIEW : auditFlowConfigService.firstNode(tenantId); + Long assigneeUserId = auditFlowConfigService == null ? null : auditFlowConfigService.resolveAssigneeUserId(tenantId, firstNode); + meeting.setCurrentAuditNode(firstNode.name()); + meeting.setLastSubmitAt(nowIsoSeconds()); + meeting.setLastActionAt(nowIsoSeconds()); + meeting.setCurrentAuditorUserId(assigneeUserId); + meetingRepository.save(meeting); + if (bizChangeLogService != null) { + bizChangeLogService.logAction("MEETING", meetingId, "MEETING_SUBMIT", request.getRemark()); + } + auditTaskRepository.save(new AuditTask( + null, + meetingId, + firstNode, + assigneeUserId, + AuditTaskStatus.PENDING, + request.getRemark() + )); + asyncJobService.enqueue( + "AUDIT_REMIND", + "meetingId=" + meetingId, + "job-audit-remind-" + meetingId + "-" + request.getIdempotencyKey() + ); + + Map result = new LinkedHashMap<>(); + result.put("meetingId", meetingId); + result.put("auditStatus", meeting.getAuditStatus().name()); + result.put("currentNode", firstNode.name()); + return result; + } + + public Map withdraw(Long meetingId, WithdrawMeetingRequest request) { + Meeting meeting = meetingRepository.findById(meetingId) + .orElseThrow(() -> new BusinessException(ErrorCodes.RESOURCE_NOT_FOUND, "会议不存在")); + + if (withdrawIdempotency.containsKey(request.getIdempotencyKey())) { + throw new BusinessException(ErrorCodes.IDEMPOTENCY_CONFLICT, "请求重复,请勿重复提交"); + } + withdrawIdempotency.put(request.getIdempotencyKey(), meetingId); + + if (meeting.getAuditStatus() != MeetingAuditStatus.IN_REVIEW) { + throw new BusinessException(ErrorCodes.INVALID_STATE, "只有审核中的会议才可撤回"); + } + + int closedTaskCount = auditTaskRepository.withdrawPendingByMeetingId(meetingId, request.getReason(), safeUserId()); + meeting.setAuditStatus(MeetingAuditStatus.PENDING); + meeting.setCurrentAuditorUserId(null); + meeting.setWithdrawReason(request.getReason()); + meeting.setLastActionAt(nowIsoSeconds()); + meetingRepository.save(meeting); + if (bizChangeLogService != null) { + bizChangeLogService.logAction("MEETING", meetingId, "MEETING_WITHDRAW", request.getReason()); + } + + Map result = new LinkedHashMap(); + result.put("meetingId", meetingId); + result.put("auditStatus", meeting.getAuditStatus().name()); + result.put("closedTaskCount", closedTaskCount); + return result; + } + + /** Soft-delete a draft meeting. */ + + public void deleteDraft(Long meetingId) { + Meeting meeting = meetingRepository.findById(meetingId) + .orElseThrow(() -> new BusinessException(ErrorCodes.RESOURCE_NOT_FOUND, "会议不存在")); + + MeetingStatus effectiveStatus = resolveEffectiveStatus(meeting); + // 婵炲濮撮幊搴g礊鐎n兘鍋撻崗澶婂⒉闁绘濞婇幃鈺呮嚋绾版ê浜惧ù锝囨焿缁€?PENDING闂佹寧绋戦悧鍡楃暤閸℃顩烽幖娣灪瀵捇鏌熺紒妯哄婵﹫闄勫濠氬炊妞嬪海顦繛鎴炴尰濮婄懓锕㈤鐘冲仏妞ゆ劑鍨归弸娆戠磽娴h灏版俊鐐插€垮畷妤佹媴缁涘鏅犻梺鍛婂笧婵炩偓婵? + if (meeting.getAuditStatus() != MeetingAuditStatus.PENDING) { + throw new BusinessException(ErrorCodes.INVALID_STATE, "只有待审核的草稿会议才可删除"); + } + if (meeting.getLastSubmitAt() != null) { + throw new BusinessException(ErrorCodes.INVALID_STATE, "已提交过的会议不能直接删除"); + } + meetingRepository.softDelete(meetingId); + if (bizChangeLogService != null) { + bizChangeLogService.logAction("MEETING", meetingId, "MEETING_DELETE_DRAFT", null); + } + } + + /** Cancel a meeting before it starts. */ + + public Map cancel(Long meetingId, String reason) { + Meeting meeting = meetingRepository.findById(meetingId) + .orElseThrow(() -> new BusinessException(ErrorCodes.RESOURCE_NOT_FOUND, "会议不存在")); + + if (reason == null || reason.trim().isEmpty()) { + throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "取消原因不能为空"); + } + + MeetingStatus effectiveStatus = resolveEffectiveStatus(meeting); + if (effectiveStatus != MeetingStatus.NOT_STARTED) { + throw new BusinessException(ErrorCodes.INVALID_STATE, "只有未开始的会议才可取消"); + } + if (meeting.getAuditStatus() == MeetingAuditStatus.IN_REVIEW) { + throw new BusinessException(ErrorCodes.INVALID_STATE, "请先撤回审核,再取消会议"); + } + if (meeting.getAuditStatus() == MeetingAuditStatus.APPROVED) { + throw new BusinessException(ErrorCodes.INVALID_STATE, "已审核通过的会议不能取消"); + } + + meeting.setStatus(MeetingStatus.CANCELED); + meeting.setCancelReason(reason.trim()); + meeting.setLastActionAt(nowIsoSeconds()); + meetingRepository.save(meeting); + if (bizChangeLogService != null) { + bizChangeLogService.logAction("MEETING", meetingId, "MEETING_CANCEL", reason.trim()); + } + + Map result = new LinkedHashMap<>(); + result.put("meetingId", meetingId); + result.put("status", MeetingStatus.CANCELED.name()); + result.put("reason", reason.trim()); + return result; + } + + public Meeting getById(Long meetingId) { + Meeting meeting = meetingRepository.findById(meetingId) + .orElseThrow(() -> new BusinessException(ErrorCodes.RESOURCE_NOT_FOUND, "会议不存在")); + applyEffectiveStatus(meeting); + return meeting; + } + + /** Resolve the effective meeting status from start/end time and current time. */ + + public MeetingStatus resolveEffectiveStatus(Meeting meeting) { + MeetingStatus dbStatus = meeting.getStatus(); + if (dbStatus == MeetingStatus.CANCELED + || dbStatus == MeetingStatus.FROZEN + || dbStatus == MeetingStatus.DELAYED) { + return dbStatus; + } + LocalDateTime now = LocalDateTime.now(); + LocalDateTime start = parseDateTime(meeting.getStartTime()); + LocalDateTime end = parseDateTime(meeting.getEndTime()); + if (start == null || end == null) { + return dbStatus != null ? dbStatus : MeetingStatus.NOT_STARTED; + } + if (now.isBefore(start)) { + return MeetingStatus.NOT_STARTED; + } else if (now.isAfter(end)) { + return MeetingStatus.COMPLETED; + } else { + return MeetingStatus.IN_PROGRESS; + } + } + + private void applyEffectiveStatus(Meeting meeting) { + meeting.setStatus(resolveEffectiveStatus(meeting)); + } + + public void updateAuditStatus(Long meetingId, MeetingAuditStatus status) { + Meeting meeting = getById(meetingId); + meeting.setAuditStatus(status); + meetingRepository.save(meeting); + } + + /** Update only the current audit node and current auditor. */ + + public void updateCurrentAuditNode(Long meetingId, String node, Long auditorUserId) { + Meeting meeting = getById(meetingId); + meeting.setCurrentAuditNode(node); + meeting.setCurrentAuditorUserId(auditorUserId); + meeting.setLastActionAt(nowIsoSeconds()); + meetingRepository.save(meeting); + } + + public Meeting updateInvoiceConfig(Long meetingId, com.writeoff.module.meeting.dto.MeetingInvoiceConfigRequest request) { + Meeting meeting = getById(meetingId); + String beforeConfigJson = meeting.getInvoiceConfigJson(); + String configJson = null; + if (request.getInvoiceModules() != null) { + try { + configJson = OBJECT_MAPPER.writeValueAsString(request.getInvoiceModules()); + } catch (Exception e) { + throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "发票配置序列化失败"); + } + } + meeting.setInvoiceConfigJson(configJson); + Meeting saved = meetingRepository.save(meeting); + if (bizChangeLogService != null) { + bizChangeLogService.logFieldChange("MEETING", meetingId, "MEETING_INVOICE_CONFIG_UPDATE", "invoiceConfigJson", "发票模块配置", beforeConfigJson, configJson, null, null); + } + return saved; + } + + public List> listChangeLogs(Long meetingId) { + getById(meetingId); + if (bizChangeLogService == null) { + return new ArrayList>(); + } + return bizChangeLogService.listByBiz("MEETING", meetingId).stream() + .map(this::toMeetingChangeLogRow) + .collect(Collectors.toList()); + } + + private Long tenantId() { + return AuthContext.requireTenantId(); + } + + private Long safeUserId() { + Long userId = AuthContext.userId(); + return userId == null ? 0L : userId; + } + + private String nowIsoSeconds() { + return LocalDateTime.now().format(SQL_ISO_SECOND_FORMATTER); + } + + private void logMeetingCreate(Meeting meeting) { + if (bizChangeLogService == null) { + return; + } + bizChangeLogService.logAction("MEETING", meeting.getId(), "MEETING_CREATE", null); + logMeetingFieldChange(meeting.getId(), "MEETING_CREATE", "topic", "会议主题", null, meeting.getTopic(), null); + logMeetingFieldChange(meeting.getId(), "MEETING_CREATE", "meetingCategory", "会议类别", null, meeting.getMeetingCategory(), null); + logMeetingFieldChange(meeting.getId(), "MEETING_CREATE", "meetingForm", "会议形式", null, meeting.getMeetingForm(), null); + logMeetingFieldChange(meeting.getId(), "MEETING_CREATE", "location", "会议地点", null, meeting.getLocation(), null); + logMeetingFieldChange(meeting.getId(), "MEETING_CREATE", "startTime", "会议开始时间", null, meeting.getStartTime(), null); + logMeetingFieldChange(meeting.getId(), "MEETING_CREATE", "endTime", "会议结束时间", null, meeting.getEndTime(), null); + logMeetingFieldChange(meeting.getId(), "MEETING_CREATE", "budgetCent", "会议预算(分)", null, meeting.getBudgetCent(), null); + logMeetingFieldChange(meeting.getId(), "MEETING_CREATE", "laborRatio", "劳务占比", null, meeting.getLaborRatio(), null); + logMeetingFieldChange(meeting.getId(), "MEETING_CREATE", "cateringRatio", "餐费占比", null, meeting.getCateringRatio(), null); + logMeetingFieldChange(meeting.getId(), "MEETING_CREATE", "status", "会议状态", null, meeting.getStatus(), null); + logMeetingFieldChange(meeting.getId(), "MEETING_CREATE", "auditStatus", "审核状态", null, meeting.getAuditStatus(), null); + } + + private void logMeetingUpdate(Meeting before, Meeting after) { + if (bizChangeLogService == null) { + return; + } + String batchId = bizChangeLogService.newBatchId(); + logMeetingFieldChange(after.getId(), "MEETING_UPDATE", "topic", "会议主题", before.getTopic(), after.getTopic(), batchId); + logMeetingFieldChange(after.getId(), "MEETING_UPDATE", "meetingCategory", "会议类别", before.getMeetingCategory(), after.getMeetingCategory(), batchId); + logMeetingFieldChange(after.getId(), "MEETING_UPDATE", "meetingForm", "会议形式", before.getMeetingForm(), after.getMeetingForm(), batchId); + logMeetingFieldChange(after.getId(), "MEETING_UPDATE", "location", "会议地点", before.getLocation(), after.getLocation(), batchId); + logMeetingFieldChange(after.getId(), "MEETING_UPDATE", "startTime", "会议开始时间", before.getStartTime(), after.getStartTime(), batchId); + logMeetingFieldChange(after.getId(), "MEETING_UPDATE", "endTime", "会议结束时间", before.getEndTime(), after.getEndTime(), batchId); + logMeetingFieldChange(after.getId(), "MEETING_UPDATE", "budgetCent", "会议预算(分)", before.getBudgetCent(), after.getBudgetCent(), batchId); + logMeetingFieldChange(after.getId(), "MEETING_UPDATE", "laborRatio", "劳务占比", before.getLaborRatio(), after.getLaborRatio(), batchId); + logMeetingFieldChange(after.getId(), "MEETING_UPDATE", "cateringRatio", "餐费占比", before.getCateringRatio(), after.getCateringRatio(), batchId); + } + + private void logMeetingFieldChange(Long meetingId, + String changeType, + String fieldCode, + String fieldName, + Object beforeValue, + Object afterValue, + String batchId) { + if (bizChangeLogService == null) { + return; + } + bizChangeLogService.logFieldChange( + "MEETING", + meetingId, + changeType, + fieldCode, + fieldName, + normalizeMeetingFieldValue(beforeValue), + normalizeMeetingFieldValue(afterValue), + batchId, + null + ); + } + + private Object normalizeMeetingFieldValue(Object value) { + if (value instanceof Enum) { + return ((Enum) value).name(); + } + return value; + } + + private Map toMeetingChangeLogRow(BizChangeLogInfo item) { + Map row = new LinkedHashMap(); + row.put("id", item.getId()); + row.put("fieldCode", item.getFieldCode()); + row.put("fieldName", resolveMeetingFieldName(item)); + row.put("beforeValue", item.getBeforeValue()); + row.put("afterValue", item.getAfterValue()); + row.put("remark", item.getRemark()); + row.put("changeType", item.getChangeType()); + row.put("operatorUserId", item.getOperatorUserId()); + row.put("operatorUserName", item.getOperatorUserName()); + row.put("relatedUserId", item.getRelatedUserId()); + row.put("relatedUserName", item.getRelatedUserName()); + row.put("batchId", item.getBatchId()); + row.put("createdAt", item.getCreatedAt()); + return row; + } + + private String resolveMeetingFieldName(BizChangeLogInfo item) { + String fieldName = item.getFieldName() == null ? "" : item.getFieldName().trim(); + if (!fieldName.isEmpty()) { + return fieldName; + } + if ("MEETING_CREATE".equals(item.getChangeType())) { + return "会议创建"; + } + if ("MEETING_SUBMIT".equals(item.getChangeType())) { + return "提交审核"; + } + if ("MEETING_WITHDRAW".equals(item.getChangeType())) { + return "撤回审核"; + } + if ("MEETING_DELETE_DRAFT".equals(item.getChangeType())) { + return "删除草稿"; + } + if ("MEETING_CANCEL".equals(item.getChangeType())) { + return "取消会议"; + } + if ("MEETING_INVOICE_CONFIG_UPDATE".equals(item.getChangeType())) { + return "发票模块配置"; + } + return "会议变更"; + } +} diff --git a/backend/src/main/java/com/writeoff/module/meeting/service/MeetingSummaryExportService.java b/backend/src/main/java/com/writeoff/module/meeting/service/MeetingSummaryExportService.java new file mode 100644 index 0000000..8e38c1f --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/meeting/service/MeetingSummaryExportService.java @@ -0,0 +1,996 @@ +package com.writeoff.module.meeting.service; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.writeoff.common.exception.BusinessException; +import com.writeoff.common.exception.ErrorCodes; +import com.writeoff.module.file.service.OssService; +import org.apache.poi.ooxml.POIXMLException; +import org.apache.poi.openxml4j.exceptions.InvalidFormatException; +import org.apache.poi.util.Units; +import org.apache.poi.xwpf.usermodel.ParagraphAlignment; +import org.apache.poi.xwpf.usermodel.XWPFDocument; +import org.apache.poi.xwpf.usermodel.XWPFParagraph; +import org.apache.poi.xwpf.usermodel.XWPFRun; +import org.apache.poi.xwpf.usermodel.XWPFTable; +import org.apache.poi.xwpf.usermodel.XWPFTableCell; +import org.apache.poi.xwpf.usermodel.XWPFTableRow; +import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTRPr; +import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTTc; +import org.springframework.core.io.ClassPathResource; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; + +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +@Service +public class MeetingSummaryExportService { + private static final String TEMPLATE_PATH = "templates/meeting-summary-template.docx"; + private static final String THEME_IMAGE_MARKER = "${themeCaption}"; + private static final String EXPERT_IMAGE_MARKER = "${chairCaption}"; + private static final String MATERIAL_IMAGE_MARKER = "${materialCaption}"; + private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + private static final DateTimeFormatter DOC_DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy年M月d日"); + private static final DateTimeFormatter DOC_TIME_FORMATTER = DateTimeFormatter.ofPattern("H:mm"); + private static final int SECTION_IMAGE_MAX_WIDTH = 520; + private static final int SECTION_IMAGE_MAX_HEIGHT = 360; + private static final int EXPERT_IMAGE_MAX_WIDTH = 440; + private static final int EXPERT_IMAGE_MAX_HEIGHT = 320; + + private final JdbcTemplate jdbcTemplate; + private final OssService ossService; + private final ObjectMapper objectMapper = new ObjectMapper(); + + public MeetingSummaryExportService(JdbcTemplate jdbcTemplate, OssService ossService) { + this.jdbcTemplate = jdbcTemplate; + this.ossService = ossService; + } + + public byte[] buildDocx(Long tenantId, Long meetingId) { + SummaryContext context = loadSummaryContext(tenantId, meetingId); + try (XWPFDocument document = openTemplateDocument(); + ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) { + AnchorCells anchorCells = locateAnchorCells(document); + replacePlaceholders(document, buildPlaceholderValues(context)); + renderImageSection(anchorCells.themeCell, context.themeImages, false, SECTION_IMAGE_MAX_WIDTH, SECTION_IMAGE_MAX_HEIGHT); + renderImageSection(anchorCells.expertCell, context.expertImages, true, EXPERT_IMAGE_MAX_WIDTH, EXPERT_IMAGE_MAX_HEIGHT); + renderImageSection(anchorCells.materialCell, context.materialImages, false, SECTION_IMAGE_MAX_WIDTH, SECTION_IMAGE_MAX_HEIGHT); + document.write(outputStream); + return outputStream.toByteArray(); + } catch (BusinessException ex) { + throw ex; + } catch (Exception ex) { + throw new BusinessException(ErrorCodes.INTERNAL_ERROR, "会议总结生成失败"); + } + } + + private SummaryContext loadSummaryContext(Long tenantId, Long meetingId) { + Map meeting = findMeeting(tenantId, meetingId); + Map materialJsonByCode = loadMaterialJsonByCode(tenantId, meetingId); + String basicInfoJson = materialJsonByCode.get("BASIC_INFO"); + if (basicInfoJson == null || basicInfoJson.trim().isEmpty()) { + throw new BusinessException( + ErrorCodes.VALIDATION_ERROR, + "会议基本信息未保存,请先在会议资料-会议基本信息中保存后再生成总结" + ); + } + + Map basicInfo = parseJsonMap(basicInfoJson); + Map writeOffDocs = parseJsonMap(materialJsonByCode.get("WRITE_OFF_DOCS")); + Map expertList = parseJsonMap(materialJsonByCode.get("EXPERT_LIST")); + Map meetingInvoice = parseJsonMap(materialJsonByCode.get("MEETING_INVOICE")); + List> expertBindings = jdbcTemplate.queryForList( + "SELECT expert_id, expert_name, title, organization FROM meeting_expert_binding " + + "WHERE tenant_id=? AND meeting_id=? ORDER BY id ASC", + tenantId, + meetingId + ); + + Map expertProfileById = new LinkedHashMap(); + Map expertProfileByName = new LinkedHashMap(); + Map expertNameById = new LinkedHashMap(); + for (Map expertBinding : expertBindings) { + Long expertId = toLong(expertBinding.get("expert_id")); + String expertName = stringValue(expertBinding.get("expert_name")); + String organization = firstNonEmptyText( + stringValue(expertBinding.get("organization")), + stringValue(expertBinding.get("title")) + ); + ExpertProfile profile = new ExpertProfile(expertId, expertName, organization); + if (expertId != null && expertId > 0L) { + expertNameById.put(expertId, expertName); + expertProfileById.put(expertId, profile); + } + if (!expertName.isEmpty()) { + expertProfileByName.put(normalizeKey(expertName), profile); + } + } + + List chairmanIds = parseIdList(basicInfo.get("chairmanExpertIds")); + List speakerIds = parseIdList(basicInfo.get("speakerExpertIds")); + List hostIds = parseIdList(basicInfo.get("hostExpertIds")); + List discussionGuestIds = parseIdList(basicInfo.get("discussionGuestExpertIds")); + + List chairmanNames = resolveExpertNames(chairmanIds, expertNameById); + List speakerNames = resolveExpertNames(speakerIds, expertNameById); + List hostNames = resolveExpertNames(hostIds, expertNameById); + List discussionGuestNames = resolveExpertNames(discussionGuestIds, expertNameById); + markExpertRoles(chairmanIds, expertProfileById, "大会主席"); + markExpertRoles(hostIds, expertProfileById, "会议主持"); + markExpertRoles(speakerIds, expertProfileById, "会议讲者"); + + markExpertRoles(discussionGuestIds, expertProfileById, "讨论嘉宾"); + + String meetingTopic = stringValue(meeting.get("topic")); + String projectName = stringValue(meeting.get("project_name")); + String organizationName = firstNonEmptyText( + stringValue(meeting.get("host_enterprise_name")), + projectName, + queryTenantName(tenantId) + ); + String meetingCategory = stringValue(meeting.get("meeting_category")); + String location = stringValue(meeting.get("location")); + String startTime = stringValue(meeting.get("start_time")); + String endTime = stringValue(meeting.get("end_time")); + String guestCountText = formatNumber(basicInfo.get("guestCount")); + if ("-".equals(guestCountText) && !expertBindings.isEmpty()) { + guestCountText = String.valueOf(expertBindings.size()); + } + + SummaryContext context = new SummaryContext(); + context.summaryTitle = firstNonEmptyText(meetingTopic, projectName, "会议") + "-会议总结"; + context.organizationName = firstNonEmptyText(organizationName, "-"); + context.meetingCategory = firstNonEmptyText(meetingCategory, "-"); + context.meetingTime = formatMeetingTime(startTime, endTime); + context.meetingLocation = firstNonEmptyText(location, "-"); + context.meetingTopic = firstNonEmptyText(meetingTopic, "-"); + context.chairAndSpeaker = firstNonEmptyText( + buildRoleSummaryText(chairmanNames, hostNames, speakerNames, discussionGuestNames), + "-" + ); + context.guests = firstNonEmptyText(guestCountText, "-"); + context.attendeePlan = formatNumber(basicInfo.get("attendeeCount")); + context.attendeeActual = formatNumber(basicInfo.get("attendeeActualCount")); + context.targetAudience = firstNonEmptyText(normalizePlainText(stringValue(basicInfo.get("targetAudience"))), "-"); + context.mainAgenda = firstNonEmptyText(normalizePlainText(stringValue(basicInfo.get("mainAgenda"))), "-"); + context.meetingEffect = firstNonEmptyText(normalizePlainText(stringValue(basicInfo.get("meetingEffect"))), "-"); + context.improvementSuggestion = firstNonEmptyText( + normalizePlainText(stringValue(basicInfo.get("improvementSuggestion"))), + "无" + ); + context.themeInstruction = "(含" + firstNonEmptyText(meetingTopic, projectName, "会议主题") + ")画面或会议横幅"; + context.footerDate = formatFooterDate(firstNonEmptyText(endTime, startTime)); + context.themeImages = collectThemeImages(writeOffDocs); + context.expertImages = collectExpertImages(expertList, expertNameById, expertProfileById, expertProfileByName); + context.materialImages = collectMaterialImages(meetingInvoice); + return context; + } + + private Map findMeeting(Long tenantId, Long meetingId) { + List> rows = jdbcTemplate.queryForList( + "SELECT m.id, m.topic, m.meeting_category, m.location, " + + "DATE_FORMAT(m.start_time, '%Y-%m-%d %H:%i:%s') AS start_time, " + + "DATE_FORMAT(m.end_time, '%Y-%m-%d %H:%i:%s') AS end_time, " + + "p.project_name, p.host_enterprise_name " + + "FROM meeting m " + + "LEFT JOIN project p ON m.tenant_id=p.tenant_id AND m.project_id=p.id " + + "WHERE m.tenant_id=? AND m.id=? AND m.is_deleted=0 LIMIT 1", + tenantId, + meetingId + ); + if (rows.isEmpty()) { + throw new BusinessException(ErrorCodes.RESOURCE_NOT_FOUND, "会议不存在"); + } + return rows.get(0); + } + + private Map loadMaterialJsonByCode(Long tenantId, Long meetingId) { + List> rows = jdbcTemplate.queryForList( + "SELECT module_code, content_json FROM meeting_material WHERE tenant_id=? AND meeting_id=? AND is_deleted=0", + tenantId, + meetingId + ); + Map result = new LinkedHashMap(); + for (Map row : rows) { + String moduleCode = stringValue(row.get("module_code")); + if (!moduleCode.isEmpty()) { + result.put(moduleCode, stringValue(row.get("content_json"))); + } + } + return result; + } + + private Map parseJsonMap(String contentJson) { + if (contentJson == null || contentJson.trim().isEmpty()) { + return new LinkedHashMap(); + } + try { + return objectMapper.readValue(contentJson, new TypeReference>() {}); + } catch (Exception ex) { + return new LinkedHashMap(); + } + } + + private String queryTenantName(Long tenantId) { + try { + String name = jdbcTemplate.queryForObject( + "SELECT tenant_name FROM tenant WHERE id=? AND is_deleted=0 LIMIT 1", + String.class, + tenantId + ); + return name == null ? "" : name.trim(); + } catch (Exception ex) { + return ""; + } + } + + private List parseIdList(Object value) { + if (!(value instanceof List)) { + return Collections.emptyList(); + } + List list = (List) value; + List result = new ArrayList(); + for (Object item : list) { + Long id = toLong(item); + if (id != null && id > 0L) { + result.add(id); + } + } + return result; + } + + private List resolveExpertNames(List expertIds, Map expertNameById) { + Set dedup = new LinkedHashSet(); + for (Long expertId : expertIds) { + String name = expertNameById.get(expertId); + if (name != null && !name.trim().isEmpty()) { + dedup.add(name.trim()); + } + } + return new ArrayList(dedup); + } + + private List collectThemeImages(Map writeOffDocs) { + List result = new ArrayList(); + Set usedKeys = new LinkedHashSet(); + Map themePhoto = mapValue(writeOffDocs.get("themePhoto")); + String themePhotoKey = stringValue(themePhoto.get("ossKey")); + String themePhotoName = firstNonEmptyText( + stringValue(themePhoto.get("name")), + stringValue(themePhoto.get("fileName")), + fileNameFromObjectKey(themePhotoKey) + ); + if (!themePhotoKey.isEmpty() && isImageFile(themePhotoName, themePhotoKey) && usedKeys.add(themePhotoKey)) { + result.add(new ImageSource(themePhotoKey, themePhotoName, "")); + } + if (result.isEmpty()) { + for (Map agenda : listOfMap(writeOffDocs.get("agenda"))) { + String objectKey = stringValue(agenda.get("ossKey")); + String fileName = firstNonEmptyText( + stringValue(agenda.get("name")), + fileNameFromObjectKey(objectKey) + ); + if (objectKey.isEmpty() || !isImageFile(fileName, objectKey) || !usedKeys.add(objectKey)) { + continue; + } + result.add(new ImageSource(objectKey, fileName, "")); + } + } + return result; + } + + private List collectExpertImages(Map expertList, Map expertNameById) { + List result = new ArrayList(); + Set usedKeys = new LinkedHashSet(); + Map onsitePhoto = mapValue(expertList.get("onsitePhoto")); + for (Map photo : listOfMap(onsitePhoto.get("photos"))) { + String objectKey = stringValue(photo.get("ossKey")); + String fileName = firstNonEmptyText( + stringValue(photo.get("name")), + fileNameFromObjectKey(objectKey) + ); + if (objectKey.isEmpty() || !isImageFile(fileName, objectKey) || !usedKeys.add(objectKey)) { + continue; + } + Long expertId = toLong(photo.get("expertId")); + String expertName = firstNonEmptyText( + stringValue(photo.get("expertName")), + expertId == null ? "" : stringValue(expertNameById.get(expertId)), + "未命名专家" + ); + result.add(new ImageSource(objectKey, fileName, expertName)); + } + return result; + } + + private List collectExpertImages(Map expertList, + Map expertNameById, + Map expertProfileById, + Map expertProfileByName) { + List result = new ArrayList(); + Set usedKeys = new LinkedHashSet(); + Map onsitePhoto = mapValue(expertList.get("onsitePhoto")); + for (Map photo : listOfMap(onsitePhoto.get("photos"))) { + String objectKey = stringValue(photo.get("ossKey")); + String fileName = firstNonEmptyText( + stringValue(photo.get("name")), + fileNameFromObjectKey(objectKey) + ); + if (objectKey.isEmpty() || !isImageFile(fileName, objectKey) || !usedKeys.add(objectKey)) { + continue; + } + Long expertId = toLong(photo.get("expertId")); + String expertName = firstNonEmptyText( + stringValue(photo.get("expertName")), + expertId == null ? "" : stringValue(expertNameById.get(expertId)), + "未命名专家" + ); + ExpertProfile profile = resolveExpertProfile(expertId, expertName, expertProfileById, expertProfileByName); + result.add(new ImageSource(objectKey, fileName, buildExpertPhotoCaption(expertName, profile))); + } + return result; + } + + private void markExpertRoles(List expertIds, Map expertProfileById, String roleLabel) { + for (Long expertId : expertIds == null ? Collections.emptyList() : expertIds) { + if (expertId == null) { + continue; + } + ExpertProfile profile = expertProfileById.get(expertId); + if (profile != null) { + profile.addRole(roleLabel); + } + } + } + + private ExpertProfile resolveExpertProfile(Long expertId, + String expertName, + Map expertProfileById, + Map expertProfileByName) { + if (expertId != null) { + ExpertProfile profile = expertProfileById.get(expertId); + if (profile != null) { + return profile; + } + } + if (expertName == null || expertName.trim().isEmpty()) { + return null; + } + return expertProfileByName.get(normalizeKey(expertName)); + } + + private String buildExpertPhotoCaption(String fallbackName, ExpertProfile profile) { + String expertName = firstNonEmptyText( + profile == null ? "" : profile.name, + fallbackName, + "未命名专家" + ); + String roles = profile == null ? "" : joinDisplayNames(new ArrayList(profile.roles)); + String organization = profile == null ? "" : profile.organization; + List parts = new ArrayList(); + parts.add(expertName); + if (!roles.isEmpty()) { + parts.add(roles); + } + if (!organization.isEmpty()) { + parts.add(organization); + } + return String.join(" / ", parts); + } + + private List collectMaterialImages(Map meetingInvoice) { + List result = new ArrayList(); + Set usedKeys = new LinkedHashSet(); + for (Map section : listOfMap(meetingInvoice.get("sections"))) { + if (!"MATERIAL_DETAIL".equalsIgnoreCase(stringValue(section.get("sectionCode")))) { + continue; + } + for (Map file : listOfMap(section.get("files"))) { + String objectKey = stringValue(file.get("ossKey")); + String fileName = firstNonEmptyText( + stringValue(file.get("fileName")), + stringValue(file.get("name")), + fileNameFromObjectKey(objectKey) + ); + if (objectKey.isEmpty() || !isImageFile(fileName, objectKey) || !usedKeys.add(objectKey)) { + continue; + } + result.add(new ImageSource(objectKey, fileName, "")); + } + } + return result; + } + + private XWPFDocument openTemplateDocument() { + ClassPathResource resource = new ClassPathResource(TEMPLATE_PATH); + try (InputStream inputStream = resource.getInputStream()) { + return new XWPFDocument(inputStream); + } catch (IOException ex) { + throw new BusinessException(ErrorCodes.INTERNAL_ERROR, "会议总结模板损坏"); + } catch (POIXMLException ex) { + throw new BusinessException(ErrorCodes.INTERNAL_ERROR, "会议总结模板损坏"); + } + } + + private AnchorCells locateAnchorCells(XWPFDocument document) { + XWPFTableCell themeCell = null; + XWPFTableCell expertCell = null; + XWPFTableCell materialCell = null; + for (XWPFTable table : document.getTables()) { + for (XWPFTableRow row : table.getRows()) { + for (XWPFTableCell cell : row.getTableCells()) { + String text = stringValue(cell.getText()); + if (themeCell == null && text.contains(THEME_IMAGE_MARKER)) { + themeCell = cell; + } + if (expertCell == null && text.contains(EXPERT_IMAGE_MARKER)) { + expertCell = cell; + } + if (materialCell == null && text.contains(MATERIAL_IMAGE_MARKER)) { + materialCell = cell; + } + } + } + } + if (themeCell == null || expertCell == null || materialCell == null) { + throw new BusinessException(ErrorCodes.INTERNAL_ERROR, "会议总结模板损坏"); + } + return new AnchorCells(themeCell, expertCell, materialCell); + } + + private Map buildPlaceholderValues(SummaryContext context) { + Map values = new LinkedHashMap(); + values.put("${summaryTitle}", context.summaryTitle); + values.put("${organizationName}", context.organizationName); + values.put("${meetingCategory}", context.meetingCategory); + values.put("${meetingTime}", context.meetingTime); + values.put("${meetingLocation}", context.meetingLocation); + values.put("${meetingTopic}", context.meetingTopic); + values.put("${chairAndSpeaker}", context.chairAndSpeaker); + values.put("${guests}", context.guests); + values.put("${attendeePlan}", context.attendeePlan); + values.put("${attendeeActual}", context.attendeeActual); + values.put("${targetAudience}", context.targetAudience); + values.put("${mainAgenda}", context.mainAgenda); + values.put("${meetingEffect}", context.meetingEffect); + values.put("${improvementSuggestion}", context.improvementSuggestion); + values.put("${themeInstruction}", context.themeInstruction); + values.put("${themeCaption}", ""); + values.put("${chairCaption}", ""); + values.put("${hostCaption}", ""); + values.put("${speakerCaption}", ""); + values.put("${discussionCaption}", ""); + values.put("${materialCaption}", ""); + values.put("${footerDate}", context.footerDate); + return values; + } + + private void replacePlaceholders(XWPFDocument document, Map placeholders) { + for (XWPFParagraph paragraph : document.getParagraphs()) { + replacePlaceholders(paragraph, placeholders); + } + for (XWPFTable table : document.getTables()) { + replacePlaceholders(table, placeholders); + } + } + + private void replacePlaceholders(XWPFTable table, Map placeholders) { + for (XWPFTableRow row : table.getRows()) { + for (XWPFTableCell cell : row.getTableCells()) { + for (XWPFParagraph paragraph : cell.getParagraphs()) { + replacePlaceholders(paragraph, placeholders); + } + for (XWPFTable nestedTable : cell.getTables()) { + replacePlaceholders(nestedTable, placeholders); + } + } + } + } + + private void replacePlaceholders(XWPFParagraph paragraph, Map placeholders) { + String original = paragraph.getParagraphText(); + if (original == null || original.isEmpty()) { + return; + } + String replaced = original; + for (Map.Entry entry : placeholders.entrySet()) { + if (replaced.contains(entry.getKey())) { + replaced = replaced.replace(entry.getKey(), safeValue(entry.getValue())); + } + } + if (!original.equals(replaced)) { + rewriteParagraph(paragraph, replaced); + } + } + + private void rewriteParagraph(XWPFParagraph paragraph, String text) { + CTRPr style = null; + if (!paragraph.getRuns().isEmpty() && paragraph.getRuns().get(0).getCTR().isSetRPr()) { + style = (CTRPr) paragraph.getRuns().get(0).getCTR().getRPr().copy(); + } + for (int i = paragraph.getRuns().size() - 1; i >= 0; i--) { + paragraph.removeRun(i); + } + XWPFRun run = paragraph.createRun(); + if (style != null) { + run.getCTR().setRPr(style); + } + setRunText(run, safeValue(text)); + } + + private void renderImageSection(XWPFTableCell cell, + List sources, + boolean showCaption, + int maxWidthPx, + int maxHeightPx) throws IOException, InvalidFormatException { + List images = loadRenderableImages(sources, maxWidthPx, maxHeightPx); + int skippedCount = Math.max(0, sources.size() - images.size()); + XWPFParagraph firstParagraph = clearCell(cell); + if (images.isEmpty()) { + String message = sources.isEmpty() ? "暂无图片" : "图片加载失败或格式暂不支持"; + writeParagraph(firstParagraph, message, ParagraphAlignment.CENTER, 11, false); + return; + } + + boolean useFirstParagraph = true; + for (RenderableImage image : images) { + XWPFParagraph imageParagraph = useFirstParagraph ? firstParagraph : cell.addParagraph(); + useFirstParagraph = false; + imageParagraph.setAlignment(ParagraphAlignment.CENTER); + imageParagraph.setSpacingAfter(80); + XWPFRun imageRun = imageParagraph.createRun(); + imageRun.addPicture( + new ByteArrayInputStream(image.bytes), + image.pictureType, + image.fileName, + Units.pixelToEMU(image.widthPx), + Units.pixelToEMU(image.heightPx) + ); + + if (showCaption && !image.caption.isEmpty()) { + XWPFParagraph captionParagraph = cell.addParagraph(); + captionParagraph.setAlignment(ParagraphAlignment.CENTER); + captionParagraph.setSpacingAfter(180); + writeParagraph(captionParagraph, image.caption, ParagraphAlignment.CENTER, 10, false); + } else { + imageParagraph.setSpacingAfter(180); + } + } + + if (skippedCount > 0) { + XWPFParagraph noteParagraph = cell.addParagraph(); + writeParagraph(noteParagraph, "部分图片因格式不支持或文件损坏未导入。", ParagraphAlignment.CENTER, 9, true); + } + } + + private List loadRenderableImages(List sources, int maxWidthPx, int maxHeightPx) { + List result = new ArrayList(); + for (ImageSource source : sources == null ? Collections.emptyList() : sources) { + RenderableImage image = loadRenderableImage(source, maxWidthPx, maxHeightPx); + if (image != null) { + result.add(image); + } + } + return result; + } + + private RenderableImage loadRenderableImage(ImageSource source, int maxWidthPx, int maxHeightPx) { + if (source == null || source.objectKey.isEmpty()) { + return null; + } + try { + byte[] objectBytes = ossService.getObjectBytes(source.objectKey); + if (objectBytes == null || objectBytes.length == 0) { + return null; + } + return buildRenderableImage(objectBytes, source.fileName, source.caption, maxWidthPx, maxHeightPx); + } catch (Exception ex) { + return null; + } + } + + private RenderableImage buildRenderableImage(byte[] sourceBytes, + String fileName, + String caption, + int maxWidthPx, + int maxHeightPx) throws IOException { + String effectiveFileName = firstNonEmptyText(fileName, "image"); + Integer pictureType = resolvePictureType(effectiveFileName); + byte[] effectiveBytes = sourceBytes; + + if (pictureType == null) { + BufferedImage image = ImageIO.read(new ByteArrayInputStream(sourceBytes)); + if (image == null) { + return null; + } + byte[] converted = writeImageBytes(image, "png"); + if (converted == null || converted.length == 0) { + return null; + } + effectiveBytes = converted; + effectiveFileName = replaceFileExtension(effectiveFileName, "png"); + pictureType = XWPFDocument.PICTURE_TYPE_PNG; + } + + BufferedImage effectiveImage = ImageIO.read(new ByteArrayInputStream(effectiveBytes)); + if (effectiveImage == null) { + return null; + } + ImageSize imageSize = scaleImage(effectiveImage.getWidth(), effectiveImage.getHeight(), maxWidthPx, maxHeightPx); + return new RenderableImage( + effectiveBytes, + pictureType.intValue(), + effectiveFileName, + safeValue(caption), + imageSize.width, + imageSize.height + ); + } + + private byte[] writeImageBytes(BufferedImage image, String formatName) throws IOException { + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + if (!ImageIO.write(image, formatName, outputStream)) { + return null; + } + return outputStream.toByteArray(); + } + + private ImageSize scaleImage(int widthPx, int heightPx, int maxWidthPx, int maxHeightPx) { + if (widthPx <= 0 || heightPx <= 0) { + return new ImageSize(Math.max(1, maxWidthPx), Math.max(1, maxHeightPx)); + } + double ratio = Math.min( + Math.min((double) maxWidthPx / (double) widthPx, (double) maxHeightPx / (double) heightPx), + 1D + ); + int scaledWidth = Math.max(1, (int) Math.round(widthPx * ratio)); + int scaledHeight = Math.max(1, (int) Math.round(heightPx * ratio)); + return new ImageSize(scaledWidth, scaledHeight); + } + + private XWPFParagraph clearCell(XWPFTableCell cell) { + int paragraphCount = cell.getParagraphs().size(); + for (int i = paragraphCount - 1; i >= 0; i--) { + cell.removeParagraph(i); + } + CTTc ctTc = cell.getCTTc(); + for (int i = ctTc.sizeOfTblArray() - 1; i >= 0; i--) { + ctTc.removeTbl(i); + } + return cell.addParagraph(); + } + + private void writeParagraph(XWPFParagraph paragraph, + String text, + ParagraphAlignment alignment, + int fontSize, + boolean italic) { + paragraph.setAlignment(alignment); + XWPFRun run = paragraph.createRun(); + if (fontSize > 0) { + run.setFontSize(fontSize); + } + run.setItalic(italic); + setRunText(run, text); + } + + private void setRunText(XWPFRun run, String text) { + String[] lines = safeValue(text).split("\n", -1); + if (lines.length == 0) { + run.setText("", 0); + return; + } + run.setText(lines[0], 0); + for (int i = 1; i < lines.length; i++) { + run.addBreak(); + run.setText(lines[i]); + } + } + + private Integer resolvePictureType(String fileName) { + String suffix = fileExtension(fileName); + if ("png".equals(suffix)) { + return Integer.valueOf(XWPFDocument.PICTURE_TYPE_PNG); + } + if ("jpg".equals(suffix) || "jpeg".equals(suffix)) { + return Integer.valueOf(XWPFDocument.PICTURE_TYPE_JPEG); + } + if ("gif".equals(suffix)) { + return Integer.valueOf(XWPFDocument.PICTURE_TYPE_GIF); + } + if ("bmp".equals(suffix)) { + return Integer.valueOf(XWPFDocument.PICTURE_TYPE_BMP); + } + return null; + } + + private String replaceFileExtension(String fileName, String targetExtension) { + String cleanName = firstNonEmptyText(fileName, "image"); + int dotIndex = cleanName.lastIndexOf('.'); + String baseName = dotIndex >= 0 ? cleanName.substring(0, dotIndex) : cleanName; + return baseName + "." + targetExtension; + } + + private String fileExtension(String fileName) { + String cleanName = stringValue(fileName).toLowerCase(Locale.ROOT); + int dotIndex = cleanName.lastIndexOf('.'); + if (dotIndex < 0 || dotIndex >= cleanName.length() - 1) { + return ""; + } + return cleanName.substring(dotIndex + 1); + } + + private String formatMeetingTime(String startTimeText, String endTimeText) { + LocalDateTime start = parseDateTime(startTimeText); + LocalDateTime end = parseDateTime(endTimeText); + if (start == null && end == null) { + return "-"; + } + if (start != null && end != null) { + if (start.toLocalDate().equals(end.toLocalDate())) { + return DOC_DATE_FORMATTER.format(start.toLocalDate()) + " " + + DOC_TIME_FORMATTER.format(start) + "~" + DOC_TIME_FORMATTER.format(end); + } + return DOC_DATE_FORMATTER.format(start.toLocalDate()) + " " + DOC_TIME_FORMATTER.format(start) + + " ~ " + + DOC_DATE_FORMATTER.format(end.toLocalDate()) + " " + DOC_TIME_FORMATTER.format(end); + } + LocalDateTime only = start != null ? start : end; + return DOC_DATE_FORMATTER.format(only.toLocalDate()) + " " + DOC_TIME_FORMATTER.format(only); + } + + private String formatFooterDate(String dateTimeText) { + LocalDateTime dateTime = parseDateTime(dateTimeText); + LocalDate date = dateTime == null ? LocalDate.now() : dateTime.toLocalDate(); + return DOC_DATE_FORMATTER.format(date); + } + + private LocalDateTime parseDateTime(String text) { + if (text == null || text.trim().isEmpty()) { + return null; + } + try { + return LocalDateTime.parse(text.trim(), DATE_TIME_FORMATTER); + } catch (Exception ex) { + return null; + } + } + + private String normalizePlainText(String value) { + if (value == null || value.trim().isEmpty()) { + return ""; + } + String normalized = value.replace("\r\n", "\n").replace("\r", "\n"); + String[] parts = normalized.split("\n"); + List items = new ArrayList(); + for (String part : parts) { + String text = part == null ? "" : part.trim(); + if (!text.isEmpty()) { + items.add(text); + } + } + return items.isEmpty() ? "" : String.join(";", items); + } + + private String buildRoleSummaryText(List chairmanNames, + List hostNames, + List speakerNames, + List discussionGuestNames) { + List parts = new ArrayList(); + String chairmanText = joinDisplayNames(chairmanNames); + String hostText = joinDisplayNames(hostNames); + String speakerText = joinDisplayNames(speakerNames); + String discussionGuestText = joinDisplayNames(discussionGuestNames); + if (!chairmanText.isEmpty()) { + parts.add("主席:" + chairmanText); + } + if (!hostText.isEmpty()) { + parts.add("主持:" + hostText); + } + if (!speakerText.isEmpty()) { + parts.add("讲者:" + speakerText); + } + if (!discussionGuestText.isEmpty()) { + parts.add("讨论嘉宾:" + discussionGuestText); + } + return parts.isEmpty() ? "" : String.join(";", parts); + } + + private String joinDisplayNames(List values) { + List filtered = new ArrayList(); + for (String value : values == null ? Collections.emptyList() : values) { + if (value != null && !value.trim().isEmpty()) { + filtered.add(value.trim()); + } + } + return filtered.isEmpty() ? "" : String.join("、", filtered); + } + + private String normalizeKey(String value) { + return stringValue(value).replace(" ", "").toLowerCase(Locale.ROOT); + } + + private String firstNonEmptyText(String... values) { + for (String value : values) { + if (value != null && !value.trim().isEmpty()) { + return value.trim(); + } + } + return ""; + } + + @SuppressWarnings("unchecked") + private List> listOfMap(Object value) { + if (!(value instanceof List)) { + return Collections.emptyList(); + } + List list = (List) value; + List> result = new ArrayList>(); + for (Object item : list) { + if (item instanceof Map) { + result.add((Map) item); + } + } + return result; + } + + @SuppressWarnings("unchecked") + private Map mapValue(Object value) { + if (value instanceof Map) { + return (Map) value; + } + return Collections.emptyMap(); + } + + private boolean isImageFile(String fileName, String objectKey) { + String name = stringValue(fileName).toLowerCase(Locale.ROOT); + String key = stringValue(objectKey).toLowerCase(Locale.ROOT); + return name.endsWith(".png") || name.endsWith(".jpg") || name.endsWith(".jpeg") + || name.endsWith(".gif") || name.endsWith(".bmp") || name.endsWith(".webp") + || key.endsWith(".png") || key.endsWith(".jpg") || key.endsWith(".jpeg") + || key.endsWith(".gif") || key.endsWith(".bmp") || key.endsWith(".webp"); + } + + private String fileNameFromObjectKey(String objectKey) { + String key = stringValue(objectKey); + if (key.isEmpty()) { + return ""; + } + int idx = key.lastIndexOf('/'); + return idx >= 0 ? key.substring(idx + 1) : key; + } + + private String formatNumber(Object value) { + Long number = toLong(value); + return number == null ? "-" : String.valueOf(number); + } + + private String stringValue(Object value) { + return value == null ? "" : String.valueOf(value).trim(); + } + + private String safeValue(String value) { + return value == null ? "" : value; + } + + private Long toLong(Object value) { + if (value instanceof Number) { + return ((Number) value).longValue(); + } + try { + String text = stringValue(value); + return text.isEmpty() ? null : Long.valueOf(text); + } catch (Exception ex) { + return null; + } + } + + private static final class SummaryContext { + private String summaryTitle; + private String organizationName; + private String meetingCategory; + private String meetingTime; + private String meetingLocation; + private String meetingTopic; + private String chairAndSpeaker; + private String guests; + private String attendeePlan; + private String attendeeActual; + private String targetAudience; + private String mainAgenda; + private String meetingEffect; + private String improvementSuggestion; + private String themeInstruction; + private String footerDate; + private List themeImages = Collections.emptyList(); + private List expertImages = Collections.emptyList(); + private List materialImages = Collections.emptyList(); + } + + private static final class AnchorCells { + private final XWPFTableCell themeCell; + private final XWPFTableCell expertCell; + private final XWPFTableCell materialCell; + + private AnchorCells(XWPFTableCell themeCell, XWPFTableCell expertCell, XWPFTableCell materialCell) { + this.themeCell = themeCell; + this.expertCell = expertCell; + this.materialCell = materialCell; + } + } + + private static final class ImageSource { + private final String objectKey; + private final String fileName; + private final String caption; + + private ImageSource(String objectKey, String fileName, String caption) { + this.objectKey = objectKey == null ? "" : objectKey.trim(); + this.fileName = fileName == null ? "" : fileName.trim(); + this.caption = caption == null ? "" : caption.trim(); + } + } + + private static final class ExpertProfile { + private final Long expertId; + private final String name; + private final String organization; + private final LinkedHashSet roles = new LinkedHashSet(); + + private ExpertProfile(Long expertId, String name, String organization) { + this.expertId = expertId; + this.name = name == null ? "" : name.trim(); + this.organization = organization == null ? "" : organization.trim(); + } + + private void addRole(String roleLabel) { + if (roleLabel != null && !roleLabel.trim().isEmpty()) { + roles.add(roleLabel.trim()); + } + } + } + + private static final class RenderableImage { + private final byte[] bytes; + private final int pictureType; + private final String fileName; + private final String caption; + private final int widthPx; + private final int heightPx; + + private RenderableImage(byte[] bytes, int pictureType, String fileName, String caption, int widthPx, int heightPx) { + this.bytes = bytes; + this.pictureType = pictureType; + this.fileName = fileName; + this.caption = caption; + this.widthPx = widthPx; + this.heightPx = heightPx; + } + } + + private static final class ImageSize { + private final int width; + private final int height; + + private ImageSize(int width, int height) { + this.width = width; + this.height = height; + } + } +} diff --git a/backend/src/main/java/com/writeoff/module/notification/controller/InAppNotificationController.java b/backend/src/main/java/com/writeoff/module/notification/controller/InAppNotificationController.java new file mode 100644 index 0000000..208a038 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/notification/controller/InAppNotificationController.java @@ -0,0 +1,44 @@ +package com.writeoff.module.notification.controller; + +import com.writeoff.common.api.ApiResponse; +import com.writeoff.common.api.PageResult; +import com.writeoff.module.notification.model.InAppNotificationInfo; +import com.writeoff.module.notification.service.InAppNotificationService; +import com.writeoff.security.DataScopeType; +import com.writeoff.security.RequirePermission; +import org.springframework.web.bind.annotation.*; + +import java.util.LinkedHashMap; +import java.util.Map; + +@RestController +@RequestMapping("/api/in-app-notifications") +public class InAppNotificationController { + private final InAppNotificationService inAppNotificationService; + + public InAppNotificationController(InAppNotificationService inAppNotificationService) { + this.inAppNotificationService = inAppNotificationService; + } + + @GetMapping + @RequirePermission(value = "notification.inapp.read", dataScope = DataScopeType.TENANT, auditAction = "IN_APP_NOTIFICATION_LIST") + public ApiResponse> listMine() { + return ApiResponse.success(inAppNotificationService.listMine()); + } + + @PostMapping("/{id}/read") + @RequirePermission(value = "notification.inapp.mark-read", dataScope = DataScopeType.TENANT, auditAction = "IN_APP_NOTIFICATION_MARK_READ") + public ApiResponse markRead(@PathVariable("id") Long id) { + inAppNotificationService.markRead(id); + return ApiResponse.success(null); + } + + @PostMapping("/read-all") + @RequirePermission(value = "notification.inapp.mark-read", dataScope = DataScopeType.TENANT, auditAction = "IN_APP_NOTIFICATION_MARK_ALL_READ") + public ApiResponse> markAllRead() { + int affected = inAppNotificationService.markAllRead(); + Map data = new LinkedHashMap(); + data.put("affected", affected); + return ApiResponse.success(data); + } +} diff --git a/backend/src/main/java/com/writeoff/module/notification/controller/NotificationDispatchController.java b/backend/src/main/java/com/writeoff/module/notification/controller/NotificationDispatchController.java new file mode 100644 index 0000000..68f191a --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/notification/controller/NotificationDispatchController.java @@ -0,0 +1,133 @@ +package com.writeoff.module.notification.controller; + +import com.writeoff.common.api.ApiResponse; +import com.writeoff.common.api.PageResult; +import com.writeoff.common.exception.BusinessException; +import com.writeoff.common.exception.ErrorCodes; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.writeoff.module.export.dto.CreateExportTaskRequest; +import com.writeoff.module.export.service.ExportTaskService; +import com.writeoff.module.notification.dto.DispatchNotificationRequest; +import com.writeoff.module.notification.dto.AliyunSmsReceiptRequest; +import com.writeoff.module.notification.dto.NotificationReceiptRequest; +import com.writeoff.module.notification.model.NotificationTaskInfo; +import com.writeoff.module.notification.service.NotificationDispatchService; +import com.writeoff.security.DataScopeType; +import com.writeoff.security.RequirePermission; +import org.springframework.web.bind.annotation.*; + +import javax.validation.Valid; +import javax.servlet.http.HttpServletRequest; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/notifications") +public class NotificationDispatchController { + private final NotificationDispatchService notificationDispatchService; + private final ExportTaskService exportTaskService; + private final ObjectMapper objectMapper = new ObjectMapper(); + + public NotificationDispatchController(NotificationDispatchService notificationDispatchService, + ExportTaskService exportTaskService) { + this.notificationDispatchService = notificationDispatchService; + this.exportTaskService = exportTaskService; + } + + @PostMapping("/tasks/export") + @RequirePermission(value = "notification.task.read", dataScope = DataScopeType.TENANT, auditAction = "NOTIFICATION_TASK_EXPORT") + public ApiResponse> exportTasks(@RequestBody @Valid CreateExportTaskRequest request) { + request.setTaskCode("NOTIFICATION_TASK_EXPORT"); + request.setBizType("NOTIFICATION_TASK"); + return ApiResponse.success(exportTaskService.create(request)); + } + + @PostMapping("/dispatch") + @RequirePermission(value = "notification.dispatch", dataScope = DataScopeType.TENANT, auditAction = "NOTIFICATION_DISPATCH") + public ApiResponse> dispatch(@RequestBody @Valid DispatchNotificationRequest request) { + return ApiResponse.success(notificationDispatchService.dispatch(request)); + } + + @GetMapping("/tasks") + @RequirePermission(value = "notification.task.read", dataScope = DataScopeType.TENANT, auditAction = "NOTIFICATION_TASK_LIST") + public ApiResponse> listTasks( + @RequestParam(value = "pageNo", required = false) Integer pageNo, + @RequestParam(value = "pageSize", required = false) Integer pageSize) { + return ApiResponse.success(notificationDispatchService.listTasks(pageNo, pageSize)); + } + + @PostMapping("/receipts") + @RequirePermission(value = "notification.dispatch", dataScope = DataScopeType.TENANT, auditAction = "NOTIFICATION_RECEIPT_INGEST") + public ApiResponse> receipt(@RequestBody @Valid NotificationReceiptRequest request) { + return ApiResponse.success(notificationDispatchService.ingestReceipt(request)); + } + + @PostMapping("/receipts/webhook") + public ApiResponse> receiptWebhook(@RequestBody @Valid NotificationReceiptRequest request, + @RequestHeader(value = "X-Receipt-Timestamp", required = false) String timestamp, + @RequestHeader(value = "X-Receipt-Signature", required = false) String signature) { + return ApiResponse.success(notificationDispatchService.ingestReceiptWebhook(request, timestamp, signature)); + } + + @PostMapping("/receipts/providers/aliyun-sms") + public ApiResponse> aliyunSmsReceipt(@RequestBody(required = false) String body, + HttpServletRequest httpServletRequest) { + List requests = parseAliyunSmsReceiptRequests(body, httpServletRequest); + return ApiResponse.success(notificationDispatchService.ingestAliyunSmsReceipts(requests)); + } + + private List parseAliyunSmsReceiptRequests(String body, HttpServletRequest request) { + String raw = body == null ? "" : body.trim(); + try { + if (!raw.isEmpty()) { + if (raw.startsWith("[")) { + List list = objectMapper.readValue(raw, new TypeReference>() {}); + return list == null ? new ArrayList() : list; + } + if (raw.startsWith("{")) { + AliyunSmsReceiptRequest single = objectMapper.readValue(raw, AliyunSmsReceiptRequest.class); + List list = new ArrayList(); + list.add(single); + return list; + } + } + } catch (Exception ex) { + throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "阿里云短信回执请求体格式非法"); + } + + Map parameterMap = request == null ? new LinkedHashMap() : request.getParameterMap(); + if (parameterMap == null || parameterMap.isEmpty()) { + throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "阿里云短信回执内容不能为空"); + } + AliyunSmsReceiptRequest single = new AliyunSmsReceiptRequest(); + single.setBizId(firstParam(parameterMap, "biz_id", "bizId")); + single.setOutId(firstParam(parameterMap, "out_id", "outId")); + single.setPhoneNumber(firstParam(parameterMap, "phone_number", "phoneNumber")); + single.setErrCode(firstParam(parameterMap, "err_code", "errCode")); + single.setErrMsg(firstParam(parameterMap, "err_msg", "errMsg")); + String success = firstParam(parameterMap, "success", "Success"); + if (success != null && success.trim().length() > 0) { + single.setSuccess(Boolean.valueOf(success.trim())); + } + single.setReceiveDate(firstParam(parameterMap, "report_time", "receiveDate", "receive_time")); + List list = new ArrayList(); + list.add(single); + return list; + } + + private String firstParam(Map parameterMap, String... names) { + if (parameterMap == null || names == null) { + return null; + } + for (String name : names) { + String[] values = parameterMap.get(name); + if (values != null && values.length > 0 && values[0] != null) { + return values[0]; + } + } + return null; + } +} diff --git a/backend/src/main/java/com/writeoff/module/notification/controller/NotificationPolicyController.java b/backend/src/main/java/com/writeoff/module/notification/controller/NotificationPolicyController.java new file mode 100644 index 0000000..aabfc3b --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/notification/controller/NotificationPolicyController.java @@ -0,0 +1,72 @@ +package com.writeoff.module.notification.controller; + +import com.writeoff.common.api.ApiResponse; +import com.writeoff.common.api.PageResult; +import com.writeoff.module.notification.dto.BindNotificationPolicyEventsRequest; +import com.writeoff.module.notification.dto.CreateNotificationPolicyRequest; +import com.writeoff.module.notification.dto.UpdateNotificationPolicyRequest; +import com.writeoff.module.notification.model.NotificationPolicyInfo; +import com.writeoff.module.notification.service.NotificationPolicyService; +import com.writeoff.security.DataScopeType; +import com.writeoff.security.RequirePermission; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.bind.annotation.RequestParam; + +import javax.validation.Valid; + +@RestController +@RequestMapping("/api/notification-policies") +public class NotificationPolicyController { + private final NotificationPolicyService notificationPolicyService; + + public NotificationPolicyController(NotificationPolicyService notificationPolicyService) { + this.notificationPolicyService = notificationPolicyService; + } + + @GetMapping + @RequirePermission(value = "notification.policy.read", dataScope = DataScopeType.TENANT, auditAction = "NOTIFICATION_POLICY_LIST") + public ApiResponse> list( + @RequestParam(value = "pageNo", defaultValue = "1") int pageNo, + @RequestParam(value = "pageSize", defaultValue = "20") int pageSize) { + return ApiResponse.success(notificationPolicyService.list(pageNo, pageSize)); + } + + @PostMapping + @RequirePermission(value = "notification.policy.manage", dataScope = DataScopeType.TENANT, auditAction = "NOTIFICATION_POLICY_CREATE") + public ApiResponse create(@RequestBody @Valid CreateNotificationPolicyRequest request) { + return ApiResponse.success(notificationPolicyService.create(request)); + } + + @PutMapping("/{id}") + @RequirePermission(value = "notification.policy.manage", dataScope = DataScopeType.TENANT, auditAction = "NOTIFICATION_POLICY_UPDATE") + public ApiResponse update(@PathVariable("id") Long id, + @RequestBody @Valid UpdateNotificationPolicyRequest request) { + return ApiResponse.success(notificationPolicyService.update(id, request)); + } + + @PostMapping("/{id}/events") + @RequirePermission(value = "notification.policy.manage", dataScope = DataScopeType.TENANT, auditAction = "NOTIFICATION_POLICY_BIND_EVENTS") + public ApiResponse bindEvents(@PathVariable("id") Long id, + @RequestBody @Valid BindNotificationPolicyEventsRequest request) { + return ApiResponse.success(notificationPolicyService.bindEvents(id, request.getEventCode())); + } + + @PostMapping("/{id}/enable") + @RequirePermission(value = "notification.policy.manage", dataScope = DataScopeType.TENANT, auditAction = "NOTIFICATION_POLICY_ENABLE") + public ApiResponse enable(@PathVariable("id") Long id) { + return ApiResponse.success(notificationPolicyService.enable(id)); + } + + @PostMapping("/{id}/disable") + @RequirePermission(value = "notification.policy.manage", dataScope = DataScopeType.TENANT, auditAction = "NOTIFICATION_POLICY_DISABLE") + public ApiResponse disable(@PathVariable("id") Long id) { + return ApiResponse.success(notificationPolicyService.disable(id)); + } + + @PostMapping("/{id}/delete") + @RequirePermission(value = "notification.policy.manage", dataScope = DataScopeType.TENANT, auditAction = "NOTIFICATION_POLICY_DELETE") + public ApiResponse delete(@PathVariable("id") Long id) { + notificationPolicyService.softDelete(id); + return ApiResponse.success("OK"); + } +} diff --git a/backend/src/main/java/com/writeoff/module/notification/controller/NotificationTextTemplateController.java b/backend/src/main/java/com/writeoff/module/notification/controller/NotificationTextTemplateController.java new file mode 100644 index 0000000..ab21947 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/notification/controller/NotificationTextTemplateController.java @@ -0,0 +1,63 @@ +package com.writeoff.module.notification.controller; + +import com.writeoff.common.api.ApiResponse; +import com.writeoff.common.api.PageResult; +import com.writeoff.module.notification.dto.CreateNotificationTextTemplateRequest; +import com.writeoff.module.notification.dto.UpdateNotificationTextTemplateRequest; +import com.writeoff.module.notification.model.NotificationTextTemplateInfo; +import com.writeoff.module.notification.service.NotificationTextTemplateService; +import com.writeoff.security.DataScopeType; +import com.writeoff.security.RequirePermission; +import org.springframework.web.bind.annotation.*; + +import javax.validation.Valid; + +@RestController +@RequestMapping("/api/notification-text-templates") +public class NotificationTextTemplateController { + private final NotificationTextTemplateService notificationTextTemplateService; + + public NotificationTextTemplateController(NotificationTextTemplateService notificationTextTemplateService) { + this.notificationTextTemplateService = notificationTextTemplateService; + } + + @GetMapping + @RequirePermission(value = "notification.text-template.read", dataScope = DataScopeType.TENANT, auditAction = "NOTIFICATION_TEXT_TEMPLATE_LIST") + public ApiResponse> list( + @RequestParam(value = "pageNo", defaultValue = "1") int pageNo, + @RequestParam(value = "pageSize", defaultValue = "20") int pageSize) { + return ApiResponse.success(notificationTextTemplateService.list(pageNo, pageSize)); + } + + @PostMapping + @RequirePermission(value = "notification.text-template.manage", dataScope = DataScopeType.TENANT, auditAction = "NOTIFICATION_TEXT_TEMPLATE_CREATE") + public ApiResponse create(@RequestBody @Valid CreateNotificationTextTemplateRequest request) { + return ApiResponse.success(notificationTextTemplateService.create(request)); + } + + @PutMapping("/{id}") + @RequirePermission(value = "notification.text-template.manage", dataScope = DataScopeType.TENANT, auditAction = "NOTIFICATION_TEXT_TEMPLATE_UPDATE") + public ApiResponse update(@PathVariable("id") Long id, + @RequestBody @Valid UpdateNotificationTextTemplateRequest request) { + return ApiResponse.success(notificationTextTemplateService.update(id, request)); + } + + @PostMapping("/{id}/enable") + @RequirePermission(value = "notification.text-template.manage", dataScope = DataScopeType.TENANT, auditAction = "NOTIFICATION_TEXT_TEMPLATE_ENABLE") + public ApiResponse enable(@PathVariable("id") Long id) { + return ApiResponse.success(notificationTextTemplateService.enable(id)); + } + + @PostMapping("/{id}/disable") + @RequirePermission(value = "notification.text-template.manage", dataScope = DataScopeType.TENANT, auditAction = "NOTIFICATION_TEXT_TEMPLATE_DISABLE") + public ApiResponse disable(@PathVariable("id") Long id) { + return ApiResponse.success(notificationTextTemplateService.disable(id)); + } + + @PostMapping("/{id}/delete") + @RequirePermission(value = "notification.text-template.manage", dataScope = DataScopeType.TENANT, auditAction = "NOTIFICATION_TEXT_TEMPLATE_DELETE") + public ApiResponse delete(@PathVariable("id") Long id) { + notificationTextTemplateService.softDelete(id); + return ApiResponse.success("OK"); + } +} diff --git a/backend/src/main/java/com/writeoff/module/notification/controller/PlatformNotifyGatewayController.java b/backend/src/main/java/com/writeoff/module/notification/controller/PlatformNotifyGatewayController.java new file mode 100644 index 0000000..13f143c --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/notification/controller/PlatformNotifyGatewayController.java @@ -0,0 +1,55 @@ +package com.writeoff.module.notification.controller; + +import com.writeoff.common.api.ApiResponse; +import com.writeoff.module.notification.dto.SavePlatformNotifyGatewayRequest; +import com.writeoff.module.notification.dto.TestPlatformNotifyGatewayRequest; +import com.writeoff.module.notification.model.PlatformNotifyGatewayInfo; +import com.writeoff.module.notification.service.PlatformNotifyGatewayService; +import com.writeoff.module.notification.service.PlatformNotifyGatewayTestService; +import com.writeoff.security.DataScopeType; +import com.writeoff.security.PermissionDomain; +import com.writeoff.security.RequirePermission; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import javax.validation.Valid; +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/platform/notify-gateways") +public class PlatformNotifyGatewayController { + private final PlatformNotifyGatewayService gatewayService; + private final PlatformNotifyGatewayTestService testService; + + public PlatformNotifyGatewayController(PlatformNotifyGatewayService gatewayService, + PlatformNotifyGatewayTestService testService) { + this.gatewayService = gatewayService; + this.testService = testService; + } + + @GetMapping + @RequirePermission(value = "platform.notify-gateway.read", domain = PermissionDomain.PLATFORM, dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "PLATFORM_NOTIFY_GATEWAY_LIST") + public ApiResponse> list() { + return ApiResponse.success(gatewayService.list()); + } + + @PutMapping("/{channelCode}") + @RequirePermission(value = "platform.notify-gateway.manage", domain = PermissionDomain.PLATFORM, dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "PLATFORM_NOTIFY_GATEWAY_SAVE") + public ApiResponse save(@PathVariable("channelCode") String channelCode, + @RequestBody SavePlatformNotifyGatewayRequest request) { + return ApiResponse.success(gatewayService.save(channelCode, request)); + } + + @PostMapping("/{channelCode}/test") + @RequirePermission(value = "platform.notify-gateway.manage", domain = PermissionDomain.PLATFORM, dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "PLATFORM_NOTIFY_GATEWAY_TEST") + public ApiResponse> test(@PathVariable("channelCode") String channelCode, + @RequestBody @Valid TestPlatformNotifyGatewayRequest request) { + return ApiResponse.success(testService.test(channelCode, request)); + } +} diff --git a/backend/src/main/java/com/writeoff/module/notification/dto/AliyunSmsReceiptRequest.java b/backend/src/main/java/com/writeoff/module/notification/dto/AliyunSmsReceiptRequest.java new file mode 100644 index 0000000..0c39a51 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/notification/dto/AliyunSmsReceiptRequest.java @@ -0,0 +1,67 @@ +package com.writeoff.module.notification.dto; + +public class AliyunSmsReceiptRequest { + private String bizId; + private String outId; + private String phoneNumber; + private String errCode; + private String errMsg; + private Boolean success; + private String receiveDate; + + public String getBizId() { + return bizId; + } + + public void setBizId(String bizId) { + this.bizId = bizId; + } + + public String getOutId() { + return outId; + } + + public void setOutId(String outId) { + this.outId = outId; + } + + public String getPhoneNumber() { + return phoneNumber; + } + + public void setPhoneNumber(String phoneNumber) { + this.phoneNumber = phoneNumber; + } + + public String getErrCode() { + return errCode; + } + + public void setErrCode(String errCode) { + this.errCode = errCode; + } + + public String getErrMsg() { + return errMsg; + } + + public void setErrMsg(String errMsg) { + this.errMsg = errMsg; + } + + public Boolean getSuccess() { + return success; + } + + public void setSuccess(Boolean success) { + this.success = success; + } + + public String getReceiveDate() { + return receiveDate; + } + + public void setReceiveDate(String receiveDate) { + this.receiveDate = receiveDate; + } +} diff --git a/backend/src/main/java/com/writeoff/module/notification/dto/BindNotificationPolicyEventsRequest.java b/backend/src/main/java/com/writeoff/module/notification/dto/BindNotificationPolicyEventsRequest.java new file mode 100644 index 0000000..2152cf5 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/notification/dto/BindNotificationPolicyEventsRequest.java @@ -0,0 +1,16 @@ +package com.writeoff.module.notification.dto; + +import javax.validation.constraints.NotBlank; + +public class BindNotificationPolicyEventsRequest { + @NotBlank(message = "事件编码不能为空") + private String eventCode; + + public String getEventCode() { + return eventCode; + } + + public void setEventCode(String eventCode) { + this.eventCode = eventCode; + } +} diff --git a/backend/src/main/java/com/writeoff/module/notification/dto/CreateNotificationPolicyRequest.java b/backend/src/main/java/com/writeoff/module/notification/dto/CreateNotificationPolicyRequest.java new file mode 100644 index 0000000..b943d2f --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/notification/dto/CreateNotificationPolicyRequest.java @@ -0,0 +1,75 @@ +package com.writeoff.module.notification.dto; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; + +public class CreateNotificationPolicyRequest { + @NotBlank(message = "策略名称不能为空") + private String policyName; + @NotBlank(message = "事件编码不能为空") + private String eventCode; + @NotBlank(message = "通知渠道不能为空") + private String channel; + @NotBlank(message = "接收对象不能为空") + private String receiverType; + @NotNull(message = "文案模板ID不能为空") + private Long templateId; + private String variablesJson; + private String status; + + public String getPolicyName() { + return policyName; + } + + public void setPolicyName(String policyName) { + this.policyName = policyName; + } + + public String getEventCode() { + return eventCode; + } + + public void setEventCode(String eventCode) { + this.eventCode = eventCode; + } + + public String getChannel() { + return channel; + } + + public void setChannel(String channel) { + this.channel = channel; + } + + public String getReceiverType() { + return receiverType; + } + + public void setReceiverType(String receiverType) { + this.receiverType = receiverType; + } + + public Long getTemplateId() { + return templateId; + } + + public void setTemplateId(Long templateId) { + this.templateId = templateId; + } + + public String getVariablesJson() { + return variablesJson; + } + + public void setVariablesJson(String variablesJson) { + this.variablesJson = variablesJson; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } +} diff --git a/backend/src/main/java/com/writeoff/module/notification/dto/CreateNotificationTextTemplateRequest.java b/backend/src/main/java/com/writeoff/module/notification/dto/CreateNotificationTextTemplateRequest.java new file mode 100644 index 0000000..3b52df0 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/notification/dto/CreateNotificationTextTemplateRequest.java @@ -0,0 +1,53 @@ +package com.writeoff.module.notification.dto; + +import javax.validation.constraints.NotBlank; + +public class CreateNotificationTextTemplateRequest { + @NotBlank(message = "文案模板名称不能为空") + private String templateName; + private String subjectTemplate; + private String titleTemplate; + @NotBlank(message = "正文模板不能为空") + private String contentTemplate; + private String status; + + public String getTemplateName() { + return templateName; + } + + public void setTemplateName(String templateName) { + this.templateName = templateName; + } + + public String getSubjectTemplate() { + return subjectTemplate; + } + + public void setSubjectTemplate(String subjectTemplate) { + this.subjectTemplate = subjectTemplate; + } + + public String getTitleTemplate() { + return titleTemplate; + } + + public void setTitleTemplate(String titleTemplate) { + this.titleTemplate = titleTemplate; + } + + public String getContentTemplate() { + return contentTemplate; + } + + public void setContentTemplate(String contentTemplate) { + this.contentTemplate = contentTemplate; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } +} diff --git a/backend/src/main/java/com/writeoff/module/notification/dto/DispatchNotificationRequest.java b/backend/src/main/java/com/writeoff/module/notification/dto/DispatchNotificationRequest.java new file mode 100644 index 0000000..c846724 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/notification/dto/DispatchNotificationRequest.java @@ -0,0 +1,62 @@ +package com.writeoff.module.notification.dto; + +import javax.validation.constraints.NotBlank; + +public class DispatchNotificationRequest { + @NotBlank(message = "幂等键不能为空") + private String idempotencyKey; + @NotBlank(message = "事件编码不能为空") + private String eventCode; + private String bizType; + private String bizId; + private String variablesJson; + private Long policyId; + + public String getIdempotencyKey() { + return idempotencyKey; + } + + public void setIdempotencyKey(String idempotencyKey) { + this.idempotencyKey = idempotencyKey; + } + + public String getEventCode() { + return eventCode; + } + + public void setEventCode(String eventCode) { + this.eventCode = eventCode; + } + + public String getBizType() { + return bizType; + } + + public void setBizType(String bizType) { + this.bizType = bizType; + } + + public String getBizId() { + return bizId; + } + + public void setBizId(String bizId) { + this.bizId = bizId; + } + + public String getVariablesJson() { + return variablesJson; + } + + public void setVariablesJson(String variablesJson) { + this.variablesJson = variablesJson; + } + + public Long getPolicyId() { + return policyId; + } + + public void setPolicyId(Long policyId) { + this.policyId = policyId; + } +} diff --git a/backend/src/main/java/com/writeoff/module/notification/dto/NotificationReceiptRequest.java b/backend/src/main/java/com/writeoff/module/notification/dto/NotificationReceiptRequest.java new file mode 100644 index 0000000..e07965e --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/notification/dto/NotificationReceiptRequest.java @@ -0,0 +1,55 @@ +package com.writeoff.module.notification.dto; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; + +public class NotificationReceiptRequest { + @NotNull(message = "任务ID不能为空") + private Long taskId; + @NotBlank(message = "供应商消息ID不能为空") + private String providerMessageId; + @NotBlank(message = "回执码不能为空") + private String receiptCode; + private String receiptMessage; + private Boolean delivered; + + public Long getTaskId() { + return taskId; + } + + public void setTaskId(Long taskId) { + this.taskId = taskId; + } + + public String getProviderMessageId() { + return providerMessageId; + } + + public void setProviderMessageId(String providerMessageId) { + this.providerMessageId = providerMessageId; + } + + public String getReceiptCode() { + return receiptCode; + } + + public void setReceiptCode(String receiptCode) { + this.receiptCode = receiptCode; + } + + public String getReceiptMessage() { + return receiptMessage; + } + + public void setReceiptMessage(String receiptMessage) { + this.receiptMessage = receiptMessage; + } + + public Boolean getDelivered() { + return delivered; + } + + public void setDelivered(Boolean delivered) { + this.delivered = delivered; + } +} diff --git a/backend/src/main/java/com/writeoff/module/notification/dto/SavePlatformNotifyGatewayRequest.java b/backend/src/main/java/com/writeoff/module/notification/dto/SavePlatformNotifyGatewayRequest.java new file mode 100644 index 0000000..219f37a --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/notification/dto/SavePlatformNotifyGatewayRequest.java @@ -0,0 +1,51 @@ +package com.writeoff.module.notification.dto; + +import java.util.Map; + +public class SavePlatformNotifyGatewayRequest { + private String gatewayName; + private String providerCode; + private String status; + private String remark; + private Map config; + + public String getGatewayName() { + return gatewayName; + } + + public void setGatewayName(String gatewayName) { + this.gatewayName = gatewayName; + } + + public String getProviderCode() { + return providerCode; + } + + public void setProviderCode(String providerCode) { + this.providerCode = providerCode; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public String getRemark() { + return remark; + } + + public void setRemark(String remark) { + this.remark = remark; + } + + public Map getConfig() { + return config; + } + + public void setConfig(Map config) { + this.config = config; + } +} diff --git a/backend/src/main/java/com/writeoff/module/notification/dto/TestPlatformNotifyGatewayRequest.java b/backend/src/main/java/com/writeoff/module/notification/dto/TestPlatformNotifyGatewayRequest.java new file mode 100644 index 0000000..9c05191 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/notification/dto/TestPlatformNotifyGatewayRequest.java @@ -0,0 +1,34 @@ +package com.writeoff.module.notification.dto; + +import javax.validation.constraints.NotBlank; + +public class TestPlatformNotifyGatewayRequest { + @NotBlank(message = "测试接收目标不能为空") + private String receiverRef; + private String subject; + private String content; + + public String getReceiverRef() { + return receiverRef; + } + + public void setReceiverRef(String receiverRef) { + this.receiverRef = receiverRef; + } + + public String getSubject() { + return subject; + } + + public void setSubject(String subject) { + this.subject = subject; + } + + public String getContent() { + return content; + } + + public void setContent(String content) { + this.content = content; + } +} diff --git a/backend/src/main/java/com/writeoff/module/notification/dto/UpdateNotificationPolicyRequest.java b/backend/src/main/java/com/writeoff/module/notification/dto/UpdateNotificationPolicyRequest.java new file mode 100644 index 0000000..f6f8a1c --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/notification/dto/UpdateNotificationPolicyRequest.java @@ -0,0 +1,4 @@ +package com.writeoff.module.notification.dto; + +public class UpdateNotificationPolicyRequest extends CreateNotificationPolicyRequest { +} diff --git a/backend/src/main/java/com/writeoff/module/notification/dto/UpdateNotificationTextTemplateRequest.java b/backend/src/main/java/com/writeoff/module/notification/dto/UpdateNotificationTextTemplateRequest.java new file mode 100644 index 0000000..2da0bd1 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/notification/dto/UpdateNotificationTextTemplateRequest.java @@ -0,0 +1,4 @@ +package com.writeoff.module.notification.dto; + +public class UpdateNotificationTextTemplateRequest extends CreateNotificationTextTemplateRequest { +} diff --git a/backend/src/main/java/com/writeoff/module/notification/model/InAppNotificationInfo.java b/backend/src/main/java/com/writeoff/module/notification/model/InAppNotificationInfo.java new file mode 100644 index 0000000..ee24044 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/notification/model/InAppNotificationInfo.java @@ -0,0 +1,43 @@ +package com.writeoff.module.notification.model; + +public class InAppNotificationInfo { + private Long id; + private String title; + private String content; + private String status; + private String createdAt; + private String readAt; + + public InAppNotificationInfo(Long id, String title, String content, String status, String createdAt, String readAt) { + this.id = id; + this.title = title; + this.content = content; + this.status = status; + this.createdAt = createdAt; + this.readAt = readAt; + } + + public Long getId() { + return id; + } + + public String getTitle() { + return title; + } + + public String getContent() { + return content; + } + + public String getStatus() { + return status; + } + + public String getCreatedAt() { + return createdAt; + } + + public String getReadAt() { + return readAt; + } +} diff --git a/backend/src/main/java/com/writeoff/module/notification/model/NotificationPolicyInfo.java b/backend/src/main/java/com/writeoff/module/notification/model/NotificationPolicyInfo.java new file mode 100644 index 0000000..de2a06f --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/notification/model/NotificationPolicyInfo.java @@ -0,0 +1,55 @@ +package com.writeoff.module.notification.model; + +public class NotificationPolicyInfo { + private Long id; + private String policyName; + private String eventCode; + private String channel; + private String receiverType; + private Long templateId; + private String variablesJson; + private String status; + + public NotificationPolicyInfo(Long id, String policyName, String eventCode, String channel, String receiverType, Long templateId, String variablesJson, String status) { + this.id = id; + this.policyName = policyName; + this.eventCode = eventCode; + this.channel = channel; + this.receiverType = receiverType; + this.templateId = templateId; + this.variablesJson = variablesJson; + this.status = status; + } + + public Long getId() { + return id; + } + + public String getPolicyName() { + return policyName; + } + + public String getEventCode() { + return eventCode; + } + + public String getChannel() { + return channel; + } + + public String getReceiverType() { + return receiverType; + } + + public Long getTemplateId() { + return templateId; + } + + public String getVariablesJson() { + return variablesJson; + } + + public String getStatus() { + return status; + } +} diff --git a/backend/src/main/java/com/writeoff/module/notification/model/NotificationTaskInfo.java b/backend/src/main/java/com/writeoff/module/notification/model/NotificationTaskInfo.java new file mode 100644 index 0000000..4b8313c --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/notification/model/NotificationTaskInfo.java @@ -0,0 +1,103 @@ +package com.writeoff.module.notification.model; + +public class NotificationTaskInfo { + private Long id; + private Long policyId; + private String eventCode; + private String channel; + private String receiverType; + private String receiverRef; + private String receiverResolveSource; + private String status; + private Integer retryCount; + private String providerMessageId; + private String receiptCode; + private String receiptMessage; + private String errorMessage; + private String createdAt; + private String sentAt; + private String receiptAt; + + public NotificationTaskInfo(Long id, Long policyId, String eventCode, String channel, String receiverType, String receiverRef, String receiverResolveSource, String status, Integer retryCount, String providerMessageId, String receiptCode, String receiptMessage, String errorMessage, String createdAt, String sentAt, String receiptAt) { + this.id = id; + this.policyId = policyId; + this.eventCode = eventCode; + this.channel = channel; + this.receiverType = receiverType; + this.receiverRef = receiverRef; + this.receiverResolveSource = receiverResolveSource; + this.status = status; + this.retryCount = retryCount; + this.providerMessageId = providerMessageId; + this.receiptCode = receiptCode; + this.receiptMessage = receiptMessage; + this.errorMessage = errorMessage; + this.createdAt = createdAt; + this.sentAt = sentAt; + this.receiptAt = receiptAt; + } + + public Long getId() { + return id; + } + + public Long getPolicyId() { + return policyId; + } + + public String getEventCode() { + return eventCode; + } + + public String getChannel() { + return channel; + } + + public String getReceiverType() { + return receiverType; + } + + public String getReceiverRef() { + return receiverRef; + } + + public String getReceiverResolveSource() { + return receiverResolveSource; + } + + public String getStatus() { + return status; + } + + public Integer getRetryCount() { + return retryCount; + } + + public String getProviderMessageId() { + return providerMessageId; + } + + public String getReceiptCode() { + return receiptCode; + } + + public String getReceiptMessage() { + return receiptMessage; + } + + public String getErrorMessage() { + return errorMessage; + } + + public String getCreatedAt() { + return createdAt; + } + + public String getSentAt() { + return sentAt; + } + + public String getReceiptAt() { + return receiptAt; + } +} diff --git a/backend/src/main/java/com/writeoff/module/notification/model/NotificationTextTemplateInfo.java b/backend/src/main/java/com/writeoff/module/notification/model/NotificationTextTemplateInfo.java new file mode 100644 index 0000000..0d02ea2 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/notification/model/NotificationTextTemplateInfo.java @@ -0,0 +1,43 @@ +package com.writeoff.module.notification.model; + +public class NotificationTextTemplateInfo { + private Long id; + private String templateName; + private String subjectTemplate; + private String titleTemplate; + private String contentTemplate; + private String status; + + public NotificationTextTemplateInfo(Long id, String templateName, String subjectTemplate, String titleTemplate, String contentTemplate, String status) { + this.id = id; + this.templateName = templateName; + this.subjectTemplate = subjectTemplate; + this.titleTemplate = titleTemplate; + this.contentTemplate = contentTemplate; + this.status = status; + } + + public Long getId() { + return id; + } + + public String getTemplateName() { + return templateName; + } + + public String getSubjectTemplate() { + return subjectTemplate; + } + + public String getTitleTemplate() { + return titleTemplate; + } + + public String getContentTemplate() { + return contentTemplate; + } + + public String getStatus() { + return status; + } +} diff --git a/backend/src/main/java/com/writeoff/module/notification/model/PlatformNotifyGatewayInfo.java b/backend/src/main/java/com/writeoff/module/notification/model/PlatformNotifyGatewayInfo.java new file mode 100644 index 0000000..2a63202 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/notification/model/PlatformNotifyGatewayInfo.java @@ -0,0 +1,71 @@ +package com.writeoff.module.notification.model; + +import java.util.Map; + +public class PlatformNotifyGatewayInfo { + private final Long id; + private final String channelCode; + private final String gatewayName; + private final String providerCode; + private final String status; + private final String remark; + private final boolean configured; + private final String updatedAt; + private final Map config; + + public PlatformNotifyGatewayInfo(Long id, + String channelCode, + String gatewayName, + String providerCode, + String status, + String remark, + boolean configured, + String updatedAt, + Map config) { + this.id = id; + this.channelCode = channelCode; + this.gatewayName = gatewayName; + this.providerCode = providerCode; + this.status = status; + this.remark = remark; + this.configured = configured; + this.updatedAt = updatedAt; + this.config = config; + } + + public Long getId() { + return id; + } + + public String getChannelCode() { + return channelCode; + } + + public String getGatewayName() { + return gatewayName; + } + + public String getProviderCode() { + return providerCode; + } + + public String getStatus() { + return status; + } + + public String getRemark() { + return remark; + } + + public boolean isConfigured() { + return configured; + } + + public String getUpdatedAt() { + return updatedAt; + } + + public Map getConfig() { + return config; + } +} diff --git a/backend/src/main/java/com/writeoff/module/notification/model/PlatformNotifyGatewayResolvedConfig.java b/backend/src/main/java/com/writeoff/module/notification/model/PlatformNotifyGatewayResolvedConfig.java new file mode 100644 index 0000000..88ed694 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/notification/model/PlatformNotifyGatewayResolvedConfig.java @@ -0,0 +1,57 @@ +package com.writeoff.module.notification.model; + +import java.util.Map; + +public class PlatformNotifyGatewayResolvedConfig { + private final Long id; + private final String channelCode; + private final String gatewayName; + private final String providerCode; + private final String status; + private final String remark; + private final Map config; + + public PlatformNotifyGatewayResolvedConfig(Long id, + String channelCode, + String gatewayName, + String providerCode, + String status, + String remark, + Map config) { + this.id = id; + this.channelCode = channelCode; + this.gatewayName = gatewayName; + this.providerCode = providerCode; + this.status = status; + this.remark = remark; + this.config = config; + } + + public Long getId() { + return id; + } + + public String getChannelCode() { + return channelCode; + } + + public String getGatewayName() { + return gatewayName; + } + + public String getProviderCode() { + return providerCode; + } + + public String getStatus() { + return status; + } + + public String getRemark() { + return remark; + } + + public Map getConfig() { + return config; + } +} diff --git a/backend/src/main/java/com/writeoff/module/notification/provider/EmailNotificationProvider.java b/backend/src/main/java/com/writeoff/module/notification/provider/EmailNotificationProvider.java new file mode 100644 index 0000000..60b1266 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/notification/provider/EmailNotificationProvider.java @@ -0,0 +1,141 @@ +package com.writeoff.module.notification.provider; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.writeoff.module.notification.model.PlatformNotifyGatewayResolvedConfig; +import com.writeoff.module.notification.service.PlatformNotifyGatewayService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.mail.SimpleMailMessage; +import org.springframework.mail.javamail.JavaMailSenderImpl; +import org.springframework.stereotype.Component; + +import java.util.Map; +import java.util.Properties; + +@Component +public class EmailNotificationProvider implements NotificationChannelProvider { + private static final Logger log = LoggerFactory.getLogger(EmailNotificationProvider.class); + private final PlatformNotifyGatewayService gatewayService; + private final ObjectMapper objectMapper = new ObjectMapper(); + private final String defaultSubject; + + public EmailNotificationProvider(PlatformNotifyGatewayService gatewayService, + @Value("${app.notification.mail.default-subject:绯荤粺閫氱煡}") String defaultSubject) { + this.gatewayService = gatewayService; + this.defaultSubject = defaultSubject == null ? "绯荤粺閫氱煡" : defaultSubject.trim(); + } + + @Override + public String channel() { + return "EMAIL"; + } + + @Override + public NotificationSendResult send(String receiverRef, String payloadJson, Map context) { + if (receiverRef == null || receiverRef.trim().isEmpty()) { + return new NotificationSendResult(false, null, "INVALID_RECEIVER", "閭鍦板潃涓嶈兘涓虹┖"); + } + long start = System.currentTimeMillis(); + try { + PlatformNotifyGatewayResolvedConfig gatewayConfig = gatewayService.resolveChannelConfig("EMAIL", true); + JavaMailSenderImpl sender = resolveMailSender(gatewayConfig); + Map runtimeConfig = gatewayConfig.getConfig(); + String subject = textOr(runtimeConfig.get("defaultSubject"), defaultSubject); + String content = payloadJson == null ? "" : payloadJson; + if (payloadJson != null && payloadJson.trim().startsWith("{")) { + Map payload = objectMapper.readValue(payloadJson, new TypeReference>() {}); + Object sub = payload.get("subject"); + Object body = payload.get("content"); + if (sub != null && String.valueOf(sub).trim().length() > 0) { + subject = String.valueOf(sub).trim(); + } + if (body != null) { + content = String.valueOf(body); + } + } + SimpleMailMessage message = new SimpleMailMessage(); + String runtimeFrom = text(runtimeConfig.get("fromAddress")); + if (!runtimeFrom.isEmpty()) { + message.setFrom(runtimeFrom); + } + message.setTo(receiverRef.trim()); + message.setSubject(subject); + message.setText(content == null ? "" : content); + log.info("email sending start, to={}, subject={}", receiverRef.trim(), subject); + sender.send(message); + String id = "EMAIL-" + System.currentTimeMillis(); + log.info("email sending success, to={}, messageId={}, elapsedMs={}", receiverRef.trim(), id, System.currentTimeMillis() - start); + return new NotificationSendResult(true, id, "SENT", "閭欢鍙戦€佹垚鍔?"); + } catch (Exception ex) { + log.error("email sending failed, to={}, elapsedMs={}, reason={}", receiverRef.trim(), System.currentTimeMillis() - start, ex.getMessage(), ex); + return new NotificationSendResult(false, null, "SEND_FAILED", ex.getMessage()); + } + } + + private JavaMailSenderImpl resolveMailSender(PlatformNotifyGatewayResolvedConfig gatewayConfig) { + if (gatewayConfig == null || gatewayConfig.getConfig() == null) { + throw new IllegalStateException("邮件网关未启用或未配置"); + } + Map config = gatewayConfig.getConfig(); + if (text(config.get("host")).isEmpty()) { + throw new IllegalStateException("邮件网关缺少 SMTP Host 配置"); + } + if (text(config.get("fromAddress")).isEmpty()) { + throw new IllegalStateException("邮件网关缺少发件邮箱配置"); + } + JavaMailSenderImpl sender = new JavaMailSenderImpl(); + sender.setHost(text(config.get("host"))); + sender.setPort(intValue(config.get("port"), 587)); + sender.setProtocol(textOr(config.get("protocol"), "smtp")); + sender.setUsername(text(config.get("username"))); + sender.setPassword(text(config.get("password"))); + Properties props = sender.getJavaMailProperties(); + props.put("mail.smtp.auth", String.valueOf(boolValue(config.get("smtpAuth"), true))); + props.put("mail.smtp.starttls.enable", String.valueOf(boolValue(config.get("starttlsEnable"), true))); + props.put("mail.smtp.starttls.required", String.valueOf(boolValue(config.get("starttlsRequired"), false))); + props.put("mail.smtp.ssl.enable", String.valueOf(boolValue(config.get("sslEnable"), false))); + props.put("mail.smtp.connectiontimeout", String.valueOf(intValue(config.get("connectTimeoutMs"), 5000))); + props.put("mail.smtp.timeout", String.valueOf(intValue(config.get("timeoutMs"), 5000))); + props.put("mail.smtp.writetimeout", String.valueOf(intValue(config.get("writeTimeoutMs"), 5000))); + return sender; + } + + private String text(Object value) { + return value == null ? "" : String.valueOf(value).trim(); + } + + private String textOr(Object value, String fallback) { + String text = text(value); + return text.isEmpty() ? fallback : text; + } + + private int intValue(Object value, int fallback) { + if (value == null) { + return fallback; + } + try { + return value instanceof Number ? ((Number) value).intValue() : Integer.parseInt(String.valueOf(value).trim()); + } catch (Exception ex) { + return fallback; + } + } + + private boolean boolValue(Object value, boolean fallback) { + if (value == null) { + return fallback; + } + if (value instanceof Boolean) { + return (Boolean) value; + } + String text = String.valueOf(value).trim(); + if ("true".equalsIgnoreCase(text) || "1".equals(text)) { + return true; + } + if ("false".equalsIgnoreCase(text) || "0".equals(text)) { + return false; + } + return fallback; + } +} diff --git a/backend/src/main/java/com/writeoff/module/notification/provider/InAppNotificationProvider.java b/backend/src/main/java/com/writeoff/module/notification/provider/InAppNotificationProvider.java new file mode 100644 index 0000000..9e19af4 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/notification/provider/InAppNotificationProvider.java @@ -0,0 +1,118 @@ +package com.writeoff.module.notification.provider; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.writeoff.module.notification.ws.NotificationWebSocketPushService; +import com.writeoff.security.AuthContext; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +import java.util.LinkedHashMap; +import java.util.Map; + +@Component +public class InAppNotificationProvider implements NotificationChannelProvider { + private final JdbcTemplate jdbcTemplate; + private final NotificationWebSocketPushService webSocketPushService; + private final ObjectMapper objectMapper = new ObjectMapper(); + + public InAppNotificationProvider(JdbcTemplate jdbcTemplate, NotificationWebSocketPushService webSocketPushService) { + this.jdbcTemplate = jdbcTemplate; + this.webSocketPushService = webSocketPushService; + } + + @Override + public String channel() { + return "IN_APP"; + } + + @Override + public NotificationSendResult send(String receiverRef, String payloadJson, Map context) { + if (receiverRef == null || receiverRef.trim().isEmpty()) { + return new NotificationSendResult(false, null, "INVALID_RECEIVER", "站内通知接收人不能为空"); + } + String normalizedReceiver = receiverRef.trim(); + String title = "系统通知"; + String content = payloadJson == null ? "" : payloadJson; + Long receiverUserId = parseReceiverUserId(normalizedReceiver); + try { + if (payloadJson != null && payloadJson.trim().startsWith("{")) { + Map payload = objectMapper.readValue(payloadJson, new TypeReference>() {}); + Object titleVal = payload.get("title"); + if (titleVal == null) { + titleVal = payload.get("subject"); + } + Object contentVal = payload.get("content"); + if (contentVal == null) { + contentVal = payload.get("message"); + } + if (titleVal != null && String.valueOf(titleVal).trim().length() > 0) { + title = String.valueOf(titleVal).trim(); + } + if (contentVal != null) { + content = String.valueOf(contentVal); + } + } + Map payload = new LinkedHashMap(); + if (payloadJson != null && payloadJson.trim().startsWith("{")) { + payload = objectMapper.readValue(payloadJson, new TypeReference>() {}); + } + if (context != null) { + payload.putAll(context); + } + String mergedPayloadJson = payloadJson; + if (!payload.isEmpty()) { + mergedPayloadJson = objectMapper.writeValueAsString(payload); + } + jdbcTemplate.update( + "INSERT INTO in_app_notification (tenant_id, receiver_ref, receiver_user_id, title, content, payload_json, status, created_by, updated_by) " + + "VALUES (?, ?, ?, ?, ?, ?, 'UNREAD', ?, ?)", + tenantId(), + normalizedReceiver, + receiverUserId, + title, + content, + mergedPayloadJson, + safeUserId(), + safeUserId() + ); + webSocketPushService.pushInAppNotification(tenantId(), normalizedReceiver, receiverUserId); + String id = "INAPP-" + System.currentTimeMillis(); + return new NotificationSendResult(true, id, "SENT", "站内通知已送达"); + } catch (Exception ex) { + return new NotificationSendResult(false, null, "SEND_FAILED", ex.getMessage()); + } + } + + private Long parseReceiverUserId(String receiverRef) { + if (receiverRef == null) { + return null; + } + String val = receiverRef.trim(); + if (val.length() == 0) { + return null; + } + if (val.startsWith("user-")) { + String idPart = val.substring("user-".length()); + try { + return Long.valueOf(idPart); + } catch (Exception ex) { + return null; + } + } + try { + return Long.valueOf(val); + } catch (Exception ex) { + return null; + } + } + + private Long safeUserId() { + Long userId = AuthContext.userId(); + return userId == null ? 0L : userId; + } + + private Long tenantId() { + return AuthContext.requireTenantId(); + } +} diff --git a/backend/src/main/java/com/writeoff/module/notification/provider/NotificationChannelProvider.java b/backend/src/main/java/com/writeoff/module/notification/provider/NotificationChannelProvider.java new file mode 100644 index 0000000..1c85e02 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/notification/provider/NotificationChannelProvider.java @@ -0,0 +1,9 @@ +package com.writeoff.module.notification.provider; + +import java.util.Map; + +public interface NotificationChannelProvider { + String channel(); + + NotificationSendResult send(String receiverRef, String payloadJson, Map context); +} diff --git a/backend/src/main/java/com/writeoff/module/notification/provider/NotificationSendResult.java b/backend/src/main/java/com/writeoff/module/notification/provider/NotificationSendResult.java new file mode 100644 index 0000000..939e781 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/notification/provider/NotificationSendResult.java @@ -0,0 +1,31 @@ +package com.writeoff.module.notification.provider; + +public class NotificationSendResult { + private final boolean accepted; + private final String providerMessageId; + private final String providerCode; + private final String providerMessage; + + public NotificationSendResult(boolean accepted, String providerMessageId, String providerCode, String providerMessage) { + this.accepted = accepted; + this.providerMessageId = providerMessageId; + this.providerCode = providerCode; + this.providerMessage = providerMessage; + } + + public boolean isAccepted() { + return accepted; + } + + public String getProviderMessageId() { + return providerMessageId; + } + + public String getProviderCode() { + return providerCode; + } + + public String getProviderMessage() { + return providerMessage; + } +} diff --git a/backend/src/main/java/com/writeoff/module/notification/provider/SmsNotificationProvider.java b/backend/src/main/java/com/writeoff/module/notification/provider/SmsNotificationProvider.java new file mode 100644 index 0000000..71d5f0c --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/notification/provider/SmsNotificationProvider.java @@ -0,0 +1,267 @@ +package com.writeoff.module.notification.provider; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.writeoff.module.notification.model.PlatformNotifyGatewayResolvedConfig; +import com.writeoff.module.notification.service.PlatformNotifyGatewayService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.io.BufferedReader; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.net.HttpURLConnection; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Base64; +import java.util.Collections; +import java.util.Date; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.SimpleTimeZone; +import java.util.UUID; + +@Component +public class SmsNotificationProvider implements NotificationChannelProvider { + private static final Logger log = LoggerFactory.getLogger(SmsNotificationProvider.class); + private final PlatformNotifyGatewayService gatewayService; + private final ObjectMapper objectMapper = new ObjectMapper(); + + public SmsNotificationProvider(PlatformNotifyGatewayService gatewayService) { + this.gatewayService = gatewayService; + } + + @Override + public String channel() { + return "SMS"; + } + + @Override + public NotificationSendResult send(String receiverRef, String payloadJson, Map context) { + if (receiverRef == null || receiverRef.trim().isEmpty()) { + return new NotificationSendResult(false, null, "INVALID_RECEIVER", "短信接收人不能为空"); + } + PlatformNotifyGatewayResolvedConfig gatewayConfig = gatewayService.resolveChannelConfig("SMS", true); + if (gatewayConfig == null) { + String legacyId = "SMS-" + System.currentTimeMillis(); + log.info("sms gateway config not enabled, fallback to legacy mock accept, receiver={}", receiverRef.trim()); + return new NotificationSendResult(true, legacyId, "LEGACY_ACCEPTED", "短信通道已按兼容模式受理"); + } + if ("ALIYUN_SMS".equalsIgnoreCase(gatewayConfig.getProviderCode())) { + return sendByAliyun(receiverRef.trim(), payloadJson, context, gatewayConfig); + } + boolean mockEnabled = boolValue(gatewayConfig.getConfig().get("mockEnabled"), "MOCK".equalsIgnoreCase(gatewayConfig.getProviderCode())); + String providerCode = gatewayConfig.getProviderCode() == null ? "SMS" : gatewayConfig.getProviderCode().trim().toUpperCase(); + String id = providerCode + "-" + System.currentTimeMillis(); + log.info("sms sending accepted, provider={}, receiver={}, mockEnabled={}", providerCode, receiverRef.trim(), mockEnabled); + return new NotificationSendResult(true, id, mockEnabled ? "MOCK_ACCEPTED" : "ACCEPTED", mockEnabled ? "短信网关已模拟受理" : "短信网关已受理"); + } + + private NotificationSendResult sendByAliyun(String receiverRef, + String payloadJson, + Map context, + PlatformNotifyGatewayResolvedConfig gatewayConfig) { + Map config = gatewayConfig.getConfig(); + boolean mockEnabled = boolValue(config.get("mockEnabled"), false); + if (mockEnabled) { + String mockId = "ALIYUN_SMS-" + System.currentTimeMillis(); + return new NotificationSendResult(true, mockId, "MOCK_ACCEPTED", "阿里云短信已按模拟模式受理"); + } + String endpoint = text(config.get("endpoint")); + if (endpoint.isEmpty()) { + endpoint = "https://dysmsapi.aliyuncs.com/"; + } else if (!endpoint.startsWith("http://") && !endpoint.startsWith("https://")) { + endpoint = "https://" + endpoint; + } + String accessKeyId = text(config.get("accessKeyId")); + String accessKeySecret = text(config.get("accessKeySecret")); + String signName = text(config.get("signName")); + String templateCode = text(config.get("templateCode")); + String regionId = textOr(config.get("regionId"), "cn-hangzhou"); + if (accessKeyId.isEmpty() || accessKeySecret.isEmpty()) { + return new NotificationSendResult(false, null, "CONFIG_ERROR", "阿里云短信 AccessKey 配置不完整"); + } + if (signName.isEmpty() || templateCode.isEmpty()) { + return new NotificationSendResult(false, null, "CONFIG_ERROR", "阿里云短信签名或模板编码未配置"); + } + try { + Map payload = parsePayload(payloadJson); + Map templateParams = buildAliyunTemplateParams(payload, context); + String outId = resolveOutId(context); + Map params = new LinkedHashMap(); + params.put("AccessKeyId", accessKeyId); + params.put("Action", "SendSms"); + params.put("Format", "JSON"); + params.put("PhoneNumbers", receiverRef); + params.put("RegionId", regionId); + params.put("SignName", signName); + params.put("SignatureMethod", "HMAC-SHA1"); + params.put("SignatureNonce", UUID.randomUUID().toString()); + params.put("SignatureVersion", "1.0"); + params.put("TemplateCode", templateCode); + params.put("TemplateParam", objectMapper.writeValueAsString(templateParams)); + params.put("Timestamp", utcTimestamp()); + params.put("Version", "2017-05-25"); + if (!outId.isEmpty()) { + params.put("OutId", outId); + } + String signature = signAliyunRpc(params, accessKeySecret); + params.put("Signature", signature); + String responseText = doGet(endpoint, params); + Map responseMap = parsePayload(responseText); + String code = text(responseMap.get("Code")); + String message = text(responseMap.get("Message")); + String bizId = text(responseMap.get("BizId")); + if (!"OK".equalsIgnoreCase(code)) { + return new NotificationSendResult(false, bizId.isEmpty() ? null : bizId, code.isEmpty() ? "SEND_FAILED" : code, message.isEmpty() ? "阿里云短信发送失败" : message); + } + return new NotificationSendResult(true, bizId.isEmpty() ? "ALIYUN_SMS-" + System.currentTimeMillis() : bizId, code, message.isEmpty() ? "阿里云短信发送成功" : message); + } catch (Exception ex) { + log.error("aliyun sms sending failed, receiver={}, err={}", receiverRef, ex.getMessage(), ex); + return new NotificationSendResult(false, null, "SEND_FAILED", ex.getMessage()); + } + } + + private Map parsePayload(String payloadJson) throws Exception { + if (payloadJson == null || payloadJson.trim().isEmpty() || !payloadJson.trim().startsWith("{")) { + return new LinkedHashMap(); + } + Map payload = objectMapper.readValue(payloadJson, new TypeReference>() {}); + return payload == null ? new LinkedHashMap() : new LinkedHashMap(payload); + } + + private Map buildAliyunTemplateParams(Map payload, Map context) { + Map params = new LinkedHashMap(); + if (payload != null) { + params.putAll(payload); + } + if (context != null) { + Object taskId = context.get("taskId"); + if (taskId != null) { + params.put("taskId", String.valueOf(taskId)); + } + } + if (!params.containsKey("content") && params.containsKey("message")) { + params.put("content", params.get("message")); + } + if (!params.containsKey("message") && params.containsKey("content")) { + params.put("message", params.get("content")); + } + return params; + } + + private String resolveOutId(Map context) { + if (context == null) { + return ""; + } + Object outId = context.get("outId"); + if (outId != null && String.valueOf(outId).trim().length() > 0) { + return String.valueOf(outId).trim(); + } + Object taskId = context.get("taskId"); + if (taskId != null && String.valueOf(taskId).trim().length() > 0) { + return "task-" + String.valueOf(taskId).trim(); + } + return ""; + } + + private String utcTimestamp() { + SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'"); + sdf.setTimeZone(new SimpleTimeZone(0, "UTC")); + return sdf.format(new Date()); + } + + private String signAliyunRpc(Map params, String accessKeySecret) throws Exception { + List keys = new ArrayList(params.keySet()); + Collections.sort(keys); + StringBuilder canonicalized = new StringBuilder(); + for (String key : keys) { + if (canonicalized.length() > 0) { + canonicalized.append("&"); + } + canonicalized.append(percentEncode(key)).append("=").append(percentEncode(params.get(key))); + } + String stringToSign = "GET&%2F&" + percentEncode(canonicalized.toString()); + Mac mac = Mac.getInstance("HmacSHA1"); + mac.init(new SecretKeySpec((accessKeySecret + "&").getBytes(StandardCharsets.UTF_8), "HmacSHA1")); + return Base64.getEncoder().encodeToString(mac.doFinal(stringToSign.getBytes(StandardCharsets.UTF_8))); + } + + private String doGet(String endpoint, Map params) throws Exception { + StringBuilder query = new StringBuilder(); + List keys = new ArrayList(params.keySet()); + Collections.sort(keys); + for (String key : keys) { + if (query.length() > 0) { + query.append("&"); + } + query.append(URLEncoder.encode(key, "UTF-8")).append("=").append(URLEncoder.encode(text(params.get(key)), "UTF-8")); + } + String url = endpoint + (endpoint.contains("?") ? "&" : "?") + query; + HttpURLConnection connection = (HttpURLConnection) new java.net.URL(url).openConnection(); + connection.setRequestMethod("GET"); + connection.setConnectTimeout(5000); + connection.setReadTimeout(10000); + connection.setDoInput(true); + int code = connection.getResponseCode(); + InputStream stream = code >= 200 && code < 300 ? connection.getInputStream() : connection.getErrorStream(); + String response = readStream(stream); + if (code < 200 || code >= 300) { + throw new IllegalStateException("HTTP " + code + ": " + response); + } + return response; + } + + private String readStream(InputStream stream) throws Exception { + if (stream == null) { + return ""; + } + BufferedReader reader = new BufferedReader(new InputStreamReader(stream, StandardCharsets.UTF_8)); + StringBuilder sb = new StringBuilder(); + String line; + while ((line = reader.readLine()) != null) { + sb.append(line); + } + return sb.toString(); + } + + private String percentEncode(String value) throws Exception { + return URLEncoder.encode(value == null ? "" : value, "UTF-8") + .replace("+", "%20") + .replace("*", "%2A") + .replace("%7E", "~"); + } + + private boolean boolValue(Object value, boolean fallback) { + if (value == null) { + return fallback; + } + if (value instanceof Boolean) { + return (Boolean) value; + } + String text = String.valueOf(value).trim(); + if ("true".equalsIgnoreCase(text) || "1".equals(text)) { + return true; + } + if ("false".equalsIgnoreCase(text) || "0".equals(text)) { + return false; + } + return fallback; + } + + private String text(Object value) { + return value == null ? "" : String.valueOf(value).trim(); + } + + private String textOr(Object value, String fallback) { + String text = text(value); + return text.isEmpty() ? fallback : text; + } +} diff --git a/backend/src/main/java/com/writeoff/module/notification/service/InAppNotificationService.java b/backend/src/main/java/com/writeoff/module/notification/service/InAppNotificationService.java new file mode 100644 index 0000000..c966d0b --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/notification/service/InAppNotificationService.java @@ -0,0 +1,95 @@ +package com.writeoff.module.notification.service; + +import com.writeoff.common.api.PageResult; +import com.writeoff.common.exception.BusinessException; +import com.writeoff.module.notification.model.InAppNotificationInfo; +import com.writeoff.security.AuthContext; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +public class InAppNotificationService { + private final JdbcTemplate jdbcTemplate; + + private static final RowMapper ROW_MAPPER = (rs, n) -> new InAppNotificationInfo( + rs.getLong("id"), + rs.getString("title"), + rs.getString("content"), + rs.getString("status"), + rs.getString("created_at"), + rs.getString("read_at") + ); + + public InAppNotificationService(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + public PageResult listMine() { + Long userId = AuthContext.userId(); + String userRef = userId == null ? "" : ("user-" + userId); + List list = jdbcTemplate.query( + "SELECT id, title, content, status, " + + "DATE_FORMAT(created_at, '%Y-%m-%d %H:%i:%s') AS created_at, " + + "DATE_FORMAT(read_at, '%Y-%m-%d %H:%i:%s') AS read_at " + + "FROM in_app_notification " + + "WHERE tenant_id=? AND is_deleted=0 AND (receiver_ref='ALL' OR receiver_ref=? OR receiver_user_id=?) " + + "ORDER BY id DESC LIMIT 200", + ROW_MAPPER, + tenantId(), + userRef, + userId + ); + return new PageResult(list, list.size(), 1, 200); + } + + @Transactional(rollbackFor = Exception.class) + public void markRead(Long id) { + Long userId = AuthContext.userId(); + String userRef = userId == null ? "" : ("user-" + userId); + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM in_app_notification " + + "WHERE tenant_id=? AND id=? AND is_deleted=0 AND (receiver_ref='ALL' OR receiver_ref=? OR receiver_user_id=?)", + Integer.class, + tenantId(), + id, + userRef, + userId + ); + if (count == null || count == 0) { + throw new BusinessException(10003, "站内通知不存在"); + } + jdbcTemplate.update( + "UPDATE in_app_notification SET status='READ', read_at=CURRENT_TIMESTAMP, updated_at=CURRENT_TIMESTAMP, updated_by=? WHERE tenant_id=? AND id=?", + safeUserId(), + tenantId(), + id + ); + } + + @Transactional(rollbackFor = Exception.class) + public int markAllRead() { + Long userId = AuthContext.userId(); + String userRef = userId == null ? "" : ("user-" + userId); + return jdbcTemplate.update( + "UPDATE in_app_notification SET status='READ', read_at=CURRENT_TIMESTAMP, updated_at=CURRENT_TIMESTAMP, updated_by=? " + + "WHERE tenant_id=? AND is_deleted=0 AND status='UNREAD' AND (receiver_ref='ALL' OR receiver_ref=? OR receiver_user_id=?)", + safeUserId(), + tenantId(), + userRef, + userId + ); + } + + private Long safeUserId() { + Long userId = AuthContext.userId(); + return userId == null ? 0L : userId; + } + + private Long tenantId() { + return AuthContext.requireTenantId(); + } +} diff --git a/backend/src/main/java/com/writeoff/module/notification/service/NotificationDeliveryProtectionService.java b/backend/src/main/java/com/writeoff/module/notification/service/NotificationDeliveryProtectionService.java new file mode 100644 index 0000000..feef0c4 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/notification/service/NotificationDeliveryProtectionService.java @@ -0,0 +1,266 @@ +package com.writeoff.module.notification.service; + +import com.writeoff.module.notification.model.PlatformNotifyGatewayResolvedConfig; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; + +import java.sql.Timestamp; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +@Service +public class NotificationDeliveryProtectionService { + private final JdbcTemplate jdbcTemplate; + private final PlatformNotifyGatewayService gatewayService; + + public NotificationDeliveryProtectionService(JdbcTemplate jdbcTemplate, + PlatformNotifyGatewayService gatewayService) { + this.jdbcTemplate = jdbcTemplate; + this.gatewayService = gatewayService; + } + + public GuardDecision checkBeforeSend(String channelCode, String receiverRef) { + String channel = normalizeChannel(channelCode); + if (!isProtectedChannel(channel)) { + return GuardDecision.allow(); + } + BreakerState breakerState = loadBreakerState(channel); + if (breakerState.breakerUntil != null && LocalDateTime.now().isBefore(breakerState.breakerUntil)) { + String untilText = breakerState.breakerUntil.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")); + return GuardDecision.block("BREAKER_OPEN", "渠道熔断中,请在 " + untilText + " 后重试"); + } + if ("SMS".equals(channel)) { + SmsGuardConfig config = resolveSmsConfig(channel); + GuardRecord record = loadGuardRecord(channel, normalizeReceiver(receiverRef)); + LocalDateTime now = LocalDateTime.now(); + if (record.lastSentAt != null && config.quietPeriodSeconds > 0 && now.isBefore(record.lastSentAt.plusSeconds(config.quietPeriodSeconds))) { + return GuardDecision.block("SMS_RATE_LIMIT_WINDOW", "同一手机号发送过于频繁,请稍后再试"); + } + if (config.dailyLimit > 0 && record.dailyCount >= config.dailyLimit) { + return GuardDecision.block("SMS_RATE_LIMIT_DAILY", "同一手机号当日发送次数已达上限"); + } + } + return GuardDecision.allow(); + } + + public void recordSuccess(String channelCode, String receiverRef) { + String channel = normalizeChannel(channelCode); + if (!isProtectedChannel(channel)) { + return; + } + if ("SMS".equals(channel)) { + String normalizedReceiver = normalizeReceiver(receiverRef); + jdbcTemplate.update( + "INSERT INTO platform_notify_delivery_guard (channel_code, receiver_ref, stat_date, daily_count, last_sent_at) " + + "VALUES (?, ?, CURRENT_DATE(), 1, CURRENT_TIMESTAMP) " + + "ON DUPLICATE KEY UPDATE daily_count=daily_count+1, last_sent_at=VALUES(last_sent_at), updated_at=CURRENT_TIMESTAMP", + channel, + normalizedReceiver + ); + } + jdbcTemplate.update( + "UPDATE platform_notify_circuit_breaker SET consecutive_failures=0, breaker_until=NULL, last_failure_message=NULL, updated_at=CURRENT_TIMESTAMP WHERE channel_code=?", + channel + ); + } + + public void recordFailure(String channelCode, String failureMessage) { + String channel = normalizeChannel(channelCode); + if (!isProtectedChannel(channel)) { + return; + } + BreakerState state = loadBreakerState(channel); + int threshold = resolveFailureThreshold(channel); + int cooldownSeconds = resolveBreakerCooldownSeconds(channel); + int nextFailures = state.consecutiveFailures + 1; + LocalDateTime breakerUntil = nextFailures >= threshold + ? LocalDateTime.now().plusSeconds(Math.max(cooldownSeconds, 1)) + : null; + if (state.exists) { + jdbcTemplate.update( + "UPDATE platform_notify_circuit_breaker SET consecutive_failures=?, breaker_until=?, last_failure_message=?, updated_at=CURRENT_TIMESTAMP WHERE channel_code=?", + nextFailures, + toTimestamp(breakerUntil), + trimMessage(failureMessage), + channel + ); + return; + } + jdbcTemplate.update( + "INSERT INTO platform_notify_circuit_breaker (channel_code, consecutive_failures, breaker_until, last_failure_message) VALUES (?, ?, ?, ?)", + channel, + nextFailures, + toTimestamp(breakerUntil), + trimMessage(failureMessage) + ); + } + + private GuardRecord loadGuardRecord(String channelCode, String receiverRef) { + List> rows = jdbcTemplate.queryForList( + "SELECT daily_count, last_sent_at FROM platform_notify_delivery_guard WHERE channel_code=? AND receiver_ref=? AND stat_date=CURRENT_DATE() LIMIT 1", + channelCode, + receiverRef + ); + if (rows.isEmpty()) { + return new GuardRecord(0, null); + } + Map row = rows.get(0); + return new GuardRecord(intValue(row.get("daily_count"), 0), toLocalDateTime(row.get("last_sent_at"))); + } + + private BreakerState loadBreakerState(String channelCode) { + List> rows = jdbcTemplate.queryForList( + "SELECT consecutive_failures, breaker_until FROM platform_notify_circuit_breaker WHERE channel_code=? LIMIT 1", + channelCode + ); + if (rows.isEmpty()) { + return new BreakerState(false, 0, null); + } + Map row = rows.get(0); + return new BreakerState(true, intValue(row.get("consecutive_failures"), 0), toLocalDateTime(row.get("breaker_until"))); + } + + private SmsGuardConfig resolveSmsConfig(String channelCode) { + PlatformNotifyGatewayResolvedConfig resolved = gatewayService.resolveChannelConfig(channelCode, false); + Map config = resolved == null ? null : resolved.getConfig(); + return new SmsGuardConfig( + intValue(config == null ? null : config.get("quietPeriodSeconds"), 30), + intValue(config == null ? null : config.get("dailyLimit"), 10) + ); + } + + private int resolveFailureThreshold(String channelCode) { + PlatformNotifyGatewayResolvedConfig resolved = gatewayService.resolveChannelConfig(channelCode, false); + Map config = resolved == null ? null : resolved.getConfig(); + return Math.max(intValue(config == null ? null : config.get("failureThreshold"), 3), 1); + } + + private int resolveBreakerCooldownSeconds(String channelCode) { + PlatformNotifyGatewayResolvedConfig resolved = gatewayService.resolveChannelConfig(channelCode, false); + Map config = resolved == null ? null : resolved.getConfig(); + return Math.max(intValue(config == null ? null : config.get("breakerCooldownSeconds"), 300), 1); + } + + private boolean isProtectedChannel(String channelCode) { + return "EMAIL".equals(channelCode) || "SMS".equals(channelCode); + } + + private String normalizeChannel(String channelCode) { + return channelCode == null ? "" : channelCode.trim().toUpperCase(Locale.ROOT); + } + + private String normalizeReceiver(String receiverRef) { + return receiverRef == null ? "" : receiverRef.trim(); + } + + private String trimMessage(String value) { + String text = value == null ? "" : value.trim(); + return text.length() > 500 ? text.substring(0, 500) : text; + } + + private int intValue(Object value, int fallback) { + if (value == null) { + return fallback; + } + try { + return value instanceof Number ? ((Number) value).intValue() : Integer.parseInt(String.valueOf(value).trim()); + } catch (Exception ex) { + return fallback; + } + } + + private Timestamp toTimestamp(LocalDateTime value) { + return value == null ? null : Timestamp.valueOf(value); + } + + private LocalDateTime toLocalDateTime(Object value) { + if (value == null) { + return null; + } + if (value instanceof Timestamp) { + return ((Timestamp) value).toLocalDateTime(); + } + if (value instanceof java.util.Date) { + return new Timestamp(((java.util.Date) value).getTime()).toLocalDateTime(); + } + if (value instanceof LocalDateTime) { + return (LocalDateTime) value; + } + if (value instanceof LocalDate) { + return ((LocalDate) value).atStartOfDay(); + } + try { + return LocalDateTime.parse(String.valueOf(value).trim().replace(' ', 'T')); + } catch (Exception ex) { + return null; + } + } + + public static final class GuardDecision { + private final boolean allowed; + private final String code; + private final String message; + + private GuardDecision(boolean allowed, String code, String message) { + this.allowed = allowed; + this.code = code; + this.message = message; + } + + public static GuardDecision allow() { + return new GuardDecision(true, "", ""); + } + + public static GuardDecision block(String code, String message) { + return new GuardDecision(false, code, message); + } + + public boolean isAllowed() { + return allowed; + } + + public String getCode() { + return code; + } + + public String getMessage() { + return message; + } + } + + private static final class GuardRecord { + private final int dailyCount; + private final LocalDateTime lastSentAt; + + private GuardRecord(int dailyCount, LocalDateTime lastSentAt) { + this.dailyCount = dailyCount; + this.lastSentAt = lastSentAt; + } + } + + private static final class BreakerState { + private final boolean exists; + private final int consecutiveFailures; + private final LocalDateTime breakerUntil; + + private BreakerState(boolean exists, int consecutiveFailures, LocalDateTime breakerUntil) { + this.exists = exists; + this.consecutiveFailures = consecutiveFailures; + this.breakerUntil = breakerUntil; + } + } + + private static final class SmsGuardConfig { + private final int quietPeriodSeconds; + private final int dailyLimit; + + private SmsGuardConfig(int quietPeriodSeconds, int dailyLimit) { + this.quietPeriodSeconds = quietPeriodSeconds; + this.dailyLimit = dailyLimit; + } + } +} diff --git a/backend/src/main/java/com/writeoff/module/notification/service/NotificationDispatchService.java b/backend/src/main/java/com/writeoff/module/notification/service/NotificationDispatchService.java new file mode 100644 index 0000000..2ba5797 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/notification/service/NotificationDispatchService.java @@ -0,0 +1,888 @@ +package com.writeoff.module.notification.service; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.writeoff.common.api.PageResult; +import com.writeoff.common.exception.BusinessException; +import com.writeoff.common.exception.ErrorCodes; +import com.writeoff.module.notification.dto.DispatchNotificationRequest; +import com.writeoff.module.notification.dto.AliyunSmsReceiptRequest; +import com.writeoff.module.notification.dto.NotificationReceiptRequest; +import com.writeoff.module.notification.model.NotificationTaskInfo; +import com.writeoff.module.notification.provider.NotificationChannelProvider; +import com.writeoff.module.notification.provider.NotificationSendResult; +import com.writeoff.module.scheduler.service.AsyncJobService; +import com.writeoff.security.AuthContext; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Locale; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.HashMap; +import java.util.Collections; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +@Service +public class NotificationDispatchService { + private static final Logger log = LoggerFactory.getLogger(NotificationDispatchService.class); + private final JdbcTemplate jdbcTemplate; + private final AsyncJobService asyncJobService; + private final NotificationDeliveryProtectionService deliveryProtectionService; + private final Map providerMap; + private final ObjectMapper objectMapper = new ObjectMapper(); + private final String webhookSecret; + private static final Pattern PLACEHOLDER_PATTERN = Pattern.compile("\\$\\{([^}]+)}"); + + private static final RowMapper TASK_ROW_MAPPER = (rs, n) -> new NotificationTaskInfo( + rs.getLong("id"), + rs.getLong("policy_id"), + rs.getString("event_code"), + rs.getString("channel"), + rs.getString("receiver_type"), + rs.getString("receiver_ref"), + rs.getString("receiver_resolve_source"), + rs.getString("status"), + rs.getInt("retry_count"), + rs.getString("provider_message_id"), + rs.getString("receipt_code"), + rs.getString("receipt_message"), + rs.getString("error_message"), + rs.getString("created_at"), + rs.getString("sent_at"), + rs.getString("receipt_at") + ); + + public NotificationDispatchService(JdbcTemplate jdbcTemplate, + AsyncJobService asyncJobService, + NotificationDeliveryProtectionService deliveryProtectionService, + List providers, + @Value("${app.notification.webhook-secret:change-me}") String webhookSecret) { + this.jdbcTemplate = jdbcTemplate; + this.asyncJobService = asyncJobService; + this.deliveryProtectionService = deliveryProtectionService; + this.webhookSecret = webhookSecret == null ? "change-me" : webhookSecret; + this.providerMap = new HashMap(); + if (providers != null) { + for (NotificationChannelProvider provider : providers) { + providerMap.put(provider.channel().toUpperCase(), provider); + } + } + } + + public PageResult listTasks(Integer pageNo, Integer pageSize) { + int resolvedPageNo = normalizePageNo(pageNo); + int resolvedPageSize = normalizePageSize(pageSize); + int offset = (resolvedPageNo - 1) * resolvedPageSize; + Long tenantId = tenantId(); + + String whereSql = " FROM notification_task WHERE tenant_id=? AND is_deleted=0"; + List args = new ArrayList(); + args.add(tenantId); + + Long total = jdbcTemplate.queryForObject("SELECT COUNT(1)" + whereSql, Long.class, args.toArray()); + String dataSql = + "SELECT id, policy_id, event_code, channel, receiver_type, receiver_ref, " + + "JSON_UNQUOTE(JSON_EXTRACT(payload_json, '$.receiverResolveSource')) AS receiver_resolve_source, " + + "status, retry_count, provider_message_id, receipt_code, receipt_message, error_message, " + + "DATE_FORMAT(created_at, '%Y-%m-%d %H:%i:%s') AS created_at, DATE_FORMAT(sent_at, '%Y-%m-%d %H:%i:%s') AS sent_at, DATE_FORMAT(receipt_at, '%Y-%m-%d %H:%i:%s') AS receipt_at " + + whereSql + + " ORDER BY id DESC LIMIT ? OFFSET ?"; + List dataArgs = new ArrayList(args); + dataArgs.add(resolvedPageSize); + dataArgs.add(offset); + List list = jdbcTemplate.query(dataSql, TASK_ROW_MAPPER, dataArgs.toArray()); + return new PageResult(list, total == null ? 0L : total, resolvedPageNo, resolvedPageSize); + } + + @Transactional(rollbackFor = Exception.class) + public Map dispatch(DispatchNotificationRequest request) { + Long filterPolicyId = request.getPolicyId(); + List> policies; + if (filterPolicyId != null && filterPolicyId > 0) { + policies = jdbcTemplate.queryForList( + "SELECT p.id, p.channel, p.receiver_type, p.template_id, p.variables_json, p.policy_name, " + + "tt.template_name, tt.subject_template, tt.title_template, tt.content_template " + + "FROM notification_policy p " + + "JOIN notification_text_template tt ON tt.tenant_id=p.tenant_id AND tt.id=p.template_id AND tt.is_deleted=0 AND tt.status='ENABLED' " + + "WHERE p.tenant_id=? AND p.id=? AND p.event_code=? AND p.status='ENABLED' AND p.is_deleted=0", + tenantId(), + filterPolicyId, + request.getEventCode() + ); + } else { + policies = jdbcTemplate.queryForList( + "SELECT p.id, p.channel, p.receiver_type, p.template_id, p.variables_json, p.policy_name, " + + "tt.template_name, tt.subject_template, tt.title_template, tt.content_template " + + "FROM notification_policy p " + + "JOIN notification_text_template tt ON tt.tenant_id=p.tenant_id AND tt.id=p.template_id AND tt.is_deleted=0 AND tt.status='ENABLED' " + + "WHERE p.tenant_id=? AND p.event_code=? AND p.status='ENABLED' AND p.is_deleted=0", + tenantId(), + request.getEventCode() + ); + } + if (policies.isEmpty()) { + if (filterPolicyId != null && filterPolicyId > 0) { + throw new BusinessException(ErrorCodes.RESOURCE_NOT_FOUND, "未找到可用通知策略(策略不存在、未启用或事件不匹配)"); + } + throw new BusinessException(ErrorCodes.RESOURCE_NOT_FOUND, "未找到可用通知策略"); + } + Map requestVars = parseVariables(request.getVariablesJson()); + int created = 0; + for (Map p : policies) { + Long policyId = ((Number) p.get("id")).longValue(); + String channel = String.valueOf(p.get("channel")); + String receiverType = String.valueOf(p.get("receiver_type")); + ReceiverResolution receiverResolution = resolveReceiver(channel, receiverType, request, requestVars); + String payloadJson = buildPolicyPayload(request, p, requestVars, receiverResolution.resolveSource, receiverResolution.receiverRef); + jdbcTemplate.update( + "INSERT INTO notification_task (tenant_id, policy_id, event_code, channel, receiver_type, receiver_ref, payload_json, status, retry_count, max_retry, idempotency_key, created_by, updated_by) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, 'PENDING', 0, 3, ?, ?, ?)", + tenantId(), + policyId, + request.getEventCode(), + channel, + receiverType, + receiverResolution.receiverRef, + payloadJson, + request.getIdempotencyKey() + "-" + policyId, + safeUserId(), + safeUserId() + ); + Long taskId = jdbcTemplate.queryForObject("SELECT IFNULL(MAX(id),0) FROM notification_task WHERE tenant_id=?", Long.class, tenantId()); + Map payload = new LinkedHashMap(); + payload.put("taskId", taskId == null ? 0L : taskId); + payload.put("eventCode", request.getEventCode()); + try { + asyncJobService.enqueue("NOTIFICATION_DISPATCH", objectMapper.writeValueAsString(payload), "notify-task-" + taskId); + } catch (Exception ex) { + throw new BusinessException(ErrorCodes.INTERNAL_ERROR, "通知任务入队失败"); + } + created++; + } + Map data = new LinkedHashMap(); + data.put("eventCode", request.getEventCode()); + data.put("taskCount", created); + return data; + } + + private ReceiverResolution resolveReceiver(String channel, String receiverType, DispatchNotificationRequest request, Map requestVars) { + String ch = normalizeUpper(channel); + String bizType = request == null ? "" : String.valueOf(request.getBizType() == null ? "" : request.getBizType()); + String bizId = request == null ? "" : String.valueOf(request.getBizId() == null ? "" : request.getBizId()); + String input = bizId.trim(); + if (!input.isEmpty() && isExplicitReceiverRef(ch, input)) { + return new ReceiverResolution(normalizeExplicitReceiverRef(ch, input), "EXPLICIT_INPUT"); + } + ReceiverTarget receiverTarget = resolveTargetUser(receiverType, bizType, input, requestVars); + String receiverRef = resolveReceiverByUser(channel, receiverTarget.userId); + return new ReceiverResolution(receiverRef, receiverTarget.source); + } + + private ReceiverTarget resolveTargetUser(String receiverType, String bizType, String bizId, Map requestVars) { + String type = normalizeUpper(receiverType); + Long directUserId = resolveUserId(bizType, bizId, requestVars); + if ("TARGET_USER".equals(type) && directUserId != null && directUserId > 0) { + return new ReceiverTarget(directUserId, "RECEIVER_TYPE_TARGET_USER"); + } + Long meetingId = resolveMeetingId(bizType, bizId, requestVars); + if ("SUBMITTER".equals(type)) { + Long submitterUserId = findMeetingSubmitterUserId(meetingId); + if (submitterUserId != null && submitterUserId > 0) { + return new ReceiverTarget(submitterUserId, "RECEIVER_TYPE_SUBMITTER"); + } + } else if ("AUDITOR".equals(type)) { + Long auditorUserId = findMeetingAuditorUserId(meetingId); + if (auditorUserId != null && auditorUserId > 0) { + return new ReceiverTarget(auditorUserId, "RECEIVER_TYPE_AUDITOR"); + } + } else if ("FINANCE_ROLE".equals(type)) { + Long financeUserId = findMeetingFinanceApproverUserId(meetingId); + if (financeUserId == null || financeUserId <= 0) { + financeUserId = findFirstFinanceRoleUserId(); + } + if (financeUserId != null && financeUserId > 0) { + return new ReceiverTarget(financeUserId, "RECEIVER_TYPE_FINANCE_ROLE"); + } + } + return new ReceiverTarget(safeUserId(), "FALLBACK_CURRENT_USER"); + } + + private Long resolveUserId(String bizType, String bizId, Map requestVars) { + if (requestVars != null) { + Long userIdFromVars = toLong(requestVars.get("targetUserId")); + if (userIdFromVars != null && userIdFromVars > 0) { + return userIdFromVars; + } + userIdFromVars = toLong(requestVars.get("userId")); + if (userIdFromVars != null && userIdFromVars > 0) { + return userIdFromVars; + } + } + String normalizedBizType = normalizeUpper(bizType); + if (!normalizedBizType.isEmpty() && !"USER".equals(normalizedBizType)) { + return null; + } + String value = bizId == null ? "" : bizId.trim(); + if (value.isEmpty()) { + return null; + } + if (value.startsWith("user-")) { + return toLong(value.substring("user-".length())); + } + return toLong(value); + } + + private String resolveReceiverByUser(String channel, Long userId) { + String ch = normalizeUpper(channel); + if ("EMAIL".equals(ch)) { + String email = userEmail(userId); + if (email.isEmpty()) { + throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "目标用户未配置邮箱,无法发送邮件通知"); + } + return email; + } + if ("SMS".equals(ch)) { + String phone = userPhone(userId); + if (phone.isEmpty()) { + throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "目标用户未配置手机号,无法发送短信通知"); + } + return phone; + } + return "user-" + (userId == null ? 0L : userId); + } + + private String userEmail(Long userId) { + if (userId == null || userId <= 0) { + return ""; + } + List> rows = jdbcTemplate.queryForList( + "SELECT email FROM sys_user WHERE tenant_id=? AND id=? AND is_deleted=0 LIMIT 1", + tenantId(), + userId + ); + if (rows.isEmpty()) { + return ""; + } + Object val = rows.get(0).get("email"); + return val == null ? "" : String.valueOf(val).trim(); + } + + private String userPhone(Long userId) { + if (userId == null || userId <= 0) { + return ""; + } + List> rows = jdbcTemplate.queryForList( + "SELECT phone FROM sys_user WHERE tenant_id=? AND id=? AND is_deleted=0 LIMIT 1", + tenantId(), + userId + ); + if (rows.isEmpty()) { + return ""; + } + Object val = rows.get(0).get("phone"); + return val == null ? "" : String.valueOf(val).trim(); + } + + private Long resolveMeetingId(String bizType, String bizId, Map requestVars) { + if (requestVars != null) { + Object meetingId = requestVars.get("meetingId"); + Long parsedFromVars = toLong(meetingId); + if (parsedFromVars != null && parsedFromVars > 0) { + return parsedFromVars; + } + } + String type = normalizeUpper(bizType); + if (!type.isEmpty() && !"MEETING".equals(type)) { + return null; + } + String val = bizId == null ? "" : bizId.trim(); + if (val.isEmpty()) { + return null; + } + if (val.startsWith("meeting-")) { + return toLong(val.substring("meeting-".length())); + } + return toLong(val); + } + + private Long findMeetingSubmitterUserId(Long meetingId) { + if (meetingId == null || meetingId <= 0) { + return null; + } + List> rows = jdbcTemplate.queryForList( + "SELECT created_by FROM meeting WHERE tenant_id=? AND id=? AND is_deleted=0 LIMIT 1", + tenantId(), + meetingId + ); + if (rows.isEmpty()) { + return null; + } + return toLong(rows.get(0).get("created_by")); + } + + private Long findMeetingAuditorUserId(Long meetingId) { + if (meetingId == null || meetingId <= 0) { + return null; + } + List> rows = jdbcTemplate.queryForList( + "SELECT current_auditor_user_id FROM meeting WHERE tenant_id=? AND id=? AND is_deleted=0 LIMIT 1", + tenantId(), + meetingId + ); + if (rows.isEmpty()) { + return null; + } + return toLong(rows.get(0).get("current_auditor_user_id")); + } + + private Long findMeetingFinanceApproverUserId(Long meetingId) { + if (meetingId == null || meetingId <= 0) { + return null; + } + List> rows = jdbcTemplate.queryForList( + "SELECT p.finance_approver_user_id AS finance_user_id " + + "FROM meeting m JOIN project p ON m.tenant_id=p.tenant_id AND m.project_id=p.id " + + "WHERE m.tenant_id=? AND m.id=? AND m.is_deleted=0 AND p.is_deleted=0 LIMIT 1", + tenantId(), + meetingId + ); + if (rows.isEmpty()) { + return null; + } + return toLong(rows.get(0).get("finance_user_id")); + } + + private Long findFirstFinanceRoleUserId() { + List> rows = jdbcTemplate.queryForList( + "SELECT u.id AS user_id " + + "FROM sys_user u " + + "JOIN user_role ur ON u.tenant_id=ur.tenant_id AND u.id=ur.user_id AND ur.is_deleted=0 " + + "JOIN role r ON ur.tenant_id=r.tenant_id AND ur.role_id=r.id AND r.is_deleted=0 " + + "WHERE u.tenant_id=? AND u.is_deleted=0 AND u.status='ENABLED' " + + "AND r.role_code IN ('FINANCE', 'FINANCE_ROLE', 'FINANCE_APPROVER') " + + "ORDER BY u.id ASC LIMIT 1", + tenantId() + ); + if (rows.isEmpty()) { + return null; + } + return toLong(rows.get(0).get("user_id")); + } + + private String normalizeUpper(String val) { + if (val == null) { + return ""; + } + return val.trim().toUpperCase(Locale.ROOT); + } + + private boolean isExplicitReceiverRef(String channel, String value) { + if (value == null || value.trim().isEmpty()) { + return false; + } + String val = value.trim(); + if ("IN_APP".equals(channel)) { + return "ALL".equalsIgnoreCase(val) || val.startsWith("user-") || isDigits(val); + } + if ("EMAIL".equals(channel)) { + return val.contains("@"); + } + if ("SMS".equals(channel)) { + return isPhoneLike(val); + } + return true; + } + + private String normalizeExplicitReceiverRef(String channel, String value) { + String val = value == null ? "" : value.trim(); + if ("IN_APP".equals(channel) && isDigits(val)) { + return "user-" + val; + } + return val; + } + + private boolean isDigits(String val) { + if (val == null || val.isEmpty()) { + return false; + } + for (int i = 0; i < val.length(); i++) { + if (!Character.isDigit(val.charAt(i))) { + return false; + } + } + return true; + } + + private boolean isPhoneLike(String val) { + if (val == null) { + return false; + } + String normalized = val.trim(); + if (normalized.startsWith("+")) { + normalized = normalized.substring(1); + } + if (normalized.length() < 6 || normalized.length() > 20) { + return false; + } + for (int i = 0; i < normalized.length(); i++) { + if (!Character.isDigit(normalized.charAt(i))) { + return false; + } + } + return true; + } + + private Long toLong(Object val) { + if (val == null) { + return null; + } + try { + if (val instanceof Number) { + return ((Number) val).longValue(); + } + String text = String.valueOf(val).trim(); + if (text.isEmpty()) { + return null; + } + return Long.valueOf(text); + } catch (Exception ex) { + return null; + } + } + + private String buildPolicyPayload(DispatchNotificationRequest request, Map policy, Map requestVars, String receiverResolveSource, String receiverResolvedRef) { + Map policyVars = parseVariables(policy.get("variables_json") == null ? null : String.valueOf(policy.get("variables_json"))); + Map merged = new LinkedHashMap(); + merged.putAll(policyVars); + merged.putAll(requestVars); + merged.put("eventCode", request.getEventCode()); + merged.put("bizType", request.getBizType()); + merged.put("bizId", request.getBizId()); + merged.put("receiverResolveSource", receiverResolveSource == null ? "UNKNOWN" : receiverResolveSource); + merged.put("receiverResolvedRef", receiverResolvedRef == null ? "" : receiverResolvedRef); + merged.put("policyId", policy.get("id")); + merged.put("policyName", policy.get("policy_name")); + merged.put("templateId", policy.get("template_id")); + merged.put("templateName", policy.get("template_name")); + + String policyName = policy.get("policy_name") == null ? "系统通知" : String.valueOf(policy.get("policy_name")); + String eventCode = request.getEventCode() == null ? "" : request.getEventCode().trim(); + String defaultTitle = policyName; + if (!eventCode.isEmpty()) { + defaultTitle = policyName + "(" + eventCode + ")"; + } + String defaultContent = "事件[" + eventCode + "]触发通知"; + + String templateSubject = policy.get("subject_template") == null ? null : String.valueOf(policy.get("subject_template")); + String templateTitle = policy.get("title_template") == null ? null : String.valueOf(policy.get("title_template")); + String templateContent = policy.get("content_template") == null ? null : String.valueOf(policy.get("content_template")); + + String overrideSubject = pickText(merged, "subject", "title"); + String overrideTitle = pickText(merged, "title", "subject"); + String overrideContent = pickText(merged, "content", "message"); + + String finalSubject = resolvePlaceholders( + chooseText(overrideSubject, templateSubject, defaultTitle), + merged + ); + String finalTitle = resolvePlaceholders( + chooseText(overrideTitle, templateTitle, defaultTitle), + merged + ); + String finalContent = resolvePlaceholders( + chooseText(overrideContent, templateContent, defaultContent), + merged + ); + + Map payload = new LinkedHashMap(); + payload.putAll(merged); + payload.put("subject", finalSubject); + payload.put("title", finalTitle); + payload.put("content", finalContent); + payload.put("message", finalContent); + try { + return objectMapper.writeValueAsString(payload); + } catch (Exception ex) { + throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "通知变量格式非法"); + } + } + + private Map parseVariables(String variablesJson) { + if (variablesJson == null || variablesJson.trim().isEmpty()) { + return new LinkedHashMap(); + } + String raw = variablesJson.trim(); + if (!raw.startsWith("{")) { + Map simple = new LinkedHashMap(); + simple.put("content", raw); + return simple; + } + try { + Map map = objectMapper.readValue(raw, new TypeReference>() {}); + if (map == null) { + return new LinkedHashMap(); + } + return new LinkedHashMap(map); + } catch (Exception ex) { + throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "variablesJson必须为JSON对象"); + } + } + + private String pickText(Map map, String preferredKey, String fallbackKey) { + if (map == null) { + return null; + } + Object preferred = map.get(preferredKey); + if (preferred != null && String.valueOf(preferred).trim().length() > 0) { + return String.valueOf(preferred); + } + Object fallback = map.get(fallbackKey); + if (fallback != null && String.valueOf(fallback).trim().length() > 0) { + return String.valueOf(fallback); + } + return null; + } + + private String chooseText(String first, String second, String fallback) { + if (first != null && first.trim().length() > 0) { + return first; + } + if (second != null && second.trim().length() > 0) { + return second; + } + return fallback; + } + + private String resolvePlaceholders(String template, Map vars) { + if (template == null) { + return ""; + } + Map safeVars = vars == null ? Collections.emptyMap() : vars; + Matcher matcher = PLACEHOLDER_PATTERN.matcher(template); + StringBuffer buffer = new StringBuffer(); + while (matcher.find()) { + String key = matcher.group(1) == null ? "" : matcher.group(1).trim(); + Object val = safeVars.get(key); + String replacement = val == null ? "" : String.valueOf(val); + matcher.appendReplacement(buffer, Matcher.quoteReplacement(replacement)); + } + matcher.appendTail(buffer); + return buffer.toString(); + } + + @Transactional(rollbackFor = Exception.class) + public void processTask(String payload) { + Long taskId = parseTaskId(payload); + Map task = findTask(taskId); + String status = String.valueOf(task.get("status")); + if ("SENT".equalsIgnoreCase(status) || "FAILED".equalsIgnoreCase(status) || "DELIVERED".equalsIgnoreCase(status)) { + return; + } + int retryCount = ((Number) task.get("retry_count")).intValue(); + int maxRetry = ((Number) task.get("max_retry")).intValue(); + String channel = String.valueOf(task.get("channel")); + String receiverRef = String.valueOf(task.get("receiver_ref")); + try { + if (receiverRef == null || receiverRef.trim().isEmpty()) { + throw new IllegalStateException("接收人为空"); + } + NotificationDeliveryProtectionService.GuardDecision guardDecision = deliveryProtectionService.checkBeforeSend(channel, receiverRef); + if (!guardDecision.isAllowed()) { + jdbcTemplate.update( + "UPDATE notification_task SET status='FAILED', retry_count=max_retry, error_message=?, receipt_code=?, updated_at=CURRENT_TIMESTAMP, updated_by=? WHERE tenant_id=? AND id=?", + guardDecision.getMessage(), + guardDecision.getCode(), + safeUserId(), + tenantId(), + taskId + ); + log.warn("notification task blocked by delivery protection, taskId={}, channel={}, receiver={}, code={}", taskId, channel, receiverRef, guardDecision.getCode()); + return; + } + NotificationChannelProvider provider = providerMap.get(channel == null ? "" : channel.toUpperCase()); + if (provider == null) { + throw new IllegalStateException("渠道适配器不存在"); + } + Map sendContext = new LinkedHashMap(); + sendContext.put("taskId", taskId); + sendContext.put("outId", "task-" + taskId); + NotificationSendResult sendResult = provider.send( + receiverRef, + task.get("payload_json") == null ? null : String.valueOf(task.get("payload_json")), + sendContext + ); + if (!sendResult.isAccepted()) { + throw new IllegalStateException("渠道未受理: " + sendResult.getProviderCode()); + } + jdbcTemplate.update( + "UPDATE notification_task SET status='SENT', provider_message_id=?, receipt_code=?, receipt_message=?, error_message=NULL, sent_at=CURRENT_TIMESTAMP, updated_at=CURRENT_TIMESTAMP, updated_by=? WHERE tenant_id=? AND id=?", + sendResult.getProviderMessageId(), + sendResult.getProviderCode(), + sendResult.getProviderMessage(), + safeUserId(), + tenantId(), + taskId + ); + try { + deliveryProtectionService.recordSuccess(channel, receiverRef); + } catch (Exception guardEx) { + log.warn("notification delivery protection success bookkeeping failed, taskId={}, err={}", taskId, guardEx.getMessage()); + } + } catch (Exception ex) { + try { + deliveryProtectionService.recordFailure(channel, ex.getMessage()); + } catch (Exception guardEx) { + log.warn("notification delivery protection failure bookkeeping failed, taskId={}, err={}", taskId, guardEx.getMessage()); + } + int nextRetry = retryCount + 1; + String nextStatus = nextRetry >= maxRetry ? "FAILED" : "PENDING"; + jdbcTemplate.update( + "UPDATE notification_task SET status=?, retry_count=?, error_message=?, updated_at=CURRENT_TIMESTAMP, updated_by=? WHERE tenant_id=? AND id=?", + nextStatus, + nextRetry, + ex.getMessage(), + safeUserId(), + tenantId(), + taskId + ); + if ("FAILED".equals(nextStatus)) { + log.warn("notification task exhausted retries, taskId={}, channel={}, receiver={}, err={}", taskId, channel, receiverRef, ex.getMessage()); + return; + } + throw new BusinessException(ErrorCodes.INTERNAL_ERROR, "通知发送失败"); + } + } + + @Transactional(rollbackFor = Exception.class) + public Map ingestReceipt(NotificationReceiptRequest request) { + Long tenantId = tenantId(); + return ingestReceiptInternal(request, tenantId, true); + } + + public Map ingestReceiptWebhook(NotificationReceiptRequest request, String timestamp, String signature) { + if (!verifySignature(request, timestamp, signature)) { + throw new BusinessException(ErrorCodes.NO_DATA_PERMISSION, "回执签名校验失败"); + } + return ingestReceiptInternal(request, null, false); + } + + public Map ingestAliyunSmsReceipt(AliyunSmsReceiptRequest request) { + NotificationReceiptRequest receiptRequest = new NotificationReceiptRequest(); + Long taskId = parseTaskIdFromOutId(request == null ? null : request.getOutId()); + if (taskId == null || taskId <= 0) { + throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "阿里云短信回执缺少可识别的任务标识"); + } + receiptRequest.setTaskId(taskId); + receiptRequest.setProviderMessageId(request == null ? null : request.getBizId()); + String errCode = request == null ? "" : String.valueOf(request.getErrCode() == null ? "" : request.getErrCode()).trim(); + boolean delivered = request != null && Boolean.TRUE.equals(request.getSuccess()) && (errCode.isEmpty() || "DELIVERED".equalsIgnoreCase(errCode) || "OK".equalsIgnoreCase(errCode)); + receiptRequest.setDelivered(delivered); + receiptRequest.setReceiptCode(errCode.isEmpty() ? (delivered ? "DELIVERED" : "FAILED") : errCode); + StringBuilder message = new StringBuilder(); + if (request != null && request.getPhoneNumber() != null && request.getPhoneNumber().trim().length() > 0) { + message.append("手机号=").append(request.getPhoneNumber().trim()); + } + if (request != null && request.getReceiveDate() != null && request.getReceiveDate().trim().length() > 0) { + if (message.length() > 0) { + message.append(";"); + } + message.append("回执时间=").append(request.getReceiveDate().trim()); + } + if (request != null && request.getErrMsg() != null && request.getErrMsg().trim().length() > 0) { + if (message.length() > 0) { + message.append(";"); + } + message.append(request.getErrMsg().trim()); + } + receiptRequest.setReceiptMessage(message.toString()); + return ingestReceiptInternal(receiptRequest, null, false); + } + + public Map ingestAliyunSmsReceipts(List requests) { + if (requests == null || requests.isEmpty()) { + throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "阿里云短信回执内容不能为空"); + } + int deliveredCount = 0; + int failedCount = 0; + List> items = new ArrayList>(); + for (AliyunSmsReceiptRequest request : requests) { + Map item = ingestAliyunSmsReceipt(request); + items.add(item); + String status = item.get("status") == null ? "" : String.valueOf(item.get("status")); + if ("DELIVERED".equalsIgnoreCase(status)) { + deliveredCount++; + } else if ("FAILED".equalsIgnoreCase(status)) { + failedCount++; + } + } + Map data = new LinkedHashMap(); + data.put("count", items.size()); + data.put("deliveredCount", deliveredCount); + data.put("failedCount", failedCount); + data.put("items", items); + return data; + } + + @Transactional(rollbackFor = Exception.class) + private Map ingestReceiptInternal(NotificationReceiptRequest request, Long tenantIdHint, boolean requireTenantAuth) { + Map task = requireTenantAuth ? findTask(request.getTaskId()) : findTaskAnyTenant(request.getTaskId()); + Long taskTenantId = tenantIdHint != null ? tenantIdHint : toLong(task.get("tenant_id")); + if (taskTenantId == null || taskTenantId <= 0) { + throw new BusinessException(ErrorCodes.RESOURCE_NOT_FOUND, "通知任务不存在"); + } + String providerMessageId = task.get("provider_message_id") == null ? "" : String.valueOf(task.get("provider_message_id")); + if (!providerMessageId.equals(request.getProviderMessageId())) { + throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "供应商消息ID不匹配"); + } + boolean delivered = request.getDelivered() != null && request.getDelivered(); + String nextStatus = delivered ? "DELIVERED" : "FAILED"; + jdbcTemplate.update( + "UPDATE notification_task SET status=?, receipt_code=?, receipt_message=?, receipt_at=CURRENT_TIMESTAMP, updated_at=CURRENT_TIMESTAMP, updated_by=? WHERE tenant_id=? AND id=?", + nextStatus, + request.getReceiptCode(), + request.getReceiptMessage(), + safeUserId(), + taskTenantId, + request.getTaskId() + ); + jdbcTemplate.update( + "INSERT INTO notification_receipt_log (tenant_id, task_id, provider_message_id, receipt_code, receipt_message, receipt_status, created_by) VALUES (?, ?, ?, ?, ?, ?, ?)", + taskTenantId, + request.getTaskId(), + request.getProviderMessageId(), + request.getReceiptCode(), + request.getReceiptMessage(), + nextStatus, + safeUserId() + ); + Map data = new LinkedHashMap(); + data.put("taskId", request.getTaskId()); + data.put("status", nextStatus); + return data; + } + + private Long parseTaskId(String payload) { + try { + Map map = objectMapper.readValue(payload, new TypeReference>() {}); + Object val = map.get("taskId"); + if (val == null) { + throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "通知任务ID缺失"); + } + return Long.valueOf(String.valueOf(val)); + } catch (Exception ex) { + throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "通知任务参数非法"); + } + } + + private Map findTask(Long taskId) { + List> list = jdbcTemplate.queryForList( + "SELECT id, tenant_id, status, retry_count, max_retry, channel, receiver_ref, provider_message_id, payload_json FROM notification_task WHERE tenant_id=? AND id=? AND is_deleted=0", + tenantId(), + taskId + ); + if (list.isEmpty()) { + throw new BusinessException(ErrorCodes.RESOURCE_NOT_FOUND, "通知任务不存在"); + } + return list.get(0); + } + + private Map findTaskAnyTenant(Long taskId) { + List> list = jdbcTemplate.queryForList( + "SELECT id, tenant_id, status, retry_count, max_retry, channel, receiver_ref, provider_message_id, payload_json FROM notification_task WHERE id=? AND is_deleted=0", + taskId + ); + if (list.isEmpty()) { + throw new BusinessException(ErrorCodes.RESOURCE_NOT_FOUND, "通知任务不存在"); + } + return list.get(0); + } + + private Long parseTaskIdFromOutId(String outId) { + if (outId == null || outId.trim().isEmpty()) { + return null; + } + String raw = outId.trim(); + if (raw.startsWith("task-")) { + raw = raw.substring("task-".length()); + } + return toLong(raw); + } + + private boolean verifySignature(NotificationReceiptRequest request, String timestamp, String signature) { + if (timestamp == null || signature == null || signature.trim().isEmpty()) { + return false; + } + String plain = String.valueOf(request.getTaskId()) + "|" + request.getProviderMessageId() + "|" + request.getReceiptCode() + "|" + timestamp; + try { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(webhookSecret.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); + byte[] bytes = mac.doFinal(plain.getBytes(StandardCharsets.UTF_8)); + StringBuilder sb = new StringBuilder(); + for (byte b : bytes) { + String hex = Integer.toHexString(b & 0xff); + if (hex.length() == 1) { + sb.append('0'); + } + sb.append(hex); + } + return sb.toString().equalsIgnoreCase(signature.trim()); + } catch (Exception ex) { + return false; + } + } + + private Long safeUserId() { + Long userId = AuthContext.userId(); + return userId == null ? 0L : userId; + } + + private Long tenantId() { + return AuthContext.requireTenantId(); + } + + private int normalizePageNo(Integer pageNo) { + if (pageNo == null || pageNo < 1) { + return 1; + } + return pageNo; + } + + private int normalizePageSize(Integer pageSize) { + if (pageSize == null || pageSize < 1) { + return 20; + } + return Math.min(pageSize, 200); + } + + private static final class ReceiverTarget { + private final Long userId; + private final String source; + + private ReceiverTarget(Long userId, String source) { + this.userId = userId; + this.source = source; + } + } + + private static final class ReceiverResolution { + private final String receiverRef; + private final String resolveSource; + + private ReceiverResolution(String receiverRef, String resolveSource) { + this.receiverRef = receiverRef; + this.resolveSource = resolveSource; + } + } +} diff --git a/backend/src/main/java/com/writeoff/module/notification/service/NotificationGatewayCryptoService.java b/backend/src/main/java/com/writeoff/module/notification/service/NotificationGatewayCryptoService.java new file mode 100644 index 0000000..b590f41 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/notification/service/NotificationGatewayCryptoService.java @@ -0,0 +1,59 @@ +package com.writeoff.module.notification.service; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import javax.crypto.Cipher; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.Base64; + +@Service +public class NotificationGatewayCryptoService { + private final SecretKeySpec secretKeySpec; + + public NotificationGatewayCryptoService(@Value("${app.notification.gateway-crypto-secret:change-me}") String secret) { + this.secretKeySpec = new SecretKeySpec(deriveKey(secret == null ? "change-me" : secret), "AES"); + } + + public String encrypt(String plainText) { + if (plainText == null || plainText.trim().isEmpty()) { + return ""; + } + try { + Cipher cipher = Cipher.getInstance("AES"); + cipher.init(Cipher.ENCRYPT_MODE, secretKeySpec); + byte[] encrypted = cipher.doFinal(plainText.getBytes(StandardCharsets.UTF_8)); + return Base64.getEncoder().encodeToString(encrypted); + } catch (Exception ex) { + throw new IllegalStateException("通知网关敏感配置加密失败", ex); + } + } + + public String decrypt(String cipherText) { + if (cipherText == null || cipherText.trim().isEmpty()) { + return ""; + } + try { + Cipher cipher = Cipher.getInstance("AES"); + cipher.init(Cipher.DECRYPT_MODE, secretKeySpec); + byte[] decoded = Base64.getDecoder().decode(cipherText.trim()); + return new String(cipher.doFinal(decoded), StandardCharsets.UTF_8); + } catch (Exception ex) { + throw new IllegalStateException("通知网关敏感配置解密失败", ex); + } + } + + private byte[] deriveKey(String secret) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] bytes = digest.digest(secret.getBytes(StandardCharsets.UTF_8)); + byte[] key = new byte[16]; + System.arraycopy(bytes, 0, key, 0, key.length); + return key; + } catch (Exception ex) { + throw new IllegalStateException("通知网关密钥初始化失败", ex); + } + } +} diff --git a/backend/src/main/java/com/writeoff/module/notification/service/NotificationPolicyService.java b/backend/src/main/java/com/writeoff/module/notification/service/NotificationPolicyService.java new file mode 100644 index 0000000..102c18a --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/notification/service/NotificationPolicyService.java @@ -0,0 +1,239 @@ +package com.writeoff.module.notification.service; + +import com.writeoff.common.api.PageResult; +import com.writeoff.common.exception.BusinessException; +import com.writeoff.module.notification.dto.CreateNotificationPolicyRequest; +import com.writeoff.module.notification.dto.UpdateNotificationPolicyRequest; +import com.writeoff.module.notification.model.NotificationPolicyInfo; +import com.writeoff.security.AuthContext; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +public class NotificationPolicyService { + private final JdbcTemplate jdbcTemplate; + + private static final RowMapper POLICY_ROW_MAPPER = (rs, n) -> new NotificationPolicyInfo( + rs.getLong("id"), + rs.getString("policy_name"), + rs.getString("event_code"), + rs.getString("channel"), + rs.getString("receiver_type"), + rs.getLong("template_id"), + rs.getString("variables_json"), + rs.getString("status") + ); + + public NotificationPolicyService(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + public PageResult list(int pageNo, int pageSize) { + int safePage = Math.max(pageNo, 1); + int safeSize = Math.min(Math.max(pageSize, 1), 100); + int offset = (safePage - 1) * safeSize; + + Integer total = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM notification_policy WHERE tenant_id=? AND is_deleted=0", + Integer.class, + tenantId() + ); + long totalCount = total == null ? 0 : total; + + List list = jdbcTemplate.query( + "SELECT * FROM notification_policy WHERE tenant_id=? AND is_deleted=0 ORDER BY id DESC LIMIT ? OFFSET ?", + POLICY_ROW_MAPPER, + tenantId(), + safeSize, + offset + ); + return new PageResult(list, totalCount, safePage, safeSize); + } + + @Transactional(rollbackFor = Exception.class) + public NotificationPolicyInfo create(CreateNotificationPolicyRequest request) { + validateTextTemplateExists(request.getTemplateId()); + String status = normalizeStatus(request.getStatus()); + jdbcTemplate.update( + "INSERT INTO notification_policy (tenant_id, policy_name, event_code, channel, receiver_type, template_id, variables_json, status, created_by, updated_by) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + tenantId(), + request.getPolicyName(), + request.getEventCode(), + request.getChannel(), + request.getReceiverType(), + request.getTemplateId(), + request.getVariablesJson(), + status, + safeUserId(), + safeUserId() + ); + Long id = jdbcTemplate.queryForObject("SELECT IFNULL(MAX(id),0) FROM notification_policy WHERE tenant_id=?", Long.class, tenantId()); + Long policyId = id == null ? 0L : id; + upsertPolicyEvent(policyId, request.getEventCode(), status); + return findById(policyId); + } + + @Transactional(rollbackFor = Exception.class) + public NotificationPolicyInfo update(Long id, UpdateNotificationPolicyRequest request) { + assertExists(id); + validateTextTemplateExists(request.getTemplateId()); + String status = normalizeStatus(request.getStatus()); + jdbcTemplate.update( + "UPDATE notification_policy SET policy_name=?, event_code=?, channel=?, receiver_type=?, template_id=?, variables_json=?, status=?, updated_at=CURRENT_TIMESTAMP, updated_by=? " + + "WHERE tenant_id=? AND id=?", + request.getPolicyName(), + request.getEventCode(), + request.getChannel(), + request.getReceiverType(), + request.getTemplateId(), + request.getVariablesJson(), + status, + safeUserId(), + tenantId(), + id + ); + upsertPolicyEvent(id, request.getEventCode(), status); + return findById(id); + } + + @Transactional(rollbackFor = Exception.class) + public NotificationPolicyInfo bindEvents(Long id, String eventCode) { + assertExists(id); + String normalizedEventCode = eventCode == null ? "" : eventCode.trim(); + if (normalizedEventCode.isEmpty()) { + throw new BusinessException(10001, "事件编码不能为空"); + } + NotificationPolicyInfo policy = findById(id); + jdbcTemplate.update( + "UPDATE notification_policy SET event_code=?, updated_at=CURRENT_TIMESTAMP, updated_by=? WHERE tenant_id=? AND id=?", + normalizedEventCode, + safeUserId(), + tenantId(), + id + ); + upsertPolicyEvent(id, normalizedEventCode, policy.getStatus()); + return findById(id); + } + + public NotificationPolicyInfo enable(Long id) { + return updateStatus(id, "ENABLED"); + } + + public NotificationPolicyInfo disable(Long id) { + return updateStatus(id, "DISABLED"); + } + + @Transactional(rollbackFor = Exception.class) + public void softDelete(Long id) { + assertExists(id); + NotificationPolicyInfo policy = findById(id); + if ("ENABLED".equals(policy.getStatus())) { + throw new BusinessException(10001, "请先停用通知策略再删除"); + } + jdbcTemplate.update( + "UPDATE notification_policy SET is_deleted=1, updated_at=CURRENT_TIMESTAMP, updated_by=? WHERE tenant_id=? AND id=?", + safeUserId(), tenantId(), id); + // 同步清除策略事件绑定 + jdbcTemplate.update( + "UPDATE notification_policy_event SET status='DISABLED', updated_at=CURRENT_TIMESTAMP, updated_by=? WHERE tenant_id=? AND policy_id=?", + safeUserId(), tenantId(), id); + } + + private NotificationPolicyInfo updateStatus(Long id, String status) { + assertExists(id); + jdbcTemplate.update( + "UPDATE notification_policy SET status=?, updated_at=CURRENT_TIMESTAMP, updated_by=? WHERE tenant_id=? AND id=?", + status, + safeUserId(), + tenantId(), + id + ); + jdbcTemplate.update( + "UPDATE notification_policy_event SET status=?, updated_at=CURRENT_TIMESTAMP, updated_by=? WHERE tenant_id=? AND policy_id=?", + status, + safeUserId(), + tenantId(), + id + ); + return findById(id); + } + + private void upsertPolicyEvent(Long policyId, String eventCode, String status) { + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM notification_policy_event WHERE tenant_id=? AND policy_id=?", + Integer.class, + tenantId(), + policyId + ); + if (count == null || count == 0) { + jdbcTemplate.update( + "INSERT INTO notification_policy_event (tenant_id, policy_id, event_code, status, created_by, updated_by) VALUES (?, ?, ?, ?, ?, ?)", + tenantId(), policyId, eventCode, status, safeUserId(), safeUserId() + ); + } else { + jdbcTemplate.update( + "UPDATE notification_policy_event SET event_code=?, status=?, updated_at=CURRENT_TIMESTAMP, updated_by=? WHERE tenant_id=? AND policy_id=?", + eventCode, status, safeUserId(), tenantId(), policyId + ); + } + } + + private String normalizeStatus(String status) { + String val = status == null ? "ENABLED" : status.trim().toUpperCase(); + if (!"ENABLED".equals(val) && !"DISABLED".equals(val)) { + throw new BusinessException(10001, "状态仅支持 ENABLED/DISABLED"); + } + return val; + } + + private void validateTextTemplateExists(Long templateId) { + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM notification_text_template WHERE tenant_id=? AND id=? AND is_deleted=0", + Integer.class, + tenantId(), + templateId + ); + if (count == null || count == 0) { + throw new BusinessException(10003, "文案模板不存在"); + } + } + + private NotificationPolicyInfo findById(Long id) { + List list = jdbcTemplate.query( + "SELECT * FROM notification_policy WHERE tenant_id=? AND id=? AND is_deleted=0", + POLICY_ROW_MAPPER, + tenantId(), + id + ); + if (list.isEmpty()) { + throw new BusinessException(10003, "通知策略不存在"); + } + return list.get(0); + } + + private void assertExists(Long id) { + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM notification_policy WHERE tenant_id=? AND id=? AND is_deleted=0", + Integer.class, + tenantId(), + id + ); + if (count == null || count == 0) { + throw new BusinessException(10003, "通知策略不存在"); + } + } + + private Long safeUserId() { + Long userId = AuthContext.userId(); + return userId == null ? 0L : userId; + } + + private Long tenantId() { + return AuthContext.requireTenantId(); + } +} diff --git a/backend/src/main/java/com/writeoff/module/notification/service/NotificationTextTemplateService.java b/backend/src/main/java/com/writeoff/module/notification/service/NotificationTextTemplateService.java new file mode 100644 index 0000000..58992cc --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/notification/service/NotificationTextTemplateService.java @@ -0,0 +1,212 @@ +package com.writeoff.module.notification.service; + +import com.writeoff.common.api.PageResult; +import com.writeoff.common.exception.BusinessException; +import com.writeoff.module.notification.dto.CreateNotificationTextTemplateRequest; +import com.writeoff.module.notification.dto.UpdateNotificationTextTemplateRequest; +import com.writeoff.module.notification.model.NotificationTextTemplateInfo; +import com.writeoff.security.AuthContext; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +public class NotificationTextTemplateService { + private final JdbcTemplate jdbcTemplate; + + private static final RowMapper ROW_MAPPER = (rs, n) -> new NotificationTextTemplateInfo( + rs.getLong("id"), + rs.getString("template_name"), + rs.getString("subject_template"), + rs.getString("title_template"), + rs.getString("content_template"), + rs.getString("status") + ); + + public NotificationTextTemplateService(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + public PageResult list(int pageNo, int pageSize) { + int safePage = Math.max(pageNo, 1); + int safeSize = Math.min(Math.max(pageSize, 1), 200); + int offset = (safePage - 1) * safeSize; + + Integer total = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM notification_text_template WHERE tenant_id=? AND is_deleted=0", + Integer.class, + tenantId() + ); + long totalCount = total == null ? 0 : total; + + List list = jdbcTemplate.query( + "SELECT id, template_name, subject_template, title_template, content_template, status " + + "FROM notification_text_template WHERE tenant_id=? AND is_deleted=0 ORDER BY id DESC LIMIT ? OFFSET ?", + ROW_MAPPER, + tenantId(), + safeSize, + offset + ); + return new PageResult(list, totalCount, safePage, safeSize); + } + + @Transactional(rollbackFor = Exception.class) + public NotificationTextTemplateInfo create(CreateNotificationTextTemplateRequest request) { + assertTemplateNameUnique(request.getTemplateName().trim(), null); + String status = normalizeStatus(request.getStatus()); + jdbcTemplate.update( + "INSERT INTO notification_text_template (tenant_id, template_name, subject_template, title_template, content_template, status, created_by, updated_by) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + tenantId(), + request.getTemplateName().trim(), + trimText(request.getSubjectTemplate()), + trimText(request.getTitleTemplate()), + request.getContentTemplate().trim(), + status, + safeUserId(), + safeUserId() + ); + Long id = jdbcTemplate.queryForObject("SELECT IFNULL(MAX(id), 0) FROM notification_text_template WHERE tenant_id=?", Long.class, tenantId()); + return findById(id == null ? 0L : id); + } + + @Transactional(rollbackFor = Exception.class) + public NotificationTextTemplateInfo update(Long id, UpdateNotificationTextTemplateRequest request) { + assertExists(id); + assertTemplateNameUnique(request.getTemplateName().trim(), id); + String status = normalizeStatus(request.getStatus()); + jdbcTemplate.update( + "UPDATE notification_text_template SET template_name=?, subject_template=?, title_template=?, content_template=?, status=?, updated_at=CURRENT_TIMESTAMP, updated_by=? " + + "WHERE tenant_id=? AND id=?", + request.getTemplateName().trim(), + trimText(request.getSubjectTemplate()), + trimText(request.getTitleTemplate()), + request.getContentTemplate().trim(), + status, + safeUserId(), + tenantId(), + id + ); + return findById(id); + } + + public NotificationTextTemplateInfo enable(Long id) { + return updateStatus(id, "ENABLED"); + } + + public NotificationTextTemplateInfo disable(Long id) { + return updateStatus(id, "DISABLED"); + } + + @Transactional(rollbackFor = Exception.class) + public void softDelete(Long id) { + assertExists(id); + NotificationTextTemplateInfo tpl = findById(id); + if ("ENABLED".equals(tpl.getStatus())) { + throw new BusinessException(10001, "请先停用文案模板再删除"); + } + // 检查是否被活跃策略引用 + Integer refCount = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM notification_policy WHERE tenant_id=? AND template_id=? AND is_deleted=0", + Integer.class, tenantId(), id); + if (refCount != null && refCount > 0) { + throw new BusinessException(10001, "该文案模板仍被通知策略引用,请先调整策略"); + } + jdbcTemplate.update( + "UPDATE notification_text_template SET is_deleted=1, updated_at=CURRENT_TIMESTAMP, updated_by=? WHERE tenant_id=? AND id=?", + safeUserId(), tenantId(), id); + } + + private NotificationTextTemplateInfo updateStatus(Long id, String status) { + assertExists(id); + jdbcTemplate.update( + "UPDATE notification_text_template SET status=?, updated_at=CURRENT_TIMESTAMP, updated_by=? WHERE tenant_id=? AND id=?", + status, + safeUserId(), + tenantId(), + id + ); + return findById(id); + } + + private NotificationTextTemplateInfo findById(Long id) { + List list = jdbcTemplate.query( + "SELECT id, template_name, subject_template, title_template, content_template, status " + + "FROM notification_text_template WHERE tenant_id=? AND id=? AND is_deleted=0", + ROW_MAPPER, + tenantId(), + id + ); + if (list.isEmpty()) { + throw new BusinessException(10003, "通知文案模板不存在"); + } + return list.get(0); + } + + private void assertExists(Long id) { + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM notification_text_template WHERE tenant_id=? AND id=? AND is_deleted=0", + Integer.class, + tenantId(), + id + ); + if (count == null || count == 0) { + throw new BusinessException(10003, "通知文案模板不存在"); + } + } + + private String normalizeStatus(String status) { + String val = status == null ? "ENABLED" : status.trim().toUpperCase(); + if (!"ENABLED".equals(val) && !"DISABLED".equals(val)) { + throw new BusinessException(10001, "状态仅支持 ENABLED/DISABLED"); + } + return val; + } + + private void assertTemplateNameUnique(String templateName, Long excludeId) { + String normalized = templateName == null ? "" : templateName.trim(); + if (normalized.length() == 0) { + throw new BusinessException(10001, "文案模板名称不能为空"); + } + Integer count; + if (excludeId == null) { + count = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM notification_text_template WHERE tenant_id=? AND template_name=? AND is_deleted=0", + Integer.class, + tenantId(), + normalized + ); + } else { + count = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM notification_text_template WHERE tenant_id=? AND template_name=? AND is_deleted=0 AND id<>?", + Integer.class, + tenantId(), + normalized, + excludeId + ); + } + if (count != null && count > 0) { + throw new BusinessException(10001, "文案模板名称已存在,请换一个名称"); + } + } + + private String trimText(String text) { + if (text == null) { + return null; + } + String val = text.trim(); + return val.length() == 0 ? null : val; + } + + private Long safeUserId() { + Long userId = AuthContext.userId(); + return userId == null ? 0L : userId; + } + + private Long tenantId() { + return AuthContext.requireTenantId(); + } +} diff --git a/backend/src/main/java/com/writeoff/module/notification/service/PlatformNotifyGatewayService.java b/backend/src/main/java/com/writeoff/module/notification/service/PlatformNotifyGatewayService.java new file mode 100644 index 0000000..bd68332 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/notification/service/PlatformNotifyGatewayService.java @@ -0,0 +1,314 @@ +package com.writeoff.module.notification.service; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.writeoff.common.exception.BusinessException; +import com.writeoff.module.notification.dto.SavePlatformNotifyGatewayRequest; +import com.writeoff.module.notification.model.PlatformNotifyGatewayInfo; +import com.writeoff.module.notification.model.PlatformNotifyGatewayResolvedConfig; +import com.writeoff.security.AuthContext; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +@Service +public class PlatformNotifyGatewayService { + private final JdbcTemplate jdbcTemplate; + private final NotificationGatewayCryptoService cryptoService; + private final ObjectMapper objectMapper = new ObjectMapper(); + + public PlatformNotifyGatewayService(JdbcTemplate jdbcTemplate, NotificationGatewayCryptoService cryptoService) { + this.jdbcTemplate = jdbcTemplate; + this.cryptoService = cryptoService; + } + + public List list() { + List> rows = jdbcTemplate.queryForList( + "SELECT id, channel_code, gateway_name, provider_code, status, config_json, secret_config_cipher, remark, " + + "DATE_FORMAT(updated_at, '%Y-%m-%d %H:%i:%s') AS updated_at " + + "FROM platform_notify_gateway WHERE is_deleted=0 ORDER BY id ASC" + ); + List list = new ArrayList(); + for (Map row : rows) { + list.add(toInfo(row)); + } + return list; + } + + public PlatformNotifyGatewayInfo save(String channelCode, SavePlatformNotifyGatewayRequest request) { + String normalizedChannel = normalizeChannel(channelCode); + Map existing = findRow(normalizedChannel); + if (existing.isEmpty()) { + throw new BusinessException(10003, "通知网关不存在"); + } + SaveConfigBundle bundle = buildConfigBundle( + normalizedChannel, + request == null ? null : request.getConfig(), + request == null ? null : request.getProviderCode(), + request == null ? null : request.getStatus() + ); + String gatewayName = normalizeText( + request == null ? null : request.getGatewayName(), + normalizedChannel.equals("EMAIL") ? "邮件网关" : "短信网关" + ); + String remark = request == null ? null : normalizeNullableText(request.getRemark()); + jdbcTemplate.update( + "UPDATE platform_notify_gateway SET gateway_name=?, provider_code=?, status=?, config_json=?, secret_config_cipher=?, remark=?, updated_by=?, updated_at=CURRENT_TIMESTAMP " + + "WHERE channel_code=? AND is_deleted=0", + gatewayName, + bundle.providerCode, + bundle.status, + toJson(bundle.publicConfig), + cryptoService.encrypt(toJson(bundle.secretConfig)), + remark, + safeUserId(), + normalizedChannel + ); + return toInfo(findRow(normalizedChannel)); + } + + public PlatformNotifyGatewayResolvedConfig resolveChannelConfig(String channelCode, boolean requireEnabled) { + String normalizedChannel = normalizeChannel(channelCode); + Map row = findRow(normalizedChannel); + if (row.isEmpty()) { + return null; + } + String status = String.valueOf(row.get("status")); + if (requireEnabled && !"ENABLED".equalsIgnoreCase(status)) { + return null; + } + Map mergedConfig = mergeConfig(row); + return new PlatformNotifyGatewayResolvedConfig( + toLong(row.get("id")), + normalizedChannel, + String.valueOf(row.get("gateway_name")), + String.valueOf(row.get("provider_code")), + status, + row.get("remark") == null ? "" : String.valueOf(row.get("remark")), + mergedConfig + ); + } + + private PlatformNotifyGatewayInfo toInfo(Map row) { + Map mergedConfig = mergeConfig(row); + return new PlatformNotifyGatewayInfo( + toLong(row.get("id")), + String.valueOf(row.get("channel_code")), + String.valueOf(row.get("gateway_name")), + String.valueOf(row.get("provider_code")), + String.valueOf(row.get("status")), + row.get("remark") == null ? "" : String.valueOf(row.get("remark")), + isConfigured(String.valueOf(row.get("channel_code")), mergedConfig), + row.get("updated_at") == null ? "" : String.valueOf(row.get("updated_at")), + mergedConfig + ); + } + + private SaveConfigBundle buildConfigBundle(String channelCode, Map input, String providerCodeRaw, String statusRaw) { + Map safeInput = input == null ? new LinkedHashMap() : input; + String status = normalizeStatus(statusRaw); + if ("EMAIL".equals(channelCode)) { + String providerCode = normalizeText(providerCodeRaw, "SMTP").toUpperCase(Locale.ROOT); + Map publicConfig = new LinkedHashMap(); + Map secretConfig = new LinkedHashMap(); + publicConfig.put("host", normalizeNullableText(safeInput.get("host"))); + publicConfig.put("port", normalizeInt(safeInput.get("port"), 587)); + publicConfig.put("protocol", normalizeText(safeInput.get("protocol"), "smtp")); + publicConfig.put("fromAddress", normalizeNullableText(safeInput.get("fromAddress"))); + publicConfig.put("defaultSubject", normalizeText(safeInput.get("defaultSubject"), "系统通知")); + publicConfig.put("smtpAuth", normalizeBoolean(safeInput.get("smtpAuth"), true)); + publicConfig.put("starttlsEnable", normalizeBoolean(safeInput.get("starttlsEnable"), true)); + publicConfig.put("starttlsRequired", normalizeBoolean(safeInput.get("starttlsRequired"), false)); + publicConfig.put("sslEnable", normalizeBoolean(safeInput.get("sslEnable"), false)); + publicConfig.put("connectTimeoutMs", normalizeInt(safeInput.get("connectTimeoutMs"), 5000)); + publicConfig.put("timeoutMs", normalizeInt(safeInput.get("timeoutMs"), 5000)); + publicConfig.put("writeTimeoutMs", normalizeInt(safeInput.get("writeTimeoutMs"), 5000)); + publicConfig.put("failureThreshold", normalizeInt(safeInput.get("failureThreshold"), 3)); + publicConfig.put("breakerCooldownSeconds", normalizeInt(safeInput.get("breakerCooldownSeconds"), 300)); + secretConfig.put("username", normalizeNullableText(safeInput.get("username"))); + secretConfig.put("password", normalizeNullableText(safeInput.get("password"))); + if ("ENABLED".equals(status)) { + assertRequired(publicConfig.get("host"), "SMTP 主机不能为空"); + assertRequired(publicConfig.get("fromAddress"), "发件邮箱不能为空"); + if (Boolean.TRUE.equals(publicConfig.get("smtpAuth"))) { + assertRequired(secretConfig.get("username"), "SMTP 用户名不能为空"); + assertRequired(secretConfig.get("password"), "SMTP 密码不能为空"); + } + } + return new SaveConfigBundle(providerCode, status, publicConfig, secretConfig); + } + + if ("SMS".equals(channelCode)) { + String providerCode = normalizeText(providerCodeRaw, "MOCK").toUpperCase(Locale.ROOT); + Map publicConfig = new LinkedHashMap(); + Map secretConfig = new LinkedHashMap(); + boolean mockEnabled = normalizeBoolean(safeInput.get("mockEnabled"), "MOCK".equals(providerCode)); + publicConfig.put("endpoint", normalizeNullableText(safeInput.get("endpoint"))); + publicConfig.put("signName", normalizeNullableText(safeInput.get("signName"))); + publicConfig.put("templateCode", normalizeNullableText(safeInput.get("templateCode"))); + publicConfig.put("regionId", normalizeText(safeInput.get("regionId"), "cn-hangzhou")); + publicConfig.put("mockEnabled", mockEnabled); + publicConfig.put("quietPeriodSeconds", normalizeInt(safeInput.get("quietPeriodSeconds"), 30)); + publicConfig.put("dailyLimit", normalizeInt(safeInput.get("dailyLimit"), 10)); + publicConfig.put("failureThreshold", normalizeInt(safeInput.get("failureThreshold"), 3)); + publicConfig.put("breakerCooldownSeconds", normalizeInt(safeInput.get("breakerCooldownSeconds"), 300)); + secretConfig.put("accessKeyId", normalizeNullableText(safeInput.get("accessKeyId"))); + secretConfig.put("accessKeySecret", normalizeNullableText(safeInput.get("accessKeySecret"))); + if ("ENABLED".equals(status) && !mockEnabled) { + assertRequired(publicConfig.get("endpoint"), "短信服务地址不能为空"); + assertRequired(publicConfig.get("signName"), "短信签名不能为空"); + assertRequired(secretConfig.get("accessKeyId"), "短信 AccessKeyId 不能为空"); + assertRequired(secretConfig.get("accessKeySecret"), "短信 AccessKeySecret 不能为空"); + } + return new SaveConfigBundle(providerCode, status, publicConfig, secretConfig); + } + + throw new BusinessException(10001, "暂不支持的通知网关渠道"); + } + + private Map mergeConfig(Map row) { + Map merged = new LinkedHashMap(); + merged.putAll(parseJsonObject(row.get("config_json") == null ? null : String.valueOf(row.get("config_json")))); + String secretJson = cryptoService.decrypt(row.get("secret_config_cipher") == null ? null : String.valueOf(row.get("secret_config_cipher"))); + merged.putAll(parseJsonObject(secretJson)); + return merged; + } + + private Map findRow(String channelCode) { + List> rows = jdbcTemplate.queryForList( + "SELECT id, channel_code, gateway_name, provider_code, status, config_json, secret_config_cipher, remark, " + + "DATE_FORMAT(updated_at, '%Y-%m-%d %H:%i:%s') AS updated_at " + + "FROM platform_notify_gateway WHERE channel_code=? AND is_deleted=0 LIMIT 1", + channelCode + ); + return rows.isEmpty() ? new LinkedHashMap() : rows.get(0); + } + + private Map parseJsonObject(String json) { + if (json == null || json.trim().isEmpty()) { + return new LinkedHashMap(); + } + try { + Map map = objectMapper.readValue(json, new TypeReference>() {}); + return map == null ? new LinkedHashMap() : new LinkedHashMap(map); + } catch (Exception ex) { + throw new BusinessException(10001, "通知网关配置格式非法"); + } + } + + private String toJson(Map map) { + try { + return objectMapper.writeValueAsString(map == null ? new LinkedHashMap() : map); + } catch (Exception ex) { + throw new BusinessException(10001, "通知网关配置序列化失败"); + } + } + + private boolean isConfigured(String channelCode, Map config) { + if ("EMAIL".equals(channelCode)) { + return !normalizeNullableText(config.get("host")).isEmpty() && !normalizeNullableText(config.get("fromAddress")).isEmpty(); + } + if ("SMS".equals(channelCode)) { + return normalizeBoolean(config.get("mockEnabled"), false) || !normalizeNullableText(config.get("endpoint")).isEmpty(); + } + return !config.isEmpty(); + } + + private void assertRequired(Object value, String message) { + if (normalizeNullableText(value).isEmpty()) { + throw new BusinessException(10001, message); + } + } + + private String normalizeChannel(String channelCode) { + String value = normalizeText(channelCode, "").toUpperCase(Locale.ROOT); + if (!"EMAIL".equals(value) && !"SMS".equals(value)) { + throw new BusinessException(10001, "通知网关渠道仅支持 EMAIL 或 SMS"); + } + return value; + } + + private String normalizeStatus(String status) { + String value = normalizeText(status, "DISABLED").toUpperCase(Locale.ROOT); + if (!"ENABLED".equals(value) && !"DISABLED".equals(value)) { + throw new BusinessException(10001, "网关状态仅支持 ENABLED 或 DISABLED"); + } + return value; + } + + private String normalizeText(Object value, String defaultValue) { + String text = normalizeNullableText(value); + return text.isEmpty() ? defaultValue : text; + } + + private String normalizeNullableText(Object value) { + return value == null ? "" : String.valueOf(value).trim(); + } + + private Integer normalizeInt(Object value, int defaultValue) { + if (value == null) { + return defaultValue; + } + try { + int parsed = value instanceof Number ? ((Number) value).intValue() : Integer.parseInt(String.valueOf(value).trim()); + return parsed <= 0 ? defaultValue : parsed; + } catch (Exception ex) { + return defaultValue; + } + } + + private Boolean normalizeBoolean(Object value, boolean defaultValue) { + if (value == null) { + return defaultValue; + } + if (value instanceof Boolean) { + return (Boolean) value; + } + String text = String.valueOf(value).trim(); + if ("true".equalsIgnoreCase(text) || "1".equals(text) || "Y".equalsIgnoreCase(text)) { + return true; + } + if ("false".equalsIgnoreCase(text) || "0".equals(text) || "N".equalsIgnoreCase(text)) { + return false; + } + return defaultValue; + } + + private Long toLong(Object value) { + if (value == null) { + return null; + } + if (value instanceof Number) { + return ((Number) value).longValue(); + } + try { + return Long.valueOf(String.valueOf(value).trim()); + } catch (Exception ex) { + return null; + } + } + + private Long safeUserId() { + Long userId = AuthContext.userId(); + return userId == null ? 0L : userId; + } + + private static final class SaveConfigBundle { + private final String providerCode; + private final String status; + private final Map publicConfig; + private final Map secretConfig; + + private SaveConfigBundle(String providerCode, String status, Map publicConfig, Map secretConfig) { + this.providerCode = providerCode; + this.status = status; + this.publicConfig = publicConfig; + this.secretConfig = secretConfig; + } + } +} diff --git a/backend/src/main/java/com/writeoff/module/notification/service/PlatformNotifyGatewayTestService.java b/backend/src/main/java/com/writeoff/module/notification/service/PlatformNotifyGatewayTestService.java new file mode 100644 index 0000000..9a41548 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/notification/service/PlatformNotifyGatewayTestService.java @@ -0,0 +1,212 @@ +package com.writeoff.module.notification.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.writeoff.common.exception.BusinessException; +import com.writeoff.module.notification.dto.TestPlatformNotifyGatewayRequest; +import com.writeoff.module.notification.model.PlatformNotifyGatewayResolvedConfig; +import com.writeoff.module.notification.provider.SmsNotificationProvider; +import com.writeoff.module.notification.provider.NotificationSendResult; +import org.springframework.mail.SimpleMailMessage; +import org.springframework.mail.javamail.JavaMailSenderImpl; +import org.springframework.stereotype.Service; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Properties; + +@Service +public class PlatformNotifyGatewayTestService { + private final PlatformNotifyGatewayService gatewayService; + private final SmsNotificationProvider smsNotificationProvider; + private final ObjectMapper objectMapper = new ObjectMapper(); + + public PlatformNotifyGatewayTestService(PlatformNotifyGatewayService gatewayService, + SmsNotificationProvider smsNotificationProvider) { + this.gatewayService = gatewayService; + this.smsNotificationProvider = smsNotificationProvider; + } + + public Map test(String channelCode, TestPlatformNotifyGatewayRequest request) { + PlatformNotifyGatewayResolvedConfig gateway = gatewayService.resolveChannelConfig(channelCode, false); + if (gateway == null) { + throw new BusinessException(10003, "通知网关不存在"); + } + if ("EMAIL".equalsIgnoreCase(gateway.getChannelCode())) { + return testEmail(gateway, request); + } + if ("SMS".equalsIgnoreCase(gateway.getChannelCode())) { + return testSms(gateway, request); + } + throw new BusinessException(10001, "暂不支持的通知网关测试渠道"); + } + + private Map testEmail(PlatformNotifyGatewayResolvedConfig gateway, TestPlatformNotifyGatewayRequest request) { + Map config = gateway.getConfig(); + String host = text(config.get("host")); + String fromAddress = text(config.get("fromAddress")); + boolean smtpAuth = boolValue(config.get("smtpAuth"), true); + String username = text(config.get("username")); + String password = text(config.get("password")); + if (host.isEmpty()) { + throw new BusinessException(10001, "SMTP 主机不能为空"); + } + if (fromAddress.isEmpty()) { + throw new BusinessException(10001, "发件邮箱不能为空"); + } + if (smtpAuth) { + if (username.isEmpty()) { + throw new BusinessException(10001, "SMTP 用户名不能为空"); + } + if (password.isEmpty()) { + throw new BusinessException(10001, "SMTP 密码不能为空"); + } + } + try { + JavaMailSenderImpl sender = new JavaMailSenderImpl(); + sender.setHost(host); + sender.setPort(intValue(config.get("port"), 587)); + sender.setProtocol(textOr(config.get("protocol"), "smtp")); + sender.setUsername(username); + sender.setPassword(password); + Properties props = sender.getJavaMailProperties(); + props.put("mail.smtp.auth", String.valueOf(smtpAuth)); + props.put("mail.smtp.starttls.enable", String.valueOf(boolValue(config.get("starttlsEnable"), true))); + props.put("mail.smtp.starttls.required", String.valueOf(boolValue(config.get("starttlsRequired"), false))); + props.put("mail.smtp.ssl.enable", String.valueOf(boolValue(config.get("sslEnable"), false))); + props.put("mail.smtp.connectiontimeout", String.valueOf(intValue(config.get("connectTimeoutMs"), 5000))); + props.put("mail.smtp.timeout", String.valueOf(intValue(config.get("timeoutMs"), 5000))); + props.put("mail.smtp.writetimeout", String.valueOf(intValue(config.get("writeTimeoutMs"), 5000))); + + SimpleMailMessage message = new SimpleMailMessage(); + message.setFrom(fromAddress); + message.setTo(request.getReceiverRef().trim()); + message.setSubject(textOr(request.getSubject(), "通知网关测试")); + message.setText(textOr(request.getContent(), "这是一封来自平台通知网关配置中心的测试邮件。")); + sender.send(message); + + Map data = new LinkedHashMap(); + data.put("channelCode", gateway.getChannelCode()); + data.put("providerCode", gateway.getProviderCode()); + data.put("receiverRef", request.getReceiverRef().trim()); + data.put("accepted", Boolean.TRUE); + data.put("message", "测试邮件发送成功"); + return data; + } catch (Exception ex) { + throw new BusinessException(10001, "测试邮件发送失败: " + ex.getMessage()); + } + } + + private Map testSms(PlatformNotifyGatewayResolvedConfig gateway, TestPlatformNotifyGatewayRequest request) { + Map config = gateway.getConfig(); + boolean mockEnabled = boolValue(config.get("mockEnabled"), "MOCK".equalsIgnoreCase(gateway.getProviderCode())); + if (!mockEnabled) { + if (text(config.get("endpoint")).isEmpty()) { + throw new BusinessException(10001, "短信服务地址不能为空"); + } + if (text(config.get("signName")).isEmpty()) { + throw new BusinessException(10001, "短信签名不能为空"); + } + if (text(config.get("accessKeyId")).isEmpty()) { + throw new BusinessException(10001, "短信 AccessKeyId 不能为空"); + } + if (text(config.get("accessKeySecret")).isEmpty()) { + throw new BusinessException(10001, "短信 AccessKeySecret 不能为空"); + } + } + if (!isPhoneLike(request.getReceiverRef())) { + throw new BusinessException(10001, "短信测试接收目标必须为手机号"); + } + Map payload = new LinkedHashMap(); + payload.put("subject", textOr(request.getSubject(), "通知网关测试")); + payload.put("content", textOr(request.getContent(), "这是一条来自平台通知网关配置中心的测试短信。")); + payload.put("signName", text(config.get("signName"))); + payload.put("templateCode", text(config.get("templateCode"))); + try { + if ("ALIYUN_SMS".equalsIgnoreCase(gateway.getProviderCode()) && !mockEnabled) { + Map sendContext = new LinkedHashMap(); + sendContext.put("outId", "test-" + System.currentTimeMillis()); + NotificationSendResult result = smsNotificationProvider.send( + request.getReceiverRef().trim(), + objectMapper.writeValueAsString(payload), + sendContext + ); + if (result == null || !result.isAccepted()) { + throw new BusinessException(10001, result == null ? "测试短信发送失败" : result.getProviderMessage()); + } + Map data = new LinkedHashMap(); + data.put("channelCode", gateway.getChannelCode()); + data.put("providerCode", gateway.getProviderCode()); + data.put("receiverRef", request.getReceiverRef().trim()); + data.put("accepted", Boolean.TRUE); + data.put("providerMessageId", result.getProviderMessageId()); + data.put("providerResultCode", result.getProviderCode()); + data.put("message", result.getProviderMessage()); + return data; + } + Map data = new LinkedHashMap(); + data.put("channelCode", gateway.getChannelCode()); + data.put("providerCode", gateway.getProviderCode()); + data.put("receiverRef", request.getReceiverRef().trim()); + data.put("accepted", Boolean.TRUE); + data.put("mockEnabled", mockEnabled); + data.put("payloadJson", objectMapper.writeValueAsString(payload)); + data.put("message", mockEnabled ? "测试短信已模拟受理" : "测试短信参数校验通过,当前版本按模拟模式返回受理"); + return data; + } catch (Exception ex) { + throw new BusinessException(10001, "测试短信构造失败"); + } + } + + private boolean isPhoneLike(String value) { + String normalized = text(value); + if (normalized.startsWith("+")) { + normalized = normalized.substring(1); + } + if (normalized.length() < 6 || normalized.length() > 20) { + return false; + } + for (int i = 0; i < normalized.length(); i++) { + if (!Character.isDigit(normalized.charAt(i))) { + return false; + } + } + return true; + } + + private String text(Object value) { + return value == null ? "" : String.valueOf(value).trim(); + } + + private String textOr(Object value, String fallback) { + String text = text(value); + return text.isEmpty() ? fallback : text; + } + + private int intValue(Object value, int fallback) { + if (value == null) { + return fallback; + } + try { + return value instanceof Number ? ((Number) value).intValue() : Integer.parseInt(String.valueOf(value).trim()); + } catch (Exception ex) { + return fallback; + } + } + + private boolean boolValue(Object value, boolean fallback) { + if (value == null) { + return fallback; + } + if (value instanceof Boolean) { + return (Boolean) value; + } + String text = String.valueOf(value).trim(); + if ("true".equalsIgnoreCase(text) || "1".equals(text)) { + return true; + } + if ("false".equalsIgnoreCase(text) || "0".equals(text)) { + return false; + } + return fallback; + } +} diff --git a/backend/src/main/java/com/writeoff/module/notification/ws/NotificationWebSocketConfig.java b/backend/src/main/java/com/writeoff/module/notification/ws/NotificationWebSocketConfig.java new file mode 100644 index 0000000..64ba1c5 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/notification/ws/NotificationWebSocketConfig.java @@ -0,0 +1,22 @@ +package com.writeoff.module.notification.ws; + +import org.springframework.context.annotation.Configuration; +import org.springframework.web.socket.config.annotation.EnableWebSocket; +import org.springframework.web.socket.config.annotation.WebSocketConfigurer; +import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry; + +@Configuration +@EnableWebSocket +public class NotificationWebSocketConfig implements WebSocketConfigurer { + private final NotificationWebSocketHandler notificationWebSocketHandler; + + public NotificationWebSocketConfig(NotificationWebSocketHandler notificationWebSocketHandler) { + this.notificationWebSocketHandler = notificationWebSocketHandler; + } + + @Override + public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { + registry.addHandler(notificationWebSocketHandler, "/ws/notifications") + .setAllowedOrigins("*"); + } +} diff --git a/backend/src/main/java/com/writeoff/module/notification/ws/NotificationWebSocketHandler.java b/backend/src/main/java/com/writeoff/module/notification/ws/NotificationWebSocketHandler.java new file mode 100644 index 0000000..346d082 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/notification/ws/NotificationWebSocketHandler.java @@ -0,0 +1,100 @@ +package com.writeoff.module.notification.ws; + +import com.writeoff.security.AuthScope; +import com.writeoff.security.JwtTokenService; +import io.jsonwebtoken.Claims; +import org.springframework.stereotype.Component; +import org.springframework.web.socket.CloseStatus; +import org.springframework.web.socket.TextMessage; +import org.springframework.web.socket.WebSocketSession; +import org.springframework.web.socket.handler.TextWebSocketHandler; + +import java.net.URI; +import java.util.LinkedHashMap; +import java.util.Map; + +@Component +public class NotificationWebSocketHandler extends TextWebSocketHandler { + private final JwtTokenService jwtTokenService; + private final NotificationWebSocketPushService pushService; + + public NotificationWebSocketHandler(JwtTokenService jwtTokenService, NotificationWebSocketPushService pushService) { + this.jwtTokenService = jwtTokenService; + this.pushService = pushService; + } + + @Override + public void afterConnectionEstablished(WebSocketSession session) throws Exception { + String token = resolveToken(session); + if (token == null || token.trim().isEmpty()) { + session.close(CloseStatus.POLICY_VIOLATION); + return; + } + try { + Claims claims = jwtTokenService.parse(token.trim()); + AuthScope scope = AuthScope.fromClaim(claims.get("scope", String.class)); + if (scope != AuthScope.TENANT) { + session.close(CloseStatus.POLICY_VIOLATION); + return; + } + Number uid = claims.get("uid", Number.class); + Number tid = claims.get("tid", Number.class); + if (uid == null || tid == null) { + session.close(CloseStatus.POLICY_VIOLATION); + return; + } + Long userId = uid.longValue(); + Long tenantId = tid.longValue(); + session.getAttributes().put("uid", userId); + session.getAttributes().put("tid", tenantId); + pushService.register(session, tenantId, userId); + Map hello = new LinkedHashMap(); + hello.put("type", "WS_CONNECTED"); + hello.put("tenantId", tenantId); + hello.put("userId", userId); + session.sendMessage(new TextMessage(new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(hello))); + } catch (Exception ex) { + session.close(CloseStatus.POLICY_VIOLATION); + } + } + + @Override + protected void handleTextMessage(WebSocketSession session, TextMessage message) { + // no-op: server push only + } + + @Override + public void handleTransportError(WebSocketSession session, Throwable exception) { + pushService.unregister(session); + } + + @Override + public void afterConnectionClosed(WebSocketSession session, CloseStatus status) { + pushService.unregister(session); + } + + private String resolveToken(WebSocketSession session) { + URI uri = session.getUri(); + if (uri == null || uri.getQuery() == null) { + return null; + } + String[] parts = uri.getQuery().split("&"); + for (String item : parts) { + int idx = item.indexOf('='); + if (idx <= 0) { + continue; + } + String key = item.substring(0, idx); + if (!"token".equals(key)) { + continue; + } + String val = item.substring(idx + 1); + try { + return java.net.URLDecoder.decode(val, "UTF-8"); + } catch (Exception ex) { + return val; + } + } + return null; + } +} diff --git a/backend/src/main/java/com/writeoff/module/notification/ws/NotificationWebSocketPushService.java b/backend/src/main/java/com/writeoff/module/notification/ws/NotificationWebSocketPushService.java new file mode 100644 index 0000000..2f44fa3 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/notification/ws/NotificationWebSocketPushService.java @@ -0,0 +1,155 @@ +package com.writeoff.module.notification.ws; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.web.socket.TextMessage; +import org.springframework.web.socket.WebSocketSession; + +import java.io.IOException; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArraySet; + +@Service +public class NotificationWebSocketPushService { + private final JdbcTemplate jdbcTemplate; + private final ObjectMapper objectMapper = new ObjectMapper(); + private final Map> userSessions = new ConcurrentHashMap>(); + private final Map> tenantSessions = new ConcurrentHashMap>(); + private final Map sessionUserKey = new ConcurrentHashMap(); + private final Map sessionTenantKey = new ConcurrentHashMap(); + + public NotificationWebSocketPushService(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + public void register(WebSocketSession session, Long tenantId, Long userId) { + if (session == null || tenantId == null || userId == null) { + return; + } + String userKey = userKey(tenantId, userId); + userSessions.computeIfAbsent(userKey, k -> new CopyOnWriteArraySet()).add(session); + tenantSessions.computeIfAbsent(tenantId, k -> new CopyOnWriteArraySet()).add(session); + sessionUserKey.put(session.getId(), userKey); + sessionTenantKey.put(session.getId(), tenantId); + } + + public void unregister(WebSocketSession session) { + if (session == null) { + return; + } + String sid = session.getId(); + String userKey = sessionUserKey.remove(sid); + Long tenantId = sessionTenantKey.remove(sid); + if (userKey != null) { + Set set = userSessions.get(userKey); + if (set != null) { + set.remove(session); + if (set.isEmpty()) { + userSessions.remove(userKey); + } + } + } + if (tenantId != null) { + Set set = tenantSessions.get(tenantId); + if (set != null) { + set.remove(session); + if (set.isEmpty()) { + tenantSessions.remove(tenantId); + } + } + } + } + + public void pushInAppNotification(Long tenantId, String receiverRef, Long receiverUserId) { + if (tenantId == null || tenantId <= 0) { + return; + } + Long targetUserId = receiverUserId; + if (targetUserId == null && receiverRef != null) { + String ref = receiverRef.trim(); + if (ref.startsWith("user-")) { + try { + targetUserId = Long.valueOf(ref.substring("user-".length())); + } catch (Exception ignored) { + } + } else { + try { + targetUserId = Long.valueOf(ref); + } catch (Exception ignored) { + } + } + } + if ("ALL".equalsIgnoreCase(receiverRef == null ? "" : receiverRef.trim())) { + pushToTenant(tenantId); + return; + } + if (targetUserId != null && targetUserId > 0) { + pushToUser(tenantId, targetUserId); + return; + } + pushToTenant(tenantId); + } + + private void pushToUser(Long tenantId, Long userId) { + String key = userKey(tenantId, userId); + Set sessions = userSessions.get(key); + if (sessions == null || sessions.isEmpty()) { + return; + } + Map data = new LinkedHashMap(); + data.put("type", "IN_APP_UNREAD_CHANGED"); + data.put("tenantId", tenantId); + data.put("userId", userId); + data.put("unreadCount", countUnread(tenantId, userId)); + broadcast(sessions, data); + } + + private void pushToTenant(Long tenantId) { + Set sessions = tenantSessions.get(tenantId); + if (sessions == null || sessions.isEmpty()) { + return; + } + Map data = new LinkedHashMap(); + data.put("type", "IN_APP_NOTIFICATION_NEW"); + data.put("tenantId", tenantId); + broadcast(sessions, data); + } + + private int countUnread(Long tenantId, Long userId) { + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM in_app_notification " + + "WHERE tenant_id=? AND is_deleted=0 AND status='UNREAD' AND (receiver_ref='ALL' OR receiver_ref=? OR receiver_user_id=?)", + Integer.class, + tenantId, + "user-" + userId, + userId + ); + return count == null ? 0 : count; + } + + private void broadcast(Set sessions, Map payload) { + String message; + try { + message = objectMapper.writeValueAsString(payload); + } catch (Exception ex) { + return; + } + for (WebSocketSession session : sessions) { + if (session == null || !session.isOpen()) { + continue; + } + try { + session.sendMessage(new TextMessage(message)); + } catch (IOException ignored) { + } + } + } + + private String userKey(Long tenantId, Long userId) { + return String.valueOf(tenantId) + ":" + String.valueOf(userId); + } +} diff --git a/backend/src/main/java/com/writeoff/module/observability/controller/ObservabilityController.java b/backend/src/main/java/com/writeoff/module/observability/controller/ObservabilityController.java new file mode 100644 index 0000000..124de56 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/observability/controller/ObservabilityController.java @@ -0,0 +1,76 @@ +package com.writeoff.module.observability.controller; + +import com.writeoff.common.api.ApiResponse; +import com.writeoff.module.observability.dto.CreateAlertRuleRequest; +import com.writeoff.module.observability.dto.UpdateAlertRuleRequest; +import com.writeoff.module.observability.model.AlertEventInfo; +import com.writeoff.module.observability.model.AlertRuleInfo; +import com.writeoff.module.observability.model.ObservabilityMetricPoint; +import com.writeoff.module.observability.service.ObservabilityService; +import com.writeoff.security.DataScopeType; +import com.writeoff.security.RequirePermission; +import org.springframework.web.bind.annotation.*; + +import javax.validation.Valid; +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/observability") +public class ObservabilityController { + private final ObservabilityService observabilityService; + + public ObservabilityController(ObservabilityService observabilityService) { + this.observabilityService = observabilityService; + } + + @GetMapping("/metrics") + @RequirePermission(value = "observability.read", dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "OBSERVABILITY_METRICS") + public ApiResponse> metrics(@RequestParam("metricCode") String metricCode, + @RequestParam(value = "minutes", required = false, defaultValue = "60") int minutes) { + return ApiResponse.success(observabilityService.queryMetric(metricCode, minutes)); + } + + @GetMapping("/metrics/export") + @RequirePermission(value = "observability.read", dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "OBSERVABILITY_METRICS_EXPORT") + public ApiResponse> exportMetrics(@RequestParam(value = "minutes", required = false, defaultValue = "60") int minutes) { + return ApiResponse.success(observabilityService.exportMetricSummary(minutes)); + } + + @GetMapping("/alert-rules") + @RequirePermission(value = "observability.read", dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "OBSERVABILITY_RULE_LIST") + public ApiResponse> rules() { + return ApiResponse.success(observabilityService.listRules()); + } + + @PostMapping("/alert-rules") + @RequirePermission(value = "observability.manage", dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "OBSERVABILITY_RULE_CREATE") + public ApiResponse createRule(@RequestBody @Valid CreateAlertRuleRequest request) { + return ApiResponse.success(observabilityService.createRule(request)); + } + + @PutMapping("/alert-rules/{id}") + @RequirePermission(value = "observability.manage", dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "OBSERVABILITY_RULE_UPDATE") + public ApiResponse updateRule(@PathVariable("id") Long id, + @RequestBody @Valid UpdateAlertRuleRequest request) { + return ApiResponse.success(observabilityService.updateRule(id, request)); + } + + @PostMapping("/alert-rules/evaluate") + @RequirePermission(value = "observability.manage", dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "OBSERVABILITY_RULE_EVALUATE") + public ApiResponse> evaluateRules() { + return ApiResponse.success(observabilityService.evaluateRules()); + } + + @PostMapping("/alert-rules/evaluate/auto") + @RequirePermission(value = "observability.manage", dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "OBSERVABILITY_RULE_EVALUATE_AUTO") + public ApiResponse> evaluateRulesAuto(@RequestParam(value = "recoveryWindowMinute", required = false, defaultValue = "10") int recoveryWindowMinute) { + return ApiResponse.success(observabilityService.evaluateRulesAuto(recoveryWindowMinute)); + } + + @GetMapping("/alert-events") + @RequirePermission(value = "observability.read", dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "OBSERVABILITY_EVENT_LIST") + public ApiResponse> events() { + return ApiResponse.success(observabilityService.listEvents()); + } +} diff --git a/backend/src/main/java/com/writeoff/module/observability/dto/CreateAlertRuleRequest.java b/backend/src/main/java/com/writeoff/module/observability/dto/CreateAlertRuleRequest.java new file mode 100644 index 0000000..a3c05fa --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/observability/dto/CreateAlertRuleRequest.java @@ -0,0 +1,75 @@ +package com.writeoff.module.observability.dto; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; + +public class CreateAlertRuleRequest { + @NotBlank(message = "规则编码不能为空") + private String ruleCode; + @NotBlank(message = "规则名称不能为空") + private String ruleName; + @NotBlank(message = "比较符不能为空") + private String compareOp; + @NotNull(message = "阈值不能为空") + private Double thresholdValue; + @NotNull(message = "窗口分钟不能为空") + private Integer windowMinute; + private Integer suppressWindowMinute; + private String status; + + public String getRuleCode() { + return ruleCode; + } + + public void setRuleCode(String ruleCode) { + this.ruleCode = ruleCode; + } + + public String getRuleName() { + return ruleName; + } + + public void setRuleName(String ruleName) { + this.ruleName = ruleName; + } + + public String getCompareOp() { + return compareOp; + } + + public void setCompareOp(String compareOp) { + this.compareOp = compareOp; + } + + public Double getThresholdValue() { + return thresholdValue; + } + + public void setThresholdValue(Double thresholdValue) { + this.thresholdValue = thresholdValue; + } + + public Integer getWindowMinute() { + return windowMinute; + } + + public void setWindowMinute(Integer windowMinute) { + this.windowMinute = windowMinute; + } + + public Integer getSuppressWindowMinute() { + return suppressWindowMinute; + } + + public void setSuppressWindowMinute(Integer suppressWindowMinute) { + this.suppressWindowMinute = suppressWindowMinute; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } +} diff --git a/backend/src/main/java/com/writeoff/module/observability/dto/UpdateAlertRuleRequest.java b/backend/src/main/java/com/writeoff/module/observability/dto/UpdateAlertRuleRequest.java new file mode 100644 index 0000000..28bd921 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/observability/dto/UpdateAlertRuleRequest.java @@ -0,0 +1,4 @@ +package com.writeoff.module.observability.dto; + +public class UpdateAlertRuleRequest extends CreateAlertRuleRequest { +} diff --git a/backend/src/main/java/com/writeoff/module/observability/job/AlertRuleAutoEvaluateJob.java b/backend/src/main/java/com/writeoff/module/observability/job/AlertRuleAutoEvaluateJob.java new file mode 100644 index 0000000..0c05a5a --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/observability/job/AlertRuleAutoEvaluateJob.java @@ -0,0 +1,47 @@ +package com.writeoff.module.observability.job; + +import com.writeoff.module.observability.service.ObservabilityService; +import com.writeoff.security.AuthContext; +import com.writeoff.security.AuthScope; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import java.util.List; + +@Component +public class AlertRuleAutoEvaluateJob { + private final ObservabilityService observabilityService; + private final JdbcTemplate jdbcTemplate; + private final boolean enabled; + private final int recoveryWindowMinute; + + public AlertRuleAutoEvaluateJob(ObservabilityService observabilityService, + JdbcTemplate jdbcTemplate, + @Value("${app.observability.auto-evaluate.enabled:true}") boolean enabled, + @Value("${app.observability.recovery-window-minute:10}") int recoveryWindowMinute) { + this.observabilityService = observabilityService; + this.jdbcTemplate = jdbcTemplate; + this.enabled = enabled; + this.recoveryWindowMinute = recoveryWindowMinute; + } + + @Scheduled(fixedDelayString = "${app.observability.auto-evaluate.interval-ms:60000}") + public void run() { + if (!enabled) { + return; + } + List tenantIds = jdbcTemplate.queryForList( + "SELECT id FROM tenant WHERE is_deleted=0 AND status='ENABLED' ORDER BY id ASC", + Long.class + ); + for (Long tenantId : tenantIds) { + try { + AuthContext.set(0L, tenantId, AuthScope.TENANT); + observabilityService.evaluateRulesAuto(recoveryWindowMinute); + } finally { + AuthContext.clear(); + } + } + } +} diff --git a/backend/src/main/java/com/writeoff/module/observability/model/AlertEventInfo.java b/backend/src/main/java/com/writeoff/module/observability/model/AlertEventInfo.java new file mode 100644 index 0000000..de6913b --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/observability/model/AlertEventInfo.java @@ -0,0 +1,61 @@ +package com.writeoff.module.observability.model; + +public class AlertEventInfo { + private Long id; + private String ruleCode; + private String metricCode; + private Double currentValue; + private Double thresholdValue; + private String alertLevel; + private String status; + private String createdAt; + private String recoveredAt; + + public AlertEventInfo(Long id, String ruleCode, String metricCode, Double currentValue, Double thresholdValue, String alertLevel, String status, String createdAt, String recoveredAt) { + this.id = id; + this.ruleCode = ruleCode; + this.metricCode = metricCode; + this.currentValue = currentValue; + this.thresholdValue = thresholdValue; + this.alertLevel = alertLevel; + this.status = status; + this.createdAt = createdAt; + this.recoveredAt = recoveredAt; + } + + public Long getId() { + return id; + } + + public String getRuleCode() { + return ruleCode; + } + + public String getMetricCode() { + return metricCode; + } + + public Double getCurrentValue() { + return currentValue; + } + + public Double getThresholdValue() { + return thresholdValue; + } + + public String getAlertLevel() { + return alertLevel; + } + + public String getStatus() { + return status; + } + + public String getCreatedAt() { + return createdAt; + } + + public String getRecoveredAt() { + return recoveredAt; + } +} diff --git a/backend/src/main/java/com/writeoff/module/observability/model/AlertRuleInfo.java b/backend/src/main/java/com/writeoff/module/observability/model/AlertRuleInfo.java new file mode 100644 index 0000000..321cf08 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/observability/model/AlertRuleInfo.java @@ -0,0 +1,55 @@ +package com.writeoff.module.observability.model; + +public class AlertRuleInfo { + private Long id; + private String ruleCode; + private String ruleName; + private String compareOp; + private Double thresholdValue; + private Integer windowMinute; + private Integer suppressWindowMinute; + private String status; + + public AlertRuleInfo(Long id, String ruleCode, String ruleName, String compareOp, Double thresholdValue, Integer windowMinute, Integer suppressWindowMinute, String status) { + this.id = id; + this.ruleCode = ruleCode; + this.ruleName = ruleName; + this.compareOp = compareOp; + this.thresholdValue = thresholdValue; + this.windowMinute = windowMinute; + this.suppressWindowMinute = suppressWindowMinute; + this.status = status; + } + + public Long getId() { + return id; + } + + public String getRuleCode() { + return ruleCode; + } + + public String getRuleName() { + return ruleName; + } + + public String getCompareOp() { + return compareOp; + } + + public Double getThresholdValue() { + return thresholdValue; + } + + public Integer getWindowMinute() { + return windowMinute; + } + + public Integer getSuppressWindowMinute() { + return suppressWindowMinute; + } + + public String getStatus() { + return status; + } +} diff --git a/backend/src/main/java/com/writeoff/module/observability/model/ObservabilityMetricPoint.java b/backend/src/main/java/com/writeoff/module/observability/model/ObservabilityMetricPoint.java new file mode 100644 index 0000000..88dbdb8 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/observability/model/ObservabilityMetricPoint.java @@ -0,0 +1,25 @@ +package com.writeoff.module.observability.model; + +public class ObservabilityMetricPoint { + private String minuteSlot; + private String metricCode; + private Double metricValue; + + public ObservabilityMetricPoint(String minuteSlot, String metricCode, Double metricValue) { + this.minuteSlot = minuteSlot; + this.metricCode = metricCode; + this.metricValue = metricValue; + } + + public String getMinuteSlot() { + return minuteSlot; + } + + public String getMetricCode() { + return metricCode; + } + + public Double getMetricValue() { + return metricValue; + } +} diff --git a/backend/src/main/java/com/writeoff/module/observability/service/ObservabilityService.java b/backend/src/main/java/com/writeoff/module/observability/service/ObservabilityService.java new file mode 100644 index 0000000..8778884 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/observability/service/ObservabilityService.java @@ -0,0 +1,429 @@ +package com.writeoff.module.observability.service; + +import com.writeoff.common.exception.BusinessException; +import com.writeoff.module.observability.dto.CreateAlertRuleRequest; +import com.writeoff.module.observability.dto.UpdateAlertRuleRequest; +import com.writeoff.module.observability.model.AlertEventInfo; +import com.writeoff.module.observability.model.AlertRuleInfo; +import com.writeoff.module.observability.model.ObservabilityMetricPoint; +import com.writeoff.security.AuthContext; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@Service +public class ObservabilityService { + private final JdbcTemplate jdbcTemplate; + + private static final RowMapper RULE_ROW_MAPPER = (rs, n) -> new AlertRuleInfo( + rs.getLong("id"), + rs.getString("rule_code"), + rs.getString("rule_name"), + rs.getString("compare_op"), + rs.getDouble("threshold_value"), + rs.getInt("window_minute"), + rs.getInt("suppress_window_minute"), + rs.getString("status") + ); + + private static final RowMapper EVENT_ROW_MAPPER = (rs, n) -> new AlertEventInfo( + rs.getLong("id"), + rs.getString("rule_code"), + rs.getString("metric_code"), + rs.getDouble("current_value"), + rs.getDouble("threshold_value"), + rs.getString("alert_level"), + rs.getString("status"), + rs.getString("created_at"), + rs.getString("recovered_at") + ); + + public ObservabilityService(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + public void recordApiMetric(String apiPath, int statusCode, long durationMs) { + String statusGroup = statusCode >= 500 ? "5XX" : (statusCode >= 400 ? "4XX" : "2XX"); + insertMetric("API_TOTAL", "status", statusGroup, 1D); + insertMetric("API_DURATION_MS", "path", normalize(apiPath), (double) durationMs); + } + + public void recordAsyncMetric(String jobType, String resultStatus) { + insertMetric("ASYNC_JOB_TOTAL", "jobType", normalize(jobType), 1D); + if ("FAILED".equalsIgnoreCase(resultStatus)) { + insertMetric("ASYNC_JOB_FAILED", "jobType", normalize(jobType), 1D); + } + } + + public void recordExportMetric(String exportCode, String resultStatus) { + insertMetric("EXPORT_TOTAL", "exportCode", normalize(exportCode), 1D); + if (!"SUCCESS".equalsIgnoreCase(resultStatus)) { + insertMetric("EXPORT_FAILED", "exportCode", normalize(exportCode), 1D); + } + } + + public List queryMetric(String metricCode, int minutes) { + int safeMinutes = minutes <= 0 ? 60 : minutes; + return jdbcTemplate.query( + "SELECT DATE_FORMAT(created_at, '%Y-%m-%d %H:%i:00') AS minute_slot, metric_code, SUM(metric_value) AS metric_value " + + "FROM observability_metric WHERE tenant_id=? AND metric_code=? AND created_at>=DATE_SUB(NOW(), INTERVAL ? MINUTE) " + + "GROUP BY DATE_FORMAT(created_at, '%Y-%m-%d %H:%i:00'), metric_code ORDER BY minute_slot ASC", + (rs, n) -> new ObservabilityMetricPoint( + rs.getString("minute_slot"), + rs.getString("metric_code"), + rs.getDouble("metric_value") + ), + tenantId(), + metricCode, + safeMinutes + ); + } + + public List listRules() { + return jdbcTemplate.query( + "SELECT * FROM alert_rule WHERE tenant_id=? AND is_deleted=0 ORDER BY id DESC", + RULE_ROW_MAPPER, + tenantId() + ); + } + + @Transactional(rollbackFor = Exception.class) + public AlertRuleInfo createRule(CreateAlertRuleRequest request) { + String status = normalizeStatus(request.getStatus()); + jdbcTemplate.update( + "INSERT INTO alert_rule (tenant_id, rule_code, rule_name, compare_op, threshold_value, window_minute, suppress_window_minute, status, created_by, updated_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + tenantId(), + request.getRuleCode(), + request.getRuleName(), + request.getCompareOp(), + request.getThresholdValue(), + request.getWindowMinute(), + normalizeSuppressMinute(request.getSuppressWindowMinute()), + status, + safeUserId(), + safeUserId() + ); + Long id = jdbcTemplate.queryForObject("SELECT IFNULL(MAX(id),0) FROM alert_rule WHERE tenant_id=?", Long.class, tenantId()); + return findRuleById(id == null ? 0L : id); + } + + @Transactional(rollbackFor = Exception.class) + public AlertRuleInfo updateRule(Long id, UpdateAlertRuleRequest request) { + assertRuleExists(id); + String status = normalizeStatus(request.getStatus()); + jdbcTemplate.update( + "UPDATE alert_rule SET rule_code=?, rule_name=?, compare_op=?, threshold_value=?, window_minute=?, suppress_window_minute=?, status=?, updated_at=CURRENT_TIMESTAMP, updated_by=? " + + "WHERE tenant_id=? AND id=?", + request.getRuleCode(), + request.getRuleName(), + request.getCompareOp(), + request.getThresholdValue(), + request.getWindowMinute(), + normalizeSuppressMinute(request.getSuppressWindowMinute()), + status, + safeUserId(), + tenantId(), + id + ); + return findRuleById(id); + } + + public Map evaluateRules() { + return evaluateRulesInternal(false, 10); + } + + public Map evaluateRulesAuto(int recoveryWindowMinute) { + int safeRecovery = recoveryWindowMinute <= 0 ? 10 : recoveryWindowMinute; + return evaluateRulesInternal(true, safeRecovery); + } + + private Map evaluateRulesInternal(boolean autoMode, int recoveryWindowMinute) { + List rules = listRules(); + int triggerCount = 0; + int recoverCount = 0; + for (AlertRuleInfo rule : rules) { + if (!"ENABLED".equalsIgnoreCase(rule.getStatus())) { + continue; + } + double current = calculateRuleValue(rule); + Long activeId = latestActiveEventId(rule.getRuleCode()); + if (hit(rule.getCompareOp(), current, rule.getThresholdValue())) { + clearRecoverCandidate(activeId); + if (inSuppressWindow(rule.getRuleCode(), rule.getSuppressWindowMinute())) { + continue; + } + triggerCount++; + jdbcTemplate.update( + "INSERT INTO alert_event (tenant_id, rule_code, metric_code, current_value, threshold_value, alert_level, status, message, created_by) VALUES (?, ?, ?, ?, ?, ?, 'ACTIVE', ?, ?)", + tenantId(), + rule.getRuleCode(), + metricCodeByRule(rule.getRuleCode()), + current, + rule.getThresholdValue(), + "WARN", + "触发告警规则: " + rule.getRuleName(), + safeUserId() + ); + } else if (activeId != null) { + if (markOrCheckRecoveryReady(activeId, recoveryWindowMinute)) { + recoverCount++; + jdbcTemplate.update( + "UPDATE alert_event SET status='RECOVERED', recovered_at=CURRENT_TIMESTAMP WHERE tenant_id=? AND id=?", + tenantId(), + activeId + ); + } + } + } + Map data = new LinkedHashMap(); + data.put("ruleTotal", rules.size()); + data.put("triggerCount", triggerCount); + data.put("recoverCount", recoverCount); + data.put("autoMode", autoMode); + data.put("recoveryWindowMinute", recoveryWindowMinute); + return data; + } + + public List listEvents() { + return jdbcTemplate.query( + "SELECT id, rule_code, metric_code, current_value, threshold_value, alert_level, status, DATE_FORMAT(created_at, '%Y-%m-%d %H:%i:%s') AS created_at, " + + "DATE_FORMAT(recovered_at, '%Y-%m-%d %H:%i:%s') AS recovered_at " + + "FROM alert_event WHERE tenant_id=? ORDER BY id DESC LIMIT 200", + EVENT_ROW_MAPPER, + tenantId() + ); + } + + public Map exportMetricSummary(int minutes) { + int safeMinutes = minutes <= 0 ? 60 : minutes; + Double total = jdbcTemplate.queryForObject( + "SELECT IFNULL(SUM(metric_value),0) FROM observability_metric WHERE tenant_id=? AND metric_code='EXPORT_TOTAL' AND created_at>=DATE_SUB(NOW(), INTERVAL ? MINUTE)", + Double.class, + tenantId(), + safeMinutes + ); + Double failed = jdbcTemplate.queryForObject( + "SELECT IFNULL(SUM(metric_value),0) FROM observability_metric WHERE tenant_id=? AND metric_code='EXPORT_FAILED' AND created_at>=DATE_SUB(NOW(), INTERVAL ? MINUTE)", + Double.class, + tenantId(), + safeMinutes + ); + List points = queryMetric("EXPORT_TOTAL", safeMinutes); + Map data = new LinkedHashMap(); + data.put("minutes", safeMinutes); + data.put("total", total == null ? 0D : total); + data.put("failed", failed == null ? 0D : failed); + data.put("points", points); + return data; + } + + private void insertMetric(String metricCode, String labelKey, String labelValue, Double value) { + jdbcTemplate.update( + "INSERT INTO observability_metric (tenant_id, metric_code, label_key, label_value, metric_value, created_by) VALUES (?, ?, ?, ?, ?, ?)", + tenantId(), + metricCode, + labelKey, + labelValue, + value, + safeUserId() + ); + } + + private double calculateRuleValue(AlertRuleInfo rule) { + String code = rule.getRuleCode(); + int minutes = rule.getWindowMinute() == null || rule.getWindowMinute() <= 0 ? 5 : rule.getWindowMinute(); + if ("API_5XX_RATE".equalsIgnoreCase(code)) { + Double total = jdbcTemplate.queryForObject( + "SELECT IFNULL(SUM(metric_value),0) FROM observability_metric WHERE tenant_id=? AND metric_code='API_TOTAL' AND created_at>=DATE_SUB(NOW(), INTERVAL ? MINUTE)", + Double.class, + tenantId(), + minutes + ); + Double f5xx = jdbcTemplate.queryForObject( + "SELECT IFNULL(SUM(metric_value),0) FROM observability_metric WHERE tenant_id=? AND metric_code='API_TOTAL' AND label_key='status' AND label_value='5XX' AND created_at>=DATE_SUB(NOW(), INTERVAL ? MINUTE)", + Double.class, + tenantId(), + minutes + ); + double all = total == null ? 0D : total; + double bad = f5xx == null ? 0D : f5xx; + return all == 0 ? 0D : (bad * 100D / all); + } + if ("ASYNC_BACKLOG".equalsIgnoreCase(code)) { + Integer cnt = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM async_job WHERE tenant_id=? AND status IN ('READY','RUNNING')", + Integer.class, + tenantId() + ); + return cnt == null ? 0D : cnt.doubleValue(); + } + if ("ASYNC_FAILED_RATE".equalsIgnoreCase(code)) { + Double total = jdbcTemplate.queryForObject( + "SELECT IFNULL(SUM(metric_value),0) FROM observability_metric WHERE tenant_id=? AND metric_code='ASYNC_JOB_TOTAL' AND created_at>=DATE_SUB(NOW(), INTERVAL ? MINUTE)", + Double.class, + tenantId(), + minutes + ); + Double failed = jdbcTemplate.queryForObject( + "SELECT IFNULL(SUM(metric_value),0) FROM observability_metric WHERE tenant_id=? AND metric_code='ASYNC_JOB_FAILED' AND created_at>=DATE_SUB(NOW(), INTERVAL ? MINUTE)", + Double.class, + tenantId(), + minutes + ); + double all = total == null ? 0D : total; + double bad = failed == null ? 0D : failed; + return all == 0 ? 0D : (bad * 100D / all); + } + return 0D; + } + + private boolean hit(String op, double current, Double threshold) { + double th = threshold == null ? 0D : threshold; + String compare = op == null ? ">=" : op.trim(); + if (">".equals(compare)) { + return current > th; + } + if ("<".equals(compare)) { + return current < th; + } + if ("<=".equals(compare)) { + return current <= th; + } + return current >= th; + } + + private String metricCodeByRule(String ruleCode) { + if ("API_5XX_RATE".equalsIgnoreCase(ruleCode)) { + return "API_TOTAL"; + } + if ("ASYNC_BACKLOG".equalsIgnoreCase(ruleCode)) { + return "ASYNC_BACKLOG"; + } + if ("ASYNC_FAILED_RATE".equalsIgnoreCase(ruleCode)) { + return "ASYNC_JOB_FAILED"; + } + return "UNKNOWN"; + } + + private AlertRuleInfo findRuleById(Long id) { + List list = jdbcTemplate.query( + "SELECT * FROM alert_rule WHERE tenant_id=? AND id=? AND is_deleted=0", + RULE_ROW_MAPPER, + tenantId(), + id + ); + if (list.isEmpty()) { + throw new BusinessException(10003, "告警规则不存在"); + } + return list.get(0); + } + + private void assertRuleExists(Long id) { + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM alert_rule WHERE tenant_id=? AND id=? AND is_deleted=0", + Integer.class, + tenantId(), + id + ); + if (count == null || count == 0) { + throw new BusinessException(10003, "告警规则不存在"); + } + } + + private String normalizeStatus(String status) { + String val = status == null ? "ENABLED" : status.trim().toUpperCase(); + if (!"ENABLED".equals(val) && !"DISABLED".equals(val)) { + throw new BusinessException(10001, "状态仅支持 ENABLED/DISABLED"); + } + return val; + } + + private String normalize(String v) { + return v == null ? "" : v.trim(); + } + + private int normalizeSuppressMinute(Integer m) { + if (m == null || m < 0) { + return 0; + } + return m; + } + + private boolean inSuppressWindow(String ruleCode, Integer suppressWindowMinute) { + int m = suppressWindowMinute == null ? 0 : suppressWindowMinute; + if (m <= 0) { + return false; + } + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM alert_event WHERE tenant_id=? AND rule_code=? AND status='ACTIVE' AND created_at>=DATE_SUB(NOW(), INTERVAL ? MINUTE)", + Integer.class, + tenantId(), + ruleCode, + m + ); + return count != null && count > 0; + } + + private Long latestActiveEventId(String ruleCode) { + List ids = jdbcTemplate.queryForList( + "SELECT id FROM alert_event WHERE tenant_id=? AND rule_code=? AND status='ACTIVE' ORDER BY id DESC LIMIT 1", + Long.class, + tenantId(), + ruleCode + ); + return ids.isEmpty() ? null : ids.get(0); + } + + private void clearRecoverCandidate(Long activeId) { + if (activeId == null) { + return; + } + jdbcTemplate.update( + "UPDATE alert_event SET recover_candidate_at=NULL WHERE tenant_id=? AND id=? AND status='ACTIVE'", + tenantId(), + activeId + ); + } + + private boolean markOrCheckRecoveryReady(Long activeId, int recoveryWindowMinute) { + List> list = jdbcTemplate.queryForList( + "SELECT recover_candidate_at FROM alert_event WHERE tenant_id=? AND id=? AND status='ACTIVE' LIMIT 1", + tenantId(), + activeId + ); + if (list.isEmpty()) { + return false; + } + Object val = list.get(0).get("recover_candidate_at"); + if (val == null) { + jdbcTemplate.update( + "UPDATE alert_event SET recover_candidate_at=CURRENT_TIMESTAMP WHERE tenant_id=? AND id=? AND status='ACTIVE'", + tenantId(), + activeId + ); + return false; + } + Integer ready = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM alert_event WHERE tenant_id=? AND id=? AND status='ACTIVE' AND recover_candidate_at IS NOT NULL AND recover_candidate_at<=DATE_SUB(NOW(), INTERVAL ? MINUTE)", + Integer.class, + tenantId(), + activeId, + recoveryWindowMinute + ); + return ready != null && ready > 0; + } + + private Long safeUserId() { + Long userId = AuthContext.userId(); + return userId == null ? 0L : userId; + } + + private Long tenantId() { + return AuthContext.requireTenantId(); + } +} diff --git a/backend/src/main/java/com/writeoff/module/ocr/config/BaiduOcrProperties.java b/backend/src/main/java/com/writeoff/module/ocr/config/BaiduOcrProperties.java new file mode 100644 index 0000000..332606a --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/ocr/config/BaiduOcrProperties.java @@ -0,0 +1,117 @@ +package com.writeoff.module.ocr.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Component +@ConfigurationProperties(prefix = "app.baidu-ocr") +public class BaiduOcrProperties { + private String apiKey; + private String secretKey; + private String tokenUrl; + private String multipleInvoiceUrl; + private String idCardUrl; + private String bankCardUrl; + private String documentExtractTaskUrl; + private String documentExtractQueryUrl; + private int connectTimeoutMs; + private int readTimeoutMs; + private long maxBytes; + private long documentExtractMaxBytes; + + public String getApiKey() { + return apiKey; + } + + public void setApiKey(String apiKey) { + this.apiKey = apiKey; + } + + public String getSecretKey() { + return secretKey; + } + + public void setSecretKey(String secretKey) { + this.secretKey = secretKey; + } + + public String getTokenUrl() { + return tokenUrl; + } + + public void setTokenUrl(String tokenUrl) { + this.tokenUrl = tokenUrl; + } + + public String getMultipleInvoiceUrl() { + return multipleInvoiceUrl; + } + + public void setMultipleInvoiceUrl(String multipleInvoiceUrl) { + this.multipleInvoiceUrl = multipleInvoiceUrl; + } + + public String getIdCardUrl() { + return idCardUrl; + } + + public void setIdCardUrl(String idCardUrl) { + this.idCardUrl = idCardUrl; + } + + public String getBankCardUrl() { + return bankCardUrl; + } + + public void setBankCardUrl(String bankCardUrl) { + this.bankCardUrl = bankCardUrl; + } + + public String getDocumentExtractTaskUrl() { + return documentExtractTaskUrl; + } + + public void setDocumentExtractTaskUrl(String documentExtractTaskUrl) { + this.documentExtractTaskUrl = documentExtractTaskUrl; + } + + public String getDocumentExtractQueryUrl() { + return documentExtractQueryUrl; + } + + public void setDocumentExtractQueryUrl(String documentExtractQueryUrl) { + this.documentExtractQueryUrl = documentExtractQueryUrl; + } + + public int getConnectTimeoutMs() { + return connectTimeoutMs; + } + + public void setConnectTimeoutMs(int connectTimeoutMs) { + this.connectTimeoutMs = connectTimeoutMs; + } + + public int getReadTimeoutMs() { + return readTimeoutMs; + } + + public void setReadTimeoutMs(int readTimeoutMs) { + this.readTimeoutMs = readTimeoutMs; + } + + public long getMaxBytes() { + return maxBytes; + } + + public void setMaxBytes(long maxBytes) { + this.maxBytes = maxBytes; + } + + public long getDocumentExtractMaxBytes() { + return documentExtractMaxBytes; + } + + public void setDocumentExtractMaxBytes(long documentExtractMaxBytes) { + this.documentExtractMaxBytes = documentExtractMaxBytes; + } +} diff --git a/backend/src/main/java/com/writeoff/module/ocr/controller/OcrController.java b/backend/src/main/java/com/writeoff/module/ocr/controller/OcrController.java new file mode 100644 index 0000000..b8c0b4d --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/ocr/controller/OcrController.java @@ -0,0 +1,75 @@ +package com.writeoff.module.ocr.controller; + +import com.writeoff.common.api.ApiResponse; +import com.writeoff.module.ocr.dto.BankCardOcrRequest; +import com.writeoff.module.ocr.dto.BankCardOcrResponse; +import com.writeoff.module.ocr.dto.DocumentExtractTaskQueryRequest; +import com.writeoff.module.ocr.dto.DocumentExtractTaskQueryResponse; +import com.writeoff.module.ocr.dto.DocumentExtractTaskSubmitRequest; +import com.writeoff.module.ocr.dto.DocumentExtractTaskSubmitResponse; +import com.writeoff.module.ocr.dto.IdCardOcrRequest; +import com.writeoff.module.ocr.dto.IdCardOcrResponse; +import com.writeoff.module.ocr.dto.MultipleInvoiceOcrRequest; +import com.writeoff.module.ocr.dto.MultipleInvoiceOcrResponse; +import com.writeoff.module.ocr.service.BaiduBankCardOcrService; +import com.writeoff.module.ocr.service.BaiduDocumentExtractService; +import com.writeoff.module.ocr.service.BaiduIdCardOcrService; +import com.writeoff.module.ocr.service.BaiduMultipleInvoiceOcrService; +import com.writeoff.security.DataScopeType; +import com.writeoff.security.RequirePermission; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/ocr") +public class OcrController { + private final BaiduMultipleInvoiceOcrService multipleInvoiceOcrService; + private final BaiduIdCardOcrService idCardOcrService; + private final BaiduBankCardOcrService bankCardOcrService; + private final BaiduDocumentExtractService documentExtractService; + + public OcrController( + BaiduMultipleInvoiceOcrService multipleInvoiceOcrService, + BaiduIdCardOcrService idCardOcrService, + BaiduBankCardOcrService bankCardOcrService, + BaiduDocumentExtractService documentExtractService + ) { + this.multipleInvoiceOcrService = multipleInvoiceOcrService; + this.idCardOcrService = idCardOcrService; + this.bankCardOcrService = bankCardOcrService; + this.documentExtractService = documentExtractService; + } + + @PostMapping("/multiple-invoice") + @RequirePermission(value = "meeting.material.save", dataScope = DataScopeType.MEETING_MODULE, auditAction = "OCR_MULTIPLE_INVOICE") + public ApiResponse multipleInvoice(@RequestBody @Validated MultipleInvoiceOcrRequest request) { + return ApiResponse.success(multipleInvoiceOcrService.recognize(request.getObjectKey())); + } + + @PostMapping("/id-card") + @RequirePermission(value = "ocr.idcard", dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "OCR_ID_CARD") + public ApiResponse idCard(@RequestBody @Validated IdCardOcrRequest request) { + return ApiResponse.success(idCardOcrService.recognize(request.getObjectKey(), request.getIdCardSide())); + } + + @PostMapping("/bank-card") + @RequirePermission(value = "ocr.bankcard", dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "OCR_BANK_CARD") + public ApiResponse bankCard(@RequestBody @Validated BankCardOcrRequest request) { + return ApiResponse.success(bankCardOcrService.recognize(request.getObjectKey())); + } + + @PostMapping("/document-extract/task") + @RequirePermission(value = "meeting.material.save", dataScope = DataScopeType.MEETING_MODULE, auditAction = "OCR_DOCUMENT_EXTRACT_SUBMIT") + public ApiResponse submitDocumentExtractTask(@RequestBody @Validated DocumentExtractTaskSubmitRequest request) { + return ApiResponse.success(documentExtractService.submitTask(request)); + } + + @PostMapping("/document-extract/query-task") + @RequirePermission(value = "meeting.material.save", dataScope = DataScopeType.MEETING_MODULE, auditAction = "OCR_DOCUMENT_EXTRACT_QUERY") + public ApiResponse queryDocumentExtractTask(@RequestBody @Validated DocumentExtractTaskQueryRequest request) { + return ApiResponse.success(documentExtractService.queryTask(request.getTaskId())); + } +} diff --git a/backend/src/main/java/com/writeoff/module/ocr/controller/PlatformOcrController.java b/backend/src/main/java/com/writeoff/module/ocr/controller/PlatformOcrController.java new file mode 100644 index 0000000..0632269 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/ocr/controller/PlatformOcrController.java @@ -0,0 +1,52 @@ +package com.writeoff.module.ocr.controller; + +import com.writeoff.common.api.ApiResponse; +import com.writeoff.module.ocr.dto.BankCardOcrRequest; +import com.writeoff.module.ocr.dto.BankCardOcrResponse; +import com.writeoff.module.ocr.dto.IdCardOcrRequest; +import com.writeoff.module.ocr.dto.IdCardOcrResponse; +import com.writeoff.module.ocr.service.BaiduBankCardOcrService; +import com.writeoff.module.ocr.service.BaiduIdCardOcrService; +import com.writeoff.security.DataScopeType; +import com.writeoff.security.PermissionDomain; +import com.writeoff.security.RequirePermission; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/platform/ocr") +public class PlatformOcrController { + private final BaiduIdCardOcrService idCardOcrService; + private final BaiduBankCardOcrService bankCardOcrService; + + public PlatformOcrController(BaiduIdCardOcrService idCardOcrService, BaiduBankCardOcrService bankCardOcrService) { + this.idCardOcrService = idCardOcrService; + this.bankCardOcrService = bankCardOcrService; + } + + @PostMapping("/id-card") + @RequirePermission( + value = "platform.ocr.idcard", + domain = PermissionDomain.PLATFORM, + dataScope = DataScopeType.GLOBAL_READONLY, + auditAction = "PLATFORM_OCR_ID_CARD" + ) + public ApiResponse idCard(@RequestBody @Validated IdCardOcrRequest request) { + return ApiResponse.success(idCardOcrService.recognize(request.getObjectKey(), request.getIdCardSide())); + } + + @PostMapping("/bank-card") + @RequirePermission( + value = "platform.ocr.bankcard", + domain = PermissionDomain.PLATFORM, + dataScope = DataScopeType.GLOBAL_READONLY, + auditAction = "PLATFORM_OCR_BANK_CARD" + ) + public ApiResponse bankCard(@RequestBody @Validated BankCardOcrRequest request) { + return ApiResponse.success(bankCardOcrService.recognize(request.getObjectKey())); + } +} + diff --git a/backend/src/main/java/com/writeoff/module/ocr/dto/BankCardOcrRequest.java b/backend/src/main/java/com/writeoff/module/ocr/dto/BankCardOcrRequest.java new file mode 100644 index 0000000..01988fa --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/ocr/dto/BankCardOcrRequest.java @@ -0,0 +1,17 @@ +package com.writeoff.module.ocr.dto; + +import javax.validation.constraints.NotBlank; + +public class BankCardOcrRequest { + @NotBlank(message = "objectKey不能为空") + private String objectKey; + + public String getObjectKey() { + return objectKey; + } + + public void setObjectKey(String objectKey) { + this.objectKey = objectKey; + } +} + diff --git a/backend/src/main/java/com/writeoff/module/ocr/dto/BankCardOcrResponse.java b/backend/src/main/java/com/writeoff/module/ocr/dto/BankCardOcrResponse.java new file mode 100644 index 0000000..f702278 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/ocr/dto/BankCardOcrResponse.java @@ -0,0 +1,73 @@ +package com.writeoff.module.ocr.dto; + +import java.util.Map; + +public class BankCardOcrResponse { + private Map raw; + private Normalized normalized; + + public Map getRaw() { + return raw; + } + + public void setRaw(Map raw) { + this.raw = raw; + } + + public Normalized getNormalized() { + return normalized; + } + + public void setNormalized(Normalized normalized) { + this.normalized = normalized; + } + + public static class Normalized { + private String bankCardNumber; + private String validDate; + private Integer bankCardType; + private String bankName; + private String holderName; + + public String getBankCardNumber() { + return bankCardNumber; + } + + public void setBankCardNumber(String bankCardNumber) { + this.bankCardNumber = bankCardNumber; + } + + public String getValidDate() { + return validDate; + } + + public void setValidDate(String validDate) { + this.validDate = validDate; + } + + public Integer getBankCardType() { + return bankCardType; + } + + public void setBankCardType(Integer bankCardType) { + this.bankCardType = bankCardType; + } + + public String getBankName() { + return bankName; + } + + public void setBankName(String bankName) { + this.bankName = bankName; + } + + public String getHolderName() { + return holderName; + } + + public void setHolderName(String holderName) { + this.holderName = holderName; + } + } +} + diff --git a/backend/src/main/java/com/writeoff/module/ocr/dto/DocumentExtractTaskQueryRequest.java b/backend/src/main/java/com/writeoff/module/ocr/dto/DocumentExtractTaskQueryRequest.java new file mode 100644 index 0000000..1322479 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/ocr/dto/DocumentExtractTaskQueryRequest.java @@ -0,0 +1,16 @@ +package com.writeoff.module.ocr.dto; + +import javax.validation.constraints.NotBlank; + +public class DocumentExtractTaskQueryRequest { + @NotBlank(message = "taskId must not be blank") + private String taskId; + + public String getTaskId() { + return taskId; + } + + public void setTaskId(String taskId) { + this.taskId = taskId; + } +} diff --git a/backend/src/main/java/com/writeoff/module/ocr/dto/DocumentExtractTaskQueryResponse.java b/backend/src/main/java/com/writeoff/module/ocr/dto/DocumentExtractTaskQueryResponse.java new file mode 100644 index 0000000..ea4cc4c --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/ocr/dto/DocumentExtractTaskQueryResponse.java @@ -0,0 +1,87 @@ +package com.writeoff.module.ocr.dto; + +import java.util.Map; + +public class DocumentExtractTaskQueryResponse { + private String taskId; + private String status; + private String reason; + private String createdAt; + private String startedAt; + private String finishedAt; + private Long duration; + private String logId; + private Map raw; + + public String getTaskId() { + return taskId; + } + + public void setTaskId(String taskId) { + this.taskId = taskId; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public String getReason() { + return reason; + } + + public void setReason(String reason) { + this.reason = reason; + } + + public String getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(String createdAt) { + this.createdAt = createdAt; + } + + public String getStartedAt() { + return startedAt; + } + + public void setStartedAt(String startedAt) { + this.startedAt = startedAt; + } + + public String getFinishedAt() { + return finishedAt; + } + + public void setFinishedAt(String finishedAt) { + this.finishedAt = finishedAt; + } + + public Long getDuration() { + return duration; + } + + public void setDuration(Long duration) { + this.duration = duration; + } + + public String getLogId() { + return logId; + } + + public void setLogId(String logId) { + this.logId = logId; + } + + public Map getRaw() { + return raw; + } + + public void setRaw(Map raw) { + this.raw = raw; + } +} diff --git a/backend/src/main/java/com/writeoff/module/ocr/dto/DocumentExtractTaskSubmitRequest.java b/backend/src/main/java/com/writeoff/module/ocr/dto/DocumentExtractTaskSubmitRequest.java new file mode 100644 index 0000000..62a8300 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/ocr/dto/DocumentExtractTaskSubmitRequest.java @@ -0,0 +1,133 @@ +package com.writeoff.module.ocr.dto; + +import javax.validation.Valid; +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.Size; +import java.util.List; + +public class DocumentExtractTaskSubmitRequest { + private String objectKey; + private String fileName; + + @Size(max = 1, message = "fileUrls supports only one url") + private List<@NotBlank(message = "file url must not be blank") String> fileUrls; + + private String manifestVersionId; + + @Valid + @Size(max = 100, message = "manifest supports at most 100 fields") + private List manifest; + + private Boolean removeDuplicates; + private String pageRange; + private Boolean extractSeal; + private Boolean eraseWatermark; + + public String getObjectKey() { + return objectKey; + } + + public void setObjectKey(String objectKey) { + this.objectKey = objectKey; + } + + public String getFileName() { + return fileName; + } + + public void setFileName(String fileName) { + this.fileName = fileName; + } + + public List getFileUrls() { + return fileUrls; + } + + public void setFileUrls(List fileUrls) { + this.fileUrls = fileUrls; + } + + public String getManifestVersionId() { + return manifestVersionId; + } + + public void setManifestVersionId(String manifestVersionId) { + this.manifestVersionId = manifestVersionId; + } + + public List getManifest() { + return manifest; + } + + public void setManifest(List manifest) { + this.manifest = manifest; + } + + public Boolean getRemoveDuplicates() { + return removeDuplicates; + } + + public void setRemoveDuplicates(Boolean removeDuplicates) { + this.removeDuplicates = removeDuplicates; + } + + public String getPageRange() { + return pageRange; + } + + public void setPageRange(String pageRange) { + this.pageRange = pageRange; + } + + public Boolean getExtractSeal() { + return extractSeal; + } + + public void setExtractSeal(Boolean extractSeal) { + this.extractSeal = extractSeal; + } + + public Boolean getEraseWatermark() { + return eraseWatermark; + } + + public void setEraseWatermark(Boolean eraseWatermark) { + this.eraseWatermark = eraseWatermark; + } + + public static class ManifestField { + @NotBlank(message = "manifest key must not be blank") + @Size(max = 30, message = "manifest key length must be <= 30") + private String key; + + @Size(max = 30, message = "manifest parentKey length must be <= 30") + private String parentKey; + + @Size(max = 100, message = "manifest description length must be <= 100") + private String description; + + public String getKey() { + return key; + } + + public void setKey(String key) { + this.key = key; + } + + public String getParentKey() { + return parentKey; + } + + public void setParentKey(String parentKey) { + this.parentKey = parentKey; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + } +} diff --git a/backend/src/main/java/com/writeoff/module/ocr/dto/DocumentExtractTaskSubmitResponse.java b/backend/src/main/java/com/writeoff/module/ocr/dto/DocumentExtractTaskSubmitResponse.java new file mode 100644 index 0000000..7735311 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/ocr/dto/DocumentExtractTaskSubmitResponse.java @@ -0,0 +1,33 @@ +package com.writeoff.module.ocr.dto; + +import java.util.Map; + +public class DocumentExtractTaskSubmitResponse { + private String taskId; + private String logId; + private Map raw; + + public String getTaskId() { + return taskId; + } + + public void setTaskId(String taskId) { + this.taskId = taskId; + } + + public String getLogId() { + return logId; + } + + public void setLogId(String logId) { + this.logId = logId; + } + + public Map getRaw() { + return raw; + } + + public void setRaw(Map raw) { + this.raw = raw; + } +} diff --git a/backend/src/main/java/com/writeoff/module/ocr/dto/IdCardOcrRequest.java b/backend/src/main/java/com/writeoff/module/ocr/dto/IdCardOcrRequest.java new file mode 100644 index 0000000..4d7fb01 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/ocr/dto/IdCardOcrRequest.java @@ -0,0 +1,30 @@ +package com.writeoff.module.ocr.dto; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.Pattern; + +public class IdCardOcrRequest { + @NotBlank(message = "objectKey不能为空") + private String objectKey; + + @NotBlank(message = "idCardSide不能为空") + @Pattern(regexp = "front|back", message = "idCardSide仅支持front或back") + private String idCardSide; + + public String getObjectKey() { + return objectKey; + } + + public void setObjectKey(String objectKey) { + this.objectKey = objectKey; + } + + public String getIdCardSide() { + return idCardSide; + } + + public void setIdCardSide(String idCardSide) { + this.idCardSide = idCardSide; + } +} + diff --git a/backend/src/main/java/com/writeoff/module/ocr/dto/IdCardOcrResponse.java b/backend/src/main/java/com/writeoff/module/ocr/dto/IdCardOcrResponse.java new file mode 100644 index 0000000..61605f5 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/ocr/dto/IdCardOcrResponse.java @@ -0,0 +1,118 @@ +package com.writeoff.module.ocr.dto; + +import java.util.Map; + +public class IdCardOcrResponse { + private Map raw; + private Normalized normalized; + + public Map getRaw() { + return raw; + } + + public void setRaw(Map raw) { + this.raw = raw; + } + + public Normalized getNormalized() { + return normalized; + } + + public void setNormalized(Normalized normalized) { + this.normalized = normalized; + } + + public static class Normalized { + private String idCardSide; + private String name; + private String idNo; + private String gender; + private String ethnicity; + private String birth; + private String address; + private String issueAuthority; + private String signDate; + private String expiryDate; + + public String getIdCardSide() { + return idCardSide; + } + + public void setIdCardSide(String idCardSide) { + this.idCardSide = idCardSide; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getIdNo() { + return idNo; + } + + public void setIdNo(String idNo) { + this.idNo = idNo; + } + + public String getGender() { + return gender; + } + + public void setGender(String gender) { + this.gender = gender; + } + + public String getEthnicity() { + return ethnicity; + } + + public void setEthnicity(String ethnicity) { + this.ethnicity = ethnicity; + } + + public String getBirth() { + return birth; + } + + public void setBirth(String birth) { + this.birth = birth; + } + + public String getAddress() { + return address; + } + + public void setAddress(String address) { + this.address = address; + } + + public String getIssueAuthority() { + return issueAuthority; + } + + public void setIssueAuthority(String issueAuthority) { + this.issueAuthority = issueAuthority; + } + + public String getSignDate() { + return signDate; + } + + public void setSignDate(String signDate) { + this.signDate = signDate; + } + + public String getExpiryDate() { + return expiryDate; + } + + public void setExpiryDate(String expiryDate) { + this.expiryDate = expiryDate; + } + } +} + diff --git a/backend/src/main/java/com/writeoff/module/ocr/dto/MultipleInvoiceOcrRequest.java b/backend/src/main/java/com/writeoff/module/ocr/dto/MultipleInvoiceOcrRequest.java new file mode 100644 index 0000000..d62c317 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/ocr/dto/MultipleInvoiceOcrRequest.java @@ -0,0 +1,17 @@ +package com.writeoff.module.ocr.dto; + +import javax.validation.constraints.NotBlank; + +public class MultipleInvoiceOcrRequest { + @NotBlank(message = "objectKey不能为空") + private String objectKey; + + public String getObjectKey() { + return objectKey; + } + + public void setObjectKey(String objectKey) { + this.objectKey = objectKey; + } +} + diff --git a/backend/src/main/java/com/writeoff/module/ocr/dto/MultipleInvoiceOcrResponse.java b/backend/src/main/java/com/writeoff/module/ocr/dto/MultipleInvoiceOcrResponse.java new file mode 100644 index 0000000..994c797 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/ocr/dto/MultipleInvoiceOcrResponse.java @@ -0,0 +1,91 @@ +package com.writeoff.module.ocr.dto; + +import java.util.Map; + +public class MultipleInvoiceOcrResponse { + private Map raw; + private Normalized normalized; + private String detectedType; + private Double probability; + + public Map getRaw() { + return raw; + } + + public void setRaw(Map raw) { + this.raw = raw; + } + + public Normalized getNormalized() { + return normalized; + } + + public void setNormalized(Normalized normalized) { + this.normalized = normalized; + } + + public String getDetectedType() { + return detectedType; + } + + public void setDetectedType(String detectedType) { + this.detectedType = detectedType; + } + + public Double getProbability() { + return probability; + } + + public void setProbability(Double probability) { + this.probability = probability; + } + + public static class Normalized { + private String invoiceCode; + private String invoiceNum; + private String name; + private Long totalAmountCent; + private Long taxCent; + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getInvoiceCode() { + return invoiceCode; + } + + public void setInvoiceCode(String invoiceCode) { + this.invoiceCode = invoiceCode; + } + + public String getInvoiceNum() { + return invoiceNum; + } + + public void setInvoiceNum(String invoiceNum) { + this.invoiceNum = invoiceNum; + } + + public Long getTotalAmountCent() { + return totalAmountCent; + } + + public void setTotalAmountCent(Long totalAmountCent) { + this.totalAmountCent = totalAmountCent; + } + + public Long getTaxCent() { + return taxCent; + } + + public void setTaxCent(Long taxCent) { + this.taxCent = taxCent; + } + } +} + diff --git a/backend/src/main/java/com/writeoff/module/ocr/service/BaiduBankCardOcrService.java b/backend/src/main/java/com/writeoff/module/ocr/service/BaiduBankCardOcrService.java new file mode 100644 index 0000000..eb49a21 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/ocr/service/BaiduBankCardOcrService.java @@ -0,0 +1,178 @@ +package com.writeoff.module.ocr.service; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.writeoff.common.exception.BusinessException; +import com.writeoff.common.exception.ErrorCodes; +import com.writeoff.module.file.service.OssService; +import com.writeoff.module.ocr.config.BaiduOcrProperties; +import com.writeoff.module.ocr.dto.BankCardOcrResponse; +import org.springframework.stereotype.Service; + +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.Map; + +@Service +public class BaiduBankCardOcrService { + private final BaiduOcrProperties props; + private final BaiduOcrTokenService tokenService; + private final OssService ossService; + private final ObjectMapper objectMapper = new ObjectMapper(); + + public BaiduBankCardOcrService( + BaiduOcrProperties props, + BaiduOcrTokenService tokenService, + OssService ossService + ) { + this.props = props; + this.tokenService = tokenService; + this.ossService = ossService; + } + + public BankCardOcrResponse recognize(String objectKey) { + String key = String.valueOf(objectKey == null ? "" : objectKey).trim(); + if (key.isEmpty()) { + throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "objectKey不能为空"); + } + if (key.toLowerCase().endsWith(".pdf")) { + throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "银行卡OCR仅支持图片文件"); + } + byte[] bytes = downloadFromOss(key); + Map raw = callBankCard(bytes); + BankCardOcrResponse response = new BankCardOcrResponse(); + response.setRaw(raw); + response.setNormalized(normalize(raw)); + return response; + } + + private Map callBankCard(byte[] bytes) { + try { + String token = tokenService.getAccessToken(); + String apiUrl = props.getBankCardUrl(); + if (apiUrl == null || apiUrl.trim().isEmpty()) { + throw new BusinessException(ErrorCodes.INTERNAL_ERROR, "百度OCR bankCardUrl未配置"); + } + String endpoint = apiUrl + "?access_token=" + URLEncoder.encode(token, "UTF-8"); + HttpURLConnection connection = (HttpURLConnection) new URL(endpoint).openConnection(); + connection.setRequestMethod("POST"); + connection.setConnectTimeout(Math.max(1000, props.getConnectTimeoutMs())); + connection.setReadTimeout(Math.max(1000, props.getReadTimeoutMs())); + connection.setDoOutput(true); + connection.setRequestProperty("Content-Type", "application/x-www-form-urlencoded"); + String imageBase64 = Base64.getEncoder().encodeToString(bytes); + String body = "image=" + URLEncoder.encode(imageBase64, "UTF-8"); + try (OutputStream os = connection.getOutputStream()) { + os.write(body.getBytes(StandardCharsets.UTF_8)); + } + int code = connection.getResponseCode(); + InputStream stream = code >= 200 && code < 300 ? connection.getInputStream() : connection.getErrorStream(); + String text = new String(readAllBytesWithLimit(stream, 2 * 1024 * 1024), StandardCharsets.UTF_8); + JsonNode json = objectMapper.readTree(text == null ? "" : text); + if (json.has("error_code")) { + String errMsg = json.has("error_msg") ? json.get("error_msg").asText() : "unknown"; + String logId = json.has("log_id") ? json.get("log_id").asText() : ""; + throw new BusinessException(ErrorCodes.INTERNAL_ERROR, "银行卡OCR识别失败: " + errMsg + (logId.isEmpty() ? "" : " (log_id=" + logId + ")")); + } + if (code < 200 || code >= 300) { + throw new BusinessException(ErrorCodes.INTERNAL_ERROR, "银行卡OCR识别失败: HTTP " + code); + } + return objectMapper.convertValue(json, new TypeReference>() {}); + } catch (Exception e) { + if (e instanceof BusinessException) { + throw (BusinessException) e; + } + throw new BusinessException(ErrorCodes.INTERNAL_ERROR, "调用百度银行卡OCR失败: " + e.getMessage()); + } + } + + @SuppressWarnings("unchecked") + private BankCardOcrResponse.Normalized normalize(Map raw) { + BankCardOcrResponse.Normalized n = new BankCardOcrResponse.Normalized(); + Object resultObj = raw.get("result"); + if (!(resultObj instanceof Map)) { + return n; + } + Map result = (Map) resultObj; + n.setBankCardNumber(asText(result.get("bank_card_number"))); + n.setValidDate(asText(result.get("valid_date"))); + n.setBankCardType(asInt(result.get("bank_card_type"))); + n.setBankName(asText(result.get("bank_name"))); + n.setHolderName(asText(result.get("holder_name"))); + return n; + } + + private static String asText(Object value) { + return value == null ? null : String.valueOf(value).trim(); + } + + private static Integer asInt(Object value) { + if (value == null) { + return null; + } + if (value instanceof Number) { + return ((Number) value).intValue(); + } + try { + return Integer.parseInt(String.valueOf(value).trim()); + } catch (Exception ignored) { + return null; + } + } + + private byte[] downloadFromOss(String objectKey) { + String signedUrl = ossService.generateDownloadUrl(objectKey); + if (signedUrl == null || signedUrl.trim().isEmpty()) { + throw new BusinessException(ErrorCodes.INTERNAL_ERROR, "生成OSS下载链接失败"); + } + try { + URL url = new URL(signedUrl); + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + conn.setRequestMethod("GET"); + conn.setConnectTimeout(Math.max(1000, props.getConnectTimeoutMs())); + conn.setReadTimeout(Math.max(1000, props.getReadTimeoutMs())); + int status = conn.getResponseCode(); + InputStream is = status >= 200 && status < 300 ? conn.getInputStream() : conn.getErrorStream(); + byte[] data = readAllBytesWithLimit(is, props.getMaxBytes()); + if (status < 200 || status >= 300) { + throw new BusinessException(ErrorCodes.INTERNAL_ERROR, "拉取OSS文件失败: HTTP " + status); + } + return data; + } catch (BusinessException be) { + throw be; + } catch (Exception e) { + throw new BusinessException(ErrorCodes.INTERNAL_ERROR, "拉取OSS文件异常: " + e.getMessage()); + } + } + + private static byte[] readAllBytesWithLimit(InputStream is, long maxBytes) throws java.io.IOException { + if (is == null) { + return new byte[0]; + } + long limit = maxBytes <= 0 ? 0 : maxBytes; + try (InputStream in = is; ByteArrayOutputStream bos = new ByteArrayOutputStream()) { + byte[] buffer = new byte[8192]; + long total = 0; + int n; + while ((n = in.read(buffer)) >= 0) { + if (n == 0) { + continue; + } + total += n; + if (limit > 0 && total > limit) { + throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "文件过大,超过限制"); + } + bos.write(buffer, 0, n); + } + return bos.toByteArray(); + } + } +} + diff --git a/backend/src/main/java/com/writeoff/module/ocr/service/BaiduDocumentExtractService.java b/backend/src/main/java/com/writeoff/module/ocr/service/BaiduDocumentExtractService.java new file mode 100644 index 0000000..ec4dfbd --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/ocr/service/BaiduDocumentExtractService.java @@ -0,0 +1,366 @@ +package com.writeoff.module.ocr.service; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.writeoff.common.exception.BusinessException; +import com.writeoff.common.exception.ErrorCodes; +import com.writeoff.module.file.service.OssService; +import com.writeoff.module.ocr.config.BaiduOcrProperties; +import com.writeoff.module.ocr.dto.DocumentExtractTaskQueryResponse; +import com.writeoff.module.ocr.dto.DocumentExtractTaskSubmitRequest; +import com.writeoff.module.ocr.dto.DocumentExtractTaskSubmitResponse; +import org.springframework.stereotype.Service; + +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Base64; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@Service +public class BaiduDocumentExtractService { + private static final long RESPONSE_MAX_BYTES = 8L * 1024 * 1024; + + private final BaiduOcrProperties props; + private final BaiduOcrTokenService tokenService; + private final OssService ossService; + private final ObjectMapper objectMapper = new ObjectMapper(); + + public BaiduDocumentExtractService( + BaiduOcrProperties props, + BaiduOcrTokenService tokenService, + OssService ossService + ) { + this.props = props; + this.tokenService = tokenService; + this.ossService = ossService; + } + + public DocumentExtractTaskSubmitResponse submitTask(DocumentExtractTaskSubmitRequest request) { + validateSubmitRequest(request); + + String objectKey = trimToNull(request.getObjectKey()); + List fileUrls = sanitizeFileUrls(request.getFileUrls()); + + List formParts = new ArrayList(); + if (objectKey != null) { + byte[] bytes = downloadFromOss(objectKey); + String resolvedFileName = resolveFileName(request.getFileName(), objectKey); + formParts.add(formParam("file", Base64.getEncoder().encodeToString(bytes))); + formParts.add(formParam("fileName", resolvedFileName)); + } else { + formParts.add(formParam("fileURLs", toJson(fileUrls))); + } + + String manifestVersionId = trimToNull(request.getManifestVersionId()); + if (manifestVersionId != null) { + formParts.add(formParam("manifestVersionId", manifestVersionId)); + } else { + formParts.add(formParam("manifest", toJson(buildManifestPayload(request.getManifest())))); + } + + if (request.getRemoveDuplicates() != null) { + formParts.add(formParam("removeDuplicates", String.valueOf(request.getRemoveDuplicates()))); + } + if (trimToNull(request.getPageRange()) != null) { + formParts.add(formParam("pageRange", request.getPageRange().trim())); + } + if (request.getExtractSeal() != null) { + formParts.add(formParam("extractSeal", String.valueOf(request.getExtractSeal()))); + } + if (request.getEraseWatermark() != null) { + formParts.add(formParam("eraseWatermark", String.valueOf(request.getEraseWatermark()))); + } + + JsonNode json = doPostForm( + props.getDocumentExtractTaskUrl(), + joinFormParts(formParts), + "Baidu document extract submit failed" + ); + + DocumentExtractTaskSubmitResponse response = new DocumentExtractTaskSubmitResponse(); + response.setTaskId(asText(json.path("result").path("taskId"))); + response.setLogId(asText(json.path("log_id"))); + response.setRaw(toMap(json)); + return response; + } + + public DocumentExtractTaskQueryResponse queryTask(String taskId) { + String normalizedTaskId = trimToNull(taskId); + if (normalizedTaskId == null) { + throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "taskId must not be blank"); + } + + JsonNode json = doPostForm( + props.getDocumentExtractQueryUrl(), + formParam("taskId", normalizedTaskId), + "Baidu document extract query failed" + ); + + JsonNode result = json.path("result"); + DocumentExtractTaskQueryResponse response = new DocumentExtractTaskQueryResponse(); + response.setTaskId(asText(result.path("taskId"))); + response.setStatus(asText(result.path("status"))); + response.setReason(asText(result.path("reason"))); + response.setCreatedAt(asText(result.path("createdAt"))); + response.setStartedAt(asText(result.path("startedAt"))); + response.setFinishedAt(asText(result.path("finishedAt"))); + response.setDuration(asLong(result.path("duration"))); + response.setLogId(asText(json.path("log_id"))); + response.setRaw(toMap(json)); + return response; + } + + private void validateSubmitRequest(DocumentExtractTaskSubmitRequest request) { + String objectKey = trimToNull(request.getObjectKey()); + List fileUrls = sanitizeFileUrls(request.getFileUrls()); + if (objectKey == null && fileUrls.isEmpty()) { + throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "objectKey or fileUrls is required"); + } + if (objectKey != null && !fileUrls.isEmpty()) { + fileUrls.clear(); + } + + String manifestVersionId = trimToNull(request.getManifestVersionId()); + if (manifestVersionId == null) { + List manifest = request.getManifest(); + if (manifest == null || manifest.isEmpty()) { + throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "manifestVersionId or manifest is required"); + } + } + } + + private List> buildManifestPayload(List manifest) { + List> payload = new ArrayList>(); + if (manifest == null) { + return payload; + } + for (DocumentExtractTaskSubmitRequest.ManifestField field : manifest) { + if (field == null) { + continue; + } + String key = trimToNull(field.getKey()); + if (key == null) { + throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "manifest key must not be blank"); + } + Map item = new LinkedHashMap(); + item.put("key", key); + item.put("parentKey", defaultString(field.getParentKey())); + item.put("description", defaultString(field.getDescription())); + payload.add(item); + } + return payload; + } + + private JsonNode doPostForm(String apiUrl, String formBody, String failurePrefix) { + String endpoint = trimToNull(apiUrl); + if (endpoint == null) { + throw new BusinessException(ErrorCodes.INTERNAL_ERROR, failurePrefix + ": endpoint is not configured"); + } + + try { + String accessToken = tokenService.getAccessToken(); + String requestUrl = endpoint + "?access_token=" + URLEncoder.encode(accessToken, "UTF-8"); + HttpURLConnection connection = (HttpURLConnection) new URL(requestUrl).openConnection(); + connection.setRequestMethod("POST"); + connection.setDoOutput(true); + connection.setConnectTimeout(Math.max(1000, props.getConnectTimeoutMs())); + connection.setReadTimeout(Math.max(1000, props.getReadTimeoutMs())); + connection.setRequestProperty("Content-Type", "application/x-www-form-urlencoded"); + + try (OutputStream os = connection.getOutputStream()) { + os.write(formBody.getBytes(StandardCharsets.UTF_8)); + } + + int code = connection.getResponseCode(); + InputStream stream = code >= 200 && code < 300 ? connection.getInputStream() : connection.getErrorStream(); + String text = new String(readAllBytesWithLimit(stream, RESPONSE_MAX_BYTES), StandardCharsets.UTF_8); + JsonNode json = objectMapper.readTree(text == null ? "" : text); + + if (json.has("error_code") && json.path("error_code").asInt() != 0) { + String errorMsg = asText(json.path("error_msg")); + String logId = asText(json.path("log_id")); + throw new BusinessException( + ErrorCodes.INTERNAL_ERROR, + failurePrefix + ": " + defaultString(errorMsg) + (logId == null ? "" : " (log_id=" + logId + ")") + ); + } + if (code < 200 || code >= 300) { + throw new BusinessException(ErrorCodes.INTERNAL_ERROR, failurePrefix + ": HTTP " + code); + } + return json; + } catch (BusinessException ex) { + throw ex; + } catch (Exception ex) { + throw new BusinessException(ErrorCodes.INTERNAL_ERROR, failurePrefix + ": " + ex.getMessage()); + } + } + + private byte[] downloadFromOss(String objectKey) { + String signedUrl = ossService.generateDownloadUrl(objectKey); + if (trimToNull(signedUrl) == null) { + throw new BusinessException(ErrorCodes.INTERNAL_ERROR, "Failed to generate OSS download url"); + } + try { + HttpURLConnection connection = (HttpURLConnection) new URL(signedUrl).openConnection(); + connection.setRequestMethod("GET"); + connection.setConnectTimeout(Math.max(1000, props.getConnectTimeoutMs())); + connection.setReadTimeout(Math.max(1000, props.getReadTimeoutMs())); + int code = connection.getResponseCode(); + InputStream stream = code >= 200 && code < 300 ? connection.getInputStream() : connection.getErrorStream(); + byte[] bytes = readAllBytesWithLimit(stream, props.getDocumentExtractMaxBytes()); + if (code < 200 || code >= 300) { + throw new BusinessException(ErrorCodes.INTERNAL_ERROR, "Failed to download OSS file: HTTP " + code); + } + if (bytes.length == 0) { + throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "file content is empty"); + } + return bytes; + } catch (BusinessException ex) { + throw ex; + } catch (Exception ex) { + throw new BusinessException(ErrorCodes.INTERNAL_ERROR, "Failed to download OSS file: " + ex.getMessage()); + } + } + + private String resolveFileName(String requestedFileName, String objectKey) { + String fileName = trimToNull(requestedFileName); + if (fileName != null) { + return fileName; + } + String inferred = inferFileNameFromObjectKey(objectKey); + if (inferred == null) { + throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "fileName is required when objectKey has no filename"); + } + return inferred; + } + + private String inferFileNameFromObjectKey(String objectKey) { + String text = trimToNull(objectKey); + if (text == null) { + return null; + } + int slash = Math.max(text.lastIndexOf('/'), text.lastIndexOf('\\')); + String fileName = slash >= 0 ? text.substring(slash + 1) : text; + return trimToNull(fileName); + } + + private List sanitizeFileUrls(List fileUrls) { + List result = new ArrayList(); + if (fileUrls == null) { + return result; + } + for (String fileUrl : fileUrls) { + String value = trimToNull(fileUrl); + if (value != null) { + result.add(value); + } + } + return result; + } + + private String toJson(Object value) { + try { + return objectMapper.writeValueAsString(value); + } catch (Exception ex) { + throw new BusinessException(ErrorCodes.INTERNAL_ERROR, "Failed to serialize request body: " + ex.getMessage()); + } + } + + private Map toMap(JsonNode json) { + return objectMapper.convertValue(json, new TypeReference>() { + }); + } + + private String joinFormParts(List formParts) { + StringBuilder builder = new StringBuilder(); + for (int i = 0; i < formParts.size(); i++) { + if (i > 0) { + builder.append('&'); + } + builder.append(formParts.get(i)); + } + return builder.toString(); + } + + private String formParam(String key, String value) { + try { + return URLEncoder.encode(key, "UTF-8") + "=" + URLEncoder.encode(value == null ? "" : value, "UTF-8"); + } catch (Exception ex) { + throw new BusinessException(ErrorCodes.INTERNAL_ERROR, "Failed to encode request body: " + ex.getMessage()); + } + } + + private static String trimToNull(String value) { + if (value == null) { + return null; + } + String text = value.trim(); + return text.isEmpty() ? null : text; + } + + private static String defaultString(String value) { + return value == null ? "" : value.trim(); + } + + private static String asText(JsonNode node) { + if (node == null || node.isMissingNode() || node.isNull()) { + return null; + } + String text = node.asText(); + if (text == null) { + return null; + } + text = text.trim(); + return text.isEmpty() ? null : text; + } + + private static Long asLong(JsonNode node) { + if (node == null || node.isMissingNode() || node.isNull()) { + return null; + } + if (node.isNumber()) { + return node.asLong(); + } + String text = asText(node); + if (text == null) { + return null; + } + try { + return Long.parseLong(text); + } catch (Exception ex) { + return null; + } + } + + private static byte[] readAllBytesWithLimit(InputStream is, long maxBytes) throws java.io.IOException { + if (is == null) { + return new byte[0]; + } + long limit = maxBytes <= 0 ? 0 : maxBytes; + try (InputStream in = is; ByteArrayOutputStream bos = new ByteArrayOutputStream()) { + byte[] buffer = new byte[8192]; + long total = 0; + int n; + while ((n = in.read(buffer)) >= 0) { + if (n == 0) { + continue; + } + total += n; + if (limit > 0 && total > limit) { + throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "file exceeds size limit"); + } + bos.write(buffer, 0, n); + } + return bos.toByteArray(); + } + } +} diff --git a/backend/src/main/java/com/writeoff/module/ocr/service/BaiduIdCardOcrService.java b/backend/src/main/java/com/writeoff/module/ocr/service/BaiduIdCardOcrService.java new file mode 100644 index 0000000..104e80e --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/ocr/service/BaiduIdCardOcrService.java @@ -0,0 +1,182 @@ +package com.writeoff.module.ocr.service; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.writeoff.common.exception.BusinessException; +import com.writeoff.common.exception.ErrorCodes; +import com.writeoff.module.file.service.OssService; +import com.writeoff.module.ocr.config.BaiduOcrProperties; +import com.writeoff.module.ocr.dto.IdCardOcrResponse; +import org.springframework.stereotype.Service; + +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.Map; + +@Service +public class BaiduIdCardOcrService { + private final BaiduOcrProperties props; + private final BaiduOcrTokenService tokenService; + private final OssService ossService; + private final ObjectMapper objectMapper = new ObjectMapper(); + + public BaiduIdCardOcrService(BaiduOcrProperties props, BaiduOcrTokenService tokenService, OssService ossService) { + this.props = props; + this.tokenService = tokenService; + this.ossService = ossService; + } + + public IdCardOcrResponse recognize(String objectKey, String idCardSide) { + String key = String.valueOf(objectKey == null ? "" : objectKey).trim(); + String side = String.valueOf(idCardSide == null ? "" : idCardSide).trim().toLowerCase(); + if (key.isEmpty()) { + throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "objectKey不能为空"); + } + if (!"front".equals(side) && !"back".equals(side)) { + throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "idCardSide仅支持front或back"); + } + if (key.toLowerCase().endsWith(".pdf")) { + throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "身份证OCR仅支持图片文件"); + } + + byte[] bytes = downloadFromOss(key); + if (bytes.length <= 0) { + throw new BusinessException(ErrorCodes.INTERNAL_ERROR, "文件内容为空"); + } + Map raw = callIdCardOcr(side, bytes); + + IdCardOcrResponse resp = new IdCardOcrResponse(); + resp.setRaw(raw); + resp.setNormalized(normalize(raw, side)); + return resp; + } + + private byte[] downloadFromOss(String objectKey) { + String signedUrl = ossService.generateDownloadUrl(objectKey); + if (signedUrl == null || signedUrl.trim().isEmpty()) { + throw new BusinessException(ErrorCodes.INTERNAL_ERROR, "生成OSS下载链接失败"); + } + try { + URL url = new URL(signedUrl); + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + conn.setRequestMethod("GET"); + conn.setConnectTimeout(Math.max(1000, props.getConnectTimeoutMs())); + conn.setReadTimeout(Math.max(1000, props.getReadTimeoutMs())); + int status = conn.getResponseCode(); + InputStream is = status >= 200 && status < 300 ? conn.getInputStream() : conn.getErrorStream(); + byte[] data = readAllBytesWithLimit(is, props.getMaxBytes()); + if (status < 200 || status >= 300) { + throw new BusinessException(ErrorCodes.INTERNAL_ERROR, "拉取OSS文件失败: HTTP " + status); + } + return data; + } catch (BusinessException be) { + throw be; + } catch (Exception e) { + throw new BusinessException(ErrorCodes.INTERNAL_ERROR, "拉取OSS文件异常: " + e.getMessage()); + } + } + + private Map callIdCardOcr(String side, byte[] bytes) { + String accessToken = tokenService.getAccessToken(); + String apiUrl = props.getIdCardUrl(); + if (apiUrl == null || apiUrl.trim().isEmpty()) { + throw new BusinessException(ErrorCodes.INTERNAL_ERROR, "百度OCR idCardUrl未配置"); + } + String b64 = Base64.getEncoder().encodeToString(bytes); + try { + String form = "id_card_side=" + URLEncoder.encode(side, "UTF-8") + + "&image=" + URLEncoder.encode(b64, "UTF-8"); + String url = apiUrl + "?access_token=" + URLEncoder.encode(accessToken, "UTF-8"); + HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection(); + conn.setRequestMethod("POST"); + conn.setDoOutput(true); + conn.setConnectTimeout(Math.max(1000, props.getConnectTimeoutMs())); + conn.setReadTimeout(Math.max(1000, props.getReadTimeoutMs())); + conn.setRequestProperty("Content-Type", "application/x-www-form-urlencoded"); + try (OutputStream os = conn.getOutputStream()) { + os.write(form.getBytes(StandardCharsets.UTF_8)); + } + int status = conn.getResponseCode(); + InputStream is = status >= 200 && status < 300 ? conn.getInputStream() : conn.getErrorStream(); + String body = new String(readAllBytesWithLimit(is, 2 * 1024 * 1024), StandardCharsets.UTF_8); + JsonNode json = objectMapper.readTree(body == null ? "" : body); + if (json.has("error_code")) { + String errMsg = json.has("error_msg") ? json.get("error_msg").asText() : "unknown"; + String logId = json.has("log_id") ? json.get("log_id").asText() : ""; + throw new BusinessException(ErrorCodes.INTERNAL_ERROR, "身份证OCR识别失败: " + errMsg + (logId.isEmpty() ? "" : " (log_id=" + logId + ")")); + } + if (status < 200 || status >= 300) { + throw new BusinessException(ErrorCodes.INTERNAL_ERROR, "身份证OCR识别失败: HTTP " + status); + } + return objectMapper.convertValue(json, new TypeReference>() { + }); + } catch (BusinessException be) { + throw be; + } catch (Exception e) { + throw new BusinessException(ErrorCodes.INTERNAL_ERROR, "身份证OCR调用异常: " + e.getMessage()); + } + } + + @SuppressWarnings("unchecked") + private IdCardOcrResponse.Normalized normalize(Map raw, String side) { + IdCardOcrResponse.Normalized n = new IdCardOcrResponse.Normalized(); + n.setIdCardSide(side); + Object wr = raw == null ? null : raw.get("words_result"); + Map words = wr instanceof Map ? (Map) wr : null; + if (words == null) { + return n; + } + n.setName(extractWord(words, "姓名")); + n.setIdNo(extractWord(words, "公民身份号码")); + n.setGender(extractWord(words, "性别")); + n.setEthnicity(extractWord(words, "民族")); + n.setBirth(extractWord(words, "出生")); + n.setAddress(extractWord(words, "住址")); + n.setIssueAuthority(extractWord(words, "签发机关")); + n.setSignDate(extractWord(words, "签发日期")); + n.setExpiryDate(extractWord(words, "失效日期")); + return n; + } + + @SuppressWarnings("unchecked") + private static String extractWord(Map wordsResult, String key) { + Object v = wordsResult.get(key); + if (!(v instanceof Map)) { + return null; + } + Object word = ((Map) v).get("words"); + String s = String.valueOf(word == null ? "" : word).trim(); + return s.isEmpty() ? null : s; + } + + private static byte[] readAllBytesWithLimit(InputStream is, long maxBytes) throws java.io.IOException { + if (is == null) { + return new byte[0]; + } + long limit = maxBytes <= 0 ? 0 : maxBytes; + try (InputStream in = is; ByteArrayOutputStream bos = new ByteArrayOutputStream()) { + byte[] buf = new byte[8192]; + long total = 0; + int n; + while ((n = in.read(buf)) >= 0) { + if (n == 0) { + continue; + } + total += n; + if (limit > 0 && total > limit) { + throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "文件过大,超过限制"); + } + bos.write(buf, 0, n); + } + return bos.toByteArray(); + } + } +} + diff --git a/backend/src/main/java/com/writeoff/module/ocr/service/BaiduMultipleInvoiceOcrService.java b/backend/src/main/java/com/writeoff/module/ocr/service/BaiduMultipleInvoiceOcrService.java new file mode 100644 index 0000000..98bcd43 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/ocr/service/BaiduMultipleInvoiceOcrService.java @@ -0,0 +1,462 @@ +package com.writeoff.module.ocr.service; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.writeoff.common.exception.BusinessException; +import com.writeoff.common.exception.ErrorCodes; +import com.writeoff.module.file.service.OssService; +import com.writeoff.module.ocr.config.BaiduOcrProperties; +import com.writeoff.module.ocr.dto.MultipleInvoiceOcrResponse; +import org.springframework.stereotype.Service; + +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.Locale; +import java.util.Map; + +@Service +public class BaiduMultipleInvoiceOcrService { + private final BaiduOcrProperties props; + private final BaiduOcrTokenService tokenService; + private final OssService ossService; + private final ObjectMapper objectMapper = new ObjectMapper(); + + public BaiduMultipleInvoiceOcrService(BaiduOcrProperties props, BaiduOcrTokenService tokenService, OssService ossService) { + this.props = props; + this.tokenService = tokenService; + this.ossService = ossService; + } + + public MultipleInvoiceOcrResponse recognize(String objectKey) { + String key = String.valueOf(objectKey == null ? "" : objectKey).trim(); + if (key.isEmpty()) { + throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "objectKey不能为空"); + } + + byte[] bytes = downloadFromOss(key); + if (bytes.length <= 0) { + throw new BusinessException(ErrorCodes.INTERNAL_ERROR, "文件内容为空"); + } + + Map raw = callBaiduMultipleInvoice(key, bytes); + MultipleInvoiceOcrResponse resp = new MultipleInvoiceOcrResponse(); + resp.setRaw(raw); + + NormalizedPick pick = pickBestResult(raw); + if (pick != null) { + resp.setDetectedType(pick.type); + resp.setProbability(pick.probability); + resp.setNormalized(normalize(pick.result, pick.type)); + } else { + resp.setDetectedType(null); + resp.setProbability(null); + resp.setNormalized(new MultipleInvoiceOcrResponse.Normalized()); + } + return resp; + } + + private byte[] downloadFromOss(String objectKey) { + String signedUrl = ossService.generateDownloadUrl(objectKey); + if (signedUrl == null || signedUrl.trim().isEmpty()) { + throw new BusinessException(ErrorCodes.INTERNAL_ERROR, "生成OSS下载链接失败"); + } + try { + URL url = new URL(signedUrl); + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + conn.setRequestMethod("GET"); + conn.setConnectTimeout(Math.max(1000, props.getConnectTimeoutMs())); + conn.setReadTimeout(Math.max(1000, props.getReadTimeoutMs())); + int status = conn.getResponseCode(); + InputStream is = status >= 200 && status < 300 ? conn.getInputStream() : conn.getErrorStream(); + byte[] data = readAllBytesWithLimit(is, props.getMaxBytes()); + if (status < 200 || status >= 300) { + throw new BusinessException(ErrorCodes.INTERNAL_ERROR, "拉取OSS文件失败: HTTP " + status); + } + return data; + } catch (BusinessException be) { + throw be; + } catch (Exception e) { + throw new BusinessException(ErrorCodes.INTERNAL_ERROR, "拉取OSS文件异常: " + e.getMessage()); + } + } + + private Map callBaiduMultipleInvoice(String objectKey, byte[] bytes) { + String accessToken = tokenService.getAccessToken(); + String apiUrl = props.getMultipleInvoiceUrl(); + if (apiUrl == null || apiUrl.trim().isEmpty()) { + throw new BusinessException(ErrorCodes.INTERNAL_ERROR, "百度OCR multipleInvoiceUrl未配置"); + } + + boolean isPdf = objectKey.toLowerCase().endsWith(".pdf"); + String fieldName = isPdf ? "pdf_file" : "image"; + String b64 = Base64.getEncoder().encodeToString(bytes); + + try { + String encoded = URLEncoder.encode(b64, "UTF-8"); + String form = fieldName + "=" + encoded; + String url = apiUrl + "?access_token=" + URLEncoder.encode(accessToken, "UTF-8"); + HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection(); + conn.setRequestMethod("POST"); + conn.setDoOutput(true); + conn.setConnectTimeout(Math.max(1000, props.getConnectTimeoutMs())); + conn.setReadTimeout(Math.max(1000, props.getReadTimeoutMs())); + conn.setRequestProperty("Content-Type", "application/x-www-form-urlencoded"); + + try (OutputStream os = conn.getOutputStream()) { + os.write(form.getBytes(StandardCharsets.UTF_8)); + } + + int status = conn.getResponseCode(); + InputStream is = status >= 200 && status < 300 ? conn.getInputStream() : conn.getErrorStream(); + String body = new String(readAllBytesWithLimit(is, 2 * 1024 * 1024), StandardCharsets.UTF_8); + JsonNode json = objectMapper.readTree(body == null ? "" : body); + + if (json.has("error_code")) { + String errMsg = json.has("error_msg") ? json.get("error_msg").asText() : "unknown"; + String logId = json.has("log_id") ? json.get("log_id").asText() : ""; + throw new BusinessException( + ErrorCodes.INTERNAL_ERROR, + "百度OCR识别失败: " + errMsg + (logId.isEmpty() ? "" : " (log_id=" + logId + ")") + ); + } + if (status < 200 || status >= 300) { + throw new BusinessException(ErrorCodes.INTERNAL_ERROR, "百度OCR识别失败: HTTP " + status); + } + return objectMapper.convertValue(json, new TypeReference>() { + }); + } catch (BusinessException be) { + throw be; + } catch (Exception e) { + throw new BusinessException(ErrorCodes.INTERNAL_ERROR, "百度OCR调用异常: " + e.getMessage()); + } + } + + private static class NormalizedPick { + final String type; + final Double probability; + final Map result; + + NormalizedPick(String type, Double probability, Map result) { + this.type = type; + this.probability = probability; + this.result = result; + } + } + + @SuppressWarnings("unchecked") + private NormalizedPick pickBestResult(Map raw) { + Object wordsResult = raw == null ? null : raw.get("words_result"); + if (!(wordsResult instanceof Iterable)) { + return null; + } + return ((Iterable) wordsResult).iterator().hasNext() ? streamPick((Iterable) wordsResult) : null; + } + + @SuppressWarnings("unchecked") + private NormalizedPick streamPick(Iterable items) { + return java.util.stream.StreamSupport.stream(items.spliterator(), false) + .filter(x -> x instanceof Map) + .map(x -> (Map) x) + .map(map -> { + String type = firstNonBlank( + trimToNull(map.get("type")), + trimToNull(map.get("detectedType")) + ); + Double prob = null; + Object p = map.get("probability"); + if (p instanceof Number) { + prob = ((Number) p).doubleValue(); + } else if (p != null) { + try { + prob = Double.parseDouble(String.valueOf(p)); + } catch (Exception ignored) { + prob = null; + } + } + Object res = map.get("result"); + Map result = res instanceof Map ? (Map) res : new LinkedHashMap<>(); + return new NormalizedPick(normalizeType(type), prob, result); + }) + .max(Comparator.comparingDouble(p -> p.probability == null ? -1.0 : p.probability)) + .orElse(null); + } + + private MultipleInvoiceOcrResponse.Normalized normalize(Map result, String type) { + MultipleInvoiceOcrResponse.Normalized normalized = new MultipleInvoiceOcrResponse.Normalized(); + if (result == null) { + return normalized; + } + + String normalizedType = normalizeType(type); + if ("vat_invoice".equals(normalizedType)) { + normalizeVatInvoice(normalized, result); + } else if ("air_ticket".equals(normalizedType)) { + normalizeAirTicket(normalized, result); + } + + fillGenericFallback(normalized, result); + return normalized; + } + + private void normalizeVatInvoice(MultipleInvoiceOcrResponse.Normalized normalized, Map result) { + normalized.setInvoiceCode(extractFirstWord( + result, + "InvoiceCode", + "InvoiceCodeConfirm", + "invoice_code", + "fapiao-daima" + )); + normalized.setInvoiceNum(extractFirstWord( + result, + "InvoiceNum", + "InvoiceNumConfirm", + "invoice_num", + "fapiao-haoma", + "invoice_number" + )); + normalized.setName(extractFirstWord( + result, + "SellerName", + "seller_name", + "name" + )); + normalized.setTaxCent(parseMoneyToCent(extractFirstWord( + result, + "TotalTax", + "TotalTaxAmount", + "tax_total", + "tax" + ))); + normalized.setTotalAmountCent(parseMoneyToCent(extractFirstWord( + result, + "AmountInFiguers", + "AmountInFigures", + "amount_in_figuers", + "amount_in_figures", + "price-tax-small", + "small_price", + "price_tax_small" + ))); + } + + private void normalizeAirTicket(MultipleInvoiceOcrResponse.Normalized normalized, Map result) { + normalized.setInvoiceCode(extractFirstWord( + result, + "invoice_code", + "InvoiceCode", + "fapiao-daima" + )); + normalized.setInvoiceNum(extractFirstWord( + result, + "invoice_num", + "InvoiceNum", + "invoice_number", + "ticket_num" + )); + normalized.setName(extractFirstWord( + result, + "name", + "Name", + "PassengName", + "PassengerName" + )); + normalized.setTaxCent(parseMoneyToCent(extractFirstWord( + result, + "commodity_tax", + "CommodityTax", + "tax_total", + "tax" + ))); + normalized.setTotalAmountCent(parseMoneyToCent(extractFirstWord( + result, + "ticket_rates", + "ticket_rates_in_figures", + "invoice_rate", + "invoice_rate_in_figure", + "TotalFare", + "total_fare", + "Fare", + "fare" + ))); + } + + private void fillGenericFallback(MultipleInvoiceOcrResponse.Normalized normalized, Map result) { + if (isBlank(normalized.getInvoiceCode())) { + normalized.setInvoiceCode(extractFirstWord( + result, + "InvoiceCode", + "InvoiceCodeConfirm", + "invoice_code", + "fapiao-daima" + )); + } + if (isBlank(normalized.getInvoiceNum())) { + normalized.setInvoiceNum(extractFirstWord( + result, + "InvoiceNum", + "InvoiceNumConfirm", + "invoice_num", + "fapiao-haoma", + "invoice_number", + "ticket_num" + )); + } + if (isBlank(normalized.getName())) { + normalized.setName(extractFirstWord( + result, + "SellerName", + "seller_name", + "PassengName", + "PassengerName", + "name", + "Name", + "buyer-name" + )); + } + if (normalized.getTaxCent() == null) { + normalized.setTaxCent(parseMoneyToCent(extractFirstWord( + result, + "TotalTax", + "commodity_tax", + "CommodityTax", + "tax", + "TotalTaxAmount", + "tax_total" + ))); + } + if (normalized.getTotalAmountCent() == null) { + normalized.setTotalAmountCent(parseMoneyToCent(extractFirstWord( + result, + "ticket_rates", + "AmountInFiguers", + "AmountInFigures", + "amount_in_figuers", + "amount_in_figures", + "TotalAmount", + "total_amount", + "TotalFare", + "total_fare", + "Fare", + "fare", + "ticket_rates_in_figures", + "invoice_rate", + "invoice_rate_in_figure", + "price-tax-small", + "small_price", + "price_tax_small" + ))); + } + } + + @SuppressWarnings("unchecked") + private static String extractFirstWord(Map result, String... keys) { + for (String key : keys) { + Object val = result.get(key); + if (val == null) { + continue; + } + if (val instanceof Iterable) { + for (Object item : (Iterable) val) { + if (item instanceof Map) { + Object word = ((Map) item).get("word"); + String text = String.valueOf(word == null ? "" : word).trim(); + if (!text.isEmpty()) { + return text; + } + } + } + } else if (val instanceof Map) { + Object word = ((Map) val).get("word"); + String text = String.valueOf(word == null ? "" : word).trim(); + if (!text.isEmpty()) { + return text; + } + } else { + String text = String.valueOf(val).trim(); + if (!text.isEmpty()) { + return text; + } + } + } + return null; + } + + private static String trimToNull(Object value) { + if (value == null) { + return null; + } + String text = String.valueOf(value).trim(); + return text.isEmpty() ? null : text; + } + + private static String firstNonBlank(String... values) { + for (String value : values) { + if (value != null && !value.trim().isEmpty()) { + return value.trim(); + } + } + return null; + } + + private static String normalizeType(String type) { + return type == null ? null : type.trim().toLowerCase(Locale.ROOT); + } + + private static boolean isBlank(String value) { + return value == null || value.trim().isEmpty(); + } + + private static Long parseMoneyToCent(String raw) { + if (raw == null) { + return null; + } + String s = raw.trim(); + if (s.isEmpty()) { + return null; + } + s = s.replaceAll("[,,\\s]", ""); + String cleaned = s.replaceAll("[^0-9.\\-]", ""); + if (cleaned.isEmpty() || cleaned.equals("-") || cleaned.equals(".")) { + return null; + } + try { + java.math.BigDecimal bd = new java.math.BigDecimal(cleaned); + bd = bd.setScale(2, java.math.RoundingMode.HALF_UP); + return bd.multiply(new java.math.BigDecimal("100")).longValueExact(); + } catch (Exception e) { + return null; + } + } + + private static byte[] readAllBytesWithLimit(InputStream is, long maxBytes) throws java.io.IOException { + if (is == null) { + return new byte[0]; + } + long limit = maxBytes <= 0 ? 0 : maxBytes; + try (InputStream in = is; ByteArrayOutputStream bos = new ByteArrayOutputStream()) { + byte[] buf = new byte[8192]; + long total = 0; + int n; + while ((n = in.read(buf)) >= 0) { + if (n == 0) { + continue; + } + total += n; + if (limit > 0 && total > limit) { + throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "文件过大,超过限制"); + } + bos.write(buf, 0, n); + } + return bos.toByteArray(); + } + } +} diff --git a/backend/src/main/java/com/writeoff/module/ocr/service/BaiduOcrTokenService.java b/backend/src/main/java/com/writeoff/module/ocr/service/BaiduOcrTokenService.java new file mode 100644 index 0000000..1449e70 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/ocr/service/BaiduOcrTokenService.java @@ -0,0 +1,111 @@ +package com.writeoff.module.ocr.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.writeoff.common.exception.BusinessException; +import com.writeoff.common.exception.ErrorCodes; +import com.writeoff.module.ocr.config.BaiduOcrProperties; +import org.springframework.stereotype.Service; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.net.HttpURLConnection; +import java.net.URL; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.Objects; + +@Service +public class BaiduOcrTokenService { + private final BaiduOcrProperties props; + private final ObjectMapper objectMapper = new ObjectMapper(); + + private final Object lock = new Object(); + private volatile String cachedToken; + private volatile long cachedTokenExpireAtMs; + + public BaiduOcrTokenService(BaiduOcrProperties props) { + this.props = props; + } + + public String getAccessToken() { + String token = cachedToken; + long expireAt = cachedTokenExpireAtMs; + long now = System.currentTimeMillis(); + if (token != null && now < expireAt) { + return token; + } + synchronized (lock) { + token = cachedToken; + expireAt = cachedTokenExpireAtMs; + now = System.currentTimeMillis(); + if (token != null && now < expireAt) { + return token; + } + refreshTokenLocked(); + if (cachedToken == null) { + throw new BusinessException(ErrorCodes.INTERNAL_ERROR, "获取百度OCR access_token失败"); + } + return cachedToken; + } + } + + private void refreshTokenLocked() { + String apiKey = String.valueOf(props.getApiKey() == null ? "" : props.getApiKey()).trim(); + String secretKey = String.valueOf(props.getSecretKey() == null ? "" : props.getSecretKey()).trim(); + if (apiKey.isEmpty() || secretKey.isEmpty()) { + throw new BusinessException(ErrorCodes.INTERNAL_ERROR, "百度OCR未配置 BAIDU_OCR_API_KEY/BAIDU_OCR_SECRET_KEY"); + } + String tokenUrl = Objects.requireNonNull(props.getTokenUrl(), "tokenUrl"); + try { + String query = "grant_type=client_credentials" + + "&client_id=" + URLEncoder.encode(apiKey, "UTF-8") + + "&client_secret=" + URLEncoder.encode(secretKey, "UTF-8"); + URL url = new URL(tokenUrl + "?" + query); + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + conn.setRequestMethod("GET"); + conn.setConnectTimeout(Math.max(1000, props.getConnectTimeoutMs())); + conn.setReadTimeout(Math.max(1000, props.getReadTimeoutMs())); + + int status = conn.getResponseCode(); + InputStream is = status >= 200 && status < 300 ? conn.getInputStream() : conn.getErrorStream(); + String body = readAll(is); + JsonNode json = objectMapper.readTree(body == null ? "" : body); + if (status < 200 || status >= 300) { + String err = json.has("error_description") ? json.get("error_description").asText() : (body == null ? "" : body); + throw new BusinessException(ErrorCodes.INTERNAL_ERROR, "百度OCR token获取失败: " + err); + } + String accessToken = json.has("access_token") ? json.get("access_token").asText() : null; + long expiresInSec = json.has("expires_in") ? json.get("expires_in").asLong(0) : 0; + if (accessToken == null || accessToken.trim().isEmpty() || expiresInSec <= 0) { + throw new BusinessException(ErrorCodes.INTERNAL_ERROR, "百度OCR token返回缺少字段"); + } + long now = System.currentTimeMillis(); + long safetyMs = 60_000L; + long expireAtMs = now + Math.max(0, expiresInSec * 1000L - safetyMs); + cachedToken = accessToken.trim(); + cachedTokenExpireAtMs = expireAtMs; + } catch (BusinessException be) { + throw be; + } catch (Exception e) { + throw new BusinessException(ErrorCodes.INTERNAL_ERROR, "百度OCR token获取异常: " + e.getMessage()); + } + } + + private static String readAll(InputStream is) throws IOException { + if (is == null) { + return ""; + } + try (BufferedReader br = new BufferedReader(new InputStreamReader(is, StandardCharsets.UTF_8))) { + StringBuilder sb = new StringBuilder(); + String line; + while ((line = br.readLine()) != null) { + sb.append(line); + } + return sb.toString(); + } + } +} + diff --git a/backend/src/main/java/com/writeoff/module/project/controller/ProjectController.java b/backend/src/main/java/com/writeoff/module/project/controller/ProjectController.java new file mode 100644 index 0000000..919ef9e --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/project/controller/ProjectController.java @@ -0,0 +1,124 @@ +package com.writeoff.module.project.controller; + +import com.writeoff.common.api.ApiResponse; +import com.writeoff.common.api.PageResult; +import com.writeoff.module.project.dto.CreateProjectRequest; +import com.writeoff.module.project.dto.SaveProjectBindingsRequest; +import com.writeoff.module.project.model.Project; +import com.writeoff.module.project.service.ProjectService; +import com.writeoff.module.export.dto.CreateExportTaskRequest; +import com.writeoff.module.export.service.ExportTaskService; +import com.writeoff.security.DataScopeType; +import com.writeoff.security.RequirePermission; +import javax.validation.Valid; +import org.springframework.web.bind.annotation.*; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/projects") +public class ProjectController { + private final ProjectService projectService; + private final ExportTaskService exportTaskService; + + public ProjectController(ProjectService projectService, ExportTaskService exportTaskService) { + this.projectService = projectService; + this.exportTaskService = exportTaskService; + } + + @GetMapping + public ApiResponse> list(@RequestParam(value = "parentOnly", required = false) Boolean parentOnly, + @RequestParam(value = "includeDeleted", required = false) Boolean includeDeleted) { + return ApiResponse.success(projectService.list(parentOnly, includeDeleted)); + } + + @GetMapping("/{id}/children") + public ApiResponse> children(@PathVariable("id") Long id, + @RequestParam(value = "includeDeleted", required = false) Boolean includeDeleted) { + return ApiResponse.success(projectService.listChildren(id, includeDeleted)); + } + + @PostMapping + @RequirePermission(value = "project.create", dataScope = DataScopeType.TENANT, auditAction = "PROJECT_CREATE") + public ApiResponse create(@RequestBody @Valid CreateProjectRequest request) { + return ApiResponse.success(projectService.create(request)); + } + + @PutMapping("/{id}") + @RequirePermission(value = "project.create", dataScope = DataScopeType.PROJECT, auditAction = "PROJECT_UPDATE") + public ApiResponse update(@PathVariable("id") Long id, + @RequestBody @Valid CreateProjectRequest request) { + return ApiResponse.success(projectService.update(id, request)); + } + + @GetMapping("/binding-candidates") + @RequirePermission(value = "project.bind.user", dataScope = DataScopeType.TENANT, auditAction = "PROJECT_BIND_CANDIDATES") + public ApiResponse> bindingCandidates() { + return ApiResponse.success(projectService.listBindingCandidates()); + } + + @GetMapping("/{id}/bindings") + @RequirePermission(value = "project.bind.user", dataScope = DataScopeType.PROJECT, auditAction = "PROJECT_BIND_READ") + public ApiResponse> bindings(@PathVariable("id") Long id) { + return ApiResponse.success(projectService.getBindings(id)); + } + + @GetMapping("/{id}/key-change-logs") + @RequirePermission(value = "project.key-change-log.read", dataScope = DataScopeType.PROJECT, auditAction = "PROJECT_KEY_CHANGE_LOG_LIST") + public ApiResponse>> keyChangeLogs(@PathVariable("id") Long id) { + return ApiResponse.success(projectService.listKeyChangeLogs(id)); + } + + @PostMapping("/{id}/bindings") + @RequirePermission(value = "project.bind.user", dataScope = DataScopeType.PROJECT, auditAction = "PROJECT_BIND_SAVE") + public ApiResponse saveBindings(@PathVariable("id") Long id, + @RequestBody @Valid SaveProjectBindingsRequest request) { + projectService.saveBindings(id, request); + return ApiResponse.success("OK"); + } + + @PostMapping("/{id}/freeze") + @RequirePermission(value = "project.freeze", dataScope = DataScopeType.PROJECT, auditAction = "PROJECT_FREEZE") + public ApiResponse> freeze(@PathVariable("id") Long id, + @RequestParam String reason) { + Project project = projectService.freeze(id, reason); + Map result = new LinkedHashMap<>(); + result.put("projectId", project.getId()); + result.put("status", project.getStatus().name()); + result.put("reason", reason); + return ApiResponse.success(result); + } + + @PostMapping("/{id}/unfreeze") + @RequirePermission(value = "project.unfreeze", dataScope = DataScopeType.PROJECT, auditAction = "PROJECT_UNFREEZE") + public ApiResponse> unfreeze(@PathVariable("id") Long id, + @RequestBody Map body) { + String reason = body == null ? null : body.get("reason"); + Project project = projectService.unfreeze(id, reason); + Map result = new LinkedHashMap<>(); + result.put("projectId", project.getId()); + result.put("status", project.getStatus().name()); + result.put("reason", reason); + return ApiResponse.success(result); + } + + @PostMapping("/{id}/archive") + @RequirePermission(value = "project.archive", dataScope = DataScopeType.PROJECT, auditAction = "PROJECT_ARCHIVE") + public ApiResponse> archive(@PathVariable("id") Long id) { + Project project = projectService.archive(id); + Map result = new LinkedHashMap<>(); + result.put("projectId", project.getId()); + result.put("status", project.getStatus().name()); + return ApiResponse.success(result); + } + + @PostMapping("/export") + @RequirePermission(value = "project.create", dataScope = DataScopeType.TENANT, auditAction = "PROJECT_EXPORT") + public ApiResponse> exportProjects(@RequestBody @Valid CreateExportTaskRequest request) { + request.setTaskCode("PROJECT_EXPORT"); + request.setBizType("PROJECT"); + return ApiResponse.success(exportTaskService.create(request)); + } +} diff --git a/backend/src/main/java/com/writeoff/module/project/dto/CreateProjectRequest.java b/backend/src/main/java/com/writeoff/module/project/dto/CreateProjectRequest.java new file mode 100644 index 0000000..b220c10 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/project/dto/CreateProjectRequest.java @@ -0,0 +1,153 @@ +package com.writeoff.module.project.dto; + +import javax.validation.constraints.Min; +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; +import java.time.LocalDate; + +public class CreateProjectRequest { + @NotBlank(message = "项目名称不能为空") + private String name; + private Long parentProjectId; + private LocalDate startDate; + private LocalDate endDate; + @NotNull(message = "项目预算不能为空") + @Min(value = 1, message = "项目预算必须大于0") + private Long budgetCent; + @NotNull(message = "会议总期数不能为空") + @Min(value = 1, message = "会议总期数必须大于0") + private Integer meetingTotal; + private Long partnerEnterpriseId; + private Boolean allowMeetingOverBudget; + private Double overBudgetThresholdRatio; + private String overBudgetApprovalChainJson; + + private Double laborFeeRatio; + private Boolean allowProjectOverBudget; + private String invoiceInfo; + private String expenseRatioJson; + private String projectFeeJson; + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public Long getParentProjectId() { + return parentProjectId; + } + + public void setParentProjectId(Long parentProjectId) { + this.parentProjectId = parentProjectId; + } + + public LocalDate getStartDate() { + return startDate; + } + + public void setStartDate(LocalDate startDate) { + this.startDate = startDate; + } + + public LocalDate getEndDate() { + return endDate; + } + + public void setEndDate(LocalDate endDate) { + this.endDate = endDate; + } + + public Long getBudgetCent() { + return budgetCent; + } + + public void setBudgetCent(Long budgetCent) { + this.budgetCent = budgetCent; + } + + public Integer getMeetingTotal() { + return meetingTotal; + } + + public void setMeetingTotal(Integer meetingTotal) { + this.meetingTotal = meetingTotal; + } + + public Long getPartnerEnterpriseId() { + return partnerEnterpriseId; + } + + public void setPartnerEnterpriseId(Long partnerEnterpriseId) { + this.partnerEnterpriseId = partnerEnterpriseId; + } + + public Boolean getAllowMeetingOverBudget() { + return allowMeetingOverBudget; + } + + public void setAllowMeetingOverBudget(Boolean allowMeetingOverBudget) { + this.allowMeetingOverBudget = allowMeetingOverBudget; + } + + public Double getOverBudgetThresholdRatio() { + return overBudgetThresholdRatio; + } + + public void setOverBudgetThresholdRatio(Double overBudgetThresholdRatio) { + this.overBudgetThresholdRatio = overBudgetThresholdRatio; + } + + public String getOverBudgetApprovalChainJson() { + return overBudgetApprovalChainJson; + } + + public void setOverBudgetApprovalChainJson(String overBudgetApprovalChainJson) { + this.overBudgetApprovalChainJson = overBudgetApprovalChainJson; + } + + + + public Double getLaborFeeRatio() { + return laborFeeRatio; + } + + public void setLaborFeeRatio(Double laborFeeRatio) { + this.laborFeeRatio = laborFeeRatio; + } + + public Boolean getAllowProjectOverBudget() { + return allowProjectOverBudget; + } + + public void setAllowProjectOverBudget(Boolean allowProjectOverBudget) { + this.allowProjectOverBudget = allowProjectOverBudget; + } + + public String getInvoiceInfo() { + return invoiceInfo; + } + + public void setInvoiceInfo(String invoiceInfo) { + this.invoiceInfo = invoiceInfo; + } + + public String getExpenseRatioJson() { + return expenseRatioJson; + } + + public void setExpenseRatioJson(String expenseRatioJson) { + this.expenseRatioJson = expenseRatioJson; + } + + public String getProjectFeeJson() { + return projectFeeJson; + } + + public void setProjectFeeJson(String projectFeeJson) { + this.projectFeeJson = projectFeeJson; + } + +} diff --git a/backend/src/main/java/com/writeoff/module/project/dto/SaveProjectBindingsRequest.java b/backend/src/main/java/com/writeoff/module/project/dto/SaveProjectBindingsRequest.java new file mode 100644 index 0000000..13f2ae4 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/project/dto/SaveProjectBindingsRequest.java @@ -0,0 +1,33 @@ +package com.writeoff.module.project.dto; + +import java.util.List; + +public class SaveProjectBindingsRequest { + private List ownerUserIds; + private List executorUserIds; + private List legacyExecutorUserIds; + + public List getOwnerUserIds() { + return ownerUserIds; + } + + public void setOwnerUserIds(List ownerUserIds) { + this.ownerUserIds = ownerUserIds; + } + + public List getExecutorUserIds() { + return executorUserIds; + } + + public void setExecutorUserIds(List executorUserIds) { + this.executorUserIds = executorUserIds; + } + + public List getLegacyExecutorUserIds() { + return legacyExecutorUserIds; + } + + public void setLegacyExecutorUserIds(List legacyExecutorUserIds) { + this.legacyExecutorUserIds = legacyExecutorUserIds; + } +} diff --git a/backend/src/main/java/com/writeoff/module/project/model/Project.java b/backend/src/main/java/com/writeoff/module/project/model/Project.java new file mode 100644 index 0000000..ae8e8b2 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/project/model/Project.java @@ -0,0 +1,434 @@ +package com.writeoff.module.project.model; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +public class Project { + /** 项目ID */ + private Long id; + /** 项目名称 */ + private String name; + /** 子项目名称(可为空) */ + private String subProjectName; + /** 上级项目ID(为空表示一级项目) */ + private Long parentProjectId; + /** 子项目数量 */ + private Integer subProjectCount; + /** 项目开始日期 */ + private LocalDate startDate; + /** 项目结束日期 */ + private LocalDate endDate; + /** 兼容历史字段:合作企业ID */ + private Long enterpriseId; + /** 兼容历史字段:合作企业名称 */ + private String enterpriseName; + /** 主办单位ID */ + private Long hostEnterpriseId; + /** 主办单位名称(当前租户) */ + private String hostEnterpriseName; + /** 合作企业ID */ + private Long partnerEnterpriseId; + /** 主办单位负责人用户ID */ + private Long hostOwnerUserId; + /** 主办单位项目执行人用户ID */ + private Long hostExecutorUserId; + /** 合作企业负责人用户ID */ + private Long partnerOwnerUserId; + /** 合作企业项目执行人用户ID */ + private Long partnerExecutorUserId; + /** 项目总预算(分) */ + private long budgetCent; + /** 项目会议总期数 */ + private int meetingTotal; + /** 已完成核销会议数量 */ + private int meetingCompletedCount; + /** 是否允许单场超支 */ + private boolean allowMeetingOverBudget; + /** 超支阈值比例(0.1=10%) */ + private double overBudgetThresholdRatio; + /** 超支审批链(JSON) */ + private String overBudgetApprovalChainJson; + /** 预算执行率 */ + private double budgetExecutionRatio; + /** 风险标记(JSON) */ + private String riskFlagsJson; + + /** 核销进度-未开始场次 */ + private int writeOffNotStartedCount; + /** 核销进度-核销中场次 */ + private int writeOffInProgressCount; + /** 核销进度-核销完成场次 */ + private int writeOffCompletedCount; + /** 劳务费用占比 */ + private double laborFeeRatio; + /** 是否允许超过项目总费用 */ + private boolean allowProjectOverBudget; + /** 发票信息快照(便于一键复制) */ + private String invoiceInfo; + /** 费用占比(JSON) */ + private String expenseRatioJson; + /** 项目费用设置(JSON) */ + private String projectFeeJson; + /** 主办单位备份执行人用户ID */ + private Long hostBackupExecutorUserId; + /** 合作企业备份执行人用户ID */ + private Long partnerBackupExecutorUserId; + /** 项目负责人审批人用户ID */ + private Long projectOwnerApproverUserId; + /** 财务审批人用户ID */ + private Long financeApproverUserId; + /** 项目中止原因 */ + private String terminatedReason; + /** 项目冻结原因 */ + private String freezeReason; + /** 归档时间 */ + private LocalDateTime archivedAt; + /** 关键变更日志(JSON) */ + private String keyChangeLogJson; + /** 项目状态 */ + private ProjectStatus status; + /** 主办单位负责人(TENANT_ADMIN) */ + private String hostOwnerUsers; + /** 主办单位项目执行人(PROJECT_OWNER) */ + private String hostExecutorUsers; + /** 合作企业负责人(PROJECT_EXECUTOR) */ + private String partnerOwnerUsers; + /** 合作企业项目执行人(EXECUTOR) */ + private String partnerExecutorUsers; + /** 是否已软删除 */ + private boolean deleted; + + public Project(Long id, String name, Long enterpriseId, long budgetCent, int meetingTotal, ProjectStatus status) { + this( + id, name, null, null, null, enterpriseId, null, null, enterpriseId, null, null, null, null, + budgetCent, meetingTotal, 0, false, 0.1d, null, 0d, null, + meetingTotal, 0, 0, + 0d, false, null, null, null, null, null, null, null, null, null, null, status + ); + } + + public Project(Long id, String name, Long enterpriseId, String enterpriseName, long budgetCent, int meetingTotal, ProjectStatus status) { + this( + id, name, null, null, null, enterpriseId, enterpriseName, null, enterpriseId, null, null, null, null, + budgetCent, meetingTotal, 0, false, 0.1d, null, 0d, null, + meetingTotal, 0, 0, + 0d, false, null, null, null, null, null, null, null, null, null, null, status + ); + } + + public Project(Long id, + String name, + String subProjectName, + LocalDate startDate, + LocalDate endDate, + Long enterpriseId, + String enterpriseName, + Long hostEnterpriseId, + Long partnerEnterpriseId, + Long hostOwnerUserId, + Long hostExecutorUserId, + Long partnerOwnerUserId, + Long partnerExecutorUserId, + long budgetCent, + int meetingTotal, + int meetingCompletedCount, + boolean allowMeetingOverBudget, + double overBudgetThresholdRatio, + String overBudgetApprovalChainJson, + double budgetExecutionRatio, + String riskFlagsJson, + + int writeOffNotStartedCount, + int writeOffInProgressCount, + int writeOffCompletedCount, + double laborFeeRatio, + boolean allowProjectOverBudget, + String invoiceInfo, + String expenseRatioJson, + Long hostBackupExecutorUserId, + Long partnerBackupExecutorUserId, + Long projectOwnerApproverUserId, + Long financeApproverUserId, + String terminatedReason, + String freezeReason, + LocalDateTime archivedAt, + String keyChangeLogJson, + ProjectStatus status) { + this.id = id; + this.name = name; + this.subProjectName = subProjectName; + this.startDate = startDate; + this.endDate = endDate; + this.enterpriseId = enterpriseId; + this.enterpriseName = enterpriseName; + this.hostEnterpriseId = hostEnterpriseId; + this.partnerEnterpriseId = partnerEnterpriseId; + this.hostOwnerUserId = hostOwnerUserId; + this.hostExecutorUserId = hostExecutorUserId; + this.partnerOwnerUserId = partnerOwnerUserId; + this.partnerExecutorUserId = partnerExecutorUserId; + this.budgetCent = budgetCent; + this.meetingTotal = meetingTotal; + this.meetingCompletedCount = meetingCompletedCount; + this.allowMeetingOverBudget = allowMeetingOverBudget; + this.overBudgetThresholdRatio = overBudgetThresholdRatio; + this.overBudgetApprovalChainJson = overBudgetApprovalChainJson; + this.budgetExecutionRatio = budgetExecutionRatio; + this.riskFlagsJson = riskFlagsJson; + + this.writeOffNotStartedCount = writeOffNotStartedCount; + this.writeOffInProgressCount = writeOffInProgressCount; + this.writeOffCompletedCount = writeOffCompletedCount; + this.laborFeeRatio = laborFeeRatio; + this.allowProjectOverBudget = allowProjectOverBudget; + this.invoiceInfo = invoiceInfo; + this.expenseRatioJson = expenseRatioJson; + this.hostBackupExecutorUserId = hostBackupExecutorUserId; + this.partnerBackupExecutorUserId = partnerBackupExecutorUserId; + this.projectOwnerApproverUserId = projectOwnerApproverUserId; + this.financeApproverUserId = financeApproverUserId; + this.terminatedReason = terminatedReason; + this.freezeReason = freezeReason; + this.archivedAt = archivedAt; + this.keyChangeLogJson = keyChangeLogJson; + this.status = status; + } + + public Long getId() { + return id; + } + + public String getName() { + return name; + } + + public String getSubProjectName() { + return subProjectName; + } + + public Integer getSubProjectCount() { + return subProjectCount; + } + + public Long getParentProjectId() { + return parentProjectId; + } + + public LocalDate getStartDate() { + return startDate; + } + + public LocalDate getEndDate() { + return endDate; + } + + public Long getEnterpriseId() { + return enterpriseId; + } + + public String getEnterpriseName() { + return enterpriseName; + } + + public Long getHostEnterpriseId() { + return hostEnterpriseId; + } + + public String getHostEnterpriseName() { + return hostEnterpriseName; + } + + public Long getPartnerEnterpriseId() { + return partnerEnterpriseId; + } + + public Long getHostOwnerUserId() { + return hostOwnerUserId; + } + + public Long getHostExecutorUserId() { + return hostExecutorUserId; + } + + public Long getPartnerOwnerUserId() { + return partnerOwnerUserId; + } + + public Long getPartnerExecutorUserId() { + return partnerExecutorUserId; + } + + public long getBudgetCent() { + return budgetCent; + } + + public int getMeetingTotal() { + return meetingTotal; + } + + public int getMeetingCompletedCount() { + return meetingCompletedCount; + } + + public boolean isAllowMeetingOverBudget() { + return allowMeetingOverBudget; + } + + public double getOverBudgetThresholdRatio() { + return overBudgetThresholdRatio; + } + + public String getOverBudgetApprovalChainJson() { + return overBudgetApprovalChainJson; + } + + public double getBudgetExecutionRatio() { + return budgetExecutionRatio; + } + + public String getRiskFlagsJson() { + return riskFlagsJson; + } + + + + public int getWriteOffNotStartedCount() { + return writeOffNotStartedCount; + } + + public int getWriteOffInProgressCount() { + return writeOffInProgressCount; + } + + public int getWriteOffCompletedCount() { + return writeOffCompletedCount; + } + + public double getLaborFeeRatio() { + return laborFeeRatio; + } + + public boolean isAllowProjectOverBudget() { + return allowProjectOverBudget; + } + + public String getInvoiceInfo() { + return invoiceInfo; + } + + public String getExpenseRatioJson() { + return expenseRatioJson; + } + + public String getProjectFeeJson() { + return projectFeeJson; + } + + public Long getHostBackupExecutorUserId() { + return hostBackupExecutorUserId; + } + + public Long getPartnerBackupExecutorUserId() { + return partnerBackupExecutorUserId; + } + + public Long getProjectOwnerApproverUserId() { + return projectOwnerApproverUserId; + } + + public Long getFinanceApproverUserId() { + return financeApproverUserId; + } + + public String getTerminatedReason() { + return terminatedReason; + } + + public String getFreezeReason() { + return freezeReason; + } + + public LocalDateTime getArchivedAt() { + return archivedAt; + } + + public String getKeyChangeLogJson() { + return keyChangeLogJson; + } + + public ProjectStatus getStatus() { + return status; + } + + public String getHostOwnerUsers() { + return hostOwnerUsers; + } + + public String getHostExecutorUsers() { + return hostExecutorUsers; + } + + public String getPartnerOwnerUsers() { + return partnerOwnerUsers; + } + + public String getPartnerExecutorUsers() { + return partnerExecutorUsers; + } + + public void setStatus(ProjectStatus status) { + this.status = status; + } + + public void setFreezeReason(String freezeReason) { + this.freezeReason = freezeReason; + } + + public void setTerminatedReason(String terminatedReason) { + this.terminatedReason = terminatedReason; + } + + public void setArchivedAt(LocalDateTime archivedAt) { + this.archivedAt = archivedAt; + } + + public void setSubProjectCount(Integer subProjectCount) { + this.subProjectCount = subProjectCount; + } + + public void setParentProjectId(Long parentProjectId) { + this.parentProjectId = parentProjectId; + } + + public void setHostEnterpriseName(String hostEnterpriseName) { + this.hostEnterpriseName = hostEnterpriseName; + } + + public void setHostOwnerUsers(String hostOwnerUsers) { + this.hostOwnerUsers = hostOwnerUsers; + } + + public void setHostExecutorUsers(String hostExecutorUsers) { + this.hostExecutorUsers = hostExecutorUsers; + } + + public void setPartnerOwnerUsers(String partnerOwnerUsers) { + this.partnerOwnerUsers = partnerOwnerUsers; + } + + public void setPartnerExecutorUsers(String partnerExecutorUsers) { + this.partnerExecutorUsers = partnerExecutorUsers; + } + + public void setProjectFeeJson(String projectFeeJson) { + this.projectFeeJson = projectFeeJson; + } + + public boolean isDeleted() { + return deleted; + } + + public void setDeleted(boolean deleted) { + this.deleted = deleted; + } +} diff --git a/backend/src/main/java/com/writeoff/module/project/model/ProjectStatus.java b/backend/src/main/java/com/writeoff/module/project/model/ProjectStatus.java new file mode 100644 index 0000000..6eaef19 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/project/model/ProjectStatus.java @@ -0,0 +1,10 @@ +package com.writeoff.module.project.model; + +public enum ProjectStatus { + WAITING, + IN_PROGRESS, + COMPLETED, + TERMINATED, + ARCHIVED, + FROZEN +} diff --git a/backend/src/main/java/com/writeoff/module/project/repository/InMemoryProjectRepository.java b/backend/src/main/java/com/writeoff/module/project/repository/InMemoryProjectRepository.java new file mode 100644 index 0000000..530aba7 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/project/repository/InMemoryProjectRepository.java @@ -0,0 +1,97 @@ +package com.writeoff.module.project.repository; + +import com.writeoff.module.project.model.Project; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Repository; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; +import java.util.stream.Collectors; + +@Repository +@ConditionalOnProperty(prefix = "app.repository", name = "mode", havingValue = "in-memory") +public class InMemoryProjectRepository implements ProjectRepository { + private final ConcurrentHashMap store = new ConcurrentHashMap<>(); + private final AtomicLong idGenerator = new AtomicLong(1000); + + @Override + public Project save(Project project) { + if (project.getId() == null) { + Project newProject = new Project( + idGenerator.incrementAndGet(), + project.getName(), + null, + project.getStartDate(), + project.getEndDate(), + project.getEnterpriseId(), + null, + project.getHostEnterpriseId(), + project.getPartnerEnterpriseId(), + project.getHostOwnerUserId(), + project.getHostExecutorUserId(), + project.getPartnerOwnerUserId(), + project.getPartnerExecutorUserId(), + project.getBudgetCent(), + project.getMeetingTotal(), + project.getMeetingCompletedCount(), + project.isAllowMeetingOverBudget(), + project.getOverBudgetThresholdRatio(), + project.getOverBudgetApprovalChainJson(), + project.getBudgetExecutionRatio(), + project.getRiskFlagsJson(), + + project.getWriteOffNotStartedCount(), + project.getWriteOffInProgressCount(), + project.getWriteOffCompletedCount(), + project.getLaborFeeRatio(), + project.isAllowProjectOverBudget(), + project.getInvoiceInfo(), + project.getExpenseRatioJson(), + project.getHostBackupExecutorUserId(), + project.getPartnerBackupExecutorUserId(), + project.getProjectOwnerApproverUserId(), + project.getFinanceApproverUserId(), + project.getTerminatedReason(), + project.getFreezeReason(), + project.getArchivedAt(), + project.getKeyChangeLogJson(), + project.getStatus() + ); + newProject.setSubProjectCount(project.getSubProjectCount()); + newProject.setParentProjectId(project.getParentProjectId()); + newProject.setHostEnterpriseName(project.getHostEnterpriseName()); + newProject.setHostOwnerUsers(project.getHostOwnerUsers()); + newProject.setHostExecutorUsers(project.getHostExecutorUsers()); + newProject.setPartnerOwnerUsers(project.getPartnerOwnerUsers()); + newProject.setPartnerExecutorUsers(project.getPartnerExecutorUsers()); + newProject.setProjectFeeJson(project.getProjectFeeJson()); + store.put(newProject.getId(), newProject); + return newProject; + } + store.put(project.getId(), project); + return project; + } + + @Override + public Optional findById(Long id) { + return Optional.ofNullable(store.get(id)); + } + + @Override + public List findAll(boolean includeDeleted) { + return new ArrayList<>(store.values()); + } + + @Override + public List findByParentProjectId(Long parentProjectId, boolean includeDeleted) { + return store.values().stream() + .filter(project -> { + Long currentParentId = project.getParentProjectId(); + return currentParentId != null && currentParentId.equals(parentProjectId); + }) + .collect(Collectors.toList()); + } +} diff --git a/backend/src/main/java/com/writeoff/module/project/repository/JdbcProjectRepository.java b/backend/src/main/java/com/writeoff/module/project/repository/JdbcProjectRepository.java new file mode 100644 index 0000000..5b2f315 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/project/repository/JdbcProjectRepository.java @@ -0,0 +1,302 @@ +package com.writeoff.module.project.repository; + +import com.writeoff.module.project.model.Project; +import com.writeoff.module.project.model.ProjectStatus; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.jdbc.support.GeneratedKeyHolder; +import org.springframework.jdbc.support.KeyHolder; +import org.springframework.stereotype.Repository; +import com.writeoff.security.AuthContext; + +import java.sql.PreparedStatement; +import java.sql.Statement; +import java.util.List; +import java.util.Optional; + +@Repository +@ConditionalOnProperty(prefix = "app.repository", name = "mode", havingValue = "jdbc", matchIfMissing = true) +public class JdbcProjectRepository implements ProjectRepository { + private final JdbcTemplate jdbcTemplate; + private static final RowMapper ROW_MAPPER = (rs, n) -> { + Project p = new Project( + rs.getLong("id"), + rs.getString("project_name"), + null, + rs.getDate("start_date") == null ? null : rs.getDate("start_date").toLocalDate(), + rs.getDate("end_date") == null ? null : rs.getDate("end_date").toLocalDate(), + rs.getObject("enterprise_id") == null ? null : rs.getLong("enterprise_id"), + rs.getString("enterprise_name"), + null, + rs.getObject("partner_enterprise_id") == null ? null : rs.getLong("partner_enterprise_id"), + null, + null, + null, + null, + rs.getLong("budget_cent"), + rs.getInt("meeting_total"), + rs.getInt("meeting_completed_count"), + rs.getInt("allow_meeting_over_budget") == 1, + rs.getBigDecimal("over_budget_threshold_ratio") == null ? 0.1d : rs.getBigDecimal("over_budget_threshold_ratio").doubleValue(), + rs.getString("over_budget_approval_chain_json"), + rs.getBigDecimal("budget_execution_ratio") == null ? 0d : rs.getBigDecimal("budget_execution_ratio").doubleValue(), + rs.getString("risk_flags_json"), + + rs.getInt("write_off_not_started_count"), + rs.getInt("write_off_in_progress_count"), + rs.getInt("write_off_completed_count"), + rs.getBigDecimal("labor_fee_ratio") == null ? 0d : rs.getBigDecimal("labor_fee_ratio").doubleValue(), + rs.getInt("allow_project_over_budget") == 1, + rs.getString("invoice_info"), + rs.getString("expense_ratio_json"), + null, + null, + null, + null, + rs.getString("terminated_reason"), + rs.getString("freeze_reason"), + rs.getTimestamp("archived_at") == null ? null : rs.getTimestamp("archived_at").toLocalDateTime(), + rs.getString("key_change_log_json"), + ProjectStatus.valueOf(rs.getString("status")) + ); + p.setSubProjectCount(rs.getObject("sub_project_count") == null ? 0 : rs.getInt("sub_project_count")); + p.setParentProjectId(rs.getObject("parent_project_id") == null ? null : rs.getLong("parent_project_id")); + p.setHostEnterpriseName(rs.getString("host_enterprise_name")); + p.setHostOwnerUsers(rs.getString("host_owner_users")); + p.setHostExecutorUsers(rs.getString("host_executor_users")); + p.setPartnerOwnerUsers(rs.getString("partner_owner_users")); + p.setPartnerExecutorUsers(rs.getString("partner_executor_users")); + p.setProjectFeeJson(rs.getString("project_fee_json")); + p.setDeleted(rs.getInt("is_deleted") == 1); + return p; + }; + + public JdbcProjectRepository(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + @Override + public Project save(Project project) { + if (project.getId() == null) { + KeyHolder keyHolder = new GeneratedKeyHolder(); + jdbcTemplate.update(connection -> { + PreparedStatement ps = connection.prepareStatement( + "INSERT INTO project (tenant_id, project_name, parent_project_id, start_date, end_date, host_enterprise_name, partner_enterprise_id, budget_cent, meeting_total, meeting_completed_count, allow_meeting_over_budget, over_budget_threshold_ratio, over_budget_approval_chain_json, budget_execution_ratio, risk_flags_json, write_off_not_started_count, write_off_in_progress_count, write_off_completed_count, labor_fee_ratio, allow_project_over_budget, invoice_info, expense_ratio_json, project_fee_json, terminated_reason, freeze_reason, archived_at, key_change_log_json, status, created_by, updated_by) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + Statement.RETURN_GENERATED_KEYS + ); + Long operator = safeUserId(); + ps.setLong(1, tenantId()); + ps.setString(2, project.getName()); + setNullableLong(ps, 3, project.getParentProjectId()); + if (project.getStartDate() == null) { + ps.setNull(4, java.sql.Types.DATE); + } else { + ps.setDate(4, java.sql.Date.valueOf(project.getStartDate())); + } + if (project.getEndDate() == null) { + ps.setNull(5, java.sql.Types.DATE); + } else { + ps.setDate(5, java.sql.Date.valueOf(project.getEndDate())); + } + ps.setString(6, project.getHostEnterpriseName()); + setNullableLong(ps, 7, project.getPartnerEnterpriseId()); + ps.setLong(8, project.getBudgetCent()); + ps.setInt(9, project.getMeetingTotal()); + ps.setInt(10, project.getMeetingCompletedCount()); + ps.setInt(11, project.isAllowMeetingOverBudget() ? 1 : 0); + ps.setBigDecimal(12, java.math.BigDecimal.valueOf(project.getOverBudgetThresholdRatio())); + ps.setString(13, project.getOverBudgetApprovalChainJson()); + ps.setBigDecimal(14, java.math.BigDecimal.valueOf(project.getBudgetExecutionRatio())); + ps.setString(15, project.getRiskFlagsJson()); + ps.setInt(16, project.getWriteOffNotStartedCount()); + ps.setInt(17, project.getWriteOffInProgressCount()); + ps.setInt(18, project.getWriteOffCompletedCount()); + ps.setBigDecimal(19, java.math.BigDecimal.valueOf(project.getLaborFeeRatio())); + ps.setInt(20, project.isAllowProjectOverBudget() ? 1 : 0); + ps.setString(21, project.getInvoiceInfo()); + ps.setString(22, project.getExpenseRatioJson()); + ps.setString(23, project.getProjectFeeJson()); + ps.setString(24, project.getTerminatedReason()); + ps.setString(25, project.getFreezeReason()); + if (project.getArchivedAt() == null) { + ps.setNull(26, java.sql.Types.TIMESTAMP); + } else { + ps.setTimestamp(26, java.sql.Timestamp.valueOf(project.getArchivedAt())); + } + ps.setString(27, project.getKeyChangeLogJson()); + ps.setString(28, project.getStatus().name()); + ps.setLong(29, operator); + ps.setLong(30, operator); + return ps; + }, keyHolder); + Number key = keyHolder.getKey(); + Long id = key == null ? null : key.longValue(); + Project created = new Project( + id, + project.getName(), + null, + project.getStartDate(), + project.getEndDate(), + project.getEnterpriseId(), + null, + null, + project.getPartnerEnterpriseId(), + null, + null, + null, + null, + project.getBudgetCent(), + project.getMeetingTotal(), + project.getMeetingCompletedCount(), + project.isAllowMeetingOverBudget(), + project.getOverBudgetThresholdRatio(), + project.getOverBudgetApprovalChainJson(), + project.getBudgetExecutionRatio(), + project.getRiskFlagsJson(), + + project.getWriteOffNotStartedCount(), + project.getWriteOffInProgressCount(), + project.getWriteOffCompletedCount(), + project.getLaborFeeRatio(), + project.isAllowProjectOverBudget(), + project.getInvoiceInfo(), + project.getExpenseRatioJson(), + null, + null, + null, + null, + project.getTerminatedReason(), + project.getFreezeReason(), + project.getArchivedAt(), + project.getKeyChangeLogJson(), + project.getStatus() + ); + created.setSubProjectCount(project.getSubProjectCount()); + created.setParentProjectId(project.getParentProjectId()); + created.setHostEnterpriseName(project.getHostEnterpriseName()); + created.setProjectFeeJson(project.getProjectFeeJson()); + return created; + } + jdbcTemplate.update( + "UPDATE project SET project_name=?, parent_project_id=?, start_date=?, end_date=?, host_enterprise_name=?, partner_enterprise_id=?, budget_cent=?, meeting_total=?, meeting_completed_count=?, allow_meeting_over_budget=?, " + + "over_budget_threshold_ratio=?, over_budget_approval_chain_json=?, budget_execution_ratio=?, risk_flags_json=?, write_off_not_started_count=?, write_off_in_progress_count=?, write_off_completed_count=?, labor_fee_ratio=?, allow_project_over_budget=?, invoice_info=?, expense_ratio_json=?, project_fee_json=?, terminated_reason=?, freeze_reason=?, archived_at=?, key_change_log_json=?, status=?, updated_by=? WHERE tenant_id=? AND id=?", + project.getName(), + project.getParentProjectId(), + project.getStartDate() == null ? null : java.sql.Date.valueOf(project.getStartDate()), + project.getEndDate() == null ? null : java.sql.Date.valueOf(project.getEndDate()), + project.getHostEnterpriseName(), + project.getPartnerEnterpriseId(), + project.getBudgetCent(), + project.getMeetingTotal(), + project.getMeetingCompletedCount(), + project.isAllowMeetingOverBudget() ? 1 : 0, + java.math.BigDecimal.valueOf(project.getOverBudgetThresholdRatio()), + project.getOverBudgetApprovalChainJson(), + java.math.BigDecimal.valueOf(project.getBudgetExecutionRatio()), + project.getRiskFlagsJson(), + + project.getWriteOffNotStartedCount(), + project.getWriteOffInProgressCount(), + project.getWriteOffCompletedCount(), + java.math.BigDecimal.valueOf(project.getLaborFeeRatio()), + project.isAllowProjectOverBudget() ? 1 : 0, + project.getInvoiceInfo(), + project.getExpenseRatioJson(), + project.getProjectFeeJson(), + project.getTerminatedReason(), + project.getFreezeReason(), + project.getArchivedAt() == null ? null : java.sql.Timestamp.valueOf(project.getArchivedAt()), + project.getKeyChangeLogJson(), + project.getStatus().name(), + safeUserId(), + tenantId(), + project.getId() + ); + return project; + } + + @Override + public Optional findById(Long id) { + List list = jdbcTemplate.query( + "SELECT p.id, p.project_name, p.parent_project_id, " + + "(SELECT COUNT(1) FROM project c WHERE c.tenant_id=p.tenant_id AND c.parent_project_id=p.id AND c.is_deleted=0) AS sub_project_count, " + + "p.start_date, p.end_date, p.partner_enterprise_id AS enterprise_id, e.enterprise_name, p.host_enterprise_name, p.partner_enterprise_id, p.budget_cent, p.meeting_total, p.meeting_completed_count, " + + "p.allow_meeting_over_budget, p.over_budget_threshold_ratio, p.over_budget_approval_chain_json, p.budget_execution_ratio, p.risk_flags_json, p.write_off_not_started_count, p.write_off_in_progress_count, p.write_off_completed_count, p.labor_fee_ratio, p.allow_project_over_budget, p.invoice_info, p.expense_ratio_json, p.project_fee_json, p.terminated_reason, p.freeze_reason, p.archived_at, p.key_change_log_json, p.status, " + + "p.is_deleted, " + + "(SELECT GROUP_CONCAT(DISTINCT su.user_name SEPARATOR '、') FROM sys_user su JOIN user_role ur ON su.tenant_id=ur.tenant_id AND su.id=ur.user_id JOIN role r ON ur.tenant_id=r.tenant_id AND ur.role_id=r.id WHERE su.tenant_id=p.tenant_id AND su.is_deleted=0 AND r.role_code='TENANT_ADMIN') AS host_owner_users, " + + "(SELECT GROUP_CONCAT(DISTINCT su.user_name SEPARATOR '、') FROM project_user_binding b JOIN sys_user su ON b.tenant_id=su.tenant_id AND b.user_id=su.id WHERE b.tenant_id=p.tenant_id AND b.project_id=p.id AND b.bind_role_code='PROJECT_OWNER' AND b.is_deleted=0 AND su.is_deleted=0) AS host_executor_users, " + + "(SELECT GROUP_CONCAT(DISTINCT su.user_name SEPARATOR '、') FROM project_user_binding b JOIN sys_user su ON b.tenant_id=su.tenant_id AND b.user_id=su.id WHERE b.tenant_id=p.tenant_id AND b.project_id=p.id AND b.bind_role_code='PROJECT_EXECUTOR' AND b.is_deleted=0 AND su.is_deleted=0) AS partner_owner_users, " + + "(SELECT GROUP_CONCAT(DISTINCT su.user_name SEPARATOR '、') FROM project_user_binding b JOIN sys_user su ON b.tenant_id=su.tenant_id AND b.user_id=su.id WHERE b.tenant_id=p.tenant_id AND b.project_id=p.id AND b.bind_role_code='EXECUTOR' AND b.is_deleted=0 AND su.is_deleted=0) AS partner_executor_users " + + "FROM project p LEFT JOIN enterprise e ON p.tenant_id=e.tenant_id AND p.partner_enterprise_id=e.id " + + "WHERE p.tenant_id=? AND p.id=? AND p.is_deleted=0", + ROW_MAPPER, + tenantId(), + id + ); + return list.stream().findFirst(); + } + + @Override + public List findAll(boolean includeDeleted) { + String whereSql = includeDeleted ? "WHERE p.tenant_id=? " : "WHERE p.tenant_id=? AND p.is_deleted=0 "; + return jdbcTemplate.query( + "SELECT p.id, p.project_name, p.parent_project_id, " + + "(SELECT COUNT(1) FROM project c WHERE c.tenant_id=p.tenant_id AND c.parent_project_id=p.id AND c.is_deleted=0) AS sub_project_count, " + + "p.start_date, p.end_date, p.partner_enterprise_id AS enterprise_id, e.enterprise_name, p.host_enterprise_name, p.partner_enterprise_id, p.budget_cent, p.meeting_total, p.meeting_completed_count, " + + "p.allow_meeting_over_budget, p.over_budget_threshold_ratio, p.over_budget_approval_chain_json, p.budget_execution_ratio, p.risk_flags_json, p.write_off_not_started_count, p.write_off_in_progress_count, p.write_off_completed_count, p.labor_fee_ratio, p.allow_project_over_budget, p.invoice_info, p.expense_ratio_json, p.project_fee_json, p.terminated_reason, p.freeze_reason, p.archived_at, p.key_change_log_json, p.status, " + + "p.is_deleted, " + + "(SELECT GROUP_CONCAT(DISTINCT su.user_name SEPARATOR '、') FROM sys_user su JOIN user_role ur ON su.tenant_id=ur.tenant_id AND su.id=ur.user_id JOIN role r ON ur.tenant_id=r.tenant_id AND ur.role_id=r.id WHERE su.tenant_id=p.tenant_id AND su.is_deleted=0 AND r.role_code='TENANT_ADMIN') AS host_owner_users, " + + "(SELECT GROUP_CONCAT(DISTINCT su.user_name SEPARATOR '、') FROM project_user_binding b JOIN sys_user su ON b.tenant_id=su.tenant_id AND b.user_id=su.id WHERE b.tenant_id=p.tenant_id AND b.project_id=p.id AND b.bind_role_code='PROJECT_OWNER' AND b.is_deleted=0 AND su.is_deleted=0) AS host_executor_users, " + + "(SELECT GROUP_CONCAT(DISTINCT su.user_name SEPARATOR '、') FROM project_user_binding b JOIN sys_user su ON b.tenant_id=su.tenant_id AND b.user_id=su.id WHERE b.tenant_id=p.tenant_id AND b.project_id=p.id AND b.bind_role_code='PROJECT_EXECUTOR' AND b.is_deleted=0 AND su.is_deleted=0) AS partner_owner_users, " + + "(SELECT GROUP_CONCAT(DISTINCT su.user_name SEPARATOR '、') FROM project_user_binding b JOIN sys_user su ON b.tenant_id=su.tenant_id AND b.user_id=su.id WHERE b.tenant_id=p.tenant_id AND b.project_id=p.id AND b.bind_role_code='EXECUTOR' AND b.is_deleted=0 AND su.is_deleted=0) AS partner_executor_users " + + "FROM project p LEFT JOIN enterprise e ON p.tenant_id=e.tenant_id AND p.partner_enterprise_id=e.id " + + whereSql + + "ORDER BY p.id DESC", + ROW_MAPPER, + tenantId() + ); + } + + @Override + public List findByParentProjectId(Long parentProjectId, boolean includeDeleted) { + String whereSql = includeDeleted + ? "WHERE p.tenant_id=? AND p.parent_project_id=? " + : "WHERE p.tenant_id=? AND p.parent_project_id=? AND p.is_deleted=0 "; + return jdbcTemplate.query( + "SELECT p.id, p.project_name, p.parent_project_id, " + + "(SELECT COUNT(1) FROM project c WHERE c.tenant_id=p.tenant_id AND c.parent_project_id=p.id AND c.is_deleted=0) AS sub_project_count, " + + "p.start_date, p.end_date, p.partner_enterprise_id AS enterprise_id, e.enterprise_name, p.host_enterprise_name, p.partner_enterprise_id, p.budget_cent, p.meeting_total, p.meeting_completed_count, " + + "p.allow_meeting_over_budget, p.over_budget_threshold_ratio, p.over_budget_approval_chain_json, p.budget_execution_ratio, p.risk_flags_json, p.write_off_not_started_count, p.write_off_in_progress_count, p.write_off_completed_count, p.labor_fee_ratio, p.allow_project_over_budget, p.invoice_info, p.expense_ratio_json, p.project_fee_json, p.terminated_reason, p.freeze_reason, p.archived_at, p.key_change_log_json, p.status, " + + "p.is_deleted, " + + "(SELECT GROUP_CONCAT(DISTINCT su.user_name SEPARATOR '、') FROM sys_user su JOIN user_role ur ON su.tenant_id=ur.tenant_id AND su.id=ur.user_id JOIN role r ON ur.tenant_id=r.tenant_id AND ur.role_id=r.id WHERE su.tenant_id=p.tenant_id AND su.is_deleted=0 AND r.role_code='TENANT_ADMIN') AS host_owner_users, " + + "(SELECT GROUP_CONCAT(DISTINCT su.user_name SEPARATOR '、') FROM project_user_binding b JOIN sys_user su ON b.tenant_id=su.tenant_id AND b.user_id=su.id WHERE b.tenant_id=p.tenant_id AND b.project_id=p.id AND b.bind_role_code='PROJECT_OWNER' AND b.is_deleted=0 AND su.is_deleted=0) AS host_executor_users, " + + "(SELECT GROUP_CONCAT(DISTINCT su.user_name SEPARATOR '、') FROM project_user_binding b JOIN sys_user su ON b.tenant_id=su.tenant_id AND b.user_id=su.id WHERE b.tenant_id=p.tenant_id AND b.project_id=p.id AND b.bind_role_code='PROJECT_EXECUTOR' AND b.is_deleted=0 AND su.is_deleted=0) AS partner_owner_users, " + + "(SELECT GROUP_CONCAT(DISTINCT su.user_name SEPARATOR '、') FROM project_user_binding b JOIN sys_user su ON b.tenant_id=su.tenant_id AND b.user_id=su.id WHERE b.tenant_id=p.tenant_id AND b.project_id=p.id AND b.bind_role_code='EXECUTOR' AND b.is_deleted=0 AND su.is_deleted=0) AS partner_executor_users " + + "FROM project p LEFT JOIN enterprise e ON p.tenant_id=e.tenant_id AND p.partner_enterprise_id=e.id " + + whereSql + + "ORDER BY p.id DESC", + ROW_MAPPER, + tenantId(), + parentProjectId + ); + } + + private static void setNullableLong(PreparedStatement ps, int index, Long value) throws java.sql.SQLException { + if (value == null) { + ps.setNull(index, java.sql.Types.BIGINT); + } else { + ps.setLong(index, value); + } + } + + private Long tenantId() { + return AuthContext.requireTenantId(); + } + + private Long safeUserId() { + Long userId = AuthContext.userId(); + return userId == null ? 0L : userId; + } +} diff --git a/backend/src/main/java/com/writeoff/module/project/repository/ProjectRepository.java b/backend/src/main/java/com/writeoff/module/project/repository/ProjectRepository.java new file mode 100644 index 0000000..e9b455d --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/project/repository/ProjectRepository.java @@ -0,0 +1,16 @@ +package com.writeoff.module.project.repository; + +import com.writeoff.module.project.model.Project; + +import java.util.List; +import java.util.Optional; + +public interface ProjectRepository { + Project save(Project project); + + Optional findById(Long id); + + List findAll(boolean includeDeleted); + + List findByParentProjectId(Long parentProjectId, boolean includeDeleted); +} diff --git a/backend/src/main/java/com/writeoff/module/project/service/ProjectService.java b/backend/src/main/java/com/writeoff/module/project/service/ProjectService.java new file mode 100644 index 0000000..aa7885f --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/project/service/ProjectService.java @@ -0,0 +1,793 @@ +package com.writeoff.module.project.service; + +import com.writeoff.common.api.PageResult; +import com.writeoff.common.exception.BusinessException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.writeoff.module.project.dto.CreateProjectRequest; +import com.writeoff.module.project.dto.SaveProjectBindingsRequest; +import com.writeoff.module.project.model.Project; +import com.writeoff.module.project.model.ProjectStatus; +import com.writeoff.module.project.repository.ProjectRepository; +import com.writeoff.module.system.model.BizChangeLogInfo; +import com.writeoff.module.system.service.BizChangeLogService; +import com.writeoff.module.system.service.DataPermissionService; +import com.writeoff.module.system.service.EnterpriseService; +import com.writeoff.security.PermissionService; +import com.writeoff.security.AuthContext; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +@Service +public class ProjectService { + private final ObjectMapper objectMapper = new ObjectMapper(); + private final ProjectRepository projectRepository; + private final DataPermissionService dataPermissionService; + private final EnterpriseService enterpriseService; + private final JdbcTemplate jdbcTemplate; + private final PermissionService permissionService; + private final BizChangeLogService bizChangeLogService; + + @Autowired + public ProjectService(ProjectRepository projectRepository, + DataPermissionService dataPermissionService, + EnterpriseService enterpriseService, + JdbcTemplate jdbcTemplate, + PermissionService permissionService, + BizChangeLogService bizChangeLogService) { + this.projectRepository = projectRepository; + this.dataPermissionService = dataPermissionService; + this.enterpriseService = enterpriseService; + this.jdbcTemplate = jdbcTemplate; + this.permissionService = permissionService; + this.bizChangeLogService = bizChangeLogService; + } + + public ProjectService(ProjectRepository projectRepository) { + this(projectRepository, null, null, null, null, null); + } + + public PageResult list(Boolean parentOnly, Boolean includeDeleted) { + List list = projectRepository.findAll(Boolean.TRUE.equals(includeDeleted)); + if (Boolean.TRUE.equals(parentOnly)) { + list = list.stream() + .filter(project -> project.getParentProjectId() == null) + .collect(Collectors.toList()); + } + if (dataPermissionService != null) { + DataPermissionService.DataScope scope = dataPermissionService.resolveCurrentUserScope(); + final Map creatorMap = scope.isProjectOwnerOnly() + ? dataPermissionService.listProjectCreators(list.stream().map(Project::getId).collect(Collectors.toCollection(HashSet::new))) + : new LinkedHashMap(); + list = list.stream() + .filter(project -> dataPermissionService.canAccessProject(project.getId(), creatorMap.get(project.getId()), scope)) + .collect(Collectors.toList()); + } + return new PageResult<>(list, list.size(), 1, 20); + } + + public List listChildren(Long parentProjectId) { + getById(parentProjectId); + List children = projectRepository.findByParentProjectId(parentProjectId, false); + if (dataPermissionService != null) { + DataPermissionService.DataScope scope = dataPermissionService.resolveCurrentUserScope(); + final Map creatorMap = scope.isProjectOwnerOnly() + ? dataPermissionService.listProjectCreators(children.stream().map(Project::getId).collect(Collectors.toCollection(HashSet::new))) + : new LinkedHashMap(); + children = children.stream() + .filter(project -> dataPermissionService.canAccessProject(project.getId(), creatorMap.get(project.getId()), scope)) + .collect(Collectors.toList()); + } + return children; + } + + public List listChildren(Long parentProjectId, Boolean includeDeleted) { + getById(parentProjectId); + List children = projectRepository.findByParentProjectId(parentProjectId, Boolean.TRUE.equals(includeDeleted)); + if (dataPermissionService != null) { + DataPermissionService.DataScope scope = dataPermissionService.resolveCurrentUserScope(); + final Map creatorMap = scope.isProjectOwnerOnly() + ? dataPermissionService.listProjectCreators(children.stream().map(Project::getId).collect(Collectors.toCollection(HashSet::new))) + : new LinkedHashMap(); + children = children.stream() + .filter(project -> dataPermissionService.canAccessProject(project.getId(), creatorMap.get(project.getId()), scope)) + .collect(Collectors.toList()); + } + return children; + } + + public Project create(CreateProjectRequest request) { + if (enterpriseService != null) { + enterpriseService.assertEnabled(request.getPartnerEnterpriseId()); + } + // 项目周期合法性校验:结束日期不能早于开始日期。 + if (request.getStartDate() != null && request.getEndDate() != null + && request.getEndDate().isBefore(request.getStartDate())) { + throw new BusinessException(10001, "项目结束日期不能早于开始日期"); + } + if (request.getParentProjectId() != null) { + getById(request.getParentProjectId()); + } + ProjectFeeSummary projectFee = buildProjectFeeSummary(request.getProjectFeeJson()); + double budgetExecutionRatio = calculateBudgetExecutionRatio(request.getBudgetCent(), projectFee.totalCent); + assertProjectBudgetConstraint( + request.getAllowProjectOverBudget() != null && request.getAllowProjectOverBudget(), + request.getOverBudgetThresholdRatio() == null ? 0.1d : request.getOverBudgetThresholdRatio(), + budgetExecutionRatio, + request.getBudgetCent(), + projectFee.totalCent + ); + Project project = new Project( + null, + request.getName(), + null, + request.getStartDate(), + request.getEndDate(), + request.getPartnerEnterpriseId(), + null, + null, + request.getPartnerEnterpriseId(), + null, + null, + null, + null, + request.getBudgetCent(), + request.getMeetingTotal(), + 0, + request.getAllowMeetingOverBudget() != null && request.getAllowMeetingOverBudget(), + request.getOverBudgetThresholdRatio() == null ? 0.1d : request.getOverBudgetThresholdRatio(), + request.getOverBudgetApprovalChainJson(), + budgetExecutionRatio, + null, + + request.getMeetingTotal(), + 0, + 0, + request.getLaborFeeRatio() == null ? 0d : request.getLaborFeeRatio(), + request.getAllowProjectOverBudget() != null && request.getAllowProjectOverBudget(), + request.getInvoiceInfo(), + request.getExpenseRatioJson(), + null, + null, + null, + null, + null, + null, + null, + null, + ProjectStatus.WAITING + ); + project.setParentProjectId(request.getParentProjectId()); + project.setHostEnterpriseName(resolveCurrentTenantName()); + project.setProjectFeeJson(projectFee.normalizedJson); + Project saved = projectRepository.save(project); + logProjectCreate(saved); + return saved; + } + + public Project update(Long projectId, CreateProjectRequest request) { + Project existing = getById(projectId); + if (enterpriseService != null && request.getPartnerEnterpriseId() != null) { + enterpriseService.assertEnabled(request.getPartnerEnterpriseId()); + } + if (request.getStartDate() != null && request.getEndDate() != null + && request.getEndDate().isBefore(request.getStartDate())) { + throw new BusinessException(10001, "项目结束日期不能早于开始日期"); + } + String requestProjectFeeJson = request.getProjectFeeJson(); + String sourceProjectFeeJson = (requestProjectFeeJson == null || requestProjectFeeJson.trim().isEmpty()) + ? existing.getProjectFeeJson() + : requestProjectFeeJson; + ProjectFeeSummary projectFee = buildProjectFeeSummary(sourceProjectFeeJson); + double budgetExecutionRatio = calculateBudgetExecutionRatio(request.getBudgetCent(), projectFee.totalCent); + assertProjectBudgetConstraint( + request.getAllowProjectOverBudget() != null && request.getAllowProjectOverBudget(), + request.getOverBudgetThresholdRatio() == null ? 0.1d : request.getOverBudgetThresholdRatio(), + budgetExecutionRatio, + request.getBudgetCent(), + projectFee.totalCent + ); + Project project = new Project( + existing.getId(), + request.getName(), + null, + request.getStartDate(), + request.getEndDate(), + request.getPartnerEnterpriseId(), + existing.getEnterpriseName(), + null, + request.getPartnerEnterpriseId(), + null, + null, + null, + null, + request.getBudgetCent(), + request.getMeetingTotal(), + existing.getMeetingCompletedCount(), + request.getAllowMeetingOverBudget() != null && request.getAllowMeetingOverBudget(), + request.getOverBudgetThresholdRatio() == null ? 0.1d : request.getOverBudgetThresholdRatio(), + request.getOverBudgetApprovalChainJson(), + budgetExecutionRatio, + existing.getRiskFlagsJson(), + + existing.getWriteOffNotStartedCount(), + existing.getWriteOffInProgressCount(), + existing.getWriteOffCompletedCount(), + request.getLaborFeeRatio() == null ? existing.getLaborFeeRatio() : request.getLaborFeeRatio(), + request.getAllowProjectOverBudget() != null && request.getAllowProjectOverBudget(), + request.getInvoiceInfo(), + request.getExpenseRatioJson(), + null, + null, + null, + null, + existing.getTerminatedReason(), + existing.getFreezeReason(), + existing.getArchivedAt(), + existing.getKeyChangeLogJson(), + existing.getStatus() + ); + project.setParentProjectId(request.getParentProjectId() == null ? existing.getParentProjectId() : request.getParentProjectId()); + project.setHostEnterpriseName(resolveCurrentTenantName()); + project.setProjectFeeJson(projectFee.normalizedJson); + Project saved = projectRepository.save(project); + logProjectUpdate(existing, saved); + return saved; + } + + public Project freeze(Long projectId, String reason) { + Project project = projectRepository.findById(projectId) + .orElseThrow(() -> new BusinessException(10003, "项目不存在")); + if (reason == null || reason.trim().isEmpty()) { + throw new BusinessException(10001, "冻结原因不能为空"); + } + project.setStatus(ProjectStatus.FROZEN); + // 冻结原因需要与状态一起落库,满足风控留痕要求。 + project.setFreezeReason(reason.trim()); + Project saved = projectRepository.save(project); + if (bizChangeLogService != null) { + bizChangeLogService.logAction("PROJECT", saved.getId(), "PROJECT_FREEZE", reason.trim()); + } + return saved; + } + + public Project getById(Long projectId) { + return projectRepository.findById(projectId) + .orElseThrow(() -> new BusinessException(10003, "项目不存在")); + } + + public Project unfreeze(Long projectId, String reason) { + Project project = getById(projectId); + if (project.getStatus() != ProjectStatus.FROZEN) { + throw new BusinessException(30001, "仅冻结状态的项目允许解冻"); + } + if (reason == null || reason.trim().isEmpty()) { + throw new BusinessException(10001, "解冻原因不能为空"); + } + project.setStatus(ProjectStatus.IN_PROGRESS); + project.setFreezeReason(null); + Project saved = projectRepository.save(project); + if (bizChangeLogService != null) { + bizChangeLogService.logAction("PROJECT", saved.getId(), "PROJECT_UNFREEZE", reason.trim()); + } + return saved; + } + + public Project archive(Long projectId) { + Project project = getById(projectId); + if (project.getStatus() != ProjectStatus.COMPLETED && project.getStatus() != ProjectStatus.FROZEN) { + throw new BusinessException(30001, "仅已完成或已冻结的项目允许归档"); + } + project.setStatus(ProjectStatus.ARCHIVED); + project.setArchivedAt(java.time.LocalDateTime.now()); + Project saved = projectRepository.save(project); + if (bizChangeLogService != null) { + bizChangeLogService.logAction("PROJECT", saved.getId(), "PROJECT_ARCHIVE", null); + } + return saved; + } + + public void markInProgress(Long projectId) { + Project project = getById(projectId); + if (project.getStatus() == ProjectStatus.WAITING) { + project.setStatus(ProjectStatus.IN_PROGRESS); + projectRepository.save(project); + } + } + + public Map listBindingCandidates() { + ensureJdbcEnabled(); + boolean projectExecutorMode = isCurrentUserProjectExecutor(); + Map result = new LinkedHashMap<>(); + result.put("ownerUsers", projectExecutorMode ? new ArrayList>() : listUsersByRoleCode("PROJECT_OWNER")); + result.put("executorUsers", projectExecutorMode ? new ArrayList>() : listUsersByRoleCode("PROJECT_EXECUTOR")); + result.put("legacyExecutorUsers", (hasExecutorBindingPermission() || projectExecutorMode) ? listUsersByRoleCode("EXECUTOR") : new ArrayList>()); + return result; + } + + public Map getBindings(Long projectId) { + ensureJdbcEnabled(); + getById(projectId); + boolean projectExecutorMode = isCurrentUserProjectExecutor(); + Map result = new LinkedHashMap<>(); + result.put("ownerUsers", projectExecutorMode ? new ArrayList>() : listProjectBoundUsers(projectId, "PROJECT_OWNER")); + result.put("executorUsers", projectExecutorMode ? new ArrayList>() : listProjectBoundUsers(projectId, "PROJECT_EXECUTOR")); + result.put("legacyExecutorUsers", (hasExecutorBindingPermission() || projectExecutorMode) ? listProjectBoundUsers(projectId, "EXECUTOR") : new ArrayList>()); + return result; + } + + public void saveBindings(Long projectId, SaveProjectBindingsRequest request) { + ensureJdbcEnabled(); + getById(projectId); + boolean projectExecutorMode = isCurrentUserProjectExecutor(); + List> beforeOwnerUsers = listProjectBoundUsers(projectId, "PROJECT_OWNER"); + List> beforeExecutorUsers = listProjectBoundUsers(projectId, "PROJECT_EXECUTOR"); + List> beforeLegacyExecutorUsers = listProjectBoundUsers(projectId, "EXECUTOR"); + List ownerUserIds = request.getOwnerUserIds() == null ? new ArrayList() : request.getOwnerUserIds(); + List executorUserIds = request.getExecutorUserIds() == null ? new ArrayList() : request.getExecutorUserIds(); + List legacyExecutorUserIds = request.getLegacyExecutorUserIds() == null ? new ArrayList() : request.getLegacyExecutorUserIds(); + boolean canBindLegacyExecutor = hasExecutorBindingPermission() || projectExecutorMode; + if (projectExecutorMode) { + ownerUserIds = listProjectBoundUserIds(projectId, "PROJECT_OWNER"); + executorUserIds = listProjectBoundUserIds(projectId, "PROJECT_EXECUTOR"); + } + if (!canBindLegacyExecutor && !legacyExecutorUserIds.isEmpty()) { + throw new BusinessException(10001, "无 project.bind.executor_user 权限"); + } + for (Long userId : ownerUserIds) { + assertUserHasRole(userId, "PROJECT_OWNER"); + } + for (Long userId : executorUserIds) { + assertUserHasRole(userId, "PROJECT_EXECUTOR"); + } + if (canBindLegacyExecutor) { + for (Long userId : legacyExecutorUserIds) { + assertUserHasRole(userId, "EXECUTOR"); + } + } else { + legacyExecutorUserIds = listProjectBoundUserIds(projectId, "EXECUTOR"); + } + jdbcTemplate.update( + "DELETE FROM project_user_binding WHERE tenant_id=? AND project_id=?", + tenantId(), + projectId + ); + for (Long userId : ownerUserIds) { + insertBinding(projectId, userId, "PROJECT_OWNER"); + } + for (Long userId : executorUserIds) { + insertBinding(projectId, userId, "PROJECT_EXECUTOR"); + } + for (Long userId : legacyExecutorUserIds) { + insertBinding(projectId, userId, "EXECUTOR"); + } + logProjectBindingChanges(projectId, beforeOwnerUsers, beforeExecutorUsers, beforeLegacyExecutorUsers, ownerUserIds, executorUserIds, legacyExecutorUserIds); + } + + public List> listKeyChangeLogs(Long projectId) { + getById(projectId); + if (bizChangeLogService == null) { + return new ArrayList>(); + } + return bizChangeLogService.listByBiz("PROJECT", projectId).stream() + .map(this::toProjectChangeLogRow) + .collect(Collectors.toList()); + } + + private List> listUsersByRoleCode(String roleCode) { + return jdbcTemplate.query( + "SELECT DISTINCT u.id AS user_id, u.user_name, u.phone, u.created_by " + + "FROM sys_user u " + + "JOIN user_role ur ON u.tenant_id=ur.tenant_id AND u.id=ur.user_id " + + "JOIN role r ON ur.tenant_id=r.tenant_id AND ur.role_id=r.id " + + "WHERE u.tenant_id=? AND u.is_deleted=0 AND u.status='ENABLED' AND r.role_code=? " + + "ORDER BY u.id DESC", + (rs, n) -> { + Map row = new LinkedHashMap<>(); + row.put("userId", rs.getLong("user_id")); + row.put("userName", rs.getString("user_name")); + row.put("phone", rs.getString("phone")); + row.put("createdBy", rs.getLong("created_by")); + return row; + }, + tenantId(), + roleCode + ); + } + + private List> listProjectBoundUsers(Long projectId, String roleCode) { + return jdbcTemplate.query( + "SELECT b.user_id, u.user_name, u.phone, u.created_by " + + "FROM project_user_binding b " + + "JOIN sys_user u ON b.tenant_id=u.tenant_id AND b.user_id=u.id " + + "WHERE b.tenant_id=? AND b.project_id=? AND b.bind_role_code=? AND b.is_deleted=0 AND u.is_deleted=0 " + + "ORDER BY b.id DESC", + (rs, n) -> { + Map row = new LinkedHashMap<>(); + row.put("userId", rs.getLong("user_id")); + row.put("userName", rs.getString("user_name")); + row.put("phone", rs.getString("phone")); + row.put("createdBy", rs.getLong("created_by")); + return row; + }, + tenantId(), + projectId, + roleCode + ); + } + + private List listProjectBoundUserIds(Long projectId, String roleCode) { + return jdbcTemplate.queryForList( + "SELECT user_id FROM project_user_binding WHERE tenant_id=? AND project_id=? AND bind_role_code=? AND is_deleted=0", + Long.class, + tenantId(), + projectId, + roleCode + ); + } + + private void assertUserHasRole(Long userId, String roleCode) { + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(1) " + + "FROM user_role ur " + + "JOIN role r ON ur.tenant_id=r.tenant_id AND ur.role_id=r.id " + + "WHERE ur.tenant_id=? AND ur.user_id=? AND r.role_code=?", + Integer.class, + tenantId(), + userId, + roleCode + ); + if (count == null || count == 0) { + throw new BusinessException(10001, "用户未分配角色: " + roleCode); + } + } + + private void insertBinding(Long projectId, Long userId, String roleCode) { + jdbcTemplate.update( + "INSERT INTO project_user_binding (tenant_id, project_id, user_id, bind_role_code, is_deleted, created_by, updated_by) " + + "VALUES (?, ?, ?, ?, 0, 0, 0)", + tenantId(), + projectId, + userId, + roleCode + ); + } + + private void ensureJdbcEnabled() { + if (jdbcTemplate == null) { + throw new BusinessException(10001, "当前仓储模式不支持项目人员绑定"); + } + } + + private boolean hasExecutorBindingPermission() { + Long userId = AuthContext.userId(); + return permissionService != null && userId != null && permissionService.hasPermission(userId, "project.bind.executor_user"); + } + + private boolean isCurrentUserProjectExecutor() { + Long userId = AuthContext.userId(); + if (userId == null) { + return false; + } + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(1) " + + "FROM user_role ur " + + "JOIN role r ON ur.tenant_id=r.tenant_id AND ur.role_id=r.id " + + "WHERE ur.tenant_id=? AND ur.user_id=? AND r.role_code='PROJECT_EXECUTOR' AND r.is_deleted=0", + Integer.class, + tenantId(), + userId + ); + return count != null && count > 0; + } + + private Long tenantId() { + return AuthContext.requireTenantId(); + } + + private String resolveCurrentTenantName() { + if (jdbcTemplate == null) { + return null; + } + List names = jdbcTemplate.query( + "SELECT tenant_name FROM tenant WHERE id=?", + (rs, n) -> rs.getString("tenant_name"), + tenantId() + ); + return names.isEmpty() ? null : names.get(0); + } + + private ProjectFeeSummary buildProjectFeeSummary(String rawProjectFeeJson) { + ObjectNode root; + try { + if (rawProjectFeeJson == null || rawProjectFeeJson.trim().isEmpty()) { + root = objectMapper.createObjectNode(); + } else { + JsonNode parsed = objectMapper.readTree(rawProjectFeeJson); + root = parsed != null && parsed.isObject() ? (ObjectNode) parsed : objectMapper.createObjectNode(); + } + } catch (Exception e) { + throw new BusinessException(10001, "项目费用配置格式不正确"); + } + + long managementFeeCent = readNonNegativeLong(root, "managementFeeCent"); + long taxFeeCent = readNonNegativeLong(root, "taxFeeCent"); + long paidAmountCent = readNonNegativeLong(root, "paidAmountCent"); + + ArrayNode customFeesNode = objectMapper.createArrayNode(); + long customTotalCent = 0L; + JsonNode customFees = root.get("customFees"); + if (customFees != null && customFees.isArray()) { + for (JsonNode feeNode : customFees) { + if (feeNode == null || !feeNode.isObject()) { + continue; + } + String name = feeNode.path("name").asText("").trim(); + if (name.isEmpty()) { + continue; + } + long amountCent = readNonNegativeLong(feeNode, "amountCent"); + String remark = feeNode.path("remark").asText(""); + ObjectNode normalizedFee = objectMapper.createObjectNode(); + normalizedFee.put("name", name); + normalizedFee.put("amountCent", amountCent); + normalizedFee.put("remark", remark); + customFeesNode.add(normalizedFee); + customTotalCent += amountCent; + } + } + + ObjectNode normalizedRoot = objectMapper.createObjectNode(); + normalizedRoot.put("managementFeeCent", managementFeeCent); + normalizedRoot.put("taxFeeCent", taxFeeCent); + normalizedRoot.put("paidAmountCent", paidAmountCent); + normalizedRoot.set("customFees", customFeesNode); + + try { + return new ProjectFeeSummary( + objectMapper.writeValueAsString(normalizedRoot), + managementFeeCent + taxFeeCent + paidAmountCent + customTotalCent + ); + } catch (Exception e) { + throw new BusinessException(10001, "项目费用配置序列化失败"); + } + } + + private long readNonNegativeLong(JsonNode node, String fieldName) { + JsonNode valueNode = node.get(fieldName); + if (valueNode == null || valueNode.isNull()) { + return 0L; + } + if (!valueNode.isNumber()) { + throw new BusinessException(10001, "项目费用字段格式不正确: " + fieldName); + } + long value = valueNode.asLong(); + if (value < 0L) { + throw new BusinessException(10001, "项目费用字段不能为负数: " + fieldName); + } + return value; + } + + private double calculateBudgetExecutionRatio(Long budgetCent, long feeTotalCent) { + long budget = budgetCent == null ? 0L : budgetCent; + if (budget <= 0L) { + return 0d; + } + return (double) feeTotalCent / budget; + } + + private void assertProjectBudgetConstraint(boolean allowProjectOverBudget, + double thresholdRatio, + double budgetExecutionRatio, + Long budgetCent, + long feeTotalCent) { + if (allowProjectOverBudget) { + return; + } + double allowedRatio = 1d + Math.max(0d, thresholdRatio); + if (budgetExecutionRatio > allowedRatio) { + long budget = budgetCent == null ? 0L : budgetCent; + long allowedTotalCent = Math.round(budget * allowedRatio); + long overCent = Math.max(0L, feeTotalCent - allowedTotalCent); + String detail = String.format( + Locale.ROOT, + "项目总费用已超过预算阈值:当前执行率%.2f%%,阈值%.2f%%,超出%.2f元", + budgetExecutionRatio * 100d, + allowedRatio * 100d, + overCent / 100d + ); + throw new BusinessException(10001, detail); + } + } + + private void logProjectCreate(Project project) { + if (bizChangeLogService == null) { + return; + } + bizChangeLogService.logAction("PROJECT", project.getId(), "PROJECT_CREATE", null); + logProjectFieldChange(project.getId(), "PROJECT_CREATE", "name", "项目名称", null, project.getName(), null); + logProjectFieldChange(project.getId(), "PROJECT_CREATE", "parentProjectId", "上级项目ID", null, project.getParentProjectId(), null); + logProjectFieldChange(project.getId(), "PROJECT_CREATE", "startDate", "项目开始日期", null, project.getStartDate(), null); + logProjectFieldChange(project.getId(), "PROJECT_CREATE", "endDate", "项目结束日期", null, project.getEndDate(), null); + logProjectFieldChange(project.getId(), "PROJECT_CREATE", "partnerEnterpriseId", "合作企业ID", null, project.getPartnerEnterpriseId(), null); + logProjectFieldChange(project.getId(), "PROJECT_CREATE", "budgetCent", "项目预算(分)", null, project.getBudgetCent(), null); + logProjectFieldChange(project.getId(), "PROJECT_CREATE", "meetingTotal", "会议总期数", null, project.getMeetingTotal(), null); + logProjectFieldChange(project.getId(), "PROJECT_CREATE", "laborFeeRatio", "劳务费用占比", null, project.getLaborFeeRatio(), null); + logProjectFieldChange(project.getId(), "PROJECT_CREATE", "invoiceInfo", "发票信息", null, project.getInvoiceInfo(), null); + logProjectFieldChange(project.getId(), "PROJECT_CREATE", "expenseRatioJson", "费用占比配置", null, project.getExpenseRatioJson(), null); + logProjectFieldChange(project.getId(), "PROJECT_CREATE", "projectFeeJson", "项目费用配置", null, project.getProjectFeeJson(), null); + logProjectFieldChange(project.getId(), "PROJECT_CREATE", "status", "项目状态", null, project.getStatus(), null); + } + + private void logProjectUpdate(Project before, Project after) { + if (bizChangeLogService == null) { + return; + } + String batchId = bizChangeLogService.newBatchId(); + logProjectFieldChange(after.getId(), "PROJECT_UPDATE", "name", "项目名称", before.getName(), after.getName(), batchId); + logProjectFieldChange(after.getId(), "PROJECT_UPDATE", "parentProjectId", "上级项目ID", before.getParentProjectId(), after.getParentProjectId(), batchId); + logProjectFieldChange(after.getId(), "PROJECT_UPDATE", "startDate", "项目开始日期", before.getStartDate(), after.getStartDate(), batchId); + logProjectFieldChange(after.getId(), "PROJECT_UPDATE", "endDate", "项目结束日期", before.getEndDate(), after.getEndDate(), batchId); + logProjectFieldChange(after.getId(), "PROJECT_UPDATE", "partnerEnterpriseId", "合作企业ID", before.getPartnerEnterpriseId(), after.getPartnerEnterpriseId(), batchId); + logProjectFieldChange(after.getId(), "PROJECT_UPDATE", "budgetCent", "项目预算(分)", before.getBudgetCent(), after.getBudgetCent(), batchId); + logProjectFieldChange(after.getId(), "PROJECT_UPDATE", "meetingTotal", "会议总期数", before.getMeetingTotal(), after.getMeetingTotal(), batchId); + logProjectFieldChange(after.getId(), "PROJECT_UPDATE", "laborFeeRatio", "劳务费用占比", before.getLaborFeeRatio(), after.getLaborFeeRatio(), batchId); + logProjectFieldChange(after.getId(), "PROJECT_UPDATE", "invoiceInfo", "发票信息", before.getInvoiceInfo(), after.getInvoiceInfo(), batchId); + logProjectFieldChange(after.getId(), "PROJECT_UPDATE", "expenseRatioJson", "费用占比配置", before.getExpenseRatioJson(), after.getExpenseRatioJson(), batchId); + logProjectFieldChange(after.getId(), "PROJECT_UPDATE", "projectFeeJson", "项目费用配置", before.getProjectFeeJson(), after.getProjectFeeJson(), batchId); + logProjectFieldChange(after.getId(), "PROJECT_UPDATE", "status", "项目状态", before.getStatus(), after.getStatus(), batchId); + } + + private void logProjectBindingChanges(Long projectId, + List> beforeOwnerUsers, + List> beforeExecutorUsers, + List> beforeLegacyExecutorUsers, + List ownerUserIds, + List executorUserIds, + List legacyExecutorUserIds) { + if (bizChangeLogService == null) { + return; + } + String batchId = bizChangeLogService.newBatchId(); + logBindingDiff(projectId, "PROJECT_OWNER", "项目负责人", beforeOwnerUsers, ownerUserIds, batchId); + logBindingDiff(projectId, "PROJECT_EXECUTOR", "项目执行人", beforeExecutorUsers, executorUserIds, batchId); + logBindingDiff(projectId, "EXECUTOR", "执行人", beforeLegacyExecutorUsers, legacyExecutorUserIds, batchId); + } + + private void logBindingDiff(Long projectId, + String fieldCode, + String fieldName, + List> beforeUsers, + List afterUserIds, + String batchId) { + Map beforeMap = new LinkedHashMap(); + for (Map item : beforeUsers) { + if (item == null) { + continue; + } + Object userIdVal = item.get("userId"); + long userId = userIdVal instanceof Number ? ((Number) userIdVal).longValue() : 0L; + if (userId > 0L) { + beforeMap.put(userId, String.valueOf(item.get("userName") == null ? "" : item.get("userName")).trim()); + } + } + Map afterMap = loadUserNameMap(afterUserIds); + Set allIds = new HashSet(); + allIds.addAll(beforeMap.keySet()); + allIds.addAll(afterMap.keySet()); + List sortedIds = new ArrayList(allIds); + sortedIds.sort(Comparator.naturalOrder()); + for (Long userId : sortedIds) { + boolean beforeExists = beforeMap.containsKey(userId); + boolean afterExists = afterMap.containsKey(userId); + String userName = beforeExists ? beforeMap.get(userId) : afterMap.get(userId); + if (!beforeExists && afterExists) { + bizChangeLogService.logRelationAdd("PROJECT", projectId, "PROJECT_BIND_ADD", fieldCode, fieldName, userId, userName, batchId, null); + } else if (beforeExists && !afterExists) { + bizChangeLogService.logRelationRemove("PROJECT", projectId, "PROJECT_BIND_REMOVE", fieldCode, fieldName, userId, userName, batchId, null); + } + } + } + + private Map loadUserNameMap(List userIds) { + Map map = new HashMap(); + if (bizChangeLogService == null) { + return map; + } + for (BizChangeLogService.UserRef item : bizChangeLogService.loadUserRefs(userIds)) { + map.put(item.getUserId(), item.getUserName()); + } + return map; + } + + private void logProjectFieldChange(Long projectId, + String changeType, + String fieldCode, + String fieldName, + Object beforeValue, + Object afterValue, + String batchId) { + if (bizChangeLogService == null) { + return; + } + bizChangeLogService.logFieldChange( + "PROJECT", + projectId, + changeType, + fieldCode, + fieldName, + normalizeProjectFieldValue(beforeValue), + normalizeProjectFieldValue(afterValue), + batchId, + null + ); + } + + private Object normalizeProjectFieldValue(Object value) { + if (value instanceof Enum) { + return ((Enum) value).name(); + } + return value; + } + + private Map toProjectChangeLogRow(BizChangeLogInfo item) { + Map row = new LinkedHashMap(); + row.put("id", item.getId()); + row.put("fieldCode", item.getFieldCode()); + row.put("fieldName", resolveProjectFieldName(item)); + row.put("beforeValue", item.getBeforeValue()); + row.put("afterValue", item.getAfterValue()); + row.put("changeReason", item.getRemark()); + row.put("handoverAt", null); + row.put("createdBy", item.getOperatorUserId()); + row.put("createdAt", item.getCreatedAt()); + row.put("changeType", item.getChangeType()); + row.put("operatorUserName", item.getOperatorUserName()); + row.put("batchId", item.getBatchId()); + return row; + } + + private String resolveProjectFieldName(BizChangeLogInfo item) { + String fieldName = item.getFieldName() == null ? "" : item.getFieldName().trim(); + if (!fieldName.isEmpty()) { + return fieldName; + } + if ("PROJECT_CREATE".equals(item.getChangeType())) { + return "项目创建"; + } + if ("PROJECT_FREEZE".equals(item.getChangeType())) { + return "项目冻结"; + } + if ("PROJECT_UNFREEZE".equals(item.getChangeType())) { + return "项目解冻"; + } + if ("PROJECT_ARCHIVE".equals(item.getChangeType())) { + return "项目归档"; + } + return "项目变更"; + } + + private static class ProjectFeeSummary { + private final String normalizedJson; + private final long totalCent; + + private ProjectFeeSummary(String normalizedJson, long totalCent) { + this.normalizedJson = normalizedJson; + this.totalCent = totalCent; + } + } +} diff --git a/backend/src/main/java/com/writeoff/module/scheduler/job/AsyncJobScheduler.java b/backend/src/main/java/com/writeoff/module/scheduler/job/AsyncJobScheduler.java new file mode 100644 index 0000000..78d7d94 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/scheduler/job/AsyncJobScheduler.java @@ -0,0 +1,77 @@ +package com.writeoff.module.scheduler.job; + +import com.writeoff.module.scheduler.model.AsyncJob; +import com.writeoff.module.scheduler.service.AsyncJobService; +import com.writeoff.module.notification.service.NotificationDispatchService; +import com.writeoff.module.export.service.ExportTaskService; +import com.writeoff.security.AuthContext; +import com.writeoff.security.AuthScope; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Component +public class AsyncJobScheduler { + private final AsyncJobService asyncJobService; + private final NotificationDispatchService notificationDispatchService; + private final ExportTaskService exportTaskService; + private final int batchSize; + private final boolean enabled; + + public AsyncJobScheduler(AsyncJobService asyncJobService, + NotificationDispatchService notificationDispatchService, + ExportTaskService exportTaskService, + @Value("${app.scheduler.batch-size:100}") int batchSize, + @Value("${app.scheduler.enabled:true}") boolean enabled) { + this.asyncJobService = asyncJobService; + this.notificationDispatchService = notificationDispatchService; + this.exportTaskService = exportTaskService; + this.batchSize = batchSize; + this.enabled = enabled; + } + + @Scheduled(fixedDelayString = "${app.scheduler.poll-interval-ms:3000}") + public void schedule() { + if (!enabled) { + return; + } + List jobs = asyncJobService.fetchReadyJobs(batchSize); + for (AsyncJob job : jobs) { + try { + if (job.getTenantId() != null && job.getTenantId() > 0) { + AuthContext.set(0L, job.getTenantId(), AuthScope.TENANT); + } else { + AuthContext.clear(); + } + asyncJobService.markRunning(job, "scheduler-1"); + try { + execute(job); + asyncJobService.markSuccess(job); + } catch (Exception ex) { + asyncJobService.markFailed(job, ex); + } + } finally { + AuthContext.clear(); + } + } + } + + private void execute(AsyncJob job) { + switch (job.getJobType()) { + case "AUDIT_REMIND": + case "TEMPLATE_EXPIRE": + case "EXPORT_REPORT": + return; + case "NOTIFICATION_DISPATCH": + notificationDispatchService.processTask(job.getPayload()); + return; + case "EXPORT_TASK": + exportTaskService.processTask(job.getPayload()); + return; + default: + throw new IllegalArgumentException("Unsupported jobType: " + job.getJobType()); + } + } +} diff --git a/backend/src/main/java/com/writeoff/module/scheduler/model/AsyncJob.java b/backend/src/main/java/com/writeoff/module/scheduler/model/AsyncJob.java new file mode 100644 index 0000000..eccda17 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/scheduler/model/AsyncJob.java @@ -0,0 +1,95 @@ +package com.writeoff.module.scheduler.model; + +import java.time.LocalDateTime; + +public class AsyncJob { + private Long id; + private Long tenantId; + private String jobType; + private String payload; + private AsyncJobStatus status; + private LocalDateTime nextRunAt; + private int retryCount; + private int maxRetry; + private String idempotencyKey; + private String lockedBy; + private LocalDateTime lockedAt; + + public AsyncJob(Long id, Long tenantId, String jobType, String payload, AsyncJobStatus status, LocalDateTime nextRunAt, int retryCount, int maxRetry, String idempotencyKey, String lockedBy, LocalDateTime lockedAt) { + this.id = id; + this.tenantId = tenantId; + this.jobType = jobType; + this.payload = payload; + this.status = status; + this.nextRunAt = nextRunAt; + this.retryCount = retryCount; + this.maxRetry = maxRetry; + this.idempotencyKey = idempotencyKey; + this.lockedBy = lockedBy; + this.lockedAt = lockedAt; + } + + public Long getId() { + return id; + } + + public Long getTenantId() { + return tenantId; + } + + public String getJobType() { + return jobType; + } + + public String getPayload() { + return payload; + } + + public AsyncJobStatus getStatus() { + return status; + } + + public LocalDateTime getNextRunAt() { + return nextRunAt; + } + + public int getRetryCount() { + return retryCount; + } + + public int getMaxRetry() { + return maxRetry; + } + + public String getIdempotencyKey() { + return idempotencyKey; + } + + public String getLockedBy() { + return lockedBy; + } + + public LocalDateTime getLockedAt() { + return lockedAt; + } + + public void setStatus(AsyncJobStatus status) { + this.status = status; + } + + public void setNextRunAt(LocalDateTime nextRunAt) { + this.nextRunAt = nextRunAt; + } + + public void setRetryCount(int retryCount) { + this.retryCount = retryCount; + } + + public void setLockedBy(String lockedBy) { + this.lockedBy = lockedBy; + } + + public void setLockedAt(LocalDateTime lockedAt) { + this.lockedAt = lockedAt; + } +} diff --git a/backend/src/main/java/com/writeoff/module/scheduler/model/AsyncJobStatus.java b/backend/src/main/java/com/writeoff/module/scheduler/model/AsyncJobStatus.java new file mode 100644 index 0000000..ec31743 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/scheduler/model/AsyncJobStatus.java @@ -0,0 +1,8 @@ +package com.writeoff.module.scheduler.model; + +public enum AsyncJobStatus { + READY, + RUNNING, + SUCCESS, + FAILED +} diff --git a/backend/src/main/java/com/writeoff/module/scheduler/repository/AsyncJobRepository.java b/backend/src/main/java/com/writeoff/module/scheduler/repository/AsyncJobRepository.java new file mode 100644 index 0000000..5c284a6 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/scheduler/repository/AsyncJobRepository.java @@ -0,0 +1,17 @@ +package com.writeoff.module.scheduler.repository; + +import com.writeoff.module.scheduler.model.AsyncJob; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +public interface AsyncJobRepository { + AsyncJob save(AsyncJob job); + + Optional findById(Long id); + + Optional findByIdempotencyKey(String idempotencyKey); + + List findReady(LocalDateTime now, int limit); +} diff --git a/backend/src/main/java/com/writeoff/module/scheduler/repository/InMemoryAsyncJobRepository.java b/backend/src/main/java/com/writeoff/module/scheduler/repository/InMemoryAsyncJobRepository.java new file mode 100644 index 0000000..59fe291 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/scheduler/repository/InMemoryAsyncJobRepository.java @@ -0,0 +1,69 @@ +package com.writeoff.module.scheduler.repository; + +import com.writeoff.module.scheduler.model.AsyncJob; +import com.writeoff.module.scheduler.model.AsyncJobStatus; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Repository; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; +import java.util.stream.Collectors; + +@Repository +@ConditionalOnProperty(prefix = "app.repository", name = "mode", havingValue = "in-memory") +public class InMemoryAsyncJobRepository implements AsyncJobRepository { + private final ConcurrentHashMap store = new ConcurrentHashMap<>(); + private final AtomicLong idGenerator = new AtomicLong(10000); + + @Override + public AsyncJob save(AsyncJob job) { + if (job.getId() == null) { + AsyncJob newJob = new AsyncJob( + idGenerator.incrementAndGet(), + job.getTenantId(), + job.getJobType(), + job.getPayload(), + job.getStatus(), + job.getNextRunAt(), + job.getRetryCount(), + job.getMaxRetry(), + job.getIdempotencyKey(), + job.getLockedBy(), + job.getLockedAt() + ); + store.put(newJob.getId(), newJob); + return newJob; + } + store.put(job.getId(), job); + return job; + } + + @Override + public Optional findById(Long id) { + return Optional.ofNullable(store.get(id)); + } + + @Override + public Optional findByIdempotencyKey(String idempotencyKey) { + if (idempotencyKey == null || idempotencyKey.isEmpty()) { + return Optional.empty(); + } + return store.values().stream() + .filter(j -> idempotencyKey.equals(j.getIdempotencyKey())) + .findFirst(); + } + + @Override + public List findReady(LocalDateTime now, int limit) { + return store.values().stream() + .filter(j -> j.getStatus() == AsyncJobStatus.READY && !j.getNextRunAt().isAfter(now)) + .sorted(Comparator.comparing(AsyncJob::getNextRunAt)) + .limit(limit) + .collect(Collectors.toCollection(ArrayList::new)); + } +} diff --git a/backend/src/main/java/com/writeoff/module/scheduler/repository/JdbcAsyncJobRepository.java b/backend/src/main/java/com/writeoff/module/scheduler/repository/JdbcAsyncJobRepository.java new file mode 100644 index 0000000..4a1dcce --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/scheduler/repository/JdbcAsyncJobRepository.java @@ -0,0 +1,109 @@ +package com.writeoff.module.scheduler.repository; + +import com.writeoff.module.scheduler.model.AsyncJob; +import com.writeoff.module.scheduler.model.AsyncJobStatus; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.jdbc.support.GeneratedKeyHolder; +import org.springframework.jdbc.support.KeyHolder; +import org.springframework.stereotype.Repository; +import com.writeoff.security.AuthContext; + +import java.sql.PreparedStatement; +import java.sql.Statement; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +@Repository +@ConditionalOnProperty(prefix = "app.repository", name = "mode", havingValue = "jdbc", matchIfMissing = true) +public class JdbcAsyncJobRepository implements AsyncJobRepository { + private final JdbcTemplate jdbcTemplate; + private static final RowMapper ROW_MAPPER = (rs, n) -> new AsyncJob( + rs.getLong("id"), + rs.getLong("tenant_id"), + rs.getString("job_type"), + rs.getString("payload"), + AsyncJobStatus.valueOf(rs.getString("status")), + rs.getTimestamp("next_run_at").toLocalDateTime(), + rs.getInt("retry_count"), + rs.getInt("max_retry"), + rs.getString("idempotency_key"), + rs.getString("locked_by"), + rs.getTimestamp("locked_at") == null ? null : rs.getTimestamp("locked_at").toLocalDateTime() + ); + + public JdbcAsyncJobRepository(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + @Override + public AsyncJob save(AsyncJob job) { + if (job.getId() == null) { + KeyHolder keyHolder = new GeneratedKeyHolder(); + jdbcTemplate.update(connection -> { + PreparedStatement ps = connection.prepareStatement( + "INSERT INTO async_job (tenant_id, job_type, payload, status, next_run_at, retry_count, max_retry, idempotency_key, locked_by, locked_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + Statement.RETURN_GENERATED_KEYS + ); + Long tenantId = job.getTenantId() == null ? tenantId() : job.getTenantId(); + ps.setLong(1, tenantId); + ps.setString(2, job.getJobType()); + ps.setString(3, job.getPayload()); + ps.setString(4, job.getStatus().name()); + ps.setObject(5, job.getNextRunAt()); + ps.setInt(6, job.getRetryCount()); + ps.setInt(7, job.getMaxRetry()); + ps.setString(8, job.getIdempotencyKey()); + ps.setString(9, job.getLockedBy()); + ps.setObject(10, job.getLockedAt()); + return ps; + }, keyHolder); + Number key = keyHolder.getKey(); + Long id = key == null ? null : key.longValue(); + return new AsyncJob(id, job.getTenantId(), job.getJobType(), job.getPayload(), job.getStatus(), job.getNextRunAt(), job.getRetryCount(), job.getMaxRetry(), job.getIdempotencyKey(), job.getLockedBy(), job.getLockedAt()); + } + jdbcTemplate.update( + "UPDATE async_job SET status=?, next_run_at=?, retry_count=?, max_retry=?, locked_by=?, locked_at=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", + job.getStatus().name(), + job.getNextRunAt(), + job.getRetryCount(), + job.getMaxRetry(), + job.getLockedBy(), + job.getLockedAt(), + job.getId() + ); + return job; + } + + @Override + public Optional findById(Long id) { + List list = jdbcTemplate.query("SELECT * FROM async_job WHERE id=?", ROW_MAPPER, id); + return list.stream().findFirst(); + } + + @Override + public Optional findByIdempotencyKey(String idempotencyKey) { + if (idempotencyKey == null || idempotencyKey.isEmpty()) { + return Optional.empty(); + } + List list = jdbcTemplate.query("SELECT * FROM async_job WHERE idempotency_key=?", ROW_MAPPER, idempotencyKey); + return list.stream().findFirst(); + } + + @Override + public List findReady(LocalDateTime now, int limit) { + return jdbcTemplate.query( + "SELECT * FROM async_job WHERE status='READY' AND next_run_at<=? ORDER BY next_run_at ASC LIMIT ?", + ROW_MAPPER, + now, + limit + ); + } + + private Long tenantId() { + Long tenantId = AuthContext.tenantId(); + return tenantId == null ? 0L : tenantId; + } +} diff --git a/backend/src/main/java/com/writeoff/module/scheduler/service/AsyncJobService.java b/backend/src/main/java/com/writeoff/module/scheduler/service/AsyncJobService.java new file mode 100644 index 0000000..900e8b7 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/scheduler/service/AsyncJobService.java @@ -0,0 +1,92 @@ +package com.writeoff.module.scheduler.service; + +import com.writeoff.common.exception.BusinessException; +import com.writeoff.module.observability.service.ObservabilityService; +import com.writeoff.module.scheduler.model.AsyncJob; +import com.writeoff.module.scheduler.model.AsyncJobStatus; +import com.writeoff.module.scheduler.repository.AsyncJobRepository; +import com.writeoff.security.AuthContext; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.util.List; + +@Service +public class AsyncJobService { + private static final Logger log = LoggerFactory.getLogger(AsyncJobService.class); + private final AsyncJobRepository asyncJobRepository; + private final ObservabilityService observabilityService; + + @Autowired + public AsyncJobService(AsyncJobRepository asyncJobRepository, ObservabilityService observabilityService) { + this.asyncJobRepository = asyncJobRepository; + this.observabilityService = observabilityService; + } + + public AsyncJobService(AsyncJobRepository asyncJobRepository) { + this(asyncJobRepository, null); + } + + public AsyncJob enqueue(String jobType, String payload, String idempotencyKey) { + if (idempotencyKey != null && asyncJobRepository.findByIdempotencyKey(idempotencyKey).isPresent()) { + throw new BusinessException(10002, "请求幂等冲突"); + } + AsyncJob job = new AsyncJob( + null, + AuthContext.tenantId(), + jobType, + payload, + AsyncJobStatus.READY, + LocalDateTime.now(), + 0, + 3, + idempotencyKey, + null, + null + ); + AsyncJob saved = asyncJobRepository.save(job); + if (observabilityService != null) { + observabilityService.recordAsyncMetric(jobType, "READY"); + } + return saved; + } + + public List fetchReadyJobs(int limit) { + return asyncJobRepository.findReady(LocalDateTime.now(), limit); + } + + public void markRunning(AsyncJob job, String worker) { + job.setStatus(AsyncJobStatus.RUNNING); + job.setLockedBy(worker); + job.setLockedAt(LocalDateTime.now()); + asyncJobRepository.save(job); + } + + public void markSuccess(AsyncJob job) { + job.setStatus(AsyncJobStatus.SUCCESS); + asyncJobRepository.save(job); + if (observabilityService != null) { + observabilityService.recordAsyncMetric(job.getJobType(), "SUCCESS"); + } + } + + public void markFailed(AsyncJob job, Exception ex) { + int retry = job.getRetryCount() + 1; + job.setRetryCount(retry); + if (retry >= job.getMaxRetry()) { + job.setStatus(AsyncJobStatus.FAILED); + log.error("Async job failed permanently, jobId={}, type={}, err={}", job.getId(), job.getJobType(), ex.getMessage()); + } else { + job.setStatus(AsyncJobStatus.READY); + job.setNextRunAt(LocalDateTime.now().plusSeconds(retry == 1 ? 30 : (retry == 2 ? 120 : 300))); + log.warn("Async job retry scheduled, jobId={}, retry={}", job.getId(), retry); + } + asyncJobRepository.save(job); + if (observabilityService != null) { + observabilityService.recordAsyncMetric(job.getJobType(), "FAILED"); + } + } +} diff --git a/backend/src/main/java/com/writeoff/module/setting/invoiceprofile/controller/InvoiceProfileController.java b/backend/src/main/java/com/writeoff/module/setting/invoiceprofile/controller/InvoiceProfileController.java new file mode 100644 index 0000000..1580e18 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/setting/invoiceprofile/controller/InvoiceProfileController.java @@ -0,0 +1,54 @@ +package com.writeoff.module.setting.invoiceprofile.controller; + +import com.writeoff.common.api.ApiResponse; +import com.writeoff.common.api.PageResult; +import com.writeoff.module.setting.invoiceprofile.dto.CreateInvoiceProfileRequest; +import com.writeoff.module.setting.invoiceprofile.dto.UpdateInvoiceProfileRequest; +import com.writeoff.module.setting.invoiceprofile.model.InvoiceProfileInfo; +import com.writeoff.module.setting.invoiceprofile.service.InvoiceProfileService; +import com.writeoff.security.DataScopeType; +import com.writeoff.security.RequirePermission; +import org.springframework.web.bind.annotation.*; + +import javax.validation.Valid; + +@RestController +@RequestMapping({"/api/invoice-profiles", "/api/invoice-heads"}) +public class InvoiceProfileController { + private final InvoiceProfileService invoiceProfileService; + + public InvoiceProfileController(InvoiceProfileService invoiceProfileService) { + this.invoiceProfileService = invoiceProfileService; + } + + @GetMapping + @RequirePermission(value = "invoice.profile.read", dataScope = DataScopeType.TENANT, auditAction = "INVOICE_PROFILE_LIST") + public ApiResponse> list() { + return ApiResponse.success(invoiceProfileService.list()); + } + + @PostMapping + @RequirePermission(value = "invoice.profile.manage", dataScope = DataScopeType.TENANT, auditAction = "INVOICE_PROFILE_CREATE") + public ApiResponse create(@RequestBody @Valid CreateInvoiceProfileRequest request) { + return ApiResponse.success(invoiceProfileService.create(request)); + } + + @PutMapping("/{id}") + @RequirePermission(value = "invoice.profile.manage", dataScope = DataScopeType.TENANT, auditAction = "INVOICE_PROFILE_UPDATE") + public ApiResponse update(@PathVariable("id") Long id, + @RequestBody @Valid UpdateInvoiceProfileRequest request) { + return ApiResponse.success(invoiceProfileService.update(id, request)); + } + + @PostMapping("/{id}/enable") + @RequirePermission(value = "invoice.profile.manage", dataScope = DataScopeType.TENANT, auditAction = "INVOICE_PROFILE_ENABLE") + public ApiResponse enable(@PathVariable("id") Long id) { + return ApiResponse.success(invoiceProfileService.enable(id)); + } + + @PostMapping("/{id}/disable") + @RequirePermission(value = "invoice.profile.manage", dataScope = DataScopeType.TENANT, auditAction = "INVOICE_PROFILE_DISABLE") + public ApiResponse disable(@PathVariable("id") Long id) { + return ApiResponse.success(invoiceProfileService.disable(id)); + } +} diff --git a/backend/src/main/java/com/writeoff/module/setting/invoiceprofile/dto/CreateInvoiceProfileRequest.java b/backend/src/main/java/com/writeoff/module/setting/invoiceprofile/dto/CreateInvoiceProfileRequest.java new file mode 100644 index 0000000..e6297e9 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/setting/invoiceprofile/dto/CreateInvoiceProfileRequest.java @@ -0,0 +1,82 @@ +package com.writeoff.module.setting.invoiceprofile.dto; + +import javax.validation.constraints.NotBlank; + +public class CreateInvoiceProfileRequest { + @NotBlank(message = "企业名称不能为空") + private String companyName; + @NotBlank(message = "税号不能为空") + private String taxNo; + @NotBlank(message = "开户行不能为空") + private String bankName; + @NotBlank(message = "账号不能为空") + private String accountNo; + private String address; + private String phone; + private Long defaultProjectId; + private String status; + + public String getCompanyName() { + return companyName; + } + + public void setCompanyName(String companyName) { + this.companyName = companyName; + } + + public String getTaxNo() { + return taxNo; + } + + public void setTaxNo(String taxNo) { + this.taxNo = taxNo; + } + + public String getBankName() { + return bankName; + } + + public void setBankName(String bankName) { + this.bankName = bankName; + } + + public String getAccountNo() { + return accountNo; + } + + public void setAccountNo(String accountNo) { + this.accountNo = accountNo; + } + + public String getAddress() { + return address; + } + + public void setAddress(String address) { + this.address = address; + } + + public String getPhone() { + return phone; + } + + public void setPhone(String phone) { + this.phone = phone; + } + + public Long getDefaultProjectId() { + return defaultProjectId; + } + + public void setDefaultProjectId(Long defaultProjectId) { + this.defaultProjectId = defaultProjectId; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } +} diff --git a/backend/src/main/java/com/writeoff/module/setting/invoiceprofile/dto/UpdateInvoiceProfileRequest.java b/backend/src/main/java/com/writeoff/module/setting/invoiceprofile/dto/UpdateInvoiceProfileRequest.java new file mode 100644 index 0000000..ef8e8de --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/setting/invoiceprofile/dto/UpdateInvoiceProfileRequest.java @@ -0,0 +1,4 @@ +package com.writeoff.module.setting.invoiceprofile.dto; + +public class UpdateInvoiceProfileRequest extends CreateInvoiceProfileRequest { +} diff --git a/backend/src/main/java/com/writeoff/module/setting/invoiceprofile/model/InvoiceProfileInfo.java b/backend/src/main/java/com/writeoff/module/setting/invoiceprofile/model/InvoiceProfileInfo.java new file mode 100644 index 0000000..57eded3 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/setting/invoiceprofile/model/InvoiceProfileInfo.java @@ -0,0 +1,61 @@ +package com.writeoff.module.setting.invoiceprofile.model; + +public class InvoiceProfileInfo { + private Long id; + private String companyName; + private String taxNo; + private String bankName; + private String accountNo; + private String address; + private String phone; + private Long defaultProjectId; + private String status; + + public InvoiceProfileInfo(Long id, String companyName, String taxNo, String bankName, String accountNo, String address, String phone, Long defaultProjectId, String status) { + this.id = id; + this.companyName = companyName; + this.taxNo = taxNo; + this.bankName = bankName; + this.accountNo = accountNo; + this.address = address; + this.phone = phone; + this.defaultProjectId = defaultProjectId; + this.status = status; + } + + public Long getId() { + return id; + } + + public String getCompanyName() { + return companyName; + } + + public String getTaxNo() { + return taxNo; + } + + public String getBankName() { + return bankName; + } + + public String getAccountNo() { + return accountNo; + } + + public String getAddress() { + return address; + } + + public String getPhone() { + return phone; + } + + public Long getDefaultProjectId() { + return defaultProjectId; + } + + public String getStatus() { + return status; + } +} diff --git a/backend/src/main/java/com/writeoff/module/setting/invoiceprofile/service/InvoiceProfileService.java b/backend/src/main/java/com/writeoff/module/setting/invoiceprofile/service/InvoiceProfileService.java new file mode 100644 index 0000000..78078d9 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/setting/invoiceprofile/service/InvoiceProfileService.java @@ -0,0 +1,170 @@ +package com.writeoff.module.setting.invoiceprofile.service; + +import com.writeoff.common.api.PageResult; +import com.writeoff.common.exception.BusinessException; +import com.writeoff.module.setting.invoiceprofile.dto.CreateInvoiceProfileRequest; +import com.writeoff.module.setting.invoiceprofile.dto.UpdateInvoiceProfileRequest; +import com.writeoff.module.setting.invoiceprofile.model.InvoiceProfileInfo; +import com.writeoff.security.AuthContext; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +public class InvoiceProfileService { + private final JdbcTemplate jdbcTemplate; + + private static final RowMapper ROW_MAPPER = (rs, n) -> new InvoiceProfileInfo( + rs.getLong("id"), + rs.getString("company_name"), + rs.getString("tax_no"), + rs.getString("bank_name"), + rs.getString("account_no"), + rs.getString("address"), + rs.getString("phone"), + rs.getObject("default_project_id") == null ? null : rs.getLong("default_project_id"), + rs.getString("status") + ); + + public InvoiceProfileService(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + public PageResult list() { + List list = jdbcTemplate.query( + "SELECT * FROM invoice_profile WHERE tenant_id=? AND is_deleted=0 ORDER BY id DESC", + ROW_MAPPER, + tenantId() + ); + return new PageResult(list, list.size(), 1, 200); + } + + @Transactional(rollbackFor = Exception.class) + public InvoiceProfileInfo create(CreateInvoiceProfileRequest request) { + assertUnique(request.getTaxNo(), request.getAccountNo(), null); + String status = normalizeStatus(request.getStatus()); + jdbcTemplate.update( + "INSERT INTO invoice_profile (tenant_id, company_name, tax_no, bank_name, account_no, address, phone, default_project_id, status, created_by, updated_by) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + tenantId(), + request.getCompanyName(), + request.getTaxNo(), + request.getBankName(), + request.getAccountNo(), + request.getAddress(), + request.getPhone(), + request.getDefaultProjectId(), + status, + safeUserId(), + safeUserId() + ); + Long id = jdbcTemplate.queryForObject("SELECT IFNULL(MAX(id),0) FROM invoice_profile WHERE tenant_id=?", Long.class, tenantId()); + return findById(id == null ? 0L : id); + } + + @Transactional(rollbackFor = Exception.class) + public InvoiceProfileInfo update(Long id, UpdateInvoiceProfileRequest request) { + assertExists(id); + assertUnique(request.getTaxNo(), request.getAccountNo(), id); + String status = normalizeStatus(request.getStatus()); + jdbcTemplate.update( + "UPDATE invoice_profile SET company_name=?, tax_no=?, bank_name=?, account_no=?, address=?, phone=?, default_project_id=?, status=?, updated_at=CURRENT_TIMESTAMP, updated_by=? " + + "WHERE tenant_id=? AND id=?", + request.getCompanyName(), + request.getTaxNo(), + request.getBankName(), + request.getAccountNo(), + request.getAddress(), + request.getPhone(), + request.getDefaultProjectId(), + status, + safeUserId(), + tenantId(), + id + ); + return findById(id); + } + + public InvoiceProfileInfo enable(Long id) { + return updateStatus(id, "ENABLED"); + } + + public InvoiceProfileInfo disable(Long id) { + return updateStatus(id, "DISABLED"); + } + + private InvoiceProfileInfo updateStatus(Long id, String status) { + assertExists(id); + jdbcTemplate.update( + "UPDATE invoice_profile SET status=?, updated_at=CURRENT_TIMESTAMP, updated_by=? WHERE tenant_id=? AND id=?", + status, + safeUserId(), + tenantId(), + id + ); + return findById(id); + } + + private void assertUnique(String taxNo, String accountNo, Long selfId) { + if (taxNo == null || taxNo.trim().isEmpty() || accountNo == null || accountNo.trim().isEmpty()) { + throw new BusinessException(10001, "税号和账号不能为空"); + } + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM invoice_profile WHERE tenant_id=? AND tax_no=? AND account_no=? AND is_deleted=0 AND (? IS NULL OR id<>?)", + Integer.class, + tenantId(), + taxNo.trim(), + accountNo.trim(), + selfId, + selfId + ); + if (count != null && count > 0) { + throw new BusinessException(10001, "同税号与账号的发票抬头已存在"); + } + } + + private InvoiceProfileInfo findById(Long id) { + List list = jdbcTemplate.query( + "SELECT * FROM invoice_profile WHERE tenant_id=? AND id=? AND is_deleted=0", + ROW_MAPPER, + tenantId(), + id + ); + if (list.isEmpty()) { + throw new BusinessException(10003, "发票抬头不存在"); + } + return list.get(0); + } + + private void assertExists(Long id) { + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM invoice_profile WHERE tenant_id=? AND id=? AND is_deleted=0", + Integer.class, + tenantId(), + id + ); + if (count == null || count == 0) { + throw new BusinessException(10003, "发票抬头不存在"); + } + } + + private String normalizeStatus(String status) { + String val = status == null ? "ENABLED" : status.trim().toUpperCase(); + if (!"ENABLED".equals(val) && !"DISABLED".equals(val)) { + throw new BusinessException(10001, "状态仅支持 ENABLED/DISABLED"); + } + return val; + } + + private Long safeUserId() { + Long userId = AuthContext.userId(); + return userId == null ? 0L : userId; + } + + private Long tenantId() { + return AuthContext.requireTenantId(); + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/controller/AuditLogController.java b/backend/src/main/java/com/writeoff/module/system/controller/AuditLogController.java new file mode 100644 index 0000000..f0edd78 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/controller/AuditLogController.java @@ -0,0 +1,78 @@ +package com.writeoff.module.system.controller; + +import com.writeoff.common.api.ApiResponse; +import com.writeoff.common.api.PageResult; +import com.writeoff.module.export.model.ExportTaskInfo; +import com.writeoff.module.system.model.OperationAuditLogInfo; +import com.writeoff.module.system.service.OperationAuditLogService; +import javax.validation.constraints.NotBlank; +import com.writeoff.security.DataScopeType; +import com.writeoff.security.RequirePermission; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/audit-logs") +public class AuditLogController { + private final OperationAuditLogService operationAuditLogService; + + public AuditLogController(OperationAuditLogService operationAuditLogService) { + this.operationAuditLogService = operationAuditLogService; + } + + @GetMapping + @RequirePermission(value = "audit.log.read", dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "AUDIT_LOG_LIST") + public ApiResponse> list( + @RequestParam(value = "userId", required = false) Long userId, + @RequestParam(value = "actionCode", required = false) String actionCode, + @RequestParam(value = "pageNo", required = false) Integer pageNo, + @RequestParam(value = "pageSize", required = false) Integer pageSize) { + return ApiResponse.success(operationAuditLogService.list(userId, actionCode, pageNo, pageSize)); + } + + @GetMapping("/export") + @RequirePermission(value = "audit.log.read", dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "AUDIT_LOG_EXPORT") + public ApiResponse> export( + @RequestParam(value = "userId", required = false) Long userId, + @RequestParam(value = "actionCode", required = false) String actionCode) { + return ApiResponse.success(operationAuditLogService.list(userId, actionCode, 1, 500)); + } + + @GetMapping("/export-tasks") + @RequirePermission(value = "audit.log.read", dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "AUDIT_LOG_EXPORT_TASKS") + public ApiResponse> exportTasks() { + return ApiResponse.success(operationAuditLogService.listExportTasks()); + } + + @PostMapping("/export-tasks") + @RequirePermission(value = "audit.log.read", dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "AUDIT_LOG_EXPORT_TASK_CREATE") + public ApiResponse> createExportTask(@RequestBody AuditLogExportTaskRequest request) { + return ApiResponse.success(operationAuditLogService.createExportTask( + request.getUserId(), + request.getActionCode(), + request.getIdempotencyKey(), + request.getFileName() + )); + } + + public static class AuditLogExportTaskRequest { + @NotBlank(message = "幂等键不能为空") + private String idempotencyKey; + private Long userId; + private String actionCode; + private String fileName; + + public String getIdempotencyKey() { return idempotencyKey; } + public void setIdempotencyKey(String idempotencyKey) { this.idempotencyKey = idempotencyKey; } + public Long getUserId() { return userId; } + public void setUserId(Long userId) { this.userId = userId; } + public String getActionCode() { return actionCode; } + public void setActionCode(String actionCode) { this.actionCode = actionCode; } + public String getFileName() { return fileName; } + public void setFileName(String fileName) { this.fileName = fileName; } + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/controller/DataPermissionController.java b/backend/src/main/java/com/writeoff/module/system/controller/DataPermissionController.java new file mode 100644 index 0000000..6af1bc7 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/controller/DataPermissionController.java @@ -0,0 +1,82 @@ +package com.writeoff.module.system.controller; + +import com.writeoff.common.api.ApiResponse; +import com.writeoff.common.api.PageResult; +import com.writeoff.module.system.dto.AssignRoleDataPermissionRequest; +import com.writeoff.module.system.dto.CreateDataPermissionPolicyRequest; +import com.writeoff.module.system.dto.UpdateDataPermissionPolicyRequest; +import com.writeoff.module.system.model.DataPermissionPolicy; +import com.writeoff.module.system.service.DataPermissionService; +import com.writeoff.security.DataScopeType; +import com.writeoff.security.RequirePermission; +import org.springframework.web.bind.annotation.*; + +import javax.validation.Valid; +import java.util.Map; + +@RestController +@RequestMapping({"/api/data-permissions", "/api/data-scope-policies"}) +public class DataPermissionController { + private final DataPermissionService dataPermissionService; + + public DataPermissionController(DataPermissionService dataPermissionService) { + this.dataPermissionService = dataPermissionService; + } + + @GetMapping + @RequirePermission(value = "data.permission.read", dataScope = DataScopeType.TENANT, auditAction = "DATA_PERMISSION_LIST") + public ApiResponse> list() { + return ApiResponse.success(dataPermissionService.listPolicies()); + } + + @PostMapping + @RequirePermission(value = "data.permission.manage", dataScope = DataScopeType.TENANT, auditAction = "DATA_PERMISSION_CREATE") + public ApiResponse create(@RequestBody @Valid CreateDataPermissionPolicyRequest request) { + return ApiResponse.success(dataPermissionService.createPolicy(request)); + } + + @PutMapping("/{id}") + @RequirePermission(value = "data.permission.manage", dataScope = DataScopeType.TENANT, auditAction = "DATA_PERMISSION_UPDATE") + public ApiResponse update(@PathVariable("id") Long id, @RequestBody @Valid UpdateDataPermissionPolicyRequest request) { + return ApiResponse.success(dataPermissionService.updatePolicy(id, request)); + } + + @PostMapping("/{id}/assign-roles") + @RequirePermission(value = "data.permission.manage", dataScope = DataScopeType.TENANT, auditAction = "DATA_PERMISSION_ASSIGN_ROLES") + public ApiResponse assignRoles(@PathVariable("id") Long id, @RequestBody @Valid AssignRoleDataPermissionRequest request) { + dataPermissionService.assignRoles(id, request); + return ApiResponse.success("OK"); + } + + @GetMapping("/{id}/roles") + @RequirePermission(value = "data.permission.read", dataScope = DataScopeType.TENANT, auditAction = "DATA_PERMISSION_ROLES") + public ApiResponse> policyRoles(@PathVariable("id") Long id) { + return ApiResponse.success(dataPermissionService.listPolicyRoleIds(id)); + } + + @PostMapping("/{id}/copy") + @RequirePermission(value = "data.permission.manage", dataScope = DataScopeType.TENANT, auditAction = "DATA_PERMISSION_COPY") + public ApiResponse copy(@PathVariable("id") Long id) { + return ApiResponse.success(dataPermissionService.copyPolicy(id)); + } + + @PostMapping("/{id}/enable") + @RequirePermission(value = "data.permission.manage", dataScope = DataScopeType.TENANT, auditAction = "DATA_PERMISSION_ENABLE") + public ApiResponse enable(@PathVariable("id") Long id) { + dataPermissionService.enablePolicy(id); + return ApiResponse.success("OK"); + } + + @PostMapping("/{id}/disable") + @RequirePermission(value = "data.permission.manage", dataScope = DataScopeType.TENANT, auditAction = "DATA_PERMISSION_DISABLE") + public ApiResponse disable(@PathVariable("id") Long id) { + dataPermissionService.disablePolicy(id); + return ApiResponse.success("OK"); + } + + @GetMapping("/current-scope") + @RequirePermission(value = "data.permission.read", dataScope = DataScopeType.TENANT, auditAction = "DATA_PERMISSION_CURRENT_SCOPE") + public ApiResponse> currentScope() { + return ApiResponse.success(dataPermissionService.currentScopeSummary()); + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/controller/DictionaryController.java b/backend/src/main/java/com/writeoff/module/system/controller/DictionaryController.java new file mode 100644 index 0000000..03e817f --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/controller/DictionaryController.java @@ -0,0 +1,86 @@ +package com.writeoff.module.system.controller; + +import com.writeoff.common.api.ApiResponse; +import com.writeoff.module.system.dto.CreatePlatformDictionaryItemRequest; +import com.writeoff.module.system.dto.UpdatePlatformDictionaryItemRequest; +import com.writeoff.module.system.model.PlatformDictionaryItem; +import com.writeoff.module.system.service.PlatformDictionaryService; +import com.writeoff.security.DataScopeType; +import com.writeoff.security.RequirePermission; +import org.springframework.web.bind.annotation.*; + +import javax.validation.Valid; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/dictionaries") +public class DictionaryController { + private final PlatformDictionaryService platformDictionaryService; + + public DictionaryController(PlatformDictionaryService platformDictionaryService) { + this.platformDictionaryService = platformDictionaryService; + } + + /** + * 租户端通用字典查询(只读)。 + */ + @GetMapping + public ApiResponse> list( + @RequestParam(value = "dictType", required = false) String dictType, + @RequestParam(value = "enabledOnly", required = false) Boolean enabledOnly + ) { + return ApiResponse.success(platformDictionaryService.list(dictType, enabledOnly)); + } + + /** + * 租户端与平台端共享读取专家字典选项。 + */ + @GetMapping("/expert-options") + public ApiResponse>> expertOptions() { + Map> data = new LinkedHashMap>(); + data.put("titles", platformDictionaryService.list("EXPERT_TITLE", true)); + data.put("hospitals", platformDictionaryService.list("EXPERT_HOSPITAL", true)); + return ApiResponse.success(data); + } + + /** + * 租户端新增字典项。 + */ + @PostMapping + @RequirePermission(value = "dictionary.manage", dataScope = DataScopeType.TENANT, auditAction = "DICTIONARY_CREATE") + public ApiResponse create(@RequestBody @Valid CreatePlatformDictionaryItemRequest request) { + return ApiResponse.success(platformDictionaryService.create(request)); + } + + /** + * 租户端更新字典项。 + */ + @PutMapping("/{id}") + @RequirePermission(value = "dictionary.manage", dataScope = DataScopeType.TENANT, auditAction = "DICTIONARY_UPDATE") + public ApiResponse update(@PathVariable("id") Long id, + @RequestBody @Valid UpdatePlatformDictionaryItemRequest request) { + return ApiResponse.success(platformDictionaryService.update(id, request)); + } + + /** + * 租户端启用字典项。 + */ + @PostMapping("/{id}/enable") + @RequirePermission(value = "dictionary.manage", dataScope = DataScopeType.TENANT, auditAction = "DICTIONARY_ENABLE") + public ApiResponse enable(@PathVariable("id") Long id) { + platformDictionaryService.enable(id); + return ApiResponse.success("OK"); + } + + /** + * 租户端停用字典项。 + */ + @PostMapping("/{id}/disable") + @RequirePermission(value = "dictionary.manage", dataScope = DataScopeType.TENANT, auditAction = "DICTIONARY_DISABLE") + public ApiResponse disable(@PathVariable("id") Long id) { + platformDictionaryService.disable(id); + return ApiResponse.success("OK"); + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/controller/EnterpriseController.java b/backend/src/main/java/com/writeoff/module/system/controller/EnterpriseController.java new file mode 100644 index 0000000..32906ab --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/controller/EnterpriseController.java @@ -0,0 +1,78 @@ +package com.writeoff.module.system.controller; + +import com.writeoff.common.api.ApiResponse; +import com.writeoff.common.api.PageResult; +import com.writeoff.module.system.dto.CreateEnterpriseRequest; +import com.writeoff.module.system.dto.EnterpriseLogoUploadSignRequest; +import com.writeoff.module.system.dto.UpdateEnterpriseRequest; +import com.writeoff.module.system.model.EnterpriseInfo; +import com.writeoff.module.system.service.EnterpriseService; +import com.writeoff.security.DataScopeType; +import com.writeoff.security.RequirePermission; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import javax.validation.Valid; + +@RestController +@RequestMapping("/api/enterprises") +public class EnterpriseController { + private final EnterpriseService enterpriseService; + + public EnterpriseController(EnterpriseService enterpriseService) { + this.enterpriseService = enterpriseService; + } + + @GetMapping + @RequirePermission(value = "enterprise.read", dataScope = DataScopeType.TENANT, auditAction = "ENTERPRISE_LIST") + public ApiResponse> list( + @RequestParam(value = "pageNo", defaultValue = "1") int pageNo, + @RequestParam(value = "pageSize", defaultValue = "20") int pageSize) { + return ApiResponse.success(enterpriseService.list(pageNo, pageSize)); + } + + @PostMapping + @RequirePermission(value = "enterprise.manage", dataScope = DataScopeType.TENANT, auditAction = "ENTERPRISE_CREATE") + public ApiResponse create(@RequestBody @Valid CreateEnterpriseRequest request) { + return ApiResponse.success(enterpriseService.create(request)); + } + + @PostMapping("/logo-upload-sign") + @RequirePermission(value = "enterprise.manage", dataScope = DataScopeType.TENANT, auditAction = "ENTERPRISE_LOGO_UPLOAD_SIGN") + public ApiResponse> logoUploadSign(@RequestBody @Valid EnterpriseLogoUploadSignRequest request) { + return ApiResponse.success(enterpriseService.presignLogoUpload(request.getFileName(), request.getContentType())); + } + + @PutMapping("/{id}") + @RequirePermission(value = "enterprise.manage", dataScope = DataScopeType.TENANT, auditAction = "ENTERPRISE_UPDATE") + public ApiResponse update(@PathVariable("id") Long id, @RequestBody @Valid UpdateEnterpriseRequest request) { + return ApiResponse.success(enterpriseService.update(id, request)); + } + + @PostMapping("/{id}/enable") + @RequirePermission(value = "enterprise.manage", dataScope = DataScopeType.TENANT, auditAction = "ENTERPRISE_ENABLE") + public ApiResponse enable(@PathVariable("id") Long id) { + enterpriseService.enable(id); + return ApiResponse.success("ok"); + } + + @PostMapping("/{id}/disable") + @RequirePermission(value = "enterprise.manage", dataScope = DataScopeType.TENANT, auditAction = "ENTERPRISE_DISABLE") + public ApiResponse disable(@PathVariable("id") Long id) { + enterpriseService.disable(id); + return ApiResponse.success("ok"); + } + + @PostMapping("/{id}/delete") + @RequirePermission(value = "enterprise.delete", dataScope = DataScopeType.TENANT, auditAction = "ENTERPRISE_DELETE") + public ApiResponse softDelete(@PathVariable("id") Long id) { + enterpriseService.softDelete(id); + return ApiResponse.success("ok"); + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/controller/GlobalSearchController.java b/backend/src/main/java/com/writeoff/module/system/controller/GlobalSearchController.java new file mode 100644 index 0000000..9d5f3a1 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/controller/GlobalSearchController.java @@ -0,0 +1,25 @@ +package com.writeoff.module.system.controller; + +import com.writeoff.common.api.ApiResponse; +import com.writeoff.module.system.model.GlobalSearchResult; +import com.writeoff.module.system.service.GlobalSearchService; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/search") +public class GlobalSearchController { + private final GlobalSearchService globalSearchService; + + public GlobalSearchController(GlobalSearchService globalSearchService) { + this.globalSearchService = globalSearchService; + } + + @GetMapping("/global") + public ApiResponse global(@RequestParam(value = "q", required = false) String keyword, + @RequestParam(value = "limitPerType", required = false) Integer limitPerType) { + return ApiResponse.success(globalSearchService.search(keyword, limitPerType)); + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/controller/MenuController.java b/backend/src/main/java/com/writeoff/module/system/controller/MenuController.java new file mode 100644 index 0000000..930d9a2 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/controller/MenuController.java @@ -0,0 +1,72 @@ +package com.writeoff.module.system.controller; + +import com.writeoff.common.api.ApiResponse; +import com.writeoff.common.api.PageResult; +import com.writeoff.module.system.dto.CreateMenuRequest; +import com.writeoff.module.system.dto.ReorderMenusRequest; +import com.writeoff.module.system.dto.UpdateMenuRequest; +import com.writeoff.module.system.model.MenuInfo; +import com.writeoff.module.system.service.MenuService; +import com.writeoff.security.AuthContext; +import com.writeoff.security.DataScopeType; +import com.writeoff.security.RequirePermission; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import javax.validation.Valid; +import java.util.List; + +@RestController +@RequestMapping("/api/menus") +public class MenuController { + private final MenuService menuService; + + public MenuController(MenuService menuService) { + this.menuService = menuService; + } + + @GetMapping + @RequirePermission(value = "role.read", dataScope = DataScopeType.TENANT, auditAction = "MENU_LIST") + public ApiResponse> list() { + return ApiResponse.success(menuService.list()); + } + + @GetMapping("/current") + public ApiResponse> current() { + Long userId = AuthContext.userId(); + if (userId == null) { + return ApiResponse.success(java.util.Collections.emptyList()); + } + return ApiResponse.success(menuService.currentUserMenus(userId)); + } + + @PostMapping + @RequirePermission(value = "role.permission.bind", dataScope = DataScopeType.TENANT, auditAction = "MENU_CREATE") + public ApiResponse create(@RequestBody @Valid CreateMenuRequest request) { + return ApiResponse.success(menuService.create(request)); + } + + @PutMapping("/{id}") + @RequirePermission(value = "role.permission.bind", dataScope = DataScopeType.TENANT, auditAction = "MENU_UPDATE") + public ApiResponse update(@PathVariable("id") Long id, @RequestBody @Valid UpdateMenuRequest request) { + return ApiResponse.success(menuService.update(id, request)); + } + + @PostMapping("/reorder") + @RequirePermission(value = "role.permission.bind", dataScope = DataScopeType.TENANT, auditAction = "MENU_REORDER") + public ApiResponse reorder(@RequestBody @Valid ReorderMenusRequest request) { + menuService.reorderMenus(request); + return ApiResponse.success("ok"); + } + + @GetMapping("/{id}/roles") + @RequirePermission(value = "role.read", dataScope = DataScopeType.TENANT, auditAction = "MENU_ROLES") + public ApiResponse> menuRoles(@PathVariable("id") Long id) { + return ApiResponse.success(menuService.getMenuRoleNames(id)); + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/controller/PermissionController.java b/backend/src/main/java/com/writeoff/module/system/controller/PermissionController.java new file mode 100644 index 0000000..7d52b90 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/controller/PermissionController.java @@ -0,0 +1,27 @@ +package com.writeoff.module.system.controller; + +import com.writeoff.common.api.ApiResponse; +import com.writeoff.common.api.PageResult; +import com.writeoff.module.system.model.PermissionInfo; +import com.writeoff.module.system.service.SystemUserService; +import com.writeoff.security.DataScopeType; +import com.writeoff.security.RequirePermission; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/permissions") +public class PermissionController { + private final SystemUserService systemUserService; + + public PermissionController(SystemUserService systemUserService) { + this.systemUserService = systemUserService; + } + + @GetMapping + @RequirePermission(value = "permission.read", dataScope = DataScopeType.TENANT, auditAction = "PERMISSION_LIST") + public ApiResponse> list() { + return ApiResponse.success(systemUserService.listPermissions()); + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/controller/PlatformAuditLogController.java b/backend/src/main/java/com/writeoff/module/system/controller/PlatformAuditLogController.java new file mode 100644 index 0000000..9a2cbdc --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/controller/PlatformAuditLogController.java @@ -0,0 +1,35 @@ +package com.writeoff.module.system.controller; + +import com.writeoff.common.api.ApiResponse; +import com.writeoff.common.api.PageResult; +import com.writeoff.module.system.model.OperationAuditLogInfo; +import com.writeoff.module.system.service.OperationAuditLogService; +import com.writeoff.security.DataScopeType; +import com.writeoff.security.PermissionDomain; +import com.writeoff.security.RequirePermission; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/platform/audit-logs") +public class PlatformAuditLogController { + private final OperationAuditLogService operationAuditLogService; + + public PlatformAuditLogController(OperationAuditLogService operationAuditLogService) { + this.operationAuditLogService = operationAuditLogService; + } + + @GetMapping + @RequirePermission(value = "platform.audit.read", domain = PermissionDomain.PLATFORM, dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "PLATFORM_AUDIT_LOG_LIST") + public ApiResponse> list( + @RequestParam(value = "userId", required = false) Long userId, + @RequestParam(value = "actionCode", required = false) String actionCode, + @RequestParam(value = "tenantId", required = false) Long tenantId, + @RequestParam(value = "scope", required = false) String scope, + @RequestParam(value = "pageNo", required = false) Integer pageNo, + @RequestParam(value = "pageSize", required = false) Integer pageSize) { + return ApiResponse.success(operationAuditLogService.listPlatform(userId, actionCode, tenantId, scope, pageNo, pageSize)); + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/controller/PlatformDictionaryController.java b/backend/src/main/java/com/writeoff/module/system/controller/PlatformDictionaryController.java new file mode 100644 index 0000000..26ca8bb --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/controller/PlatformDictionaryController.java @@ -0,0 +1,95 @@ +package com.writeoff.module.system.controller; + +import com.writeoff.common.api.ApiResponse; +import com.writeoff.module.system.dto.CreatePlatformDictionaryItemRequest; +import com.writeoff.module.system.dto.CreatePlatformDictionaryTypeRequest; +import com.writeoff.module.system.dto.UpdatePlatformDictionaryItemRequest; +import com.writeoff.module.system.model.PlatformDictionaryItem; +import com.writeoff.module.system.model.PlatformDictionaryType; +import com.writeoff.module.system.service.PlatformDictionaryService; +import com.writeoff.security.DataScopeType; +import com.writeoff.security.PermissionDomain; +import com.writeoff.security.RequirePermission; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import javax.validation.Valid; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/platform/dictionaries") +public class PlatformDictionaryController { + private final PlatformDictionaryService platformDictionaryService; + + public PlatformDictionaryController(PlatformDictionaryService platformDictionaryService) { + this.platformDictionaryService = platformDictionaryService; + } + + @GetMapping + @RequirePermission(value = "platform.dictionary.read", domain = PermissionDomain.PLATFORM, dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "PLATFORM_DICTIONARY_LIST") + public ApiResponse> list( + @RequestParam(value = "dictType", required = false) String dictType, + @RequestParam(value = "enabledOnly", required = false) Boolean enabledOnly + ) { + return ApiResponse.success(platformDictionaryService.list(dictType, enabledOnly)); + } + + @GetMapping("/types") + @RequirePermission(value = "platform.dictionary.read", domain = PermissionDomain.PLATFORM, dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "PLATFORM_DICTIONARY_TYPE_LIST") + public ApiResponse> listTypes( + @RequestParam(value = "enabledOnly", required = false) Boolean enabledOnly + ) { + return ApiResponse.success(platformDictionaryService.listTypes(enabledOnly)); + } + + @PostMapping("/types") + @RequirePermission(value = "platform.dictionary.manage", domain = PermissionDomain.PLATFORM, dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "PLATFORM_DICTIONARY_TYPE_CREATE") + public ApiResponse createType(@RequestBody @Valid CreatePlatformDictionaryTypeRequest request) { + return ApiResponse.success(platformDictionaryService.createType(request)); + } + + @PostMapping + @RequirePermission(value = "platform.dictionary.manage", domain = PermissionDomain.PLATFORM, dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "PLATFORM_DICTIONARY_CREATE") + public ApiResponse create(@RequestBody @Valid CreatePlatformDictionaryItemRequest request) { + return ApiResponse.success(platformDictionaryService.create(request)); + } + + @PutMapping("/{id}") + @RequirePermission(value = "platform.dictionary.manage", domain = PermissionDomain.PLATFORM, dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "PLATFORM_DICTIONARY_UPDATE") + public ApiResponse update(@PathVariable("id") Long id, @RequestBody @Valid UpdatePlatformDictionaryItemRequest request) { + return ApiResponse.success(platformDictionaryService.update(id, request)); + } + + @PostMapping("/{id}/enable") + @RequirePermission(value = "platform.dictionary.manage", domain = PermissionDomain.PLATFORM, dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "PLATFORM_DICTIONARY_ENABLE") + public ApiResponse enable(@PathVariable("id") Long id) { + platformDictionaryService.enable(id); + return ApiResponse.success("ok"); + } + + @PostMapping("/{id}/disable") + @RequirePermission(value = "platform.dictionary.manage", domain = PermissionDomain.PLATFORM, dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "PLATFORM_DICTIONARY_DISABLE") + public ApiResponse disable(@PathVariable("id") Long id) { + platformDictionaryService.disable(id); + return ApiResponse.success("ok"); + } + + /** + * 供专家模块读取的共享字典选项。 + */ + @GetMapping("/expert-options") + public ApiResponse>> expertOptions() { + Map> data = new LinkedHashMap>(); + data.put("titles", platformDictionaryService.list("EXPERT_TITLE", true)); + data.put("hospitals", platformDictionaryService.list("EXPERT_HOSPITAL", true)); + return ApiResponse.success(data); + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/controller/PlatformMenuController.java b/backend/src/main/java/com/writeoff/module/system/controller/PlatformMenuController.java new file mode 100644 index 0000000..2c37074 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/controller/PlatformMenuController.java @@ -0,0 +1,83 @@ +package com.writeoff.module.system.controller; + +import com.writeoff.common.api.ApiResponse; +import com.writeoff.common.api.PageResult; +import com.writeoff.module.system.dto.BindPlatformMenuRolesRequest; +import com.writeoff.module.system.dto.CreateMenuRequest; +import com.writeoff.module.system.dto.ReorderMenusRequest; +import com.writeoff.module.system.dto.UpdateMenuRequest; +import com.writeoff.module.system.model.MenuInfo; +import com.writeoff.module.system.service.PlatformMenuService; +import com.writeoff.security.AuthContext; +import com.writeoff.security.AuthScope; +import com.writeoff.security.DataScopeType; +import com.writeoff.security.PermissionDomain; +import com.writeoff.security.RequirePermission; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Collections; +import java.util.List; +import javax.validation.Valid; + +@RestController +@RequestMapping("/api/platform/menus") +public class PlatformMenuController { + private final PlatformMenuService platformMenuService; + + public PlatformMenuController(PlatformMenuService platformMenuService) { + this.platformMenuService = platformMenuService; + } + + @GetMapping + @RequirePermission(value = "platform.menu.manage", domain = PermissionDomain.PLATFORM, dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "PLATFORM_MENU_LIST") + public ApiResponse> list() { + return ApiResponse.success(platformMenuService.list()); + } + + @GetMapping("/current") + public ApiResponse> current() { + Long userId = AuthContext.userId(); + if (userId == null || AuthContext.scope() != AuthScope.PLATFORM) { + return ApiResponse.success(Collections.emptyList()); + } + return ApiResponse.success(platformMenuService.currentUserMenus(userId)); + } + + @PostMapping + @RequirePermission(value = "platform.menu.manage", domain = PermissionDomain.PLATFORM, dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "PLATFORM_MENU_CREATE") + public ApiResponse create(@RequestBody @Valid CreateMenuRequest request) { + return ApiResponse.success(platformMenuService.create(request)); + } + + @PutMapping("/{id}") + @RequirePermission(value = "platform.menu.manage", domain = PermissionDomain.PLATFORM, dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "PLATFORM_MENU_UPDATE") + public ApiResponse update(@PathVariable("id") Long id, @RequestBody @Valid UpdateMenuRequest request) { + return ApiResponse.success(platformMenuService.update(id, request)); + } + + @PostMapping("/reorder") + @RequirePermission(value = "platform.menu.manage", domain = PermissionDomain.PLATFORM, dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "PLATFORM_MENU_REORDER") + public ApiResponse reorder(@RequestBody @Valid ReorderMenusRequest request) { + platformMenuService.reorderMenus(request); + return ApiResponse.success("ok"); + } + + @GetMapping("/{id}/roles") + @RequirePermission(value = "platform.role.read", domain = PermissionDomain.PLATFORM, dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "PLATFORM_MENU_ROLES") + public ApiResponse> menuRoles(@PathVariable("id") Long id) { + return ApiResponse.success(platformMenuService.getMenuRoleIds(id)); + } + + @PostMapping("/{id}/roles") + @RequirePermission(value = "platform.menu.manage", domain = PermissionDomain.PLATFORM, dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "PLATFORM_MENU_BIND_ROLES") + public ApiResponse bindRoles(@PathVariable("id") Long id, @RequestBody @Valid BindPlatformMenuRolesRequest request) { + platformMenuService.bindMenuRoles(id, request); + return ApiResponse.success("ok"); + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/controller/PlatformPermissionController.java b/backend/src/main/java/com/writeoff/module/system/controller/PlatformPermissionController.java new file mode 100644 index 0000000..97136c6 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/controller/PlatformPermissionController.java @@ -0,0 +1,28 @@ +package com.writeoff.module.system.controller; + +import com.writeoff.common.api.ApiResponse; +import com.writeoff.common.api.PageResult; +import com.writeoff.module.system.model.PermissionInfo; +import com.writeoff.module.system.service.PlatformIamService; +import com.writeoff.security.DataScopeType; +import com.writeoff.security.PermissionDomain; +import com.writeoff.security.RequirePermission; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/platform/permissions") +public class PlatformPermissionController { + private final PlatformIamService platformIamService; + + public PlatformPermissionController(PlatformIamService platformIamService) { + this.platformIamService = platformIamService; + } + + @GetMapping + @RequirePermission(value = "platform.permission.read", domain = PermissionDomain.PLATFORM, dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "PLATFORM_PERMISSION_LIST") + public ApiResponse> list() { + return ApiResponse.success(platformIamService.listPermissions()); + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/controller/PlatformRoleController.java b/backend/src/main/java/com/writeoff/module/system/controller/PlatformRoleController.java new file mode 100644 index 0000000..620e2e5 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/controller/PlatformRoleController.java @@ -0,0 +1,93 @@ +package com.writeoff.module.system.controller; + +import com.writeoff.common.api.ApiResponse; +import com.writeoff.common.api.PageResult; +import com.writeoff.module.system.dto.BindRoleMenusRequest; +import com.writeoff.module.system.dto.BindRolePermissionsRequest; +import com.writeoff.module.system.dto.CreateRoleRequest; +import com.writeoff.module.system.dto.UpdateRoleRequest; +import com.writeoff.module.system.model.RoleInfo; +import com.writeoff.module.system.service.PlatformIamService; +import com.writeoff.module.system.service.PlatformMenuService; +import com.writeoff.security.DataScopeType; +import com.writeoff.security.PermissionDomain; +import com.writeoff.security.RequirePermission; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import javax.validation.Valid; + +@RestController +@RequestMapping("/api/platform/roles") +public class PlatformRoleController { + private final PlatformIamService platformIamService; + private final PlatformMenuService platformMenuService; + + public PlatformRoleController(PlatformIamService platformIamService, PlatformMenuService platformMenuService) { + this.platformIamService = platformIamService; + this.platformMenuService = platformMenuService; + } + + @GetMapping + @RequirePermission(value = "platform.role.read", domain = PermissionDomain.PLATFORM, dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "PLATFORM_ROLE_LIST") + public ApiResponse> list() { + return ApiResponse.success(platformIamService.listRoles()); + } + + @PostMapping + @RequirePermission(value = "platform.role.manage", domain = PermissionDomain.PLATFORM, dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "PLATFORM_ROLE_CREATE") + public ApiResponse create(@RequestBody @Valid CreateRoleRequest request) { + return ApiResponse.success(platformIamService.createRole(request)); + } + + @PutMapping("/{id}") + @RequirePermission(value = "platform.role.manage", domain = PermissionDomain.PLATFORM, dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "PLATFORM_ROLE_UPDATE") + public ApiResponse update(@PathVariable("id") Long id, @RequestBody @Valid UpdateRoleRequest request) { + return ApiResponse.success(platformIamService.updateRole(id, request)); + } + + @PostMapping("/{id}/enable") + @RequirePermission(value = "platform.role.manage", domain = PermissionDomain.PLATFORM, dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "PLATFORM_ROLE_ENABLE") + public ApiResponse enable(@PathVariable("id") Long id) { + platformIamService.enableRole(id); + return ApiResponse.success("OK"); + } + + @PostMapping("/{id}/disable") + @RequirePermission(value = "platform.role.manage", domain = PermissionDomain.PLATFORM, dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "PLATFORM_ROLE_DISABLE") + public ApiResponse disable(@PathVariable("id") Long id) { + platformIamService.disableRole(id); + return ApiResponse.success("OK"); + } + + @GetMapping("/{id}/permissions") + @RequirePermission(value = "platform.role.read", domain = PermissionDomain.PLATFORM, dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "PLATFORM_ROLE_PERMISSIONS") + public ApiResponse> getRolePermissions(@PathVariable("id") Long id) { + return ApiResponse.success(platformIamService.getRolePermissionIds(id)); + } + + @PostMapping("/{id}/permissions") + @RequirePermission(value = "platform.role.manage", domain = PermissionDomain.PLATFORM, dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "PLATFORM_ROLE_BIND_PERMISSIONS") + public ApiResponse bindPermissions(@PathVariable("id") Long id, @RequestBody @Valid BindRolePermissionsRequest request) { + platformIamService.bindRolePermissions(id, request); + return ApiResponse.success("OK"); + } + + @GetMapping("/{id}/menus") + @RequirePermission(value = "platform.role.read", domain = PermissionDomain.PLATFORM, dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "PLATFORM_ROLE_MENUS") + public ApiResponse> getRoleMenus(@PathVariable("id") Long id) { + return ApiResponse.success(platformMenuService.getRoleMenuIds(id)); + } + + @PostMapping("/{id}/menus") + @RequirePermission(value = "platform.role.manage", domain = PermissionDomain.PLATFORM, dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "PLATFORM_ROLE_BIND_MENUS") + public ApiResponse bindMenus(@PathVariable("id") Long id, @RequestBody @Valid BindRoleMenusRequest request) { + platformMenuService.bindRoleMenus(id, request); + return ApiResponse.success("OK"); + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/controller/PlatformTenantController.java b/backend/src/main/java/com/writeoff/module/system/controller/PlatformTenantController.java new file mode 100644 index 0000000..1badfb8 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/controller/PlatformTenantController.java @@ -0,0 +1,97 @@ +package com.writeoff.module.system.controller; + +import com.writeoff.common.api.ApiResponse; +import com.writeoff.common.api.PageResult; +import com.writeoff.module.system.dto.CreateTenantAdminRequest; +import com.writeoff.module.system.dto.CreateTenantRequest; +import com.writeoff.module.system.dto.EnterpriseLogoUploadSignRequest; +import com.writeoff.module.system.dto.UpdateTenantRequest; +import com.writeoff.module.system.model.TenantInfo; +import com.writeoff.module.system.service.TenantService; +import com.writeoff.security.DataScopeType; +import com.writeoff.security.PermissionDomain; +import com.writeoff.security.RequirePermission; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import javax.validation.Valid; + +@RestController +@RequestMapping("/api/platform/tenants") +public class PlatformTenantController { + private final TenantService tenantService; + + public PlatformTenantController(TenantService tenantService) { + this.tenantService = tenantService; + } + + @GetMapping + @RequirePermission(value = "platform.tenant.manage", domain = PermissionDomain.PLATFORM, dataScope = DataScopeType.TENANT, auditAction = "PLATFORM_TENANT_LIST") + public ApiResponse> list() { + return ApiResponse.success(tenantService.list()); + } + + @GetMapping("/{id}/admin") + @RequirePermission(value = "platform.tenant.manage", domain = PermissionDomain.PLATFORM, dataScope = DataScopeType.TENANT, auditAction = "PLATFORM_TENANT_GET_ADMIN") + public ApiResponse> getAdmin(@PathVariable("id") Long id) { + return ApiResponse.success(tenantService.getTenantAdmin(id, "TENANT_ADMIN")); + } + + @PostMapping + @RequirePermission(value = "platform.tenant.manage", domain = PermissionDomain.PLATFORM, dataScope = DataScopeType.TENANT, auditAction = "PLATFORM_TENANT_CREATE") + public ApiResponse create(@RequestBody @Valid CreateTenantRequest request) { + return ApiResponse.success(tenantService.create(request)); + } + + @PostMapping("/logo-upload-sign") + @RequirePermission(value = "platform.tenant.manage", domain = PermissionDomain.PLATFORM, dataScope = DataScopeType.TENANT, auditAction = "PLATFORM_TENANT_LOGO_UPLOAD_SIGN") + public ApiResponse> logoUploadSign(@RequestBody @Valid EnterpriseLogoUploadSignRequest request) { + return ApiResponse.success(tenantService.presignLogoUpload(request.getFileName(), request.getContentType())); + } + + @PostMapping("/{id}/enable") + @RequirePermission(value = "platform.tenant.manage", domain = PermissionDomain.PLATFORM, dataScope = DataScopeType.TENANT, auditAction = "PLATFORM_TENANT_ENABLE") + public ApiResponse enable(@PathVariable("id") Long id) { + tenantService.enable(id); + return ApiResponse.success("ok"); + } + + @PostMapping("/{id}/disable") + @RequirePermission(value = "platform.tenant.manage", domain = PermissionDomain.PLATFORM, dataScope = DataScopeType.TENANT, auditAction = "PLATFORM_TENANT_DISABLE") + public ApiResponse disable(@PathVariable("id") Long id) { + tenantService.disable(id); + return ApiResponse.success("ok"); + } + + @PutMapping("/{id}") + @RequirePermission(value = "platform.tenant.manage", domain = PermissionDomain.PLATFORM, dataScope = DataScopeType.TENANT, auditAction = "PLATFORM_TENANT_UPDATE") + public ApiResponse update(@PathVariable("id") Long id, + @RequestBody @Valid UpdateTenantRequest request) { + return ApiResponse.success(tenantService.updateTenant(id, request.getTenantName(), request.getLogoUrl())); + } + + @PostMapping("/{id}/delete") + @RequirePermission(value = "platform.tenant.manage", domain = PermissionDomain.PLATFORM, dataScope = DataScopeType.TENANT, auditAction = "PLATFORM_TENANT_DELETE") + public ApiResponse softDelete(@PathVariable("id") Long id) { + tenantService.softDelete(id); + return ApiResponse.success("ok"); + } + + @PostMapping("/{id}/admin") + @RequirePermission(value = "platform.tenant.manage", domain = PermissionDomain.PLATFORM, dataScope = DataScopeType.TENANT, auditAction = "PLATFORM_TENANT_SET_ADMIN") + public ApiResponse> setAdmin(@PathVariable("id") Long id, + @RequestBody @Valid CreateTenantAdminRequest request) { + return ApiResponse.success(tenantService.createTenantAdmin(id, request)); + } + + @PostMapping("/{id}/init-baseline") + @RequirePermission(value = "platform.tenant.manage", domain = PermissionDomain.PLATFORM, dataScope = DataScopeType.TENANT, auditAction = "PLATFORM_TENANT_INIT_BASELINE") + public ApiResponse> initBaseline(@PathVariable("id") Long id) { + return ApiResponse.success(tenantService.initTenantBaseline(id)); + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/controller/PlatformUserController.java b/backend/src/main/java/com/writeoff/module/system/controller/PlatformUserController.java new file mode 100644 index 0000000..7efaeb8 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/controller/PlatformUserController.java @@ -0,0 +1,86 @@ +package com.writeoff.module.system.controller; + +import com.writeoff.common.api.ApiResponse; +import com.writeoff.common.api.PageResult; +import com.writeoff.common.model.ImportResult; +import com.writeoff.module.system.dto.AssignUserRoleRequest; +import com.writeoff.module.system.dto.CreateUserRequest; +import com.writeoff.module.system.dto.ImportUsersRequest; +import com.writeoff.module.system.dto.ResetPasswordRequest; +import com.writeoff.module.system.model.SystemUser; +import com.writeoff.module.system.service.PlatformIamService; +import com.writeoff.security.DataScopeType; +import com.writeoff.security.PermissionDomain; +import com.writeoff.security.RequirePermission; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import javax.validation.Valid; + +@RestController +@RequestMapping("/api/platform/users") +public class PlatformUserController { + private final PlatformIamService platformIamService; + + public PlatformUserController(PlatformIamService platformIamService) { + this.platformIamService = platformIamService; + } + + @GetMapping + @RequirePermission(value = "platform.user.manage", domain = PermissionDomain.PLATFORM, dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "PLATFORM_USER_LIST") + public ApiResponse> list(@RequestParam(value = "keyword", required = false) String keyword) { + return ApiResponse.success(platformIamService.listUsers(keyword)); + } + + @PostMapping + @RequirePermission(value = "platform.user.manage", domain = PermissionDomain.PLATFORM, dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "PLATFORM_USER_CREATE") + public ApiResponse create(@RequestBody @Valid CreateUserRequest request) { + return ApiResponse.success(platformIamService.createUser(request)); + } + + @PostMapping("/import") + @RequirePermission(value = "platform.user.manage", domain = PermissionDomain.PLATFORM, dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "PLATFORM_USER_IMPORT") + public ApiResponse importUsers(@RequestBody @Valid ImportUsersRequest request) { + return ApiResponse.success(platformIamService.importUsers(request.getUsers())); + } + + @PutMapping("/{id}") + @RequirePermission(value = "platform.user.manage", domain = PermissionDomain.PLATFORM, dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "PLATFORM_USER_UPDATE") + public ApiResponse update(@PathVariable("id") Long id, @RequestBody @Valid CreateUserRequest request) { + return ApiResponse.success(platformIamService.updateUser(id, request)); + } + + @PostMapping("/assign-role") + @RequirePermission(value = "platform.user.manage", domain = PermissionDomain.PLATFORM, dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "PLATFORM_USER_ASSIGN_ROLE") + public ApiResponse assignRole(@RequestBody @Valid AssignUserRoleRequest request) { + platformIamService.assignRole(request); + return ApiResponse.success("OK"); + } + + @PostMapping("/{id}/enable") + @RequirePermission(value = "platform.user.manage", domain = PermissionDomain.PLATFORM, dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "PLATFORM_USER_ENABLE") + public ApiResponse enable(@PathVariable("id") Long id) { + platformIamService.enableUser(id); + return ApiResponse.success("OK"); + } + + @PostMapping("/{id}/disable") + @RequirePermission(value = "platform.user.manage", domain = PermissionDomain.PLATFORM, dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "PLATFORM_USER_DISABLE") + public ApiResponse disable(@PathVariable("id") Long id) { + platformIamService.disableUser(id); + return ApiResponse.success("OK"); + } + + @PostMapping("/{id}/reset-password") + @RequirePermission(value = "platform.user.manage", domain = PermissionDomain.PLATFORM, dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "PLATFORM_USER_RESET_PASSWORD") + public ApiResponse resetPassword(@PathVariable("id") Long id, @RequestBody @Valid ResetPasswordRequest request) { + platformIamService.resetPassword(id, request); + return ApiResponse.success("OK"); + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/controller/ProfileController.java b/backend/src/main/java/com/writeoff/module/system/controller/ProfileController.java new file mode 100644 index 0000000..dee5ba1 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/controller/ProfileController.java @@ -0,0 +1,71 @@ +package com.writeoff.module.system.controller; + +import com.writeoff.common.api.ApiResponse; +import com.writeoff.module.system.dto.ChangePasswordRequest; +import com.writeoff.module.system.dto.UpdateProfilePreferencesRequest; +import com.writeoff.module.system.model.ProfilePreferencesInfo; +import com.writeoff.module.system.service.PlatformIamService; +import com.writeoff.module.system.service.SystemUserService; +import com.writeoff.security.AuthContext; +import com.writeoff.security.AuthScope; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import javax.validation.Valid; + +@RestController +@RequestMapping("/api/profile") +public class ProfileController { + + private final SystemUserService systemUserService; + private final PlatformIamService platformIamService; + + public ProfileController(SystemUserService systemUserService, PlatformIamService platformIamService) { + this.systemUserService = systemUserService; + this.platformIamService = platformIamService; + } + + @GetMapping("/preferences") + public ApiResponse preferences() { + Long userId = AuthContext.userId(); + if (userId == null) { + throw new com.writeoff.common.exception.BusinessException(11003, "会话失效"); + } + if (AuthContext.scope() == AuthScope.PLATFORM) { + return ApiResponse.success(platformIamService.getMyPreferences(userId)); + } + return ApiResponse.success(systemUserService.getMyPreferences(userId)); + } + + @PostMapping("/change-password") + // 这里因为是用户级别操作自身数据,只要拦截器确保已登录,不需要 RequirePermission 进行复杂的权限校验 + // 审计可以由切面或服务层内部记录,这里重点是提供 C 端基础功能 + public ApiResponse changePassword(@RequestBody @Valid ChangePasswordRequest request) { + Long userId = AuthContext.userId(); + if (userId == null) { + throw new com.writeoff.common.exception.BusinessException(11003, "会话失效"); + } + if (AuthContext.scope() == AuthScope.PLATFORM) { + platformIamService.changeMyPassword(userId, request.getOldPassword(), request.getNewPassword()); + return ApiResponse.success("OK"); + } + systemUserService.changeMyPassword(userId, request.getOldPassword(), request.getNewPassword()); + return ApiResponse.success("OK"); + } + + @PutMapping("/preferences") + public ApiResponse updatePreferences(@RequestBody UpdateProfilePreferencesRequest request) { + Long userId = AuthContext.userId(); + if (userId == null) { + throw new com.writeoff.common.exception.BusinessException(11003, "会话失效"); + } + if (AuthContext.scope() == AuthScope.PLATFORM) { + return ApiResponse.success(platformIamService.updateMyPreferences(userId, request)); + } + return ApiResponse.success(systemUserService.updateMyPreferences(userId, request)); + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/controller/RoleController.java b/backend/src/main/java/com/writeoff/module/system/controller/RoleController.java new file mode 100644 index 0000000..7b26fae --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/controller/RoleController.java @@ -0,0 +1,102 @@ +package com.writeoff.module.system.controller; + +import com.writeoff.common.api.ApiResponse; +import com.writeoff.common.api.PageResult; +import com.writeoff.module.system.dto.BindRolePermissionsRequest; +import com.writeoff.module.system.dto.BindRoleMenusRequest; +import com.writeoff.module.system.dto.CreateRoleRequest; +import com.writeoff.module.system.dto.UpdateRoleRequest; +import com.writeoff.module.system.model.RoleInfo; +import com.writeoff.module.system.service.MenuService; +import com.writeoff.module.system.service.SystemUserService; +import com.writeoff.security.DataScopeType; +import com.writeoff.security.RequirePermission; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import javax.validation.Valid; + +@RestController +@RequestMapping("/api/roles") +public class RoleController { + private final SystemUserService systemUserService; + private final MenuService menuService; + + public RoleController(SystemUserService systemUserService, MenuService menuService) { + this.systemUserService = systemUserService; + this.menuService = menuService; + } + + @GetMapping + @RequirePermission(value = "role.read", dataScope = DataScopeType.TENANT, auditAction = "ROLE_LIST") + public ApiResponse> list( + @RequestParam(value = "pageNo", defaultValue = "1") int pageNo, + @RequestParam(value = "pageSize", defaultValue = "20") int pageSize) { + return ApiResponse.success(systemUserService.listRoles(pageNo, pageSize)); + } + + @PostMapping + @RequirePermission(value = "role.create", dataScope = DataScopeType.TENANT, auditAction = "ROLE_CREATE") + public ApiResponse create(@RequestBody @Valid CreateRoleRequest request) { + return ApiResponse.success(systemUserService.createRole(request)); + } + + @PutMapping("/{id}") + @RequirePermission(value = "role.update", dataScope = DataScopeType.TENANT, auditAction = "ROLE_UPDATE") + public ApiResponse update(@PathVariable("id") Long id, @RequestBody @Valid UpdateRoleRequest request) { + return ApiResponse.success(systemUserService.updateRole(id, request)); + } + + @PostMapping("/{id}/enable") + @RequirePermission(value = "role.enable", dataScope = DataScopeType.TENANT, auditAction = "ROLE_ENABLE") + public ApiResponse enable(@PathVariable("id") Long id) { + systemUserService.enableRole(id); + return ApiResponse.success("OK"); + } + + @PostMapping("/{id}/disable") + @RequirePermission(value = "role.disable", dataScope = DataScopeType.TENANT, auditAction = "ROLE_DISABLE") + public ApiResponse disable(@PathVariable("id") Long id) { + systemUserService.disableRole(id); + return ApiResponse.success("OK"); + } + + @PostMapping("/{id}/delete") + @RequirePermission(value = "role.delete", dataScope = DataScopeType.TENANT, auditAction = "ROLE_DELETE") + public ApiResponse softDelete(@PathVariable("id") Long id) { + systemUserService.softDeleteRole(id); + return ApiResponse.success("OK"); + } + + @GetMapping("/{id}/permissions") + @RequirePermission(value = "role.read", dataScope = DataScopeType.TENANT, auditAction = "ROLE_PERMISSIONS") + public ApiResponse> getRolePermissions(@PathVariable("id") Long id) { + return ApiResponse.success(systemUserService.getRolePermissionIds(id)); + } + + @PostMapping("/{id}/permissions") + @RequirePermission(value = "role.permission.bind", dataScope = DataScopeType.TENANT, auditAction = "ROLE_BIND_PERMISSIONS") + public ApiResponse bindPermissions(@PathVariable("id") Long id, @RequestBody @Valid BindRolePermissionsRequest request) { + systemUserService.bindRolePermissions(id, request); + return ApiResponse.success("OK"); + } + + @GetMapping("/{id}/menus") + @RequirePermission(value = "role.read", dataScope = DataScopeType.TENANT, auditAction = "ROLE_MENUS") + public ApiResponse> getRoleMenus(@PathVariable("id") Long id) { + return ApiResponse.success(menuService.getRoleMenuIds(id)); + } + + @PostMapping("/{id}/menus") + @RequirePermission(value = "role.permission.bind", dataScope = DataScopeType.TENANT, auditAction = "ROLE_BIND_MENUS") + public ApiResponse bindMenus(@PathVariable("id") Long id, @RequestBody @Valid BindRoleMenusRequest request) { + menuService.bindRoleMenus(id, request); + return ApiResponse.success("OK"); + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/controller/SystemController.java b/backend/src/main/java/com/writeoff/module/system/controller/SystemController.java new file mode 100644 index 0000000..9402ee7 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/controller/SystemController.java @@ -0,0 +1,56 @@ +package com.writeoff.module.system.controller; + +import com.writeoff.common.api.ApiResponse; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.LinkedHashMap; +import java.util.Map; + +@RestController +@RequestMapping({"/api/system", "/api"}) +public class SystemController { + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Value("${spring.application.name:writeoff-backend}") + private String applicationName; + + @Value("${app.runtime.version:${APP_VERSION:${project.version:0.0.1-SNAPSHOT}}}") + private String applicationVersion; + + @Value("${app.runtime.build-time:${BUILD_TIME:unknown}}") + private String buildTime; + + @GetMapping("/health") + public ApiResponse> health() { + return ApiResponse.success(buildHealthPayload()); + } + + private Map buildHealthPayload() { + Map result = new LinkedHashMap<>(); + result.put("status", "UP"); + result.put("app", applicationName); + result.put("timestamp", LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME)); + result.put("version", applicationVersion); + result.put("buildTime", buildTime); + result.put("stateStore", "MYSQL"); + + try { + jdbcTemplate.queryForObject("SELECT 1", Integer.class); + result.put("database", "UP"); + } catch (Exception e) { + result.put("database", "DOWN"); + result.put("databaseError", e.getMessage()); + result.put("status", "DEGRADED"); + } + return result; + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/controller/TenantController.java b/backend/src/main/java/com/writeoff/module/system/controller/TenantController.java new file mode 100644 index 0000000..bf637a1 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/controller/TenantController.java @@ -0,0 +1,70 @@ +package com.writeoff.module.system.controller; + +import com.writeoff.common.api.ApiResponse; +import com.writeoff.common.api.PageResult; +import com.writeoff.common.exception.BusinessException; +import com.writeoff.module.system.dto.CreateTenantRequest; +import com.writeoff.module.system.dto.EnterpriseLogoUploadSignRequest; +import com.writeoff.module.system.dto.UpdateTenantRequest; +import com.writeoff.module.system.model.TenantInfo; +import com.writeoff.module.system.service.TenantService; +import com.writeoff.security.AuthContext; +import com.writeoff.security.DataScopeType; +import com.writeoff.security.PermissionDomain; +import com.writeoff.security.RequirePermission; +import org.springframework.web.bind.annotation.*; + +import javax.validation.Valid; + +@RestController +@RequestMapping("/api/tenants") +public class TenantController { + private final TenantService tenantService; + + public TenantController(TenantService tenantService) { + this.tenantService = tenantService; + } + + @GetMapping + @RequirePermission(value = "tenant.manage", dataScope = DataScopeType.TENANT, auditAction = "TENANT_LIST") + public ApiResponse> list() { + return ApiResponse.success(tenantService.listCurrentTenant()); + } + + @PostMapping + @RequirePermission(value = "platform.tenant.manage", domain = PermissionDomain.PLATFORM, dataScope = DataScopeType.TENANT, auditAction = "TENANT_CREATE") + public ApiResponse create(@RequestBody @Valid CreateTenantRequest request) { + return ApiResponse.success(tenantService.create(request)); + } + + @PostMapping("/logo-upload-sign") + @RequirePermission(value = "tenant.manage", dataScope = DataScopeType.TENANT, auditAction = "TENANT_LOGO_UPLOAD_SIGN") + public ApiResponse> logoUploadSign(@RequestBody @Valid EnterpriseLogoUploadSignRequest request) { + return ApiResponse.success(tenantService.presignLogoUpload(request.getFileName(), request.getContentType())); + } + + @PutMapping("/{id}") + @RequirePermission(value = "tenant.manage", dataScope = DataScopeType.TENANT, auditAction = "TENANT_UPDATE") + public ApiResponse update(@PathVariable("id") Long id, + @RequestBody @Valid UpdateTenantRequest request) { + Long currentTenantId = AuthContext.requireTenantId(); + if (!currentTenantId.equals(id)) { + throw new BusinessException(10003, "租户不存在"); + } + return ApiResponse.success(tenantService.updateTenant(id, request.getTenantName(), request.getLogoUrl())); + } + + @PostMapping("/{id}/enable") + @RequirePermission(value = "platform.tenant.manage", domain = PermissionDomain.PLATFORM, dataScope = DataScopeType.TENANT, auditAction = "TENANT_ENABLE") + public ApiResponse enable(@PathVariable("id") Long id) { + tenantService.enable(id); + return ApiResponse.success("ok"); + } + + @PostMapping("/{id}/disable") + @RequirePermission(value = "platform.tenant.manage", domain = PermissionDomain.PLATFORM, dataScope = DataScopeType.TENANT, auditAction = "TENANT_DISABLE") + public ApiResponse disable(@PathVariable("id") Long id) { + tenantService.disable(id); + return ApiResponse.success("ok"); + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/controller/UserController.java b/backend/src/main/java/com/writeoff/module/system/controller/UserController.java new file mode 100644 index 0000000..cfcbcbf --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/controller/UserController.java @@ -0,0 +1,133 @@ +package com.writeoff.module.system.controller; + +import com.writeoff.common.api.ApiResponse; +import com.writeoff.common.api.PageResult; +import com.writeoff.common.model.ImportResult; +import com.writeoff.module.system.dto.AssignUserRoleRequest; +import com.writeoff.module.system.dto.CreateUserRequest; +import com.writeoff.module.system.dto.CreateUserDelegationRequest; +import com.writeoff.module.system.dto.ImportUsersRequest; +import com.writeoff.module.system.dto.ResetPasswordRequest; +import com.writeoff.module.system.model.SystemUser; +import com.writeoff.module.system.model.UserDelegationInfo; +import com.writeoff.module.system.model.UserRoleHistory; +import com.writeoff.module.system.service.SystemUserService; +import com.writeoff.module.system.service.UserDelegationService; +import com.writeoff.module.export.dto.CreateExportTaskRequest; +import com.writeoff.module.export.service.ExportTaskService; +import com.writeoff.security.DataScopeType; +import com.writeoff.security.RequirePermission; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.PutMapping; + +import javax.validation.Valid; + +@RestController +@RequestMapping("/api/users") +public class UserController { + private final SystemUserService systemUserService; + private final UserDelegationService userDelegationService; + private final ExportTaskService exportTaskService; + + public UserController(SystemUserService systemUserService, UserDelegationService userDelegationService, ExportTaskService exportTaskService) { + this.systemUserService = systemUserService; + this.userDelegationService = userDelegationService; + this.exportTaskService = exportTaskService; + } + + @GetMapping + @RequirePermission(value = "user.read", dataScope = DataScopeType.TENANT, auditAction = "USER_LIST") + public ApiResponse> list( + @RequestParam(value = "pageNo", defaultValue = "1") int pageNo, + @RequestParam(value = "pageSize", defaultValue = "20") int pageSize, + @RequestParam(value = "includeDeleted", required = false) Boolean includeDeleted, + @RequestParam(value = "keyword", required = false) String keyword) { + return ApiResponse.success(systemUserService.listUsers(pageNo, pageSize, includeDeleted, keyword)); + } + + @PostMapping + @RequirePermission(value = "user.create", dataScope = DataScopeType.TENANT, auditAction = "USER_CREATE") + public ApiResponse create(@RequestBody @Valid CreateUserRequest request) { + return ApiResponse.success(systemUserService.createUser(request)); + } + + @PutMapping("/{id}") + @RequirePermission(value = "user.update", dataScope = DataScopeType.TENANT, auditAction = "USER_UPDATE") + public ApiResponse update(@PathVariable("id") Long id, @RequestBody @Valid CreateUserRequest request) { + return ApiResponse.success(systemUserService.updateUser(id, request)); + } + + @PostMapping("/assign-role") + @RequirePermission(value = "user.role.assign", dataScope = DataScopeType.TENANT, auditAction = "USER_ASSIGN_ROLE") + public ApiResponse assignRole(@RequestBody @Valid AssignUserRoleRequest request) { + systemUserService.assignRole(request.getUserId(), request.getRoleId()); + return ApiResponse.success("OK"); + } + + @PostMapping("/{id}/enable") + @RequirePermission(value = "user.enable", dataScope = DataScopeType.TENANT, auditAction = "USER_ENABLE") + public ApiResponse enable(@PathVariable("id") Long id) { + systemUserService.enableUser(id); + return ApiResponse.success("OK"); + } + + @PostMapping("/{id}/disable") + @RequirePermission(value = "user.disable", dataScope = DataScopeType.TENANT, auditAction = "USER_DISABLE") + public ApiResponse disable(@PathVariable("id") Long id) { + systemUserService.disableUser(id); + return ApiResponse.success("OK"); + } + + @PostMapping("/{id}/delete") + @RequirePermission(value = "user.delete", dataScope = DataScopeType.TENANT, auditAction = "USER_DELETE") + public ApiResponse softDelete(@PathVariable("id") Long id) { + systemUserService.softDeleteUser(id); + return ApiResponse.success("OK"); + } + + @PostMapping("/{id}/reset-password") + @RequirePermission(value = "user.password.reset", dataScope = DataScopeType.TENANT, auditAction = "USER_RESET_PASSWORD") + public ApiResponse resetPassword(@PathVariable("id") Long id, @RequestBody @Valid ResetPasswordRequest request) { + systemUserService.resetPassword(id, request); + return ApiResponse.success("OK"); + } + + @GetMapping("/{id}/role-history") + @RequirePermission(value = "user.role.history.read", dataScope = DataScopeType.TENANT, auditAction = "USER_ROLE_HISTORY") + public ApiResponse> roleHistory(@PathVariable("id") Long id) { + return ApiResponse.success(systemUserService.listUserRoleHistory(id)); + } + + @GetMapping("/{id}/delegations") + @RequirePermission(value = "user.delegation.manage", dataScope = DataScopeType.TENANT, auditAction = "USER_DELEGATION_LIST") + public ApiResponse> delegations(@PathVariable("id") Long id) { + return ApiResponse.success(userDelegationService.listByUserId(id)); + } + + @PostMapping("/{id}/delegations") + @RequirePermission(value = "user.delegation.manage", dataScope = DataScopeType.TENANT, auditAction = "USER_DELEGATION_CREATE") + public ApiResponse createDelegation(@PathVariable("id") Long id, + @RequestBody @Valid CreateUserDelegationRequest request) { + return ApiResponse.success(userDelegationService.create(id, request)); + } + + @PostMapping("/import") + @RequirePermission(value = "user.import", dataScope = DataScopeType.TENANT, auditAction = "USER_IMPORT") + public ApiResponse importUsers(@RequestBody @Valid ImportUsersRequest request) { + return ApiResponse.success(systemUserService.importUsers(request.getUsers())); + } + + @PostMapping("/export") + @RequirePermission(value = "user.read", dataScope = DataScopeType.TENANT, auditAction = "USER_EXPORT") + public ApiResponse> exportUsers(@RequestBody @Valid CreateExportTaskRequest request) { + request.setTaskCode("USER_EXPORT"); + request.setBizType("USER"); + return ApiResponse.success(exportTaskService.create(request)); + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/controller/UserDelegationController.java b/backend/src/main/java/com/writeoff/module/system/controller/UserDelegationController.java new file mode 100644 index 0000000..66780ae --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/controller/UserDelegationController.java @@ -0,0 +1,31 @@ +package com.writeoff.module.system.controller; + +import com.writeoff.common.api.ApiResponse; +import com.writeoff.module.system.dto.DisableDelegationRequest; +import com.writeoff.module.system.service.UserDelegationService; +import com.writeoff.security.DataScopeType; +import com.writeoff.security.RequirePermission; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import javax.validation.Valid; + +@RestController +@RequestMapping("/api/delegations") +public class UserDelegationController { + private final UserDelegationService userDelegationService; + + public UserDelegationController(UserDelegationService userDelegationService) { + this.userDelegationService = userDelegationService; + } + + @PostMapping("/{id}/disable") + @RequirePermission(value = "user.delegation.manage", dataScope = DataScopeType.TENANT, auditAction = "USER_DELEGATION_DISABLE") + public ApiResponse disable(@PathVariable("id") Long id, @RequestBody @Valid DisableDelegationRequest request) { + userDelegationService.disable(id, request); + return ApiResponse.success("OK"); + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/dto/AssignRoleDataPermissionRequest.java b/backend/src/main/java/com/writeoff/module/system/dto/AssignRoleDataPermissionRequest.java new file mode 100644 index 0000000..f2fb200 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/dto/AssignRoleDataPermissionRequest.java @@ -0,0 +1,26 @@ +package com.writeoff.module.system.dto; + +import javax.validation.constraints.NotNull; +import java.util.List; + +public class AssignRoleDataPermissionRequest { + @NotNull(message = "角色ID不能为空") + private List roleIds; + private String assignMode; + + public List getRoleIds() { + return roleIds; + } + + public void setRoleIds(List roleIds) { + this.roleIds = roleIds; + } + + public String getAssignMode() { + return assignMode; + } + + public void setAssignMode(String assignMode) { + this.assignMode = assignMode; + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/dto/AssignUserRoleRequest.java b/backend/src/main/java/com/writeoff/module/system/dto/AssignUserRoleRequest.java new file mode 100644 index 0000000..fce0ed9 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/dto/AssignUserRoleRequest.java @@ -0,0 +1,18 @@ +package com.writeoff.module.system.dto; + +import javax.validation.constraints.NotNull; + +public class AssignUserRoleRequest { + @NotNull(message = "用户ID不能为空") + private Long userId; + @NotNull(message = "角色ID不能为空") + private Long roleId; + + public Long getUserId() { + return userId; + } + + public Long getRoleId() { + return roleId; + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/dto/BindPlatformMenuRolesRequest.java b/backend/src/main/java/com/writeoff/module/system/dto/BindPlatformMenuRolesRequest.java new file mode 100644 index 0000000..afc96ec --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/dto/BindPlatformMenuRolesRequest.java @@ -0,0 +1,13 @@ +package com.writeoff.module.system.dto; + +import javax.validation.constraints.NotNull; +import java.util.List; + +public class BindPlatformMenuRolesRequest { + @NotNull(message = "角色ID列表不能为空") + private List roleIds; + + public List getRoleIds() { + return roleIds; + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/dto/BindRoleMenusRequest.java b/backend/src/main/java/com/writeoff/module/system/dto/BindRoleMenusRequest.java new file mode 100644 index 0000000..4293ded --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/dto/BindRoleMenusRequest.java @@ -0,0 +1,13 @@ +package com.writeoff.module.system.dto; + +import javax.validation.constraints.NotNull; +import java.util.List; + +public class BindRoleMenusRequest { + @NotNull(message = "菜单ID列表不能为空") + private List menuIds; + + public List getMenuIds() { + return menuIds; + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/dto/BindRolePermissionsRequest.java b/backend/src/main/java/com/writeoff/module/system/dto/BindRolePermissionsRequest.java new file mode 100644 index 0000000..ebc85c3 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/dto/BindRolePermissionsRequest.java @@ -0,0 +1,17 @@ +package com.writeoff.module.system.dto; + +import javax.validation.constraints.NotNull; +import java.util.List; + +public class BindRolePermissionsRequest { + @NotNull(message = "权限ID列表不能为空") + private List permissionIds; + + public List getPermissionIds() { + return permissionIds; + } + + public void setPermissionIds(List permissionIds) { + this.permissionIds = permissionIds; + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/dto/ChangePasswordRequest.java b/backend/src/main/java/com/writeoff/module/system/dto/ChangePasswordRequest.java new file mode 100644 index 0000000..824b081 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/dto/ChangePasswordRequest.java @@ -0,0 +1,29 @@ +package com.writeoff.module.system.dto; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.Size; + +public class ChangePasswordRequest { + @NotBlank(message = "原密码不能为空") + private String oldPassword; + + @NotBlank(message = "新密码不能为空") + @Size(min = 6, message = "新密码长度至少6位") + private String newPassword; + + public String getOldPassword() { + return oldPassword; + } + + public void setOldPassword(String oldPassword) { + this.oldPassword = oldPassword; + } + + public String getNewPassword() { + return newPassword; + } + + public void setNewPassword(String newPassword) { + this.newPassword = newPassword; + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/dto/CreateDataPermissionPolicyRequest.java b/backend/src/main/java/com/writeoff/module/system/dto/CreateDataPermissionPolicyRequest.java new file mode 100644 index 0000000..0ba31c1 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/dto/CreateDataPermissionPolicyRequest.java @@ -0,0 +1,110 @@ +package com.writeoff.module.system.dto; + +import javax.validation.constraints.NotBlank; + +public class CreateDataPermissionPolicyRequest { + @NotBlank(message = "策略名称不能为空") + private String policyName; + @NotBlank(message = "项目范围不能为空") + private String projectScope; + private String projectIdsCsv; + @NotBlank(message = "会议范围不能为空") + private String meetingScope; + private String meetingIdsCsv; + @NotBlank(message = "用户范围不能为空") + private String userScope; + private String userIdsCsv; + @NotBlank(message = "专家范围不能为空") + private String expertScope; + private String expertIdsCsv; + private String moduleScope; + private Boolean exportAllowed; + + public String getPolicyName() { + return policyName; + } + + public void setPolicyName(String policyName) { + this.policyName = policyName; + } + + public String getProjectScope() { + return projectScope; + } + + public void setProjectScope(String projectScope) { + this.projectScope = projectScope; + } + + public String getProjectIdsCsv() { + return projectIdsCsv; + } + + public void setProjectIdsCsv(String projectIdsCsv) { + this.projectIdsCsv = projectIdsCsv; + } + + public String getMeetingScope() { + return meetingScope; + } + + public void setMeetingScope(String meetingScope) { + this.meetingScope = meetingScope; + } + + public String getMeetingIdsCsv() { + return meetingIdsCsv; + } + + public void setMeetingIdsCsv(String meetingIdsCsv) { + this.meetingIdsCsv = meetingIdsCsv; + } + + public String getModuleScope() { + return moduleScope; + } + + public String getUserScope() { + return userScope; + } + + public void setUserScope(String userScope) { + this.userScope = userScope; + } + + public String getUserIdsCsv() { + return userIdsCsv; + } + + public void setUserIdsCsv(String userIdsCsv) { + this.userIdsCsv = userIdsCsv; + } + + public String getExpertScope() { + return expertScope; + } + + public void setExpertScope(String expertScope) { + this.expertScope = expertScope; + } + + public String getExpertIdsCsv() { + return expertIdsCsv; + } + + public void setExpertIdsCsv(String expertIdsCsv) { + this.expertIdsCsv = expertIdsCsv; + } + + public void setModuleScope(String moduleScope) { + this.moduleScope = moduleScope; + } + + public Boolean getExportAllowed() { + return exportAllowed; + } + + public void setExportAllowed(Boolean exportAllowed) { + this.exportAllowed = exportAllowed; + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/dto/CreateEnterpriseRequest.java b/backend/src/main/java/com/writeoff/module/system/dto/CreateEnterpriseRequest.java new file mode 100644 index 0000000..13807e3 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/dto/CreateEnterpriseRequest.java @@ -0,0 +1,22 @@ +package com.writeoff.module.system.dto; + +import javax.validation.constraints.NotBlank; + +public class CreateEnterpriseRequest { + @NotBlank(message = "企业名称不能为空") + private String enterpriseName; + private String enterpriseUrl; + private String logoUrl; + + public String getEnterpriseName() { + return enterpriseName; + } + + public String getEnterpriseUrl() { + return enterpriseUrl; + } + + public String getLogoUrl() { + return logoUrl; + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/dto/CreateMenuRequest.java b/backend/src/main/java/com/writeoff/module/system/dto/CreateMenuRequest.java new file mode 100644 index 0000000..1bca260 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/dto/CreateMenuRequest.java @@ -0,0 +1,37 @@ +package com.writeoff.module.system.dto; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; + +public class CreateMenuRequest { + @NotBlank(message = "菜单编码不能为空") + private String menuCode; + @NotBlank(message = "菜单名称不能为空") + private String menuName; + @NotBlank(message = "路由地址不能为空") + private String routePath; + @NotBlank(message = "权限码不能为空") + private String permissionCode; + @NotNull(message = "排序不能为空") + private Integer sortNo; + + public String getMenuCode() { + return menuCode; + } + + public String getMenuName() { + return menuName; + } + + public String getRoutePath() { + return routePath; + } + + public String getPermissionCode() { + return permissionCode; + } + + public Integer getSortNo() { + return sortNo; + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/dto/CreatePlatformDictionaryItemRequest.java b/backend/src/main/java/com/writeoff/module/system/dto/CreatePlatformDictionaryItemRequest.java new file mode 100644 index 0000000..8f63429 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/dto/CreatePlatformDictionaryItemRequest.java @@ -0,0 +1,56 @@ +package com.writeoff.module.system.dto; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; + +public class CreatePlatformDictionaryItemRequest { + @NotBlank(message = "字典类型不能为空") + private String dictType; + @NotBlank(message = "字典编码不能为空") + private String dictCode; + @NotBlank(message = "字典名称不能为空") + private String dictName; + @NotNull(message = "排序不能为空") + private Integer sortNo; + private String remark; + + public String getDictType() { + return dictType; + } + + public void setDictType(String dictType) { + this.dictType = dictType; + } + + public String getDictCode() { + return dictCode; + } + + public void setDictCode(String dictCode) { + this.dictCode = dictCode; + } + + public String getDictName() { + return dictName; + } + + public void setDictName(String dictName) { + this.dictName = dictName; + } + + public Integer getSortNo() { + return sortNo; + } + + public void setSortNo(Integer sortNo) { + this.sortNo = sortNo; + } + + public String getRemark() { + return remark; + } + + public void setRemark(String remark) { + this.remark = remark; + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/dto/CreatePlatformDictionaryTypeRequest.java b/backend/src/main/java/com/writeoff/module/system/dto/CreatePlatformDictionaryTypeRequest.java new file mode 100644 index 0000000..6a4b79b --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/dto/CreatePlatformDictionaryTypeRequest.java @@ -0,0 +1,46 @@ +package com.writeoff.module.system.dto; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; + +public class CreatePlatformDictionaryTypeRequest { + @NotBlank(message = "字典类型编码不能为空") + private String dictType; + @NotBlank(message = "字典类型名称不能为空") + private String dictName; + @NotNull(message = "排序不能为空") + private Integer sortNo; + private String remark; + + public String getDictType() { + return dictType; + } + + public void setDictType(String dictType) { + this.dictType = dictType; + } + + public String getDictName() { + return dictName; + } + + public void setDictName(String dictName) { + this.dictName = dictName; + } + + public Integer getSortNo() { + return sortNo; + } + + public void setSortNo(Integer sortNo) { + this.sortNo = sortNo; + } + + public String getRemark() { + return remark; + } + + public void setRemark(String remark) { + this.remark = remark; + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/dto/CreateRoleRequest.java b/backend/src/main/java/com/writeoff/module/system/dto/CreateRoleRequest.java new file mode 100644 index 0000000..ca2953d --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/dto/CreateRoleRequest.java @@ -0,0 +1,26 @@ +package com.writeoff.module.system.dto; + +import javax.validation.constraints.NotBlank; + +public class CreateRoleRequest { + @NotBlank(message = "角色编码不能为空") + private String roleCode; + @NotBlank(message = "角色名称不能为空") + private String roleName; + + public String getRoleCode() { + return roleCode; + } + + public void setRoleCode(String roleCode) { + this.roleCode = roleCode; + } + + public String getRoleName() { + return roleName; + } + + public void setRoleName(String roleName) { + this.roleName = roleName; + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/dto/CreateTenantAdminRequest.java b/backend/src/main/java/com/writeoff/module/system/dto/CreateTenantAdminRequest.java new file mode 100644 index 0000000..9c766db --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/dto/CreateTenantAdminRequest.java @@ -0,0 +1,32 @@ +package com.writeoff.module.system.dto; + +import javax.validation.constraints.NotBlank; + +public class CreateTenantAdminRequest { + @NotBlank(message = "管理员姓名不能为空") + private String userName; + + @NotBlank(message = "管理员手机号不能为空") + private String phone; + + @NotBlank(message = "管理员邮箱不能为空") + private String email; + + private String roleCode; + + public String getUserName() { + return userName; + } + + public String getPhone() { + return phone; + } + + public String getEmail() { + return email; + } + + public String getRoleCode() { + return roleCode; + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/dto/CreateTenantRequest.java b/backend/src/main/java/com/writeoff/module/system/dto/CreateTenantRequest.java new file mode 100644 index 0000000..570f164 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/dto/CreateTenantRequest.java @@ -0,0 +1,35 @@ +package com.writeoff.module.system.dto; + +import javax.validation.constraints.NotBlank; + +public class CreateTenantRequest { + @NotBlank(message = "租户编码不能为空") + private String tenantCode; + @NotBlank(message = "租户名称不能为空") + private String tenantName; + private String logoUrl; + + public String getTenantCode() { + return tenantCode; + } + + public void setTenantCode(String tenantCode) { + this.tenantCode = tenantCode; + } + + public String getTenantName() { + return tenantName; + } + + public void setTenantName(String tenantName) { + this.tenantName = tenantName; + } + + public String getLogoUrl() { + return logoUrl; + } + + public void setLogoUrl(String logoUrl) { + this.logoUrl = logoUrl; + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/dto/CreateUserDelegationRequest.java b/backend/src/main/java/com/writeoff/module/system/dto/CreateUserDelegationRequest.java new file mode 100644 index 0000000..e5a661a --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/dto/CreateUserDelegationRequest.java @@ -0,0 +1,30 @@ +package com.writeoff.module.system.dto; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; + +public class CreateUserDelegationRequest { + @NotNull(message = "代理人不能为空") + private Long delegateUserId; + @NotBlank(message = "生效时间不能为空") + private String effectiveFrom; + @NotBlank(message = "失效时间不能为空") + private String effectiveTo; + private String reason; + + public Long getDelegateUserId() { + return delegateUserId; + } + + public String getEffectiveFrom() { + return effectiveFrom; + } + + public String getEffectiveTo() { + return effectiveTo; + } + + public String getReason() { + return reason; + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/dto/CreateUserRequest.java b/backend/src/main/java/com/writeoff/module/system/dto/CreateUserRequest.java new file mode 100644 index 0000000..1c15362 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/dto/CreateUserRequest.java @@ -0,0 +1,67 @@ +package com.writeoff.module.system.dto; + +import javax.validation.constraints.Email; +import javax.validation.constraints.NotBlank; + +public class CreateUserRequest { + @NotBlank(message = "\u7528\u6237\u540d\u4e0d\u80fd\u4e3a\u7a7a") + private String userName; + + @NotBlank(message = "\u624b\u673a\u53f7\u4e0d\u80fd\u4e3a\u7a7a") + private String phone; + + private String password; + @NotBlank(message = "\u90ae\u7bb1\u4e0d\u80fd\u4e3a\u7a7a") + @Email(message = "\u90ae\u7bb1\u683c\u5f0f\u4e0d\u6b63\u786e") + private String email; + private String validFrom; + private String validTo; + + public String getUserName() { + return userName; + } + + public String getPhone() { + return phone; + } + + public String getPassword() { + return password; + } + + public String getEmail() { + return email; + } + + public String getValidFrom() { + return validFrom; + } + + public String getValidTo() { + return validTo; + } + + public void setUserName(String userName) { + this.userName = userName; + } + + public void setPhone(String phone) { + this.phone = phone; + } + + public void setPassword(String password) { + this.password = password; + } + + public void setEmail(String email) { + this.email = email; + } + + public void setValidFrom(String validFrom) { + this.validFrom = validFrom; + } + + public void setValidTo(String validTo) { + this.validTo = validTo; + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/dto/DisableDelegationRequest.java b/backend/src/main/java/com/writeoff/module/system/dto/DisableDelegationRequest.java new file mode 100644 index 0000000..7ee67df --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/dto/DisableDelegationRequest.java @@ -0,0 +1,12 @@ +package com.writeoff.module.system.dto; + +import javax.validation.constraints.NotBlank; + +public class DisableDelegationRequest { + @NotBlank(message = "停用原因不能为空") + private String reason; + + public String getReason() { + return reason; + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/dto/EnterpriseLogoUploadSignRequest.java b/backend/src/main/java/com/writeoff/module/system/dto/EnterpriseLogoUploadSignRequest.java new file mode 100644 index 0000000..b1384d4 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/dto/EnterpriseLogoUploadSignRequest.java @@ -0,0 +1,17 @@ +package com.writeoff.module.system.dto; + +import javax.validation.constraints.NotBlank; + +public class EnterpriseLogoUploadSignRequest { + @NotBlank(message = "文件名不能为空") + private String fileName; + private String contentType; + + public String getFileName() { + return fileName; + } + + public String getContentType() { + return contentType; + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/dto/ImportUserItemRequest.java b/backend/src/main/java/com/writeoff/module/system/dto/ImportUserItemRequest.java new file mode 100644 index 0000000..21d0e36 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/dto/ImportUserItemRequest.java @@ -0,0 +1,67 @@ +package com.writeoff.module.system.dto; + +public class ImportUserItemRequest { + private String userName; + private String phone; + private String password; + private String email; + private String validFrom; + private String validTo; + private String roleCode; + + public String getUserName() { + return userName; + } + + public void setUserName(String userName) { + this.userName = userName; + } + + public String getPhone() { + return phone; + } + + public void setPhone(String phone) { + this.phone = phone; + } + + public String getPassword() { + return password; + } + + public void setPassword(String password) { + this.password = password; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + public String getValidFrom() { + return validFrom; + } + + public void setValidFrom(String validFrom) { + this.validFrom = validFrom; + } + + public String getValidTo() { + return validTo; + } + + public void setValidTo(String validTo) { + this.validTo = validTo; + } + + public String getRoleCode() { + return roleCode; + } + + public void setRoleCode(String roleCode) { + this.roleCode = roleCode; + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/dto/ImportUsersRequest.java b/backend/src/main/java/com/writeoff/module/system/dto/ImportUsersRequest.java new file mode 100644 index 0000000..4238ace --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/dto/ImportUsersRequest.java @@ -0,0 +1,19 @@ +package com.writeoff.module.system.dto; + +import javax.validation.Valid; +import javax.validation.constraints.NotEmpty; +import java.util.List; + +public class ImportUsersRequest { + @Valid + @NotEmpty(message = "导入列表不能为空") + private List users; + + public List getUsers() { + return users; + } + + public void setUsers(List users) { + this.users = users; + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/dto/ReorderMenusRequest.java b/backend/src/main/java/com/writeoff/module/system/dto/ReorderMenusRequest.java new file mode 100644 index 0000000..3ab8ed7 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/dto/ReorderMenusRequest.java @@ -0,0 +1,28 @@ +package com.writeoff.module.system.dto; + +import javax.validation.constraints.NotNull; +import java.util.List; + +public class ReorderMenusRequest { + @NotNull(message = "菜单排序列表不能为空") + private List menus; + + public List getMenus() { + return menus; + } + + public static class MenuSortItem { + @NotNull(message = "菜单ID不能为空") + private Long id; + @NotNull(message = "排序值不能为空") + private Integer sortNo; + + public Long getId() { + return id; + } + + public Integer getSortNo() { + return sortNo; + } + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/dto/ResetPasswordRequest.java b/backend/src/main/java/com/writeoff/module/system/dto/ResetPasswordRequest.java new file mode 100644 index 0000000..86e928e --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/dto/ResetPasswordRequest.java @@ -0,0 +1,16 @@ +package com.writeoff.module.system.dto; + +import javax.validation.constraints.NotBlank; + +public class ResetPasswordRequest { + @NotBlank(message = "新密码不能为空") + private String newPassword; + + public String getNewPassword() { + return newPassword; + } + + public void setNewPassword(String newPassword) { + this.newPassword = newPassword; + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/dto/UpdateDataPermissionPolicyRequest.java b/backend/src/main/java/com/writeoff/module/system/dto/UpdateDataPermissionPolicyRequest.java new file mode 100644 index 0000000..0fa25f1 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/dto/UpdateDataPermissionPolicyRequest.java @@ -0,0 +1,4 @@ +package com.writeoff.module.system.dto; + +public class UpdateDataPermissionPolicyRequest extends CreateDataPermissionPolicyRequest { +} diff --git a/backend/src/main/java/com/writeoff/module/system/dto/UpdateEnterpriseRequest.java b/backend/src/main/java/com/writeoff/module/system/dto/UpdateEnterpriseRequest.java new file mode 100644 index 0000000..b022c58 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/dto/UpdateEnterpriseRequest.java @@ -0,0 +1,22 @@ +package com.writeoff.module.system.dto; + +import javax.validation.constraints.NotBlank; + +public class UpdateEnterpriseRequest { + @NotBlank(message = "企业名称不能为空") + private String enterpriseName; + private String enterpriseUrl; + private String logoUrl; + + public String getEnterpriseName() { + return enterpriseName; + } + + public String getEnterpriseUrl() { + return enterpriseUrl; + } + + public String getLogoUrl() { + return logoUrl; + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/dto/UpdateMenuRequest.java b/backend/src/main/java/com/writeoff/module/system/dto/UpdateMenuRequest.java new file mode 100644 index 0000000..45143b2 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/dto/UpdateMenuRequest.java @@ -0,0 +1,37 @@ +package com.writeoff.module.system.dto; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; + +public class UpdateMenuRequest { + @NotBlank(message = "菜单名称不能为空") + private String menuName; + @NotBlank(message = "路由地址不能为空") + private String routePath; + @NotBlank(message = "权限码不能为空") + private String permissionCode; + @NotNull(message = "排序不能为空") + private Integer sortNo; + @NotBlank(message = "状态不能为空") + private String status; + + public String getMenuName() { + return menuName; + } + + public String getRoutePath() { + return routePath; + } + + public String getPermissionCode() { + return permissionCode; + } + + public Integer getSortNo() { + return sortNo; + } + + public String getStatus() { + return status; + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/dto/UpdatePlatformDictionaryItemRequest.java b/backend/src/main/java/com/writeoff/module/system/dto/UpdatePlatformDictionaryItemRequest.java new file mode 100644 index 0000000..51ca323 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/dto/UpdatePlatformDictionaryItemRequest.java @@ -0,0 +1,46 @@ +package com.writeoff.module.system.dto; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; + +public class UpdatePlatformDictionaryItemRequest { + @NotBlank(message = "字典名称不能为空") + private String dictName; + @NotNull(message = "排序不能为空") + private Integer sortNo; + private String remark; + @NotBlank(message = "状态不能为空") + private String status; + + public String getDictName() { + return dictName; + } + + public void setDictName(String dictName) { + this.dictName = dictName; + } + + public Integer getSortNo() { + return sortNo; + } + + public void setSortNo(Integer sortNo) { + this.sortNo = sortNo; + } + + public String getRemark() { + return remark; + } + + public void setRemark(String remark) { + this.remark = remark; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/dto/UpdateProfilePreferencesRequest.java b/backend/src/main/java/com/writeoff/module/system/dto/UpdateProfilePreferencesRequest.java new file mode 100644 index 0000000..ecda537 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/dto/UpdateProfilePreferencesRequest.java @@ -0,0 +1,31 @@ +package com.writeoff.module.system.dto; + +public class UpdateProfilePreferencesRequest { + private String themeMode; + private String density; + private String themeScheme; + + public String getThemeMode() { + return themeMode; + } + + public void setThemeMode(String themeMode) { + this.themeMode = themeMode; + } + + public String getDensity() { + return density; + } + + public void setDensity(String density) { + this.density = density; + } + + public String getThemeScheme() { + return themeScheme; + } + + public void setThemeScheme(String themeScheme) { + this.themeScheme = themeScheme; + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/dto/UpdateRoleRequest.java b/backend/src/main/java/com/writeoff/module/system/dto/UpdateRoleRequest.java new file mode 100644 index 0000000..407c4e1 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/dto/UpdateRoleRequest.java @@ -0,0 +1,16 @@ +package com.writeoff.module.system.dto; + +import javax.validation.constraints.NotBlank; + +public class UpdateRoleRequest { + @NotBlank(message = "角色名称不能为空") + private String roleName; + + public String getRoleName() { + return roleName; + } + + public void setRoleName(String roleName) { + this.roleName = roleName; + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/dto/UpdateTenantRequest.java b/backend/src/main/java/com/writeoff/module/system/dto/UpdateTenantRequest.java new file mode 100644 index 0000000..e1a23af --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/dto/UpdateTenantRequest.java @@ -0,0 +1,17 @@ +package com.writeoff.module.system.dto; + +import javax.validation.constraints.NotBlank; + +public class UpdateTenantRequest { + @NotBlank(message = "租户名称不能为空") + private String tenantName; + private String logoUrl; + + public String getTenantName() { + return tenantName; + } + + public String getLogoUrl() { + return logoUrl; + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/job/UserDelegationExpireScheduler.java b/backend/src/main/java/com/writeoff/module/system/job/UserDelegationExpireScheduler.java new file mode 100644 index 0000000..1dbc2f0 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/job/UserDelegationExpireScheduler.java @@ -0,0 +1,19 @@ +package com.writeoff.module.system.job; + +import com.writeoff.module.system.service.UserDelegationService; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Component +public class UserDelegationExpireScheduler { + private final UserDelegationService userDelegationService; + + public UserDelegationExpireScheduler(UserDelegationService userDelegationService) { + this.userDelegationService = userDelegationService; + } + + @Scheduled(fixedDelayString = "${app.user-delegation.expire-check-ms:60000}") + public void expireDelegations() { + userDelegationService.markAutoExpiredAllTenants(); + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/model/BizChangeLogInfo.java b/backend/src/main/java/com/writeoff/module/system/model/BizChangeLogInfo.java new file mode 100644 index 0000000..40c2ed5 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/model/BizChangeLogInfo.java @@ -0,0 +1,111 @@ +package com.writeoff.module.system.model; + +public class BizChangeLogInfo { + private final Long id; + private final String bizType; + private final Long bizId; + private final String changeType; + private final String fieldCode; + private final String fieldName; + private final String beforeValue; + private final String afterValue; + private final Long relatedUserId; + private final String relatedUserName; + private final Long operatorUserId; + private final String operatorUserName; + private final String batchId; + private final String remark; + private final String createdAt; + + public BizChangeLogInfo(Long id, + String bizType, + Long bizId, + String changeType, + String fieldCode, + String fieldName, + String beforeValue, + String afterValue, + Long relatedUserId, + String relatedUserName, + Long operatorUserId, + String operatorUserName, + String batchId, + String remark, + String createdAt) { + this.id = id; + this.bizType = bizType; + this.bizId = bizId; + this.changeType = changeType; + this.fieldCode = fieldCode; + this.fieldName = fieldName; + this.beforeValue = beforeValue; + this.afterValue = afterValue; + this.relatedUserId = relatedUserId; + this.relatedUserName = relatedUserName; + this.operatorUserId = operatorUserId; + this.operatorUserName = operatorUserName; + this.batchId = batchId; + this.remark = remark; + this.createdAt = createdAt; + } + + public Long getId() { + return id; + } + + public String getBizType() { + return bizType; + } + + public Long getBizId() { + return bizId; + } + + public String getChangeType() { + return changeType; + } + + public String getFieldCode() { + return fieldCode; + } + + public String getFieldName() { + return fieldName; + } + + public String getBeforeValue() { + return beforeValue; + } + + public String getAfterValue() { + return afterValue; + } + + public Long getRelatedUserId() { + return relatedUserId; + } + + public String getRelatedUserName() { + return relatedUserName; + } + + public Long getOperatorUserId() { + return operatorUserId; + } + + public String getOperatorUserName() { + return operatorUserName; + } + + public String getBatchId() { + return batchId; + } + + public String getRemark() { + return remark; + } + + public String getCreatedAt() { + return createdAt; + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/model/DataPermissionPolicy.java b/backend/src/main/java/com/writeoff/module/system/model/DataPermissionPolicy.java new file mode 100644 index 0000000..8bc35d1 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/model/DataPermissionPolicy.java @@ -0,0 +1,97 @@ +package com.writeoff.module.system.model; + +public class DataPermissionPolicy { + private Long id; + private String policyName; + private String projectScope; + private String projectIdsCsv; + private String meetingScope; + private String meetingIdsCsv; + private String userScope; + private String userIdsCsv; + private String expertScope; + private String expertIdsCsv; + private String moduleScope; + private Boolean exportAllowed; + private String status; + + public DataPermissionPolicy(Long id, + String policyName, + String projectScope, + String projectIdsCsv, + String meetingScope, + String meetingIdsCsv, + String userScope, + String userIdsCsv, + String expertScope, + String expertIdsCsv, + String moduleScope, + Boolean exportAllowed, + String status) { + this.id = id; + this.policyName = policyName; + this.projectScope = projectScope; + this.projectIdsCsv = projectIdsCsv; + this.meetingScope = meetingScope; + this.meetingIdsCsv = meetingIdsCsv; + this.userScope = userScope; + this.userIdsCsv = userIdsCsv; + this.expertScope = expertScope; + this.expertIdsCsv = expertIdsCsv; + this.moduleScope = moduleScope; + this.exportAllowed = exportAllowed; + this.status = status; + } + + public Long getId() { + return id; + } + + public String getPolicyName() { + return policyName; + } + + public String getProjectScope() { + return projectScope; + } + + public String getProjectIdsCsv() { + return projectIdsCsv; + } + + public String getMeetingScope() { + return meetingScope; + } + + public String getMeetingIdsCsv() { + return meetingIdsCsv; + } + + public String getUserScope() { + return userScope; + } + + public String getUserIdsCsv() { + return userIdsCsv; + } + + public String getExpertScope() { + return expertScope; + } + + public String getExpertIdsCsv() { + return expertIdsCsv; + } + + public String getModuleScope() { + return moduleScope; + } + + public Boolean getExportAllowed() { + return exportAllowed; + } + + public String getStatus() { + return status; + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/model/EnterpriseInfo.java b/backend/src/main/java/com/writeoff/module/system/model/EnterpriseInfo.java new file mode 100644 index 0000000..90adf19 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/model/EnterpriseInfo.java @@ -0,0 +1,43 @@ +package com.writeoff.module.system.model; + +public class EnterpriseInfo { + private Long id; + private String enterpriseName; + private String enterpriseUrl; + private String logoUrl; + private String status; + private String createdAt; + + public EnterpriseInfo(Long id, String enterpriseName, String enterpriseUrl, String logoUrl, String status, String createdAt) { + this.id = id; + this.enterpriseName = enterpriseName; + this.enterpriseUrl = enterpriseUrl; + this.logoUrl = logoUrl; + this.status = status; + this.createdAt = createdAt; + } + + public Long getId() { + return id; + } + + public String getEnterpriseName() { + return enterpriseName; + } + + public String getEnterpriseUrl() { + return enterpriseUrl; + } + + public String getLogoUrl() { + return logoUrl; + } + + public String getStatus() { + return status; + } + + public String getCreatedAt() { + return createdAt; + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/model/GlobalSearchGroup.java b/backend/src/main/java/com/writeoff/module/system/model/GlobalSearchGroup.java new file mode 100644 index 0000000..8798081 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/model/GlobalSearchGroup.java @@ -0,0 +1,27 @@ +package com.writeoff.module.system.model; + +import java.util.List; + +public class GlobalSearchGroup { + private final String type; + private final String label; + private final List items; + + public GlobalSearchGroup(String type, String label, List items) { + this.type = type; + this.label = label; + this.items = items; + } + + public String getType() { + return type; + } + + public String getLabel() { + return label; + } + + public List getItems() { + return items; + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/model/GlobalSearchItem.java b/backend/src/main/java/com/writeoff/module/system/model/GlobalSearchItem.java new file mode 100644 index 0000000..7a08a77 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/model/GlobalSearchItem.java @@ -0,0 +1,37 @@ +package com.writeoff.module.system.model; + +public class GlobalSearchItem { + private final String type; + private final String title; + private final String subtitle; + private final String routePath; + private final String badge; + + public GlobalSearchItem(String type, String title, String subtitle, String routePath, String badge) { + this.type = type; + this.title = title; + this.subtitle = subtitle; + this.routePath = routePath; + this.badge = badge; + } + + public String getType() { + return type; + } + + public String getTitle() { + return title; + } + + public String getSubtitle() { + return subtitle; + } + + public String getRoutePath() { + return routePath; + } + + public String getBadge() { + return badge; + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/model/GlobalSearchResult.java b/backend/src/main/java/com/writeoff/module/system/model/GlobalSearchResult.java new file mode 100644 index 0000000..16a5314 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/model/GlobalSearchResult.java @@ -0,0 +1,27 @@ +package com.writeoff.module.system.model; + +import java.util.List; + +public class GlobalSearchResult { + private final String keyword; + private final int total; + private final List groups; + + public GlobalSearchResult(String keyword, int total, List groups) { + this.keyword = keyword; + this.total = total; + this.groups = groups; + } + + public String getKeyword() { + return keyword; + } + + public int getTotal() { + return total; + } + + public List getGroups() { + return groups; + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/model/MenuInfo.java b/backend/src/main/java/com/writeoff/module/system/model/MenuInfo.java new file mode 100644 index 0000000..7c00eba --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/model/MenuInfo.java @@ -0,0 +1,49 @@ +package com.writeoff.module.system.model; + +public class MenuInfo { + private Long id; + private String menuCode; + private String menuName; + private String routePath; + private String permissionCode; + private Integer sortNo; + private String status; + + public MenuInfo(Long id, String menuCode, String menuName, String routePath, String permissionCode, Integer sortNo, String status) { + this.id = id; + this.menuCode = menuCode; + this.menuName = menuName; + this.routePath = routePath; + this.permissionCode = permissionCode; + this.sortNo = sortNo; + this.status = status; + } + + public Long getId() { + return id; + } + + public String getMenuCode() { + return menuCode; + } + + public String getMenuName() { + return menuName; + } + + public String getRoutePath() { + return routePath; + } + + public String getPermissionCode() { + return permissionCode; + } + + public Integer getSortNo() { + return sortNo; + } + + public String getStatus() { + return status; + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/model/OperationAuditLogInfo.java b/backend/src/main/java/com/writeoff/module/system/model/OperationAuditLogInfo.java new file mode 100644 index 0000000..6365a6d --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/model/OperationAuditLogInfo.java @@ -0,0 +1,99 @@ +package com.writeoff.module.system.model; + +public class OperationAuditLogInfo { + private Long id; + private Long tenantId; + private Long userId; + private String scope; + private String actionCode; + private String bizType; + private String bizId; + private String httpMethod; + private String requestUri; + private String requestId; + private Integer statusCode; + private Boolean success; + private String errorMessage; + private String ip; + private String createdAt; + + public OperationAuditLogInfo(Long id, Long tenantId, Long userId, String scope, String actionCode, String bizType, String bizId, + String httpMethod, String requestUri, String requestId, Integer statusCode, Boolean success, + String errorMessage, String ip, String createdAt) { + this.id = id; + this.tenantId = tenantId; + this.userId = userId; + this.scope = scope; + this.actionCode = actionCode; + this.bizType = bizType; + this.bizId = bizId; + this.httpMethod = httpMethod; + this.requestUri = requestUri; + this.requestId = requestId; + this.statusCode = statusCode; + this.success = success; + this.errorMessage = errorMessage; + this.ip = ip; + this.createdAt = createdAt; + } + + public Long getId() { + return id; + } + + public Long getTenantId() { + return tenantId; + } + + public Long getUserId() { + return userId; + } + + public String getScope() { + return scope; + } + + public String getActionCode() { + return actionCode; + } + + public String getBizType() { + return bizType; + } + + public String getBizId() { + return bizId; + } + + public String getHttpMethod() { + return httpMethod; + } + + public String getRequestUri() { + return requestUri; + } + + public String getRequestId() { + return requestId; + } + + public Integer getStatusCode() { + return statusCode; + } + + public Boolean getSuccess() { + return success; + } + + public String getErrorMessage() { + return errorMessage; + } + + public String getIp() { + return ip; + } + + public String getCreatedAt() { + return createdAt; + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/model/PermissionInfo.java b/backend/src/main/java/com/writeoff/module/system/model/PermissionInfo.java new file mode 100644 index 0000000..540c03b --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/model/PermissionInfo.java @@ -0,0 +1,31 @@ +package com.writeoff.module.system.model; + +public class PermissionInfo { + private Long id; + private String permissionCode; + private String permissionName; + private String module; + + public PermissionInfo(Long id, String permissionCode, String permissionName, String module) { + this.id = id; + this.permissionCode = permissionCode; + this.permissionName = permissionName; + this.module = module; + } + + public Long getId() { + return id; + } + + public String getPermissionCode() { + return permissionCode; + } + + public String getPermissionName() { + return permissionName; + } + + public String getModule() { + return module; + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/model/PlatformDictionaryItem.java b/backend/src/main/java/com/writeoff/module/system/model/PlatformDictionaryItem.java new file mode 100644 index 0000000..d4935cc --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/model/PlatformDictionaryItem.java @@ -0,0 +1,52 @@ +package com.writeoff.module.system.model; + +/** + * 平台级数据字典项,供全平台租户共享读取。 + */ +public class PlatformDictionaryItem { + private Long id; + private String dictType; + private String dictCode; + private String dictName; + private Integer sortNo; + private String status; + private String remark; + + public PlatformDictionaryItem(Long id, String dictType, String dictCode, String dictName, Integer sortNo, String status, String remark) { + this.id = id; + this.dictType = dictType; + this.dictCode = dictCode; + this.dictName = dictName; + this.sortNo = sortNo; + this.status = status; + this.remark = remark; + } + + public Long getId() { + return id; + } + + public String getDictType() { + return dictType; + } + + public String getDictCode() { + return dictCode; + } + + public String getDictName() { + return dictName; + } + + public Integer getSortNo() { + return sortNo; + } + + public String getStatus() { + return status; + } + + public String getRemark() { + return remark; + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/model/PlatformDictionaryType.java b/backend/src/main/java/com/writeoff/module/system/model/PlatformDictionaryType.java new file mode 100644 index 0000000..c50b96d --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/model/PlatformDictionaryType.java @@ -0,0 +1,46 @@ +package com.writeoff.module.system.model; + +/** + * 平台级字典类型定义。 + */ +public class PlatformDictionaryType { + private Long id; + private String dictType; + private String dictName; + private Integer sortNo; + private String status; + private String remark; + + public PlatformDictionaryType(Long id, String dictType, String dictName, Integer sortNo, String status, String remark) { + this.id = id; + this.dictType = dictType; + this.dictName = dictName; + this.sortNo = sortNo; + this.status = status; + this.remark = remark; + } + + public Long getId() { + return id; + } + + public String getDictType() { + return dictType; + } + + public String getDictName() { + return dictName; + } + + public Integer getSortNo() { + return sortNo; + } + + public String getStatus() { + return status; + } + + public String getRemark() { + return remark; + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/model/PlatformRoleInfo.java b/backend/src/main/java/com/writeoff/module/system/model/PlatformRoleInfo.java new file mode 100644 index 0000000..be22438 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/model/PlatformRoleInfo.java @@ -0,0 +1,31 @@ +package com.writeoff.module.system.model; + +public class PlatformRoleInfo { + private Long id; + private String roleCode; + private String roleName; + private String status; + + public PlatformRoleInfo(Long id, String roleCode, String roleName, String status) { + this.id = id; + this.roleCode = roleCode; + this.roleName = roleName; + this.status = status; + } + + public Long getId() { + return id; + } + + public String getRoleCode() { + return roleCode; + } + + public String getRoleName() { + return roleName; + } + + public String getStatus() { + return status; + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/model/ProfilePreferencesInfo.java b/backend/src/main/java/com/writeoff/module/system/model/ProfilePreferencesInfo.java new file mode 100644 index 0000000..507c76a --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/model/ProfilePreferencesInfo.java @@ -0,0 +1,25 @@ +package com.writeoff.module.system.model; + +public class ProfilePreferencesInfo { + private final String themeMode; + private final String density; + private final String themeScheme; + + public ProfilePreferencesInfo(String themeMode, String density, String themeScheme) { + this.themeMode = themeMode; + this.density = density; + this.themeScheme = themeScheme; + } + + public String getThemeMode() { + return themeMode; + } + + public String getDensity() { + return density; + } + + public String getThemeScheme() { + return themeScheme; + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/model/RoleInfo.java b/backend/src/main/java/com/writeoff/module/system/model/RoleInfo.java new file mode 100644 index 0000000..68a4fda --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/model/RoleInfo.java @@ -0,0 +1,31 @@ +package com.writeoff.module.system.model; + +public class RoleInfo { + private Long id; + private String roleCode; + private String roleName; + private String status; + + public RoleInfo(Long id, String roleCode, String roleName, String status) { + this.id = id; + this.roleCode = roleCode; + this.roleName = roleName; + this.status = status; + } + + public Long getId() { + return id; + } + + public String getRoleCode() { + return roleCode; + } + + public String getRoleName() { + return roleName; + } + + public String getStatus() { + return status; + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/model/SystemUser.java b/backend/src/main/java/com/writeoff/module/system/model/SystemUser.java new file mode 100644 index 0000000..f681c3b --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/model/SystemUser.java @@ -0,0 +1,75 @@ +package com.writeoff.module.system.model; + +public class SystemUser { + private Long id; + private String userName; + private String phone; + private String email; + private String status; + private String validFrom; + private String validTo; + private String roleCodes; + private String roleNames; + private boolean deleted; + + public SystemUser(Long id, String userName, String phone, String email, String status, String validFrom, String validTo) { + this(id, userName, phone, email, status, validFrom, validTo, "", ""); + } + + public SystemUser(Long id, String userName, String phone, String email, String status, String validFrom, String validTo, String roleCodes, String roleNames) { + this(id, userName, phone, email, status, validFrom, validTo, roleCodes, roleNames, false); + } + + public SystemUser(Long id, String userName, String phone, String email, String status, String validFrom, String validTo, String roleCodes, String roleNames, boolean deleted) { + this.id = id; + this.userName = userName; + this.phone = phone; + this.email = email; + this.status = status; + this.validFrom = validFrom; + this.validTo = validTo; + this.roleCodes = roleCodes; + this.roleNames = roleNames; + this.deleted = deleted; + } + + public Long getId() { + return id; + } + + public String getUserName() { + return userName; + } + + public String getPhone() { + return phone; + } + + public String getEmail() { + return email; + } + + public String getStatus() { + return status; + } + + public String getValidFrom() { + return validFrom; + } + + public String getValidTo() { + return validTo; + } + + public String getRoleCodes() { + return roleCodes; + } + + public String getRoleNames() { + return roleNames; + } + + public boolean isDeleted() { + return deleted; + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/model/TenantInfo.java b/backend/src/main/java/com/writeoff/module/system/model/TenantInfo.java new file mode 100644 index 0000000..e0c0868 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/model/TenantInfo.java @@ -0,0 +1,43 @@ +package com.writeoff.module.system.model; + +public class TenantInfo { + private Long id; + private String tenantCode; + private String tenantName; + private String logoUrl; + private String status; + private String createdAt; + + public TenantInfo(Long id, String tenantCode, String tenantName, String logoUrl, String status, String createdAt) { + this.id = id; + this.tenantCode = tenantCode; + this.tenantName = tenantName; + this.logoUrl = logoUrl; + this.status = status; + this.createdAt = createdAt; + } + + public Long getId() { + return id; + } + + public String getTenantCode() { + return tenantCode; + } + + public String getTenantName() { + return tenantName; + } + + public String getLogoUrl() { + return logoUrl; + } + + public String getStatus() { + return status; + } + + public String getCreatedAt() { + return createdAt; + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/model/UserDelegationInfo.java b/backend/src/main/java/com/writeoff/module/system/model/UserDelegationInfo.java new file mode 100644 index 0000000..b7e8a1c --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/model/UserDelegationInfo.java @@ -0,0 +1,61 @@ +package com.writeoff.module.system.model; + +public class UserDelegationInfo { + private Long id; + private Long userId; + private Long delegateUserId; + private String effectiveFrom; + private String effectiveTo; + private String status; + private String reason; + private String disabledReason; + private String createdAt; + + public UserDelegationInfo(Long id, Long userId, Long delegateUserId, String effectiveFrom, String effectiveTo, String status, String reason, String disabledReason, String createdAt) { + this.id = id; + this.userId = userId; + this.delegateUserId = delegateUserId; + this.effectiveFrom = effectiveFrom; + this.effectiveTo = effectiveTo; + this.status = status; + this.reason = reason; + this.disabledReason = disabledReason; + this.createdAt = createdAt; + } + + public Long getId() { + return id; + } + + public Long getUserId() { + return userId; + } + + public Long getDelegateUserId() { + return delegateUserId; + } + + public String getEffectiveFrom() { + return effectiveFrom; + } + + public String getEffectiveTo() { + return effectiveTo; + } + + public String getStatus() { + return status; + } + + public String getReason() { + return reason; + } + + public String getDisabledReason() { + return disabledReason; + } + + public String getCreatedAt() { + return createdAt; + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/model/UserRoleHistory.java b/backend/src/main/java/com/writeoff/module/system/model/UserRoleHistory.java new file mode 100644 index 0000000..2e263ab --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/model/UserRoleHistory.java @@ -0,0 +1,43 @@ +package com.writeoff.module.system.model; + +public class UserRoleHistory { + private Long id; + private Long userId; + private Long oldRoleId; + private Long newRoleId; + private String actionType; + private String createdAt; + + public UserRoleHistory(Long id, Long userId, Long oldRoleId, Long newRoleId, String actionType, String createdAt) { + this.id = id; + this.userId = userId; + this.oldRoleId = oldRoleId; + this.newRoleId = newRoleId; + this.actionType = actionType; + this.createdAt = createdAt; + } + + public Long getId() { + return id; + } + + public Long getUserId() { + return userId; + } + + public Long getOldRoleId() { + return oldRoleId; + } + + public Long getNewRoleId() { + return newRoleId; + } + + public String getActionType() { + return actionType; + } + + public String getCreatedAt() { + return createdAt; + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/service/BizChangeLogService.java b/backend/src/main/java/com/writeoff/module/system/service/BizChangeLogService.java new file mode 100644 index 0000000..8633e3a --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/service/BizChangeLogService.java @@ -0,0 +1,268 @@ +package com.writeoff.module.system.service; + +import com.writeoff.module.system.model.BizChangeLogInfo; +import com.writeoff.security.AuthContext; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.UUID; + +@Service +public class BizChangeLogService { + private static final RowMapper ROW_MAPPER = (rs, n) -> new BizChangeLogInfo( + rs.getLong("id"), + rs.getString("biz_type"), + rs.getLong("biz_id"), + rs.getString("change_type"), + rs.getString("field_code"), + rs.getString("field_name"), + rs.getString("before_value"), + rs.getString("after_value"), + rs.getObject("related_user_id") == null ? null : rs.getLong("related_user_id"), + rs.getString("related_user_name"), + rs.getLong("operator_user_id"), + rs.getString("operator_user_name"), + rs.getString("batch_id"), + rs.getString("remark"), + rs.getString("created_at") + ); + + private final JdbcTemplate jdbcTemplate; + + public BizChangeLogService(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + public List listByBiz(String bizType, Long bizId) { + return jdbcTemplate.query( + "SELECT id, biz_type, biz_id, change_type, field_code, field_name, before_value, after_value, " + + "related_user_id, related_user_name, operator_user_id, operator_user_name, batch_id, remark, " + + "DATE_FORMAT(created_at, '%Y-%m-%d %H:%i:%s') AS created_at " + + "FROM biz_change_log " + + "WHERE tenant_id=? AND biz_type=? AND biz_id=? AND is_deleted=0 " + + "ORDER BY id DESC", + ROW_MAPPER, + tenantId(), + safeTrim(bizType), + bizId + ); + } + + public void logFieldChange(String bizType, + Long bizId, + String changeType, + String fieldCode, + String fieldName, + Object beforeValue, + Object afterValue, + String batchId, + String remark) { + if (Objects.equals(normalizeValue(beforeValue), normalizeValue(afterValue))) { + return; + } + insert( + bizType, + bizId, + changeType, + fieldCode, + fieldName, + stringify(beforeValue), + stringify(afterValue), + null, + null, + batchId, + remark + ); + } + + public void logAction(String bizType, Long bizId, String changeType, String remark) { + insert(bizType, bizId, changeType, null, null, null, null, null, null, null, remark); + } + + public void logRelationAdd(String bizType, + Long bizId, + String changeType, + String fieldCode, + String fieldName, + Long relatedUserId, + String relatedUserName, + String batchId, + String remark) { + insert( + bizType, + bizId, + changeType, + fieldCode, + fieldName, + null, + relatedUserName, + relatedUserId, + relatedUserName, + batchId, + remark + ); + } + + public void logRelationRemove(String bizType, + Long bizId, + String changeType, + String fieldCode, + String fieldName, + Long relatedUserId, + String relatedUserName, + String batchId, + String remark) { + insert( + bizType, + bizId, + changeType, + fieldCode, + fieldName, + relatedUserName, + null, + relatedUserId, + relatedUserName, + batchId, + remark + ); + } + + public String newBatchId() { + return UUID.randomUUID().toString().replace("-", ""); + } + + public String loadUserName(Long userId) { + long id = userId == null ? 0L : userId; + if (id <= 0L) { + return ""; + } + List list = jdbcTemplate.query( + "SELECT user_name FROM sys_user WHERE tenant_id=? AND id=? AND is_deleted=0 LIMIT 1", + (rs, n) -> rs.getString("user_name"), + tenantId(), + id + ); + return list.isEmpty() ? "" : safeTrim(list.get(0)); + } + + public List loadUserRefs(List userIds) { + List validIds = new ArrayList(); + for (Long userId : userIds) { + long id = userId == null ? 0L : userId; + if (id > 0L && !validIds.contains(id)) { + validIds.add(id); + } + } + if (validIds.isEmpty()) { + return new ArrayList(); + } + StringBuilder sql = new StringBuilder("SELECT id, user_name FROM sys_user WHERE tenant_id=? AND is_deleted=0 AND id IN ("); + List args = new ArrayList(); + args.add(tenantId()); + for (int i = 0; i < validIds.size(); i++) { + if (i > 0) { + sql.append(","); + } + sql.append("?"); + args.add(validIds.get(i)); + } + sql.append(")"); + return jdbcTemplate.query( + sql.toString(), + (rs, n) -> new UserRef(rs.getLong("id"), safeTrim(rs.getString("user_name"))), + args.toArray() + ); + } + + private void insert(String bizType, + Long bizId, + String changeType, + String fieldCode, + String fieldName, + String beforeValue, + String afterValue, + Long relatedUserId, + String relatedUserName, + String batchId, + String remark) { + jdbcTemplate.update( + "INSERT INTO biz_change_log (tenant_id, biz_type, biz_id, change_type, field_code, field_name, before_value, after_value, " + + "related_user_id, related_user_name, operator_user_id, operator_user_name, batch_id, remark, is_deleted) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0)", + tenantId(), + safeTrim(bizType), + bizId == null ? 0L : bizId, + safeTrim(changeType), + emptyToNull(fieldCode), + emptyToNull(fieldName), + truncate(beforeValue, 2000), + truncate(afterValue, 2000), + relatedUserId, + emptyToNull(relatedUserName), + safeUserId(), + emptyToNull(loadUserName(safeUserId())), + emptyToNull(batchId), + truncate(remark, 500) + ); + } + + private String normalizeValue(Object value) { + return stringify(value); + } + + private String stringify(Object value) { + if (value == null) { + return null; + } + String text = String.valueOf(value).trim(); + return text.isEmpty() ? null : text; + } + + private String truncate(String value, int maxLength) { + String normalized = stringify(value); + if (normalized == null || normalized.length() <= maxLength) { + return normalized; + } + return normalized.substring(0, maxLength); + } + + private String emptyToNull(String value) { + String normalized = safeTrim(value); + return normalized.isEmpty() ? null : normalized; + } + + private String safeTrim(String value) { + return value == null ? "" : value.trim(); + } + + private Long tenantId() { + return AuthContext.requireTenantId(); + } + + private Long safeUserId() { + Long userId = AuthContext.userId(); + return userId == null ? 0L : userId; + } + + public static class UserRef { + private final Long userId; + private final String userName; + + public UserRef(Long userId, String userName) { + this.userId = userId; + this.userName = userName; + } + + public Long getUserId() { + return userId; + } + + public String getUserName() { + return userName; + } + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/service/DataPermissionService.java b/backend/src/main/java/com/writeoff/module/system/service/DataPermissionService.java new file mode 100644 index 0000000..ac2205f --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/service/DataPermissionService.java @@ -0,0 +1,681 @@ +package com.writeoff.module.system.service; + +import com.writeoff.common.api.PageResult; +import com.writeoff.common.exception.BusinessException; +import com.writeoff.module.system.dto.AssignRoleDataPermissionRequest; +import com.writeoff.module.system.dto.CreateDataPermissionPolicyRequest; +import com.writeoff.module.system.dto.UpdateDataPermissionPolicyRequest; +import com.writeoff.module.system.model.DataPermissionPolicy; +import com.writeoff.security.AuthContext; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +@Service +public class DataPermissionService { + private final JdbcTemplate jdbcTemplate; + + private static final RowMapper POLICY_ROW_MAPPER = (rs, n) -> new DataPermissionPolicy( + rs.getLong("id"), + rs.getString("policy_name"), + rs.getString("project_scope"), + rs.getString("project_ids_csv"), + rs.getString("meeting_scope"), + rs.getString("meeting_ids_csv"), + rs.getString("user_scope"), + rs.getString("user_ids_csv"), + rs.getString("expert_scope"), + rs.getString("expert_ids_csv"), + rs.getString("module_scope"), + rs.getInt("export_allowed") == 1, + rs.getString("status") + ); + + public DataPermissionService(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + public PageResult listPolicies() { + List list = jdbcTemplate.query( + "SELECT * FROM data_permission_policy WHERE tenant_id=? ORDER BY id DESC", + POLICY_ROW_MAPPER + , + tenantId() + ); + return new PageResult<>(list, list.size(), 1, 50); + } + + public DataPermissionPolicy createPolicy(CreateDataPermissionPolicyRequest request) { + jdbcTemplate.update( + "INSERT INTO data_permission_policy (tenant_id, policy_name, project_scope, project_ids_csv, meeting_scope, meeting_ids_csv, user_scope, user_ids_csv, expert_scope, expert_ids_csv, module_scope, export_allowed, status) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'ENABLED')", + tenantId(), + request.getPolicyName(), + request.getProjectScope(), + request.getProjectIdsCsv(), + request.getMeetingScope(), + request.getMeetingIdsCsv(), + request.getUserScope(), + request.getUserIdsCsv(), + request.getExpertScope(), + request.getExpertIdsCsv(), + request.getModuleScope(), + Boolean.TRUE.equals(request.getExportAllowed()) ? 1 : 0 + ); + Long id = jdbcTemplate.queryForObject("SELECT IFNULL(MAX(id), 0) FROM data_permission_policy WHERE tenant_id=?", Long.class, tenantId()); + return findById(id == null ? 0L : id); + } + + public DataPermissionPolicy updatePolicy(Long id, UpdateDataPermissionPolicyRequest request) { + assertPolicyExists(id); + jdbcTemplate.update( + "UPDATE data_permission_policy SET policy_name=?, project_scope=?, project_ids_csv=?, meeting_scope=?, meeting_ids_csv=?, user_scope=?, user_ids_csv=?, expert_scope=?, expert_ids_csv=?, module_scope=?, export_allowed=?, updated_at=CURRENT_TIMESTAMP WHERE tenant_id=? AND id=?", + request.getPolicyName(), + request.getProjectScope(), + request.getProjectIdsCsv(), + request.getMeetingScope(), + request.getMeetingIdsCsv(), + request.getUserScope(), + request.getUserIdsCsv(), + request.getExpertScope(), + request.getExpertIdsCsv(), + request.getModuleScope(), + Boolean.TRUE.equals(request.getExportAllowed()) ? 1 : 0, + tenantId(), + id + ); + return findById(id); + } + + public DataPermissionPolicy copyPolicy(Long id) { + DataPermissionPolicy source = findById(id); + String copiedName = source.getPolicyName() + "_COPY_" + System.currentTimeMillis(); + jdbcTemplate.update( + "INSERT INTO data_permission_policy (tenant_id, policy_name, project_scope, project_ids_csv, meeting_scope, meeting_ids_csv, user_scope, user_ids_csv, expert_scope, expert_ids_csv, module_scope, export_allowed, status) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'ENABLED')", + tenantId(), + copiedName, + source.getProjectScope(), + source.getProjectIdsCsv(), + source.getMeetingScope(), + source.getMeetingIdsCsv(), + source.getUserScope(), + source.getUserIdsCsv(), + source.getExpertScope(), + source.getExpertIdsCsv(), + source.getModuleScope(), + Boolean.TRUE.equals(source.getExportAllowed()) ? 1 : 0 + ); + Long newId = jdbcTemplate.queryForObject("SELECT IFNULL(MAX(id), 0) FROM data_permission_policy WHERE tenant_id=?", Long.class, tenantId()); + return findById(newId == null ? 0L : newId); + } + + public void enablePolicy(Long id) { + assertPolicyExists(id); + jdbcTemplate.update( + "UPDATE data_permission_policy SET status='ENABLED', updated_at=CURRENT_TIMESTAMP WHERE tenant_id=? AND id=?", + tenantId(), + id + ); + } + + public void disablePolicy(Long id) { + assertPolicyExists(id); + jdbcTemplate.update( + "UPDATE data_permission_policy SET status='DISABLED', updated_at=CURRENT_TIMESTAMP WHERE tenant_id=? AND id=?", + tenantId(), + id + ); + } + + public void assignRoles(Long policyId, AssignRoleDataPermissionRequest request) { + assertPolicyExists(policyId); + String assignMode = request.getAssignMode() == null ? "APPEND" : request.getAssignMode().trim().toUpperCase(); + if ("REPLACE".equals(assignMode)) { + jdbcTemplate.update("DELETE FROM role_data_permission WHERE tenant_id=? AND policy_id=?", tenantId(), policyId); + } + List roleIds = request.getRoleIds() == null ? new ArrayList() : request.getRoleIds(); + for (Long roleId : roleIds) { + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM role_data_permission WHERE tenant_id=? AND role_id=? AND policy_id=?", + Integer.class, + tenantId(), + roleId, + policyId + ); + if (count != null && count > 0) { + continue; + } + jdbcTemplate.update( + "INSERT INTO role_data_permission (tenant_id, role_id, policy_id) VALUES (?, ?, ?)", + tenantId(), + roleId, + policyId + ); + } + } + + public Map currentScopeSummary() { + Long userId = AuthContext.userId(); + DataScope scope = resolveCurrentUserScope(); + Map data = new LinkedHashMap<>(); + data.put("userId", userId); + data.put("projectAll", scope.isProjectAll()); + data.put("projectIds", new ArrayList<>(scope.getProjectIds())); + data.put("projectOwnerOnly", scope.isProjectOwnerOnly()); + data.put("meetingAll", scope.isMeetingAll()); + data.put("meetingIds", new ArrayList<>(scope.getMeetingIds())); + data.put("meetingOwnerOnly", scope.isMeetingOwnerOnly()); + data.put("userAll", scope.isUserAll()); + data.put("userIds", new ArrayList<>(scope.getUserIds())); + data.put("userOwnerOnly", scope.isUserOwnerOnly()); + data.put("expertAll", scope.isExpertAll()); + data.put("expertIds", new ArrayList<>(scope.getExpertIds())); + data.put("expertOwnerOnly", scope.isExpertOwnerOnly()); + data.put("matchedPolicyIds", userId == null ? new ArrayList() : listMatchedPolicyIds(userId)); + data.put("exportAllowed", canExportCurrentUser()); + return data; + } + + public List listPolicyRoleIds(Long policyId) { + assertPolicyExists(policyId); + return jdbcTemplate.queryForList( + "SELECT role_id FROM role_data_permission WHERE tenant_id=? AND policy_id=? ORDER BY role_id ASC", + Long.class, + tenantId(), + policyId + ); + } + + public DataScope resolveCurrentUserScope() { + Long userId = AuthContext.userId(); + if (userId == null) { + return DataScope.allowAll(); + } + return resolveUserScope(userId); + } + + public DataScope resolveUserScope(Long userId) { + List policies = jdbcTemplate.query( + "SELECT DISTINCT p.* FROM user_role ur " + + "JOIN role_data_permission rdp ON ur.role_id=rdp.role_id AND ur.tenant_id=rdp.tenant_id " + + "JOIN data_permission_policy p ON rdp.policy_id=p.id AND p.tenant_id=rdp.tenant_id " + + "WHERE ur.user_id=? AND ur.tenant_id=? AND p.status='ENABLED'", + POLICY_ROW_MAPPER, + userId, + tenantId() + ); + if (policies.isEmpty()) { + return DataScope.denyAll(); + } + boolean projectAll = false; + boolean meetingAll = false; + boolean userAll = false; + boolean expertAll = false; + boolean projectOwnerOnly = false; + boolean meetingOwnerOnly = false; + boolean userOwnerOnly = false; + boolean expertOwnerOnly = false; + Set projectIds = new HashSet<>(); + Set meetingIds = new HashSet<>(); + Set userIds = new HashSet<>(); + Set expertIds = new HashSet<>(); + for (DataPermissionPolicy p : policies) { + if ("ALL".equalsIgnoreCase(p.getProjectScope())) { + projectAll = true; + } else if ("IDS".equalsIgnoreCase(p.getProjectScope())) { + projectIds.addAll(parseIds(p.getProjectIdsCsv())); + } else if ("OWNER".equalsIgnoreCase(p.getProjectScope())) { + projectOwnerOnly = true; + } + if ("ALL".equalsIgnoreCase(p.getMeetingScope())) { + meetingAll = true; + } else if ("IDS".equalsIgnoreCase(p.getMeetingScope())) { + meetingIds.addAll(parseIds(p.getMeetingIdsCsv())); + } else if ("OWNER".equalsIgnoreCase(p.getMeetingScope())) { + meetingOwnerOnly = true; + } + if ("ALL".equalsIgnoreCase(p.getUserScope())) { + userAll = true; + } else if ("IDS".equalsIgnoreCase(p.getUserScope())) { + userIds.addAll(parseIds(p.getUserIdsCsv())); + } else if ("OWNER".equalsIgnoreCase(p.getUserScope())) { + userOwnerOnly = true; + } + if ("ALL".equalsIgnoreCase(p.getExpertScope())) { + expertAll = true; + } else if ("IDS".equalsIgnoreCase(p.getExpertScope())) { + expertIds.addAll(parseIds(p.getExpertIdsCsv())); + } else if ("OWNER".equalsIgnoreCase(p.getExpertScope())) { + expertOwnerOnly = true; + } + } + DataScope baseScope = new DataScope( + projectAll, projectIds, projectOwnerOnly, + meetingAll, meetingIds, meetingOwnerOnly, + userAll, userIds, userOwnerOnly, + expertAll, expertIds, expertOwnerOnly + ); + return applyProjectBindingScopeIfNeeded(userId, baseScope); + } + + public boolean canAccessProject(Long projectId, DataScope scope) { + return canAccessProject(projectId, null, scope); + } + + public boolean canAccessProject(Long projectId, Long createdBy, DataScope scope) { + if (scope.isProjectAll() || scope.getProjectIds().contains(projectId)) { + return true; + } + return scope.isProjectOwnerOnly() && AuthContext.userId() != null && AuthContext.userId().equals(createdBy); + } + + public boolean canAccessMeeting(Long meetingId, Long projectId, DataScope scope) { + return canAccessMeeting(meetingId, projectId, null, null, scope); + } + + public boolean canAccessMeeting(Long meetingId, Long projectId, Long meetingCreatedBy, Long projectCreatedBy, DataScope scope) { + if (scope.isMeetingAll()) { + return true; + } + if (scope.getMeetingIds().contains(meetingId)) { + return true; + } + if (scope.isMeetingOwnerOnly() && AuthContext.userId() != null && AuthContext.userId().equals(meetingCreatedBy)) { + return true; + } + if (scope.isProjectOwnerOnly()) { + return canAccessProject(projectId, projectCreatedBy, scope); + } + return canAccessProject(projectId, scope); + } + + public boolean canAccessUser(Long targetUserId, Long targetCreatedBy, DataScope scope) { + if (scope.isUserAll() || scope.getUserIds().contains(targetUserId)) { + return true; + } + return scope.isUserOwnerOnly() && AuthContext.userId() != null && AuthContext.userId().equals(targetCreatedBy); + } + + public boolean canAccessExpert(Long expertId, Long expertCreatedBy, DataScope scope) { + if (scope.isExpertAll() || scope.getExpertIds().contains(expertId)) { + return true; + } + return scope.isExpertOwnerOnly() && AuthContext.userId() != null && AuthContext.userId().equals(expertCreatedBy); + } + + public boolean canExportCurrentUser() { + Long userId = AuthContext.userId(); + if (userId == null) { + return false; + } + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM user_role ur " + + "JOIN role_data_permission rdp ON ur.role_id=rdp.role_id AND ur.tenant_id=rdp.tenant_id " + + "JOIN data_permission_policy p ON rdp.policy_id=p.id AND p.tenant_id=rdp.tenant_id " + + "WHERE ur.user_id=? AND ur.tenant_id=? AND p.status='ENABLED' AND p.export_allowed=1", + Integer.class, + userId, + tenantId() + ); + return count != null && count > 0; + } + + private List listMatchedPolicyIds(Long userId) { + return jdbcTemplate.queryForList( + "SELECT DISTINCT p.id FROM user_role ur " + + "JOIN role_data_permission rdp ON ur.role_id=rdp.role_id AND ur.tenant_id=rdp.tenant_id " + + "JOIN data_permission_policy p ON rdp.policy_id=p.id AND p.tenant_id=rdp.tenant_id " + + "WHERE ur.user_id=? AND ur.tenant_id=? AND p.status='ENABLED' ORDER BY p.id ASC", + Long.class, + userId, + tenantId() + ); + } + + private DataScope applyProjectBindingScopeIfNeeded(Long userId, DataScope baseScope) { + Set roleCodes = listUserRoleCodes(userId); + if (roleCodes.contains("TENANT_ADMIN")) { + return baseScope; + } + boolean ownerScoped = roleCodes.contains("PROJECT_OWNER"); + boolean executorScoped = roleCodes.contains("PROJECT_EXECUTOR"); + boolean legacyExecutorScoped = roleCodes.contains("EXECUTOR"); + if (!ownerScoped && !executorScoped && !legacyExecutorScoped) { + return baseScope; + } + Set boundProjectIds = listBoundProjectIds(userId, ownerScoped, executorScoped, legacyExecutorScoped); + if (boundProjectIds.isEmpty()) { + return DataScope.denyAll(); + } + Set effectiveProjectIds = new HashSet<>(boundProjectIds); + if (!baseScope.isProjectAll() && !baseScope.isProjectOwnerOnly()) { + effectiveProjectIds.retainAll(baseScope.getProjectIds()); + } + if (effectiveProjectIds.isEmpty()) { + return DataScope.denyAll(); + } + return new DataScope( + false, effectiveProjectIds, baseScope.isProjectOwnerOnly(), + false, new HashSet(), false, + baseScope.isUserAll(), new HashSet(baseScope.getUserIds()), baseScope.isUserOwnerOnly(), + baseScope.isExpertAll(), new HashSet(baseScope.getExpertIds()), baseScope.isExpertOwnerOnly() + ); + } + + public Map listProjectCreators(Collection projectIds) { + Map result = new LinkedHashMap<>(); + if (projectIds == null || projectIds.isEmpty()) { + return result; + } + String inSql = buildInClause(projectIds.size()); + List args = new ArrayList<>(); + args.add(tenantId()); + args.addAll(projectIds); + List> rows = jdbcTemplate.queryForList( + "SELECT id, created_by FROM project WHERE tenant_id=? AND is_deleted=0 AND id IN (" + inSql + ")", + args.toArray() + ); + for (Map row : rows) { + result.put(((Number) row.get("id")).longValue(), ((Number) row.get("created_by")).longValue()); + } + return result; + } + + public Map listMeetingCreators(Collection meetingIds) { + Map result = new LinkedHashMap<>(); + if (meetingIds == null || meetingIds.isEmpty()) { + return result; + } + String inSql = buildInClause(meetingIds.size()); + List args = new ArrayList<>(); + args.add(tenantId()); + args.addAll(meetingIds); + List> rows = jdbcTemplate.queryForList( + "SELECT id, created_by FROM meeting WHERE tenant_id=? AND is_deleted=0 AND id IN (" + inSql + ")", + args.toArray() + ); + for (Map row : rows) { + result.put(((Number) row.get("id")).longValue(), ((Number) row.get("created_by")).longValue()); + } + return result; + } + + public Map listMeetingProjectIds(Collection meetingIds) { + Map result = new LinkedHashMap<>(); + if (meetingIds == null || meetingIds.isEmpty()) { + return result; + } + String inSql = buildInClause(meetingIds.size()); + List args = new ArrayList<>(); + args.add(tenantId()); + args.addAll(meetingIds); + List> rows = jdbcTemplate.queryForList( + "SELECT id, project_id FROM meeting WHERE tenant_id=? AND is_deleted=0 AND id IN (" + inSql + ")", + args.toArray() + ); + for (Map row : rows) { + result.put(((Number) row.get("id")).longValue(), ((Number) row.get("project_id")).longValue()); + } + return result; + } + + public Map listExpertCreators(Collection expertIds) { + Map result = new LinkedHashMap<>(); + if (expertIds == null || expertIds.isEmpty()) { + return result; + } + String inSql = buildInClause(expertIds.size()); + List args = new ArrayList<>(); + args.add(0L); + args.addAll(expertIds); + List> rows = jdbcTemplate.queryForList( + "SELECT id, created_by FROM expert WHERE tenant_id=? AND is_deleted=0 AND id IN (" + inSql + ")", + args.toArray() + ); + for (Map row : rows) { + result.put(((Number) row.get("id")).longValue(), ((Number) row.get("created_by")).longValue()); + } + return result; + } + + private Set listUserRoleCodes(Long userId) { + List roleCodes = jdbcTemplate.queryForList( + "SELECT DISTINCT r.role_code FROM user_role ur " + + "JOIN role r ON ur.tenant_id=r.tenant_id AND ur.role_id=r.id " + + "WHERE ur.tenant_id=? AND ur.user_id=? AND r.is_deleted=0", + String.class, + tenantId(), + userId + ); + return new HashSet<>(roleCodes); + } + + private Set listBoundProjectIds(Long userId, boolean ownerScoped, boolean executorScoped, boolean legacyExecutorScoped) { + List ids; + if (ownerScoped && executorScoped && legacyExecutorScoped) { + ids = jdbcTemplate.queryForList( + "SELECT DISTINCT project_id FROM project_user_binding " + + "WHERE tenant_id=? AND user_id=? AND is_deleted=0 AND bind_role_code IN ('PROJECT_OWNER', 'PROJECT_EXECUTOR', 'EXECUTOR')", + Long.class, + tenantId(), + userId + ); + } else if (ownerScoped && executorScoped) { + ids = jdbcTemplate.queryForList( + "SELECT DISTINCT project_id FROM project_user_binding " + + "WHERE tenant_id=? AND user_id=? AND is_deleted=0 AND bind_role_code IN ('PROJECT_OWNER', 'PROJECT_EXECUTOR')", + Long.class, + tenantId(), + userId + ); + } else if (ownerScoped && legacyExecutorScoped) { + ids = jdbcTemplate.queryForList( + "SELECT DISTINCT project_id FROM project_user_binding " + + "WHERE tenant_id=? AND user_id=? AND is_deleted=0 AND bind_role_code IN ('PROJECT_OWNER', 'EXECUTOR')", + Long.class, + tenantId(), + userId + ); + } else if (executorScoped && legacyExecutorScoped) { + ids = jdbcTemplate.queryForList( + "SELECT DISTINCT project_id FROM project_user_binding " + + "WHERE tenant_id=? AND user_id=? AND is_deleted=0 AND bind_role_code IN ('PROJECT_EXECUTOR', 'EXECUTOR')", + Long.class, + tenantId(), + userId + ); + } else if (ownerScoped) { + ids = jdbcTemplate.queryForList( + "SELECT DISTINCT project_id FROM project_user_binding " + + "WHERE tenant_id=? AND user_id=? AND is_deleted=0 AND bind_role_code='PROJECT_OWNER'", + Long.class, + tenantId(), + userId + ); + } else { + if (executorScoped) { + ids = jdbcTemplate.queryForList( + "SELECT DISTINCT project_id FROM project_user_binding " + + "WHERE tenant_id=? AND user_id=? AND is_deleted=0 AND bind_role_code='PROJECT_EXECUTOR'", + Long.class, + tenantId(), + userId + ); + } else { + ids = jdbcTemplate.queryForList( + "SELECT DISTINCT project_id FROM project_user_binding " + + "WHERE tenant_id=? AND user_id=? AND is_deleted=0 AND bind_role_code='EXECUTOR'", + Long.class, + tenantId(), + userId + ); + } + } + return new HashSet<>(ids); + } + + private Set parseIds(String idsCsv) { + Set ids = new HashSet<>(); + if (idsCsv == null || idsCsv.trim().isEmpty()) { + return ids; + } + String[] arr = idsCsv.split(","); + for (String item : arr) { + String val = item.trim(); + if (val.isEmpty()) { + continue; + } + try { + ids.add(Long.parseLong(val)); + } catch (NumberFormatException ignored) { + } + } + return ids; + } + + private void assertPolicyExists(Long id) { + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM data_permission_policy WHERE tenant_id=? AND id=?", + Integer.class, + tenantId(), + id + ); + if (count == null || count == 0) { + throw new BusinessException(10003, "数据权限策略不存在"); + } + } + + private DataPermissionPolicy findById(Long id) { + List list = jdbcTemplate.query( + "SELECT * FROM data_permission_policy WHERE tenant_id=? AND id=?", + POLICY_ROW_MAPPER, + tenantId(), + id + ); + if (list.isEmpty()) { + throw new BusinessException(10003, "数据权限策略不存在"); + } + return list.get(0); + } + + public static class DataScope { + private final boolean projectAll; + private final Set projectIds; + private final boolean projectOwnerOnly; + private final boolean meetingAll; + private final Set meetingIds; + private final boolean meetingOwnerOnly; + private final boolean userAll; + private final Set userIds; + private final boolean userOwnerOnly; + private final boolean expertAll; + private final Set expertIds; + private final boolean expertOwnerOnly; + + public DataScope(boolean projectAll, + Set projectIds, + boolean projectOwnerOnly, + boolean meetingAll, + Set meetingIds, + boolean meetingOwnerOnly, + boolean userAll, + Set userIds, + boolean userOwnerOnly, + boolean expertAll, + Set expertIds, + boolean expertOwnerOnly) { + this.projectAll = projectAll; + this.projectIds = projectIds; + this.projectOwnerOnly = projectOwnerOnly; + this.meetingAll = meetingAll; + this.meetingIds = meetingIds; + this.meetingOwnerOnly = meetingOwnerOnly; + this.userAll = userAll; + this.userIds = userIds; + this.userOwnerOnly = userOwnerOnly; + this.expertAll = expertAll; + this.expertIds = expertIds; + this.expertOwnerOnly = expertOwnerOnly; + } + + public static DataScope allowAll() { + return new DataScope(true, new HashSet(), false, true, new HashSet(), false, true, new HashSet(), false, true, new HashSet(), false); + } + + public static DataScope denyAll() { + return new DataScope(false, new HashSet(), false, false, new HashSet(), false, false, new HashSet(), false, false, new HashSet(), false); + } + + public boolean isProjectAll() { + return projectAll; + } + + public Set getProjectIds() { + return projectIds; + } + + public boolean isProjectOwnerOnly() { + return projectOwnerOnly; + } + + public boolean isMeetingAll() { + return meetingAll; + } + + public Set getMeetingIds() { + return meetingIds; + } + + public boolean isMeetingOwnerOnly() { + return meetingOwnerOnly; + } + + public boolean isUserAll() { + return userAll; + } + + public Set getUserIds() { + return userIds; + } + + public boolean isUserOwnerOnly() { + return userOwnerOnly; + } + + public boolean isExpertAll() { + return expertAll; + } + + public Set getExpertIds() { + return expertIds; + } + + public boolean isExpertOwnerOnly() { + return expertOwnerOnly; + } + } + + private String buildInClause(int size) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < size; i++) { + if (i > 0) { + sb.append(","); + } + sb.append("?"); + } + return sb.toString(); + } + + private Long tenantId() { + return AuthContext.requireTenantId(); + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/service/EnterpriseService.java b/backend/src/main/java/com/writeoff/module/system/service/EnterpriseService.java new file mode 100644 index 0000000..8048b66 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/service/EnterpriseService.java @@ -0,0 +1,239 @@ +package com.writeoff.module.system.service; + +import com.writeoff.common.api.PageResult; +import com.writeoff.common.exception.BusinessException; +import com.writeoff.module.file.service.OssService; +import com.writeoff.module.system.dto.CreateEnterpriseRequest; +import com.writeoff.module.system.dto.UpdateEnterpriseRequest; +import com.writeoff.module.system.model.EnterpriseInfo; +import com.writeoff.security.AuthContext; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.UUID; + +@Service +public class EnterpriseService { + private final JdbcTemplate jdbcTemplate; + private final OssService ossService; + + private static final RowMapper ENTERPRISE_ROW_MAPPER = (rs, n) -> new EnterpriseInfo( + rs.getLong("id"), + rs.getString("enterprise_name"), + rs.getString("enterprise_url"), + rs.getString("logo_url"), + rs.getString("status"), + rs.getString("created_at") + ); + + public EnterpriseService(JdbcTemplate jdbcTemplate, OssService ossService) { + this.jdbcTemplate = jdbcTemplate; + this.ossService = ossService; + } + + public PageResult list(int pageNo, int pageSize) { + int safePage = Math.max(pageNo, 1); + int safeSize = Math.min(Math.max(pageSize, 1), 100); + int offset = (safePage - 1) * safeSize; + + Integer total = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM enterprise WHERE tenant_id=? AND is_deleted=0", + Integer.class, + tenantId() + ); + long totalCount = total == null ? 0 : total; + + List list = jdbcTemplate.query( + "SELECT id, enterprise_name, enterprise_url, logo_url, status, " + + "DATE_FORMAT(created_at, '%Y-%m-%d %H:%i:%s') AS created_at " + + "FROM enterprise WHERE tenant_id=? AND is_deleted=0 ORDER BY id DESC LIMIT ? OFFSET ?", + ENTERPRISE_ROW_MAPPER, + tenantId(), + safeSize, + offset + ); + return new PageResult(list, totalCount, safePage, safeSize); + } + + public EnterpriseInfo create(CreateEnterpriseRequest request) { + String name = request.getEnterpriseName().trim(); + Integer exists = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM enterprise WHERE tenant_id=? AND enterprise_name=? AND is_deleted=0", + Integer.class, + tenantId(), + name + ); + if (exists != null && exists > 0) { + throw new BusinessException(10001, "企业名称已存在"); + } + jdbcTemplate.update( + "INSERT INTO enterprise (tenant_id, enterprise_name, enterprise_url, logo_url, status, is_deleted, created_by, updated_by) " + + "VALUES (?, ?, ?, ?, 'ENABLED', 0, ?, ?)", + tenantId(), + name, + normalizeNullable(request.getEnterpriseUrl()), + normalizeNullable(request.getLogoUrl()), + safeUserId(), + safeUserId() + ); + Long id = jdbcTemplate.queryForObject("SELECT IFNULL(MAX(id), 0) FROM enterprise WHERE tenant_id=?", Long.class, tenantId()); + return findById(id == null ? 0L : id); + } + + public EnterpriseInfo update(Long id, UpdateEnterpriseRequest request) { + assertExists(id); + String name = request.getEnterpriseName().trim(); + Integer dup = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM enterprise WHERE tenant_id=? AND enterprise_name=? AND id<>? AND is_deleted=0", + Integer.class, + tenantId(), + name, + id + ); + if (dup != null && dup > 0) { + throw new BusinessException(10001, "企业名称已存在"); + } + jdbcTemplate.update( + "UPDATE enterprise SET enterprise_name=?, enterprise_url=?, logo_url=?, updated_by=?, updated_at=CURRENT_TIMESTAMP " + + "WHERE tenant_id=? AND id=?", + name, + normalizeNullable(request.getEnterpriseUrl()), + normalizeNullable(request.getLogoUrl()), + safeUserId(), + tenantId(), + id + ); + return findById(id); + } + + public void enable(Long id) { + assertExists(id); + jdbcTemplate.update( + "UPDATE enterprise SET status='ENABLED', updated_by=?, updated_at=CURRENT_TIMESTAMP WHERE tenant_id=? AND id=?", + safeUserId(), + tenantId(), + id + ); + } + + public void disable(Long id) { + assertExists(id); + jdbcTemplate.update( + "UPDATE enterprise SET status='DISABLED', updated_by=?, updated_at=CURRENT_TIMESTAMP WHERE tenant_id=? AND id=?", + safeUserId(), + tenantId(), + id + ); + } + + public void softDelete(Long id) { + assertExists(id); + // 检查是否有活跃项目关联 + Integer activeProjects = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM project WHERE tenant_id=? AND partner_enterprise_id=? AND is_deleted=0 AND status NOT IN ('ARCHIVED', 'TERMINATED')", + Integer.class, + tenantId(), + id + ); + if (activeProjects != null && activeProjects > 0) { + throw new BusinessException(10001, "该企业下存在活跃项目,请先归档相关项目后再删除"); + } + jdbcTemplate.update( + "UPDATE enterprise SET is_deleted=1, status='DISABLED', updated_by=?, updated_at=CURRENT_TIMESTAMP WHERE tenant_id=? AND id=?", + safeUserId(), + tenantId(), + id + ); + } + + public void assertEnabled(Long enterpriseId) { + if (enterpriseId == null) { + return; + } + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM enterprise WHERE tenant_id=? AND id=? AND is_deleted=0 AND status='ENABLED'", + Integer.class, + tenantId(), + enterpriseId + ); + if (count == null || count == 0) { + throw new BusinessException(10003, "企业不存在或已停用"); + } + } + + public Map presignLogoUpload(String fileName, String contentType) { + String name = fileName == null ? "" : fileName.trim(); + if (name.isEmpty()) { + throw new BusinessException(10001, "文件名不能为空"); + } + String ext = ""; + int idx = name.lastIndexOf('.'); + if (idx > 0 && idx < name.length() - 1) { + ext = "." + name.substring(idx + 1).toLowerCase(); + } + String normalizedContentType = normalizeContentType(contentType); + String objectKey = "enterprise/logo/" + tenantId() + "/" + safeUserId() + "/" + UUID.randomUUID().toString().replace("-", "") + ext; + String uploadUrl = ossService.generateUploadUrl(objectKey, normalizedContentType); + Map data = new LinkedHashMap(); + data.put("objectKey", objectKey); + data.put("uploadUrl", uploadUrl); + data.put("contentType", normalizedContentType); + data.put("method", "PUT"); + return data; + } + + private EnterpriseInfo findById(Long id) { + List list = jdbcTemplate.query( + "SELECT id, enterprise_name, enterprise_url, logo_url, status, " + + "DATE_FORMAT(created_at, '%Y-%m-%d %H:%i:%s') AS created_at " + + "FROM enterprise WHERE tenant_id=? AND id=? AND is_deleted=0", + ENTERPRISE_ROW_MAPPER, + tenantId(), + id + ); + if (list.isEmpty()) { + throw new BusinessException(10003, "企业不存在"); + } + return list.get(0); + } + + private void assertExists(Long id) { + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM enterprise WHERE tenant_id=? AND id=? AND is_deleted=0", + Integer.class, + tenantId(), + id + ); + if (count == null || count == 0) { + throw new BusinessException(10003, "企业不存在"); + } + } + + private String normalizeNullable(String value) { + if (value == null) { + return null; + } + String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } + + private String normalizeContentType(String contentType) { + if (contentType == null || contentType.trim().isEmpty()) { + return "application/octet-stream"; + } + return contentType.trim(); + } + + private Long tenantId() { + return AuthContext.requireTenantId(); + } + + private Long safeUserId() { + Long userId = AuthContext.userId(); + return userId == null ? 0L : userId; + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/service/GlobalSearchService.java b/backend/src/main/java/com/writeoff/module/system/service/GlobalSearchService.java new file mode 100644 index 0000000..ef9c262 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/service/GlobalSearchService.java @@ -0,0 +1,365 @@ +package com.writeoff.module.system.service; + +import com.writeoff.module.system.model.GlobalSearchGroup; +import com.writeoff.module.system.model.GlobalSearchItem; +import com.writeoff.module.system.model.GlobalSearchResult; +import com.writeoff.module.system.model.MenuInfo; +import com.writeoff.security.AuthContext; +import com.writeoff.security.AuthScope; +import com.writeoff.security.PermissionService; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; + +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +@Service +public class GlobalSearchService { + private final JdbcTemplate jdbcTemplate; + private final MenuService menuService; + private final PlatformMenuService platformMenuService; + private final PermissionService permissionService; + private final DataPermissionService dataPermissionService; + + public GlobalSearchService(JdbcTemplate jdbcTemplate, + MenuService menuService, + PlatformMenuService platformMenuService, + PermissionService permissionService, + DataPermissionService dataPermissionService) { + this.jdbcTemplate = jdbcTemplate; + this.menuService = menuService; + this.platformMenuService = platformMenuService; + this.permissionService = permissionService; + this.dataPermissionService = dataPermissionService; + } + + public GlobalSearchResult search(String keyword, Integer limitPerType) { + String normalizedKeyword = normalizeKeyword(keyword); + int safeLimit = normalizeLimit(limitPerType); + Long userId = AuthContext.userId(); + if (userId == null || normalizedKeyword.isEmpty()) { + return new GlobalSearchResult(normalizedKeyword, 0, Collections.emptyList()); + } + + List groups = AuthContext.scope() == AuthScope.PLATFORM + ? searchPlatform(userId, normalizedKeyword, safeLimit) + : searchTenant(userId, normalizedKeyword, safeLimit); + int total = 0; + for (GlobalSearchGroup group : groups) { + total += group.getItems() == null ? 0 : group.getItems().size(); + } + return new GlobalSearchResult(normalizedKeyword, total, groups); + } + + private List searchTenant(Long userId, String keyword, int limit) { + List groups = new ArrayList(); + + List menuItems = searchTenantMenus(userId, keyword, limit); + if (!menuItems.isEmpty()) { + groups.add(new GlobalSearchGroup("MENU", "菜单", menuItems)); + } + + if (permissionService.hasPermission(userId, "meeting.read")) { + List meetingItems = searchTenantMeetings(keyword, limit); + if (!meetingItems.isEmpty()) { + groups.add(new GlobalSearchGroup("MEETING", "会议", meetingItems)); + } + } + + if (permissionService.hasPermission(userId, "user.read")) { + List userItems = searchTenantUsers(keyword, limit); + if (!userItems.isEmpty()) { + groups.add(new GlobalSearchGroup("USER", "用户", userItems)); + } + } + + return groups; + } + + private List searchPlatform(Long userId, String keyword, int limit) { + List groups = new ArrayList(); + + List menuItems = searchPlatformMenus(userId, keyword, limit); + if (!menuItems.isEmpty()) { + groups.add(new GlobalSearchGroup("MENU", "平台菜单", menuItems)); + } + + if (permissionService.hasPlatformPermission(userId, "platform.user.manage")) { + List userItems = searchPlatformUsers(keyword, limit); + if (!userItems.isEmpty()) { + groups.add(new GlobalSearchGroup("USER", "平台用户", userItems)); + } + } + + return groups; + } + + private List searchTenantMenus(Long userId, String keyword, int limit) { + List menus = menuService.currentUserMenus(userId); + return buildMenuItems(menus, keyword, limit); + } + + private List searchPlatformMenus(Long userId, String keyword, int limit) { + List menus = platformMenuService.currentUserMenus(userId); + return buildMenuItems(menus, keyword, limit); + } + + private List buildMenuItems(List menus, String keyword, int limit) { + List items = new ArrayList(); + if (menus == null || menus.isEmpty()) { + return items; + } + for (MenuInfo menu : menus) { + if (!matchesKeyword(keyword, menu.getMenuName(), menu.getRoutePath(), menu.getMenuCode())) { + continue; + } + items.add(new GlobalSearchItem( + "MENU", + safeText(menu.getMenuName()), + safeText(normalizeMenuRoutePath(menu.getRoutePath())), + normalizeMenuRoutePath(menu.getRoutePath()), + null + )); + if (items.size() >= limit) { + break; + } + } + return items; + } + + private List searchTenantMeetings(String keyword, int limit) { + String like = "%" + keyword + "%"; + List> rows = jdbcTemplate.queryForList( + "SELECT m.id, m.topic, m.start_time, m.status, m.audit_status, m.project_id, " + + "m.created_by AS meeting_created_by, p.created_by AS project_created_by, p.project_name " + + "FROM meeting m " + + "LEFT JOIN project p ON m.tenant_id=p.tenant_id AND m.project_id=p.id AND p.is_deleted=0 " + + "WHERE m.tenant_id=? AND m.is_deleted=0 " + + "AND (m.topic LIKE ? OR p.project_name LIKE ? OR CAST(m.id AS CHAR) LIKE ?) " + + "ORDER BY m.id DESC LIMIT ?", + tenantId(), + like, + like, + like, + limit * 4 + ); + DataPermissionService.DataScope scope = dataPermissionService == null + ? DataPermissionService.DataScope.allowAll() + : dataPermissionService.resolveCurrentUserScope(); + List items = new ArrayList(); + for (Map row : rows) { + Long meetingId = toLong(row.get("id")); + Long projectId = toLong(row.get("project_id")); + Long meetingCreatedBy = toLong(row.get("meeting_created_by")); + Long projectCreatedBy = toLong(row.get("project_created_by")); + if (meetingId == null || meetingId <= 0) { + continue; + } + if (dataPermissionService != null && !dataPermissionService.canAccessMeeting(meetingId, projectId, meetingCreatedBy, projectCreatedBy, scope)) { + continue; + } + String topic = safeText(row.get("topic")); + String projectName = safeText(row.get("project_name")); + String startTime = safeText(row.get("start_time")); + String subtitle = joinParts( + projectName.isEmpty() ? null : "项目:" + projectName, + startTime.isEmpty() ? null : "时间:" + startTime + ); + items.add(new GlobalSearchItem( + "MEETING", + topic.isEmpty() ? "会议#" + meetingId : topic, + subtitle, + "/meetings?meetingId=" + meetingId + "&topic=" + topic, + safeText(row.get("status")) + )); + if (items.size() >= limit) { + break; + } + } + return items; + } + + private List searchTenantUsers(String keyword, int limit) { + String like = "%" + keyword + "%"; + List> rows = jdbcTemplate.queryForList( + "SELECT u.id, u.user_name, u.phone, u.email, u.status, u.created_by, " + + "COALESCE(GROUP_CONCAT(DISTINCT r.role_name ORDER BY r.id SEPARATOR '、'), '') AS role_names " + + "FROM sys_user u " + + "LEFT JOIN user_role ur ON u.tenant_id=ur.tenant_id AND u.id=ur.user_id " + + "LEFT JOIN role r ON ur.tenant_id=r.tenant_id AND ur.role_id=r.id AND r.is_deleted=0 " + + "WHERE u.tenant_id=? AND u.is_deleted=0 AND (u.user_name LIKE ? OR u.phone LIKE ? OR u.email LIKE ?) " + + "GROUP BY u.id, u.user_name, u.phone, u.email, u.status, u.created_by " + + "ORDER BY u.id DESC LIMIT ?", + tenantId(), + like, + like, + like, + limit * 4 + ); + DataPermissionService.DataScope scope = dataPermissionService == null + ? DataPermissionService.DataScope.allowAll() + : dataPermissionService.resolveCurrentUserScope(); + List items = new ArrayList(); + for (Map row : rows) { + Long userId = toLong(row.get("id")); + Long createdBy = toLong(row.get("created_by")); + if (userId == null || userId <= 0) { + continue; + } + if (dataPermissionService != null && !dataPermissionService.canAccessUser(userId, createdBy, scope)) { + continue; + } + String userName = safeText(row.get("user_name")); + String phone = safeText(row.get("phone")); + String email = safeText(row.get("email")); + String roleNames = safeText(row.get("role_names")); + String subtitle = joinParts( + phone.isEmpty() ? null : "手机号:" + phone, + email.isEmpty() ? null : email, + roleNames.isEmpty() ? null : "角色:" + roleNames + ); + items.add(new GlobalSearchItem( + "USER", + userName.isEmpty() ? ("用户#" + userId) : userName, + subtitle, + "/users?keyword=" + resolveUserKeyword(phone, email, userName, userId), + safeText(row.get("status")) + )); + if (items.size() >= limit) { + break; + } + } + return items; + } + + private List searchPlatformUsers(String keyword, int limit) { + String like = "%" + keyword + "%"; + List> rows = jdbcTemplate.queryForList( + "SELECT id, user_name, phone, email, status " + + "FROM platform_user " + + "WHERE is_deleted=0 AND (user_name LIKE ? OR phone LIKE ? OR email LIKE ?) " + + "ORDER BY id DESC LIMIT ?", + like, + like, + like, + limit + ); + List items = new ArrayList(); + for (Map row : rows) { + Long userId = toLong(row.get("id")); + String userName = safeText(row.get("user_name")); + String phone = safeText(row.get("phone")); + String email = safeText(row.get("email")); + String subtitle = joinParts( + phone.isEmpty() ? null : "手机号:" + phone, + email.isEmpty() ? null : email + ); + items.add(new GlobalSearchItem( + "USER", + userName.isEmpty() ? ("平台用户#" + (userId == null ? 0L : userId)) : userName, + subtitle, + "/platform/users?keyword=" + resolveUserKeyword(phone, email, userName, userId), + safeText(row.get("status")) + )); + } + return items; + } + + private boolean matchesKeyword(String keyword, String... values) { + if (keyword == null || keyword.isEmpty()) { + return false; + } + String normalizedKeyword = keyword.toLowerCase(Locale.ROOT); + if (values == null) { + return false; + } + for (String value : values) { + if (value == null) { + continue; + } + if (value.toLowerCase(Locale.ROOT).contains(normalizedKeyword)) { + return true; + } + } + return false; + } + + private String normalizeKeyword(String keyword) { + return keyword == null ? "" : keyword.trim(); + } + + private int normalizeLimit(Integer limitPerType) { + if (limitPerType == null) { + return 5; + } + return Math.max(1, Math.min(limitPerType, 10)); + } + + private String normalizeMenuRoutePath(String routePath) { + String raw = safeText(routePath); + if ("/permissions".equals(raw)) { + return "/menus?tab=permissions"; + } + if ("/platform/permissions".equals(raw)) { + return "/platform/menus?tab=permissions"; + } + return raw; + } + + private String resolveUserKeyword(String phone, String email, String userName, Long userId) { + if (phone != null && !phone.trim().isEmpty()) { + return phone.trim(); + } + if (email != null && !email.trim().isEmpty()) { + return email.trim(); + } + if (userName != null && !userName.trim().isEmpty()) { + return userName.trim(); + } + return String.valueOf(userId == null ? 0L : userId); + } + + private String joinParts(String... parts) { + StringBuilder builder = new StringBuilder(); + if (parts == null) { + return ""; + } + for (String part : parts) { + if (part == null || part.trim().isEmpty()) { + continue; + } + if (builder.length() > 0) { + builder.append(" | "); + } + builder.append(part.trim()); + } + return builder.toString(); + } + + private String safeText(Object value) { + return value == null ? "" : String.valueOf(value).trim(); + } + + private Long toLong(Object value) { + if (value == null) { + return null; + } + if (value instanceof Number) { + return ((Number) value).longValue(); + } + try { + return Long.valueOf(String.valueOf(value).trim()); + } catch (Exception ex) { + return null; + } + } + + private Long tenantId() { + return AuthContext.requireTenantId(); + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/service/MenuService.java b/backend/src/main/java/com/writeoff/module/system/service/MenuService.java new file mode 100644 index 0000000..34878a9 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/service/MenuService.java @@ -0,0 +1,238 @@ +package com.writeoff.module.system.service; + +import com.writeoff.common.api.PageResult; +import com.writeoff.common.exception.BusinessException; +import com.writeoff.module.system.dto.BindRoleMenusRequest; +import com.writeoff.module.system.dto.CreateMenuRequest; +import com.writeoff.module.system.dto.ReorderMenusRequest; +import com.writeoff.module.system.dto.UpdateMenuRequest; +import com.writeoff.module.system.model.MenuInfo; +import com.writeoff.security.AuthContext; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.LinkedHashSet; +import java.util.List; + +@Service +public class MenuService { + private final JdbcTemplate jdbcTemplate; + + private static final RowMapper MENU_ROW_MAPPER = (rs, n) -> new MenuInfo( + rs.getLong("id"), + rs.getString("menu_code"), + rs.getString("menu_name"), + rs.getString("route_path"), + rs.getString("permission_code"), + rs.getInt("sort_no"), + rs.getString("status") + ); + + public MenuService(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + public PageResult list() { + List list = jdbcTemplate.query( + "SELECT id, menu_code, menu_name, route_path, permission_code, sort_no, status " + + "FROM menu WHERE tenant_id=? AND is_deleted=0 ORDER BY sort_no ASC, id ASC", + MENU_ROW_MAPPER, + tenantId() + ); + return new PageResult<>(list, list.size(), 1, 200); + } + + public List currentUserMenus(Long userId) { + return jdbcTemplate.query( + "SELECT DISTINCT m.id, m.menu_code, m.menu_name, m.route_path, m.permission_code, m.sort_no, m.status " + + "FROM user_role ur " + + "JOIN role_menu rm ON ur.tenant_id=rm.tenant_id AND ur.role_id=rm.role_id " + + "JOIN menu m ON rm.tenant_id=m.tenant_id AND rm.menu_id=m.id " + + "WHERE ur.tenant_id=? AND ur.user_id=? AND m.is_deleted=0 AND m.status='ENABLED' " + + "AND (m.permission_code IS NULL OR EXISTS (" + + " SELECT 1 FROM user_role ur2 " + + " JOIN role_permission rp2 ON ur2.tenant_id=rp2.tenant_id AND ur2.role_id=rp2.role_id " + + " JOIN permission p2 ON rp2.permission_id=p2.id " + + " WHERE ur2.tenant_id=ur.tenant_id AND ur2.user_id=ur.user_id AND p2.permission_code=m.permission_code" + + ")) " + + "ORDER BY m.sort_no ASC, m.id ASC", + MENU_ROW_MAPPER, + tenantId(), + userId + ); + } + + public MenuInfo create(CreateMenuRequest request) { + assertPermissionExists(request.getPermissionCode().trim()); + Integer dup = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM menu WHERE tenant_id=? AND menu_code=?", + Integer.class, + tenantId(), + request.getMenuCode().trim() + ); + if (dup != null && dup > 0) { + throw new BusinessException(10001, "菜单编码已存在"); + } + jdbcTemplate.update( + "INSERT INTO menu (tenant_id, menu_code, menu_name, route_path, permission_code, sort_no, status, is_deleted, created_by, updated_by) " + + "VALUES (?, ?, ?, ?, ?, ?, 'ENABLED', 0, ?, ?)", + tenantId(), + request.getMenuCode().trim(), + request.getMenuName().trim(), + request.getRoutePath().trim(), + request.getPermissionCode().trim(), + request.getSortNo(), + safeUserId(), + safeUserId() + ); + Long id = jdbcTemplate.queryForObject("SELECT IFNULL(MAX(id), 0) FROM menu WHERE tenant_id=?", Long.class, tenantId()); + return findById(id == null ? 0L : id); + } + + public MenuInfo update(Long id, UpdateMenuRequest request) { + assertMenuExists(id); + assertPermissionExists(request.getPermissionCode().trim()); + String status = request.getStatus().trim().toUpperCase(); + if (!"ENABLED".equals(status) && !"DISABLED".equals(status)) { + throw new BusinessException(10001, "菜单状态非法"); + } + jdbcTemplate.update( + "UPDATE menu SET menu_name=?, route_path=?, permission_code=?, sort_no=?, status=?, updated_by=?, updated_at=CURRENT_TIMESTAMP " + + "WHERE tenant_id=? AND id=?", + request.getMenuName().trim(), + request.getRoutePath().trim(), + request.getPermissionCode().trim(), + request.getSortNo(), + status, + safeUserId(), + tenantId(), + id + ); + return findById(id); + } + + public List getRoleMenuIds(Long roleId) { + assertRoleExists(roleId); + return jdbcTemplate.queryForList( + "SELECT rm.menu_id FROM role_menu rm " + + "JOIN menu m ON rm.tenant_id=m.tenant_id AND rm.menu_id=m.id " + + "WHERE rm.tenant_id=? AND rm.role_id=? AND m.is_deleted=0 ORDER BY rm.menu_id ASC", + Long.class, + tenantId(), + roleId + ); + } + + @Transactional + public void bindRoleMenus(Long roleId, BindRoleMenusRequest request) { + assertRoleExists(roleId); + if (request.getMenuIds() == null || request.getMenuIds().isEmpty()) { + jdbcTemplate.update("DELETE FROM role_menu WHERE tenant_id=? AND role_id=?", tenantId(), roleId); + return; + } + LinkedHashSet menuIds = new LinkedHashSet(request.getMenuIds()); + for (Long menuId : menuIds) { + assertMenuExists(menuId); + } + jdbcTemplate.update("DELETE FROM role_menu WHERE tenant_id=? AND role_id=?", tenantId(), roleId); + Long nextId = jdbcTemplate.queryForObject("SELECT IFNULL(MAX(id), 0) + 1 FROM role_menu", Long.class); + long currentId = nextId == null ? 1L : nextId; + for (Long menuId : menuIds) { + jdbcTemplate.update( + "INSERT INTO role_menu (id, tenant_id, role_id, menu_id) VALUES (?, ?, ?, ?)", + currentId++, + tenantId(), + roleId, + menuId + ); + } + } + + public List getMenuRoleNames(Long menuId) { + assertMenuExists(menuId); + return jdbcTemplate.queryForList( + "SELECT r.role_name FROM role_menu rm " + + "JOIN role r ON rm.tenant_id=r.tenant_id AND rm.role_id=r.id " + + "WHERE rm.tenant_id=? AND rm.menu_id=? AND r.is_deleted=0 ORDER BY r.id ASC", + String.class, + tenantId(), + menuId + ); + } + + public void reorderMenus(ReorderMenusRequest request) { + if (request.getMenus() == null || request.getMenus().isEmpty()) { + return; + } + for (ReorderMenusRequest.MenuSortItem item : request.getMenus()) { + assertMenuExists(item.getId()); + jdbcTemplate.update( + "UPDATE menu SET sort_no=?, updated_by=?, updated_at=CURRENT_TIMESTAMP WHERE tenant_id=? AND id=?", + item.getSortNo(), + safeUserId(), + tenantId(), + item.getId() + ); + } + } + + private MenuInfo findById(Long id) { + List list = jdbcTemplate.query( + "SELECT id, menu_code, menu_name, route_path, permission_code, sort_no, status " + + "FROM menu WHERE tenant_id=? AND id=? AND is_deleted=0", + MENU_ROW_MAPPER, + tenantId(), + id + ); + if (list.isEmpty()) { + throw new BusinessException(10003, "菜单不存在"); + } + return list.get(0); + } + + private void assertMenuExists(Long menuId) { + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM menu WHERE tenant_id=? AND id=? AND is_deleted=0", + Integer.class, + tenantId(), + menuId + ); + if (count == null || count == 0) { + throw new BusinessException(10003, "菜单不存在"); + } + } + + private void assertRoleExists(Long roleId) { + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM role WHERE tenant_id=? AND id=? AND is_deleted=0", + Integer.class, + tenantId(), + roleId + ); + if (count == null || count == 0) { + throw new BusinessException(10003, "角色不存在"); + } + } + + private void assertPermissionExists(String permissionCode) { + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM permission WHERE permission_code=?", + Integer.class, + permissionCode + ); + if (count == null || count == 0) { + throw new BusinessException(10003, "权限码不存在"); + } + } + + private Long tenantId() { + return AuthContext.requireTenantId(); + } + + private Long safeUserId() { + Long userId = AuthContext.userId(); + return userId == null ? 0L : userId; + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/service/OperationAuditLogService.java b/backend/src/main/java/com/writeoff/module/system/service/OperationAuditLogService.java new file mode 100644 index 0000000..dbf3038 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/service/OperationAuditLogService.java @@ -0,0 +1,214 @@ +package com.writeoff.module.system.service; + +import com.writeoff.common.api.PageResult; +import com.writeoff.module.export.dto.CreateExportTaskRequest; +import com.writeoff.module.export.model.ExportTaskInfo; +import com.writeoff.module.export.service.ExportTaskService; +import com.writeoff.module.system.model.OperationAuditLogInfo; +import com.writeoff.security.AuthContext; +import com.writeoff.security.AuthScope; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Service; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayList; +import java.util.List; + +@Service +public class OperationAuditLogService { + private static final Logger log = LoggerFactory.getLogger(OperationAuditLogService.class); + private final JdbcTemplate jdbcTemplate; + private final ExportTaskService exportTaskService; + + private static final RowMapper ROW_MAPPER = (rs, n) -> new OperationAuditLogInfo( + rs.getLong("id"), + rs.getLong("tenant_id"), + rs.getLong("user_id"), + rs.getString("scope"), + rs.getString("action_code"), + rs.getString("biz_type"), + rs.getString("biz_id"), + rs.getString("http_method"), + rs.getString("request_uri"), + rs.getString("request_id"), + rs.getInt("status_code"), + rs.getInt("success") == 1, + rs.getString("error_message"), + rs.getString("ip"), + rs.getString("created_at") + ); + private static final RowMapper EXPORT_TASK_ROW_MAPPER = (rs, n) -> new ExportTaskInfo( + rs.getLong("id"), + rs.getString("task_code"), + rs.getString("biz_type"), + rs.getString("biz_id"), + rs.getString("file_name"), + rs.getString("file_oss_key"), + rs.getString("status"), + rs.getInt("retry_count"), + rs.getInt("download_count"), + rs.getString("token_expire_at"), + rs.getString("error_message"), + rs.getString("created_at"), + rs.getString("finished_at") + ); + + public OperationAuditLogService(JdbcTemplate jdbcTemplate, ExportTaskService exportTaskService) { + this.jdbcTemplate = jdbcTemplate; + this.exportTaskService = exportTaskService; + } + + public void log(Long tenantId, Long userId, AuthScope scope, String actionCode, String bizType, String bizId, + String httpMethod, String requestUri, String requestQuery, String requestId, Integer statusCode, + boolean success, String errorMessage, String ip, String userAgent) { + jdbcTemplate.update( + "INSERT INTO operation_audit_log (tenant_id, user_id, scope, action_code, biz_type, biz_id, http_method, request_uri, request_query, request_id, status_code, success, error_message, ip, user_agent) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + tenantId == null ? 0L : tenantId, + userId == null ? 0L : userId, + scope == null ? AuthScope.TENANT.name() : scope.name(), + actionCode == null ? "UNKNOWN" : actionCode, + bizType, + bizId, + httpMethod, + requestUri, + requestQuery, + requestId, + statusCode == null ? 200 : statusCode, + success ? 1 : 0, + errorMessage, + ip, + userAgent + ); + } + + public PageResult list(Long userId, String actionCode, Integer pageNo, Integer pageSize) { + Long tenantId = tenantId(); + int resolvedPageNo = normalizePageNo(pageNo); + int resolvedPageSize = normalizePageSize(pageSize); + int offset = (resolvedPageNo - 1) * resolvedPageSize; + StringBuilder whereSql = new StringBuilder( + " FROM operation_audit_log WHERE UPPER(TRIM(IFNULL(scope, 'TENANT')))='TENANT' AND tenant_id=?" + ); + List args = new ArrayList(); + args.add(tenantId); + if (userId != null) { + whereSql.append(" AND user_id=?"); + args.add(userId); + } + if (actionCode != null && !actionCode.trim().isEmpty()) { + whereSql.append(" AND action_code=?"); + args.add(actionCode.trim()); + } + String countSql = "SELECT COUNT(1)" + whereSql; + Long total = jdbcTemplate.queryForObject(countSql, Long.class, args.toArray()); + String dataSql = "SELECT id, tenant_id, user_id, scope, action_code, biz_type, biz_id, http_method, request_uri, request_id, status_code, success, error_message, ip, " + + "DATE_FORMAT(created_at, '%Y-%m-%d %H:%i:%s') AS created_at " + + whereSql + + " ORDER BY id DESC LIMIT ? OFFSET ?"; + List dataArgs = new ArrayList(args); + dataArgs.add(resolvedPageSize); + dataArgs.add(offset); + log.info("[AuditLog][TENANT] SQL: {}", dataSql); + log.info("[AuditLog][TENANT] ARGS: {}", args); + List list = jdbcTemplate.query(dataSql, ROW_MAPPER, dataArgs.toArray()); + return new PageResult(list, total == null ? 0L : total, resolvedPageNo, resolvedPageSize); + } + + public PageResult listPlatform(Long userId, String actionCode, Long tenantId, String scope, Integer pageNo, Integer pageSize) { + int resolvedPageNo = normalizePageNo(pageNo); + int resolvedPageSize = normalizePageSize(pageSize); + int offset = (resolvedPageNo - 1) * resolvedPageSize; + StringBuilder whereSql = new StringBuilder(" FROM operation_audit_log WHERE 1=1"); + List args = new ArrayList(); + if (tenantId != null) { + whereSql.append(" AND tenant_id=?"); + args.add(tenantId); + } + if (scope != null && !scope.trim().isEmpty()) { + whereSql.append(" AND UPPER(TRIM(IFNULL(scope, ''))) = ?"); + args.add(scope.trim().toUpperCase()); + } + if (userId != null) { + whereSql.append(" AND user_id=?"); + args.add(userId); + } + if (actionCode != null && !actionCode.trim().isEmpty()) { + whereSql.append(" AND action_code=?"); + args.add(actionCode.trim()); + } + String countSql = "SELECT COUNT(1)" + whereSql; + Long total = jdbcTemplate.queryForObject(countSql, Long.class, args.toArray()); + String dataSql = "SELECT id, tenant_id, user_id, scope, action_code, biz_type, biz_id, http_method, request_uri, request_id, status_code, success, error_message, ip, " + + "DATE_FORMAT(created_at, '%Y-%m-%d %H:%i:%s') AS created_at " + + whereSql + + " ORDER BY id DESC LIMIT ? OFFSET ?"; + List dataArgs = new ArrayList(args); + dataArgs.add(resolvedPageSize); + dataArgs.add(offset); + log.info("[AuditLog][PLATFORM] SQL: {}", dataSql); + log.info("[AuditLog][PLATFORM] ARGS: {}", args); + List list = jdbcTemplate.query(dataSql, ROW_MAPPER, dataArgs.toArray()); + return new PageResult(list, total == null ? 0L : total, resolvedPageNo, resolvedPageSize); + } + + public PageResult listExportTasks() { + List list = jdbcTemplate.query( + "SELECT id, task_code, biz_type, biz_id, file_name, file_oss_key, status, retry_count, IFNULL(download_count,0) AS download_count, " + + "DATE_FORMAT(download_token_expire_at, '%Y-%m-%d %H:%i:%s') AS token_expire_at, error_message, " + + "DATE_FORMAT(created_at, '%Y-%m-%d %H:%i:%s') AS created_at, DATE_FORMAT(finished_at, '%Y-%m-%d %H:%i:%s') AS finished_at " + + "FROM export_task WHERE tenant_id=? AND biz_type='AUDIT_LOG' AND is_deleted=0 ORDER BY id DESC LIMIT 300", + EXPORT_TASK_ROW_MAPPER, + tenantId() + ); + return new PageResult(list, list.size(), 1, 300); + } + + public java.util.Map createExportTask(Long userId, String actionCode, String idempotencyKey, String fileName) { + CreateExportTaskRequest request = new CreateExportTaskRequest(); + request.setIdempotencyKey(idempotencyKey); + request.setTaskCode("AUDIT_LOG_EXPORT"); + request.setBizType("AUDIT_LOG"); + request.setBizId(null); + request.setFiltersJson(buildFilterJson(userId, actionCode)); + request.setFileName(fileName); + return exportTaskService.create(request); + } + + private String buildFilterJson(Long userId, String actionCode) { + StringBuilder sb = new StringBuilder("{"); + boolean hasPrev = false; + if (userId != null) { + sb.append("\"userId\":").append(userId); + hasPrev = true; + } + if (actionCode != null && !actionCode.trim().isEmpty()) { + if (hasPrev) { + sb.append(","); + } + sb.append("\"actionCode\":\"").append(actionCode.trim().replace("\"", "")).append("\""); + } + sb.append("}"); + return sb.toString(); + } + + private Long tenantId() { + return AuthContext.requireTenantId(); + } + + private int normalizePageNo(Integer pageNo) { + if (pageNo == null || pageNo < 1) { + return 1; + } + return pageNo; + } + + private int normalizePageSize(Integer pageSize) { + if (pageSize == null || pageSize < 1) { + return 20; + } + return Math.min(pageSize, 200); + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/service/PlatformDictionaryService.java b/backend/src/main/java/com/writeoff/module/system/service/PlatformDictionaryService.java new file mode 100644 index 0000000..80b27b7 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/service/PlatformDictionaryService.java @@ -0,0 +1,267 @@ +package com.writeoff.module.system.service; + +import com.writeoff.common.exception.BusinessException; +import com.writeoff.module.system.dto.CreatePlatformDictionaryItemRequest; +import com.writeoff.module.system.dto.CreatePlatformDictionaryTypeRequest; +import com.writeoff.module.system.dto.UpdatePlatformDictionaryItemRequest; +import com.writeoff.module.system.model.PlatformDictionaryItem; +import com.writeoff.module.system.model.PlatformDictionaryType; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Locale; +import java.util.regex.Pattern; + +@Service +public class PlatformDictionaryService { + private final JdbcTemplate jdbcTemplate; + + private static final RowMapper ITEM_ROW_MAPPER = (rs, n) -> new PlatformDictionaryItem( + rs.getLong("id"), + rs.getString("dict_type"), + rs.getString("dict_code"), + rs.getString("dict_name"), + rs.getInt("sort_no"), + rs.getString("status"), + rs.getString("remark") + ); + private static final RowMapper TYPE_ROW_MAPPER = (rs, n) -> new PlatformDictionaryType( + rs.getLong("id"), + rs.getString("dict_type"), + rs.getString("dict_name"), + rs.getInt("sort_no"), + rs.getString("status"), + rs.getString("remark") + ); + private static final Pattern DICT_TYPE_PATTERN = Pattern.compile("^[A-Z][A-Z0-9_]{1,63}$"); + + public PlatformDictionaryService(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + public List list(String dictType, Boolean enabledOnly) { + StringBuilder sql = new StringBuilder( + "SELECT id, dict_type, dict_code, dict_name, sort_no, status, remark " + + "FROM platform_dictionary_item WHERE is_deleted=0" + ); + if (dictType != null && !dictType.trim().isEmpty()) { + sql.append(" AND dict_type='").append(dictType.trim().replace("'", "''")).append("'"); + } + if (Boolean.TRUE.equals(enabledOnly)) { + sql.append(" AND status='ENABLED'"); + } + sql.append(" ORDER BY dict_type ASC, sort_no ASC, id ASC"); + return jdbcTemplate.query(sql.toString(), ITEM_ROW_MAPPER); + } + + public List listTypes(Boolean enabledOnly) { + StringBuilder sql = new StringBuilder( + "SELECT id, dict_type, dict_name, sort_no, status, remark " + + "FROM platform_dictionary_type WHERE is_deleted=0" + ); + if (Boolean.TRUE.equals(enabledOnly)) { + sql.append(" AND status='ENABLED'"); + } + sql.append(" ORDER BY sort_no ASC, id ASC"); + return jdbcTemplate.query(sql.toString(), TYPE_ROW_MAPPER); + } + + public PlatformDictionaryType createType(CreatePlatformDictionaryTypeRequest request) { + String dictType = normalizeDictType(request.getDictType()); + Integer dup = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM platform_dictionary_type WHERE dict_type=? AND is_deleted=0", + Integer.class, + dictType + ); + if (dup != null && dup > 0) { + throw new BusinessException(10001, "字典类型已存在"); + } + jdbcTemplate.update( + "INSERT INTO platform_dictionary_type (dict_type, dict_name, sort_no, status, remark, created_by, updated_by) " + + "VALUES (?, ?, ?, 'ENABLED', ?, 0, 0)", + dictType, + request.getDictName().trim(), + request.getSortNo(), + request.getRemark() + ); + Long id = jdbcTemplate.queryForObject("SELECT IFNULL(MAX(id), 0) FROM platform_dictionary_type", Long.class); + return findTypeById(id == null ? 0L : id); + } + + public PlatformDictionaryItem create(CreatePlatformDictionaryItemRequest request) { + String dictType = normalizeDictType(request.getDictType()); + assertTypeExists(dictType); + String dictCode = normalizeDictCode(request.getDictCode()); + Integer dup = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM platform_dictionary_item WHERE dict_type=? AND dict_code=? AND is_deleted=0", + Integer.class, + dictType, + dictCode + ); + if (dup != null && dup > 0) { + throw new BusinessException(10001, "字典编码已存在"); + } + jdbcTemplate.update( + "INSERT INTO platform_dictionary_item (dict_type, dict_code, dict_name, sort_no, status, remark, created_by, updated_by) " + + "VALUES (?, ?, ?, ?, 'ENABLED', ?, 0, 0)", + dictType, + dictCode, + request.getDictName().trim(), + request.getSortNo(), + request.getRemark() + ); + Long id = jdbcTemplate.queryForObject("SELECT IFNULL(MAX(id), 0) FROM platform_dictionary_item", Long.class); + return findById(id == null ? 0L : id); + } + + public PlatformDictionaryItem update(Long id, UpdatePlatformDictionaryItemRequest request) { + assertExists(id); + String status = normalizeStatus(request.getStatus()); + jdbcTemplate.update( + "UPDATE platform_dictionary_item SET dict_name=?, sort_no=?, remark=?, status=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", + request.getDictName().trim(), + request.getSortNo(), + request.getRemark(), + status, + id + ); + return findById(id); + } + + public void enable(Long id) { + assertExists(id); + jdbcTemplate.update("UPDATE platform_dictionary_item SET status='ENABLED', updated_at=CURRENT_TIMESTAMP WHERE id=?", id); + } + + public void disable(Long id) { + assertExists(id); + jdbcTemplate.update("UPDATE platform_dictionary_item SET status='DISABLED', updated_at=CURRENT_TIMESTAMP WHERE id=?", id); + } + + private PlatformDictionaryItem findById(Long id) { + List list = jdbcTemplate.query( + "SELECT id, dict_type, dict_code, dict_name, sort_no, status, remark FROM platform_dictionary_item WHERE id=? AND is_deleted=0", + ITEM_ROW_MAPPER, + id + ); + if (list.isEmpty()) { + throw new BusinessException(10003, "字典项不存在"); + } + return list.get(0); + } + + private void assertExists(Long id) { + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM platform_dictionary_item WHERE id=? AND is_deleted=0", + Integer.class, + id + ); + if (count == null || count == 0) { + throw new BusinessException(10003, "字典项不存在"); + } + } + + private String normalizeDictType(String value) { + String val = value == null ? "" : value.trim().toUpperCase(); + if (!DICT_TYPE_PATTERN.matcher(val).matches()) { + throw new BusinessException(10001, "字典类型编码格式非法,仅支持大写字母/数字/下划线,且需以字母开头"); + } + return val; + } + + private String normalizeDictCode(String value) { + String val = value == null ? "" : value.trim().toUpperCase(); + if (val.isEmpty()) { + throw new BusinessException(10001, "字典编码不能为空"); + } + return val; + } + + private String normalizeStatus(String value) { + String val = value == null ? "" : value.trim().toUpperCase(); + if (!"ENABLED".equals(val) && !"DISABLED".equals(val)) { + throw new BusinessException(10001, "状态必须为 ENABLED 或 DISABLED"); + } + return val; + } + + private PlatformDictionaryType findTypeById(Long id) { + List list = jdbcTemplate.query( + "SELECT id, dict_type, dict_name, sort_no, status, remark FROM platform_dictionary_type WHERE id=? AND is_deleted=0", + TYPE_ROW_MAPPER, + id + ); + if (list.isEmpty()) { + throw new BusinessException(10003, "字典类型不存在"); + } + return list.get(0); + } + + public PlatformDictionaryItem findEnabledItemByName(String dictType, String dictName) { + String type = dictType == null ? "" : dictType.trim().toUpperCase(Locale.ROOT); + String name = dictName == null ? "" : dictName.trim(); + if (type.isEmpty() || name.isEmpty()) { + return null; + } + List list = jdbcTemplate.query( + "SELECT id, dict_type, dict_code, dict_name, sort_no, status, remark " + + "FROM platform_dictionary_item WHERE dict_type=? AND dict_name=? AND status='ENABLED' AND is_deleted=0 LIMIT 1", + ITEM_ROW_MAPPER, + type, + name + ); + return list.isEmpty() ? null : list.get(0); + } + + public PlatformDictionaryItem createEnabledItem(String dictType, String dictName, String dictCodePrefix, String remark) { + String type = normalizeDictType(dictType); + String name = dictName == null ? "" : dictName.trim(); + if (name.isEmpty()) { + throw new BusinessException(10001, "????????"); + } + PlatformDictionaryItem existing = findEnabledItemByName(type, name); + if (existing != null) { + return existing; + } + assertTypeExists(type); + String basePrefix = dictCodePrefix == null || dictCodePrefix.trim().isEmpty() + ? type + : dictCodePrefix.trim().toUpperCase(Locale.ROOT); + String code = basePrefix; + int suffix = 1; + while (existsDictCode(type, code)) { + code = basePrefix + "_" + suffix; + suffix += 1; + } + CreatePlatformDictionaryItemRequest request = new CreatePlatformDictionaryItemRequest(); + request.setDictType(type); + request.setDictCode(code); + request.setDictName(name); + request.setSortNo(9999); + request.setRemark(remark); + return create(request); + } + + private boolean existsDictCode(String dictType, String dictCode) { + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM platform_dictionary_item WHERE dict_type=? AND dict_code=? AND is_deleted=0", + Integer.class, + dictType, + dictCode + ); + return count != null && count > 0; + } + + private void assertTypeExists(String dictType) { + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM platform_dictionary_type WHERE dict_type=? AND is_deleted=0", + Integer.class, + dictType + ); + if (count == null || count == 0) { + throw new BusinessException(10003, "字典类型不存在,请先新增字典类型"); + } + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/service/PlatformIamService.java b/backend/src/main/java/com/writeoff/module/system/service/PlatformIamService.java new file mode 100644 index 0000000..a70eb57 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/service/PlatformIamService.java @@ -0,0 +1,573 @@ +package com.writeoff.module.system.service; + +import com.writeoff.common.api.PageResult; +import com.writeoff.common.exception.BusinessException; +import com.writeoff.common.model.ImportResult; +import com.writeoff.common.util.ImportValidationUtils; +import com.writeoff.module.system.dto.AssignUserRoleRequest; +import com.writeoff.module.system.dto.BindRolePermissionsRequest; +import com.writeoff.module.system.dto.CreateRoleRequest; +import com.writeoff.module.system.dto.CreateUserRequest; +import com.writeoff.module.system.dto.ImportUserItemRequest; +import com.writeoff.module.system.dto.ResetPasswordRequest; +import com.writeoff.module.system.dto.UpdateProfilePreferencesRequest; +import com.writeoff.module.system.dto.UpdateRoleRequest; +import com.writeoff.module.system.model.PermissionInfo; +import com.writeoff.module.system.model.ProfilePreferencesInfo; +import com.writeoff.module.system.model.RoleInfo; +import com.writeoff.module.system.model.SystemUser; +import com.writeoff.security.PasswordCodecService; +import com.writeoff.security.PasswordPolicyService; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Service; + +import java.sql.Timestamp; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +@Service +public class PlatformIamService { + private final JdbcTemplate jdbcTemplate; + private final PasswordPolicyService passwordPolicyService; + private final PasswordCodecService passwordCodecService; + + private static final RowMapper USER_ROW_MAPPER = (rs, n) -> new SystemUser( + rs.getLong("id"), + rs.getString("user_name"), + rs.getString("phone"), + rs.getString("email"), + rs.getString("status"), + rs.getString("valid_from"), + rs.getString("valid_to") + ); + + private static final RowMapper ROLE_ROW_MAPPER = (rs, n) -> new RoleInfo( + rs.getLong("id"), + rs.getString("role_code"), + rs.getString("role_name"), + rs.getString("status") + ); + + private static final RowMapper PERMISSION_ROW_MAPPER = (rs, n) -> new PermissionInfo( + rs.getLong("id"), + rs.getString("permission_code"), + rs.getString("permission_name"), + rs.getString("module") + ); + + public PlatformIamService(JdbcTemplate jdbcTemplate, PasswordPolicyService passwordPolicyService, PasswordCodecService passwordCodecService) { + this.jdbcTemplate = jdbcTemplate; + this.passwordPolicyService = passwordPolicyService; + this.passwordCodecService = passwordCodecService; + } + + public PageResult listUsers() { + return listUsers(null); + } + + public PageResult listUsers(String keyword) { + String normalizedKeyword = keyword == null ? "" : keyword.trim(); + StringBuilder sql = new StringBuilder( + "SELECT id, user_name, phone, email, status, " + + "DATE_FORMAT(valid_from, '%Y-%m-%d %H:%i:%s') AS valid_from, " + + "DATE_FORMAT(valid_to, '%Y-%m-%d %H:%i:%s') AS valid_to " + + "FROM platform_user WHERE is_deleted=0" + ); + List args = new java.util.ArrayList(); + if (!normalizedKeyword.isEmpty()) { + sql.append(" AND (user_name LIKE ? OR phone LIKE ? OR email LIKE ?)"); + String like = "%" + normalizedKeyword + "%"; + args.add(like); + args.add(like); + args.add(like); + } + sql.append(" ORDER BY id DESC"); + List list = jdbcTemplate.query( + sql.toString(), + USER_ROW_MAPPER, + args.toArray() + ); + return new PageResult(list, list.size(), 1, 100); + } + + public SystemUser createUser(CreateUserRequest request) { + assertPhoneAvailable(request.getPhone(), null); + ImportValidationUtils.validateOptionalEmail(request.getEmail()); + ImportValidationUtils.validateDateRange(request.getValidFrom(), request.getValidTo()); + if (request.getPassword() == null || request.getPassword().trim().isEmpty()) { + throw new BusinessException(10001, "\u5bc6\u7801\u4e0d\u80fd\u4e3a\u7a7a"); + } + passwordPolicyService.validate(request.getPassword()); + String passwordHash = passwordCodecService.encode(request.getPassword()); + jdbcTemplate.update( + "INSERT INTO platform_user (user_name, phone, email, password_hash, status, valid_from, valid_to, is_deleted, created_by, updated_by) " + + "VALUES (?, ?, ?, ?, 'ENABLED', ?, ?, 0, 0, 0)", + request.getUserName().trim(), + request.getPhone().trim(), + normalizeOptionalText(request.getEmail()), + passwordHash, + parseTimestampOrDefault(request.getValidFrom(), LocalDateTime.now()), + parseTimestampOrDefault(request.getValidTo(), LocalDateTime.of(2099, 12, 31, 23, 59, 59)) + ); + Long id = jdbcTemplate.queryForObject("SELECT IFNULL(MAX(id), 0) FROM platform_user", Long.class); + return getUserById(id == null ? 0L : id); + } + + public SystemUser updateUser(Long id, CreateUserRequest request) { + assertUserExists(id); + assertPhoneAvailable(request.getPhone(), id); + ImportValidationUtils.validateOptionalEmail(request.getEmail()); + ImportValidationUtils.validateDateRange(request.getValidFrom(), request.getValidTo()); + String normalizedEmail = normalizeOptionalText(request.getEmail()); + if (request.getPassword() != null && !request.getPassword().trim().isEmpty()) { + passwordPolicyService.validate(request.getPassword()); + jdbcTemplate.update( + "UPDATE platform_user SET user_name=?, phone=?, email=?, password_hash=?, valid_from=?, valid_to=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", + request.getUserName().trim(), + request.getPhone().trim(), + normalizedEmail, + passwordCodecService.encode(request.getPassword()), + parseTimestampOrDefault(request.getValidFrom(), LocalDateTime.now()), + parseTimestampOrDefault(request.getValidTo(), LocalDateTime.of(2099, 12, 31, 23, 59, 59)), + id + ); + } else { + jdbcTemplate.update( + "UPDATE platform_user SET user_name=?, phone=?, email=?, valid_from=?, valid_to=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", + request.getUserName().trim(), + request.getPhone().trim(), + normalizedEmail, + parseTimestampOrDefault(request.getValidFrom(), LocalDateTime.now()), + parseTimestampOrDefault(request.getValidTo(), LocalDateTime.of(2099, 12, 31, 23, 59, 59)), + id + ); + } + return getUserById(id); + } + + public ImportResult importUsers(List users) { + ImportResult result = new ImportResult(); + result.setTotal(users == null ? 0 : users.size()); + if (users == null) { + return result; + } + Set batchPhones = new HashSet(); + for (int i = 0; i < users.size(); i++) { + ImportUserItemRequest item = users.get(i); + int rowNo = i + 2; + try { + Long roleId = validateImportUser(item, batchPhones); + CreateUserRequest request = new CreateUserRequest(); + request.setUserName(item.getUserName() == null ? null : item.getUserName().trim()); + request.setPhone(item.getPhone() == null ? null : item.getPhone().trim()); + request.setPassword(item.getPassword() == null ? null : item.getPassword().trim()); + request.setEmail(item.getEmail() == null ? null : item.getEmail().trim()); + request.setValidFrom(item.getValidFrom()); + request.setValidTo(item.getValidTo()); + SystemUser created = createUser(request); + if (roleId != null) { + assignRole(created.getId(), roleId); + } + result.markSuccess(); + } catch (Exception ex) { + result.addError(rowNo, buildUserIdentifier(item), resolveImportMessage(ex)); + } + } + return result; + } + + public void assignRole(AssignUserRoleRequest request) { + assignRole(request.getUserId(), request.getRoleId()); + } + + public void assignRole(Long userId, Long roleId) { + assertUserExists(userId); + assertRoleExists(roleId); + Integer exists = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM platform_user_role WHERE user_id=? AND role_id=?", + Integer.class, + userId, + roleId + ); + if (exists != null && exists > 0) { + return; + } + Long nextId = jdbcTemplate.queryForObject("SELECT IFNULL(MAX(id), 0) + 1 FROM platform_user_role", Long.class); + jdbcTemplate.update( + "INSERT INTO platform_user_role (id, user_id, role_id) VALUES (?, ?, ?)", + nextId == null ? 1L : nextId, + userId, + roleId + ); + } + + public void enableUser(Long userId) { + assertUserExists(userId); + jdbcTemplate.update("UPDATE platform_user SET status='ENABLED', updated_at=CURRENT_TIMESTAMP WHERE id=?", userId); + } + + public void disableUser(Long userId) { + assertUserExists(userId); + jdbcTemplate.update("UPDATE platform_user SET status='DISABLED', updated_at=CURRENT_TIMESTAMP WHERE id=?", userId); + } + + public void resetPassword(Long userId, ResetPasswordRequest request) { + assertUserExists(userId); + passwordPolicyService.validate(request.getNewPassword()); + jdbcTemplate.update( + "UPDATE platform_user SET password_hash=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", + passwordCodecService.encode(request.getNewPassword()), + userId + ); + } + + public void changeMyPassword(Long userId, String oldPassword, String newPassword) { + assertUserExists(userId); + passwordPolicyService.validate(newPassword); + String currentPasswordHash = jdbcTemplate.queryForObject( + "SELECT password_hash FROM platform_user WHERE id=? AND is_deleted=0 LIMIT 1", + String.class, + userId + ); + if (!passwordCodecService.matches(oldPassword, currentPasswordHash)) { + throw new BusinessException(11001, "鍘熷瘑鐮佷笉姝g‘"); + } + jdbcTemplate.update( + "UPDATE platform_user SET password_hash=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", + passwordCodecService.encode(newPassword), + userId + ); + } + + public void onSuccessfulLogin(Long userId, String rawPassword) { + List hashes = jdbcTemplate.queryForList( + "SELECT password_hash FROM platform_user WHERE id=? AND is_deleted=0 LIMIT 1", + String.class, + userId + ); + if (hashes.isEmpty()) { + return; + } + String currentHash = hashes.get(0); + if (passwordCodecService.isEncoded(currentHash) || !passwordCodecService.matches(rawPassword, currentHash)) { + return; + } + jdbcTemplate.update( + "UPDATE platform_user SET password_hash=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", + passwordCodecService.encode(rawPassword), + userId + ); + } + + public ProfilePreferencesInfo getMyPreferences(Long userId) { + assertUserExists(userId); + List> rows = jdbcTemplate.queryForList( + "SELECT ui_theme_mode, ui_density, ui_theme_scheme FROM platform_user WHERE id=? AND is_deleted=0 LIMIT 1", + userId + ); + if (rows.isEmpty()) { + throw new BusinessException(10003, "用户不存在"); + } + return new ProfilePreferencesInfo( + normalizeThemeMode(rows.get(0).get("ui_theme_mode")), + normalizeDensity(rows.get(0).get("ui_density")), + normalizeThemeScheme(rows.get(0).get("ui_theme_scheme")) + ); + } + + public ProfilePreferencesInfo updateMyPreferences(Long userId, UpdateProfilePreferencesRequest request) { + assertUserExists(userId); + String themeMode = normalizeThemeMode(request == null ? null : request.getThemeMode()); + String density = normalizeDensity(request == null ? null : request.getDensity()); + String themeScheme = normalizeThemeScheme(request == null ? null : request.getThemeScheme()); + jdbcTemplate.update( + "UPDATE platform_user SET ui_theme_mode=?, ui_density=?, ui_theme_scheme=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", + themeMode, + density, + themeScheme, + userId + ); + return new ProfilePreferencesInfo(themeMode, density, themeScheme); + } + + public PageResult listRoles() { + List list = jdbcTemplate.query( + "SELECT id, role_code, role_name, status FROM platform_role WHERE is_deleted=0 ORDER BY id DESC", + ROLE_ROW_MAPPER + ); + return new PageResult(list, list.size(), 1, 100); + } + + public RoleInfo createRole(CreateRoleRequest request) { + Integer exists = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM platform_role WHERE role_code=?", + Integer.class, + request.getRoleCode().trim() + ); + if (exists != null && exists > 0) { + throw new BusinessException(10001, "\u89d2\u8272\u7f16\u7801\u5df2\u5b58\u5728"); + } + Long nextId = jdbcTemplate.queryForObject("SELECT IFNULL(MAX(id), 0) + 1 FROM platform_role", Long.class); + long roleId = nextId == null ? 1L : nextId; + jdbcTemplate.update( + "INSERT INTO platform_role (id, role_code, role_name, status, is_deleted, created_by, updated_by) VALUES (?, ?, ?, 'ENABLED', 0, 0, 0)", + roleId, + request.getRoleCode().trim(), + request.getRoleName().trim() + ); + return new RoleInfo(roleId, request.getRoleCode().trim(), request.getRoleName().trim(), "ENABLED"); + } + + public RoleInfo updateRole(Long roleId, UpdateRoleRequest request) { + assertRoleExists(roleId); + jdbcTemplate.update( + "UPDATE platform_role SET role_name=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", + request.getRoleName().trim(), + roleId + ); + return jdbcTemplate.queryForObject( + "SELECT id, role_code, role_name, status FROM platform_role WHERE id=?", + ROLE_ROW_MAPPER, + roleId + ); + } + + public void enableRole(Long roleId) { + updateRoleStatus(roleId, "ENABLED"); + } + + public void disableRole(Long roleId) { + updateRoleStatus(roleId, "DISABLED"); + } + + public PageResult listPermissions() { + List list = jdbcTemplate.query( + "SELECT id, permission_code, permission_name, module FROM platform_permission ORDER BY module ASC, id ASC", + PERMISSION_ROW_MAPPER + ); + return new PageResult(list, list.size(), 1, 200); + } + + public List getRolePermissionIds(Long roleId) { + assertRoleExists(roleId); + return jdbcTemplate.queryForList( + "SELECT permission_id FROM platform_role_permission WHERE role_id=? ORDER BY permission_id ASC", + Long.class, + roleId + ); + } + + public void bindRolePermissions(Long roleId, BindRolePermissionsRequest request) { + assertRoleExists(roleId); + jdbcTemplate.update("DELETE FROM platform_role_permission WHERE role_id=?", roleId); + if (request.getPermissionIds() == null || request.getPermissionIds().isEmpty()) { + return; + } + Long nextId = jdbcTemplate.queryForObject("SELECT IFNULL(MAX(id), 0) + 1 FROM platform_role_permission", Long.class); + long currentId = nextId == null ? 1L : nextId; + for (Long permissionId : request.getPermissionIds()) { + Integer permExists = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM platform_permission WHERE id=?", + Integer.class, + permissionId + ); + if (permExists == null || permExists == 0) { + throw new BusinessException(10003, "\u6743\u9650\u4e0d\u5b58\u5728"); + } + jdbcTemplate.update( + "INSERT INTO platform_role_permission (id, role_id, permission_id) VALUES (?, ?, ?)", + currentId++, + roleId, + permissionId + ); + } + } + + private SystemUser getUserById(Long userId) { + List list = jdbcTemplate.query( + "SELECT id, user_name, phone, email, status, " + + "DATE_FORMAT(valid_from, '%Y-%m-%d %H:%i:%s') AS valid_from, " + + "DATE_FORMAT(valid_to, '%Y-%m-%d %H:%i:%s') AS valid_to " + + "FROM platform_user WHERE id=? AND is_deleted=0", + USER_ROW_MAPPER, + userId + ); + if (list.isEmpty()) { + throw new BusinessException(10003, "\u7528\u6237\u4e0d\u5b58\u5728"); + } + return list.get(0); + } + + private void assertUserExists(Long userId) { + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM platform_user WHERE id=? AND is_deleted=0", + Integer.class, + userId + ); + if (count == null || count == 0) { + throw new BusinessException(10003, "\u7528\u6237\u4e0d\u5b58\u5728"); + } + } + + private void assertRoleExists(Long roleId) { + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM platform_role WHERE id=? AND is_deleted=0", + Integer.class, + roleId + ); + if (count == null || count == 0) { + throw new BusinessException(10003, "\u89d2\u8272\u4e0d\u5b58\u5728"); + } + } + + private void updateRoleStatus(Long roleId, String status) { + assertRoleExists(roleId); + jdbcTemplate.update( + "UPDATE platform_role SET status=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", + status, + roleId + ); + } + + private void assertPhoneAvailable(String phone, Long excludeUserId) { + ImportValidationUtils.validatePhone(phone); + String normalizedPhone = phone.trim(); + Integer count; + if (excludeUserId == null) { + count = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM platform_user WHERE phone=? AND is_deleted=0", + Integer.class, + normalizedPhone + ); + } else { + count = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM platform_user WHERE phone=? AND is_deleted=0 AND id<>?", + Integer.class, + normalizedPhone, + excludeUserId + ); + } + if (count != null && count > 0) { + throw new BusinessException(10001, "\u624b\u673a\u53f7\u5df2\u5b58\u5728"); + } + } + + private Long validateImportUser(ImportUserItemRequest item, Set batchPhones) { + if (item == null) { + throw new BusinessException(10001, "\u5bfc\u5165\u884c\u4e0d\u80fd\u4e3a\u7a7a"); + } + if (item.getUserName() == null || item.getUserName().trim().isEmpty()) { + throw new BusinessException(10001, "\u7528\u6237\u540d\u4e0d\u80fd\u4e3a\u7a7a"); + } + if (item.getPassword() == null || item.getPassword().trim().isEmpty()) { + throw new BusinessException(10001, "\u5bc6\u7801\u4e0d\u80fd\u4e3a\u7a7a"); + } + ImportValidationUtils.validatePhone(item.getPhone()); + ImportValidationUtils.validateOptionalEmail(item.getEmail()); + ImportValidationUtils.validateDateRange(item.getValidFrom(), item.getValidTo()); + passwordPolicyService.validate(item.getPassword().trim()); + String phone = ImportValidationUtils.trim(item.getPhone()); + if (!batchPhones.add(phone)) { + throw new BusinessException(10001, "\u6279\u6b21\u5185\u624b\u673a\u53f7\u91cd\u590d"); + } + String roleCode = ImportValidationUtils.trim(item.getRoleCode()); + if (roleCode.isEmpty()) { + return null; + } + Long roleId = findRoleIdByCode(roleCode); + if (roleId == null) { + throw new BusinessException(10001, "\u89d2\u8272\u7f16\u7801\u4e0d\u5b58\u5728: " + roleCode); + } + return roleId; + } + + private Long findRoleIdByCode(String roleCode) { + List ids = jdbcTemplate.queryForList( + "SELECT id FROM platform_role WHERE role_code=? AND is_deleted=0 LIMIT 1", + Long.class, + roleCode + ); + return ids.isEmpty() ? null : ids.get(0); + } + + private String buildUserIdentifier(ImportUserItemRequest item) { + if (item == null) { + return ""; + } + String userName = item.getUserName() == null ? "" : item.getUserName().trim(); + String phone = item.getPhone() == null ? "" : item.getPhone().trim(); + if (!userName.isEmpty() && !phone.isEmpty()) { + return userName + "/" + phone; + } + return !userName.isEmpty() ? userName : phone; + } + + private String resolveImportMessage(Exception ex) { + if (ex instanceof BusinessException) { + return ex.getMessage(); + } + return ex.getMessage() == null || ex.getMessage().trim().isEmpty() + ? "\u5bfc\u5165\u5931\u8d25" + : ex.getMessage(); + } + + private String normalizeOptionalText(String raw) { + String value = ImportValidationUtils.trim(raw); + return value.isEmpty() ? null : value; + } + + private Timestamp parseTimestampOrDefault(String raw, LocalDateTime defaultValue) { + if (raw == null || raw.trim().isEmpty()) { + return Timestamp.valueOf(defaultValue); + } + String normalized = normalizeDateTimeString(raw); + return Timestamp.valueOf(LocalDateTime.parse(normalized, DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))); + } + + private String normalizeDateTimeString(String raw) { + String normalized = raw.trim().replace("T", " "); + if (normalized.length() == 16) { + return normalized + ":00"; + } + return normalized; + } + + public String normalizeThemeMode(Object raw) { + String value = raw == null ? "" : String.valueOf(raw).trim().toUpperCase(); + if ("LIGHT".equals(value) || "DARK".equals(value) || "SYSTEM".equals(value)) { + return value; + } + return "SYSTEM"; + } + + public String normalizeDensity(Object raw) { + String value = raw == null ? "" : String.valueOf(raw).trim().toUpperCase(); + if ("COMPACT".equals(value) || "COMFORTABLE".equals(value)) { + return value; + } + return "COMFORTABLE"; + } + + public String normalizeThemeScheme(Object raw) { + String value = raw == null ? "" : String.valueOf(raw).trim().toUpperCase(); + if ( + "SLATE".equals(value) || + "OCEAN".equals(value) || + "FOREST".equals(value) || + "GRAPHITE".equals(value) || + "AMBER".equals(value) || + "RUBY".equals(value) || + "MIST".equals(value) || + "SAGE".equals(value) || + "DAWN".equals(value) + ) { + return value; + } + return "SLATE"; + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/service/PlatformMenuService.java b/backend/src/main/java/com/writeoff/module/system/service/PlatformMenuService.java new file mode 100644 index 0000000..a264d76 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/service/PlatformMenuService.java @@ -0,0 +1,239 @@ +package com.writeoff.module.system.service; + +import com.writeoff.common.api.PageResult; +import com.writeoff.common.exception.BusinessException; +import com.writeoff.module.system.dto.BindPlatformMenuRolesRequest; +import com.writeoff.module.system.dto.BindRoleMenusRequest; +import com.writeoff.module.system.dto.CreateMenuRequest; +import com.writeoff.module.system.dto.ReorderMenusRequest; +import com.writeoff.module.system.dto.UpdateMenuRequest; +import com.writeoff.module.system.model.MenuInfo; +import com.writeoff.security.PermissionService; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +public class PlatformMenuService { + private final JdbcTemplate jdbcTemplate; + private final PermissionService permissionService; + + private static final RowMapper MENU_ROW_MAPPER = (rs, n) -> new MenuInfo( + rs.getLong("id"), + rs.getString("menu_code"), + rs.getString("menu_name"), + rs.getString("route_path"), + rs.getString("permission_code"), + rs.getInt("sort_no"), + rs.getString("status") + ); + + public PlatformMenuService(JdbcTemplate jdbcTemplate, PermissionService permissionService) { + this.jdbcTemplate = jdbcTemplate; + this.permissionService = permissionService; + } + + public PageResult list() { + List list = jdbcTemplate.query( + "SELECT id, menu_code, menu_name, route_path, permission_code, sort_no, status " + + "FROM platform_menu WHERE is_deleted=0 ORDER BY sort_no ASC, id ASC", + MENU_ROW_MAPPER + ); + return new PageResult(list, list.size(), 1, 200); + } + + public List currentUserMenus(Long userId) { + List menus = jdbcTemplate.query( + "SELECT DISTINCT m.id, m.menu_code, m.menu_name, m.route_path, m.permission_code, m.sort_no, m.status " + + "FROM platform_user_role ur " + + "JOIN platform_role_menu rm ON ur.role_id=rm.role_id " + + "JOIN platform_menu m ON rm.menu_id=m.id " + + "WHERE ur.user_id=? AND m.is_deleted=0 AND m.status='ENABLED' " + + "ORDER BY m.sort_no ASC, m.id ASC", + MENU_ROW_MAPPER, + userId + ); + java.util.List perms = permissionService.getPlatformPermissions(userId); + java.util.Set permSet = new java.util.HashSet(perms); + java.util.List filtered = new java.util.ArrayList(); + for (MenuInfo menu : menus) { + String code = menu.getPermissionCode(); + if (code == null || code.trim().isEmpty() || permSet.contains(code)) { + filtered.add(menu); + } + } + return filtered; + } + + public MenuInfo create(CreateMenuRequest request) { + assertPermissionExists(request.getPermissionCode().trim()); + Integer dup = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM platform_menu WHERE menu_code=?", + Integer.class, + request.getMenuCode().trim() + ); + if (dup != null && dup > 0) { + throw new BusinessException(10001, "菜单编码已存在"); + } + jdbcTemplate.update( + "INSERT INTO platform_menu (menu_code, menu_name, route_path, permission_code, sort_no, status, is_deleted, created_by, updated_by) " + + "VALUES (?, ?, ?, ?, ?, 'ENABLED', 0, 0, 0)", + request.getMenuCode().trim(), + request.getMenuName().trim(), + request.getRoutePath().trim(), + request.getPermissionCode().trim(), + request.getSortNo() + ); + Long id = jdbcTemplate.queryForObject("SELECT IFNULL(MAX(id), 0) FROM platform_menu", Long.class); + return findById(id == null ? 0L : id); + } + + public MenuInfo update(Long id, UpdateMenuRequest request) { + assertMenuExists(id); + assertPermissionExists(request.getPermissionCode().trim()); + String status = request.getStatus().trim().toUpperCase(); + if (!"ENABLED".equals(status) && !"DISABLED".equals(status)) { + throw new BusinessException(10001, "菜单状态非法"); + } + jdbcTemplate.update( + "UPDATE platform_menu SET menu_name=?, route_path=?, permission_code=?, sort_no=?, status=?, updated_at=CURRENT_TIMESTAMP " + + "WHERE id=?", + request.getMenuName().trim(), + request.getRoutePath().trim(), + request.getPermissionCode().trim(), + request.getSortNo(), + status, + id + ); + return findById(id); + } + + public void reorderMenus(ReorderMenusRequest request) { + if (request.getMenus() == null || request.getMenus().isEmpty()) { + return; + } + for (ReorderMenusRequest.MenuSortItem item : request.getMenus()) { + assertMenuExists(item.getId()); + jdbcTemplate.update( + "UPDATE platform_menu SET sort_no=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", + item.getSortNo(), + item.getId() + ); + } + } + + public List getMenuRoleIds(Long menuId) { + assertMenuExists(menuId); + return jdbcTemplate.queryForList( + "SELECT role_id FROM platform_role_menu WHERE menu_id=? ORDER BY role_id ASC", + Long.class, + menuId + ); + } + + public List getMenuRoleNames(Long menuId) { + assertMenuExists(menuId); + return jdbcTemplate.queryForList( + "SELECT r.role_name FROM platform_role_menu rm " + + "JOIN platform_role r ON rm.role_id=r.id " + + "WHERE rm.menu_id=? AND r.is_deleted=0 ORDER BY r.id ASC", + String.class, + menuId + ); + } + + public void bindMenuRoles(Long menuId, BindPlatformMenuRolesRequest request) { + assertMenuExists(menuId); + jdbcTemplate.update("DELETE FROM platform_role_menu WHERE menu_id=?", menuId); + if (request.getRoleIds() == null || request.getRoleIds().isEmpty()) { + return; + } + Long nextId = jdbcTemplate.queryForObject("SELECT IFNULL(MAX(id), 0) + 1 FROM platform_role_menu", Long.class); + long currentId = nextId == null ? 1L : nextId; + for (Long roleId : request.getRoleIds()) { + assertRoleExists(roleId); + jdbcTemplate.update( + "INSERT INTO platform_role_menu (id, role_id, menu_id) VALUES (?, ?, ?)", + currentId++, + roleId, + menuId + ); + } + } + + public List getRoleMenuIds(Long roleId) { + assertRoleExists(roleId); + return jdbcTemplate.queryForList( + "SELECT menu_id FROM platform_role_menu WHERE role_id=? ORDER BY menu_id ASC", + Long.class, + roleId + ); + } + + public void bindRoleMenus(Long roleId, BindRoleMenusRequest request) { + assertRoleExists(roleId); + jdbcTemplate.update("DELETE FROM platform_role_menu WHERE role_id=?", roleId); + if (request.getMenuIds() == null || request.getMenuIds().isEmpty()) { + return; + } + Long nextId = jdbcTemplate.queryForObject("SELECT IFNULL(MAX(id), 0) + 1 FROM platform_role_menu", Long.class); + long currentId = nextId == null ? 1L : nextId; + for (Long menuId : request.getMenuIds()) { + assertMenuExists(menuId); + jdbcTemplate.update( + "INSERT INTO platform_role_menu (id, role_id, menu_id) VALUES (?, ?, ?)", + currentId++, + roleId, + menuId + ); + } + } + + private MenuInfo findById(Long id) { + List list = jdbcTemplate.query( + "SELECT id, menu_code, menu_name, route_path, permission_code, sort_no, status " + + "FROM platform_menu WHERE id=? AND is_deleted=0", + MENU_ROW_MAPPER, + id + ); + if (list.isEmpty()) { + throw new BusinessException(10003, "菜单不存在"); + } + return list.get(0); + } + + private void assertMenuExists(Long menuId) { + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM platform_menu WHERE id=? AND is_deleted=0", + Integer.class, + menuId + ); + if (count == null || count == 0) { + throw new BusinessException(10003, "菜单不存在"); + } + } + + private void assertRoleExists(Long roleId) { + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM platform_role WHERE id=? AND is_deleted=0", + Integer.class, + roleId + ); + if (count == null || count == 0) { + throw new BusinessException(10003, "角色不存在"); + } + } + + private void assertPermissionExists(String permissionCode) { + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM platform_permission WHERE permission_code=?", + Integer.class, + permissionCode + ); + if (count == null || count == 0) { + throw new BusinessException(10003, "权限码不存在"); + } + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/service/PlatformRoleService.java b/backend/src/main/java/com/writeoff/module/system/service/PlatformRoleService.java new file mode 100644 index 0000000..ddc28d1 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/service/PlatformRoleService.java @@ -0,0 +1,33 @@ +package com.writeoff.module.system.service; + +import com.writeoff.common.api.PageResult; +import com.writeoff.module.system.model.PlatformRoleInfo; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +public class PlatformRoleService { + private final JdbcTemplate jdbcTemplate; + + private static final RowMapper ROLE_ROW_MAPPER = (rs, n) -> new PlatformRoleInfo( + rs.getLong("id"), + rs.getString("role_code"), + rs.getString("role_name"), + rs.getString("status") + ); + + public PlatformRoleService(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + public PageResult list() { + List list = jdbcTemplate.query( + "SELECT id, role_code, role_name, status FROM platform_role WHERE is_deleted=0 ORDER BY id DESC", + ROLE_ROW_MAPPER + ); + return new PageResult(list, list.size(), 1, 100); + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/service/SystemUserService.java b/backend/src/main/java/com/writeoff/module/system/service/SystemUserService.java new file mode 100644 index 0000000..4d70a84 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/service/SystemUserService.java @@ -0,0 +1,962 @@ +package com.writeoff.module.system.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.writeoff.common.api.PageResult; +import com.writeoff.common.exception.BusinessException; +import com.writeoff.common.model.ImportResult; +import com.writeoff.common.util.ImportValidationUtils; +import com.writeoff.module.notification.dto.DispatchNotificationRequest; +import com.writeoff.module.notification.service.NotificationDispatchService; +import com.writeoff.module.system.dto.CreateRoleRequest; +import com.writeoff.module.system.dto.CreateUserRequest; +import com.writeoff.module.system.dto.BindRolePermissionsRequest; +import com.writeoff.module.system.dto.ImportUserItemRequest; +import com.writeoff.module.system.dto.ResetPasswordRequest; +import com.writeoff.module.system.dto.UpdateProfilePreferencesRequest; +import com.writeoff.module.system.dto.UpdateRoleRequest; +import com.writeoff.module.system.model.PermissionInfo; +import com.writeoff.module.system.model.ProfilePreferencesInfo; +import com.writeoff.module.system.model.RoleInfo; +import com.writeoff.module.system.model.SystemUser; +import com.writeoff.module.system.model.UserRoleHistory; +import com.writeoff.security.AuthContext; +import com.writeoff.security.PasswordCodecService; +import com.writeoff.security.PasswordPolicyService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.jdbc.support.GeneratedKeyHolder; +import org.springframework.jdbc.support.KeyHolder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; + +import java.sql.PreparedStatement; +import java.sql.Statement; +import java.sql.Timestamp; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Collectors; + +@Service +public class SystemUserService { + private static final Logger log = LoggerFactory.getLogger(SystemUserService.class); + private final JdbcTemplate jdbcTemplate; + private final DataPermissionService dataPermissionService; + private final NotificationDispatchService notificationDispatchService; + private final PasswordPolicyService passwordPolicyService; + private final PasswordCodecService passwordCodecService; + private final TransactionTemplate transactionTemplate; + private final ObjectMapper objectMapper = new ObjectMapper(); + + private static final RowMapper USER_ROW_MAPPER = (rs, n) -> new SystemUser( + rs.getLong("id"), + rs.getString("user_name"), + rs.getString("phone"), + rs.getString("email"), + rs.getString("status"), + rs.getString("valid_from"), + rs.getString("valid_to"), + rs.getString("role_codes"), + rs.getString("role_names"), + rs.getInt("is_deleted") == 1 + ); + + private static final RowMapper ROLE_ROW_MAPPER = (rs, n) -> new RoleInfo( + rs.getLong("id"), + rs.getString("role_code"), + rs.getString("role_name"), + rs.getString("status") + ); + private static final RowMapper PERMISSION_ROW_MAPPER = (rs, n) -> new PermissionInfo( + rs.getLong("id"), + rs.getString("permission_code"), + rs.getString("permission_name"), + rs.getString("module") + ); + + private static final RowMapper USER_ROLE_HISTORY_ROW_MAPPER = (rs, n) -> new UserRoleHistory( + rs.getLong("id"), + rs.getLong("user_id"), + rs.getObject("old_role_id") == null ? null : rs.getLong("old_role_id"), + rs.getLong("new_role_id"), + rs.getString("action_type"), + rs.getString("created_at") + ); + + public SystemUserService(JdbcTemplate jdbcTemplate, + DataPermissionService dataPermissionService, + NotificationDispatchService notificationDispatchService, + PasswordPolicyService passwordPolicyService, + PasswordCodecService passwordCodecService, + PlatformTransactionManager transactionManager) { + this.jdbcTemplate = jdbcTemplate; + this.dataPermissionService = dataPermissionService; + this.notificationDispatchService = notificationDispatchService; + this.passwordPolicyService = passwordPolicyService; + this.passwordCodecService = passwordCodecService; + this.transactionTemplate = new TransactionTemplate(transactionManager); + } + + public PageResult listUsers(int pageNo, int pageSize, Boolean includeDeleted) { + return listUsers(pageNo, pageSize, includeDeleted, null); + } + + public PageResult listUsers(int pageNo, int pageSize, Boolean includeDeleted, String keyword) { + int safePage = Math.max(pageNo, 1); + int safeSize = Math.min(Math.max(pageSize, 1), 100); + int offset = (safePage - 1) * safeSize; + boolean withDeleted = Boolean.TRUE.equals(includeDeleted); + String normalizedKeyword = keyword == null ? "" : keyword.trim(); + + StringBuilder totalWhereSql = new StringBuilder("WHERE u.tenant_id=?"); + StringBuilder dataWhereSql = new StringBuilder("WHERE u.tenant_id=?"); + List whereArgs = new ArrayList(); + whereArgs.add(tenantId()); + if (!withDeleted) { + totalWhereSql.append(" AND u.is_deleted=0"); + dataWhereSql.append(" AND u.is_deleted=0"); + } + if (!normalizedKeyword.isEmpty()) { + String like = "%" + normalizedKeyword + "%"; + totalWhereSql.append(" AND (u.user_name LIKE ? OR u.phone LIKE ? OR u.email LIKE ?)"); + dataWhereSql.append(" AND (u.user_name LIKE ? OR u.phone LIKE ? OR u.email LIKE ?)"); + whereArgs.add(like); + whereArgs.add(like); + whereArgs.add(like); + } + + Integer total = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM sys_user u " + totalWhereSql, + Integer.class, + whereArgs.toArray() + ); + long totalCount = total == null ? 0 : total; + + List dataArgs = new ArrayList(whereArgs); + dataArgs.add(safeSize); + dataArgs.add(offset); + List list = jdbcTemplate.query( + "SELECT u.id, u.user_name, u.phone, u.email, u.status, " + + "DATE_FORMAT(u.valid_from, '%Y-%m-%d %H:%i:%s') AS valid_from, " + + "DATE_FORMAT(u.valid_to, '%Y-%m-%d %H:%i:%s') AS valid_to, " + + "COALESCE(GROUP_CONCAT(DISTINCT r.role_code ORDER BY r.id SEPARATOR ','), '') AS role_codes, " + + "COALESCE(GROUP_CONCAT(DISTINCT r.role_name ORDER BY r.id SEPARATOR ','), '') AS role_names, " + + "u.is_deleted AS is_deleted " + + "FROM sys_user u " + + "LEFT JOIN user_role ur ON u.tenant_id=ur.tenant_id AND u.id=ur.user_id " + + "LEFT JOIN role r ON ur.tenant_id=r.tenant_id AND ur.role_id=r.id AND r.is_deleted=0 " + + dataWhereSql + " " + + "GROUP BY u.id, u.user_name, u.phone, u.email, u.status, u.valid_from, u.valid_to, u.is_deleted " + + "ORDER BY u.id DESC LIMIT ? OFFSET ?", + USER_ROW_MAPPER, + dataArgs.toArray() + ); + if (dataPermissionService != null) { + DataPermissionService.DataScope scope = dataPermissionService.resolveCurrentUserScope(); + Map creatorMap = listUserCreators(list.stream().map(SystemUser::getId).collect(Collectors.toList())); + list = list.stream() + .filter(user -> dataPermissionService.canAccessUser(user.getId(), creatorMap.get(user.getId()), scope)) + .collect(Collectors.toList()); + } + return new PageResult<>(list, totalCount, safePage, safeSize); + } + + public PageResult listUsers(int pageNo, int pageSize) { + return listUsers(pageNo, pageSize, false, null); + } + + public SystemUser createUser(CreateUserRequest request) { + final String userName = request.getUserName() == null ? "" : request.getUserName().trim(); + final String phone = request.getPhone() == null ? "" : request.getPhone().trim(); + final String email = request.getEmail() == null ? "" : request.getEmail().trim(); + final String rawPassword = request.getPassword() == null ? "" : request.getPassword().trim(); + if (rawPassword.isEmpty()) { + throw new BusinessException(10001, "\u5bc6\u7801\u4e0d\u80fd\u4e3a\u7a7a"); + } + passwordPolicyService.validate(rawPassword); + final String validFrom = request.getValidFrom() == null || request.getValidFrom().trim().isEmpty() + ? LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")) + : normalizeDateTimeString(request.getValidFrom()); + final String validTo = request.getValidTo() == null || request.getValidTo().trim().isEmpty() + ? "2099-12-31 23:59:59" + : normalizeDateTimeString(request.getValidTo()); + return transactionTemplate.execute(status -> { + assertPhoneAvailable(phone, null); + String passwordHash = passwordCodecService.encode(rawPassword); + String tenantSwitchAccountKey = resolveTenantSwitchAccountKeyByPhone(phone, null); + KeyHolder keyHolder = new GeneratedKeyHolder(); + jdbcTemplate.update(connection -> { + PreparedStatement ps = connection.prepareStatement( + "INSERT INTO sys_user (tenant_id, user_name, phone, email, password_hash, tenant_switch_account_key, status, valid_from, valid_to, created_by, updated_by) " + + "VALUES (?, ?, ?, ?, ?, ?, 'ENABLED', ?, ?, ?, ?)", + Statement.RETURN_GENERATED_KEYS + ); + Timestamp validFromTimestamp = parseTimestamp(validFrom); + Timestamp validToTimestamp = parseTimestamp(validTo); + Long operator = safeUserId(); + ps.setLong(1, tenantId()); + ps.setString(2, userName); + ps.setString(3, phone); + ps.setString(4, email); + ps.setString(5, passwordHash); + ps.setString(6, tenantSwitchAccountKey); + ps.setTimestamp(7, validFromTimestamp); + ps.setTimestamp(8, validToTimestamp); + ps.setLong(9, operator); + ps.setLong(10, operator); + return ps; + }, keyHolder); + Long id = keyHolder.getKey() == null ? null : keyHolder.getKey().longValue(); + autoAssignExecutorRoleWhenCreatorIsProjectExecutor(id); + sendUserCreatedMail(id, userName, phone, email, validFrom, validTo); + return new SystemUser(id, userName, phone, email, "ENABLED", validFrom, validTo, "", ""); + }); + } + + public SystemUser updateUser(Long userId, CreateUserRequest request) { + assertUserExists(userId); + assertPhoneAvailable(request.getPhone(), userId); + String loadedTenantSwitchAccountKey = loadTenantSwitchAccountKey(userId); + final String tenantSwitchAccountKey = + loadedTenantSwitchAccountKey == null || loadedTenantSwitchAccountKey.trim().isEmpty() + ? resolveTenantSwitchAccountKeyByPhone(request.getPhone(), userId) + : loadedTenantSwitchAccountKey; + jdbcTemplate.update(connection -> { + PreparedStatement ps = connection.prepareStatement( + "UPDATE sys_user SET user_name=?, phone=?, email=?, tenant_switch_account_key=?, valid_from=?, valid_to=?, updated_by=?, updated_at=CURRENT_TIMESTAMP" + + (request.getPassword() == null || request.getPassword().trim().isEmpty() ? "" : ", password_hash=?") + + " WHERE tenant_id=? AND id=?" + ); + Timestamp validFrom = parseTimestamp(request.getValidFrom()); + Timestamp validTo = parseTimestamp(request.getValidTo()); + Long operator = safeUserId(); + int idx = 1; + ps.setString(idx++, request.getUserName()); + ps.setString(idx++, request.getPhone()); + ps.setString(idx++, request.getEmail()); + ps.setString(idx++, tenantSwitchAccountKey); + ps.setTimestamp(idx++, validFrom == null ? Timestamp.valueOf(LocalDateTime.now()) : validFrom); + ps.setTimestamp(idx++, validTo == null ? Timestamp.valueOf(LocalDateTime.of(2099, 12, 31, 23, 59, 59)) : validTo); + ps.setLong(idx++, operator); + if (request.getPassword() != null && !request.getPassword().trim().isEmpty()) { + passwordPolicyService.validate(request.getPassword()); + ps.setString(idx++, passwordCodecService.encode(request.getPassword())); + } + ps.setLong(idx++, tenantId()); + ps.setLong(idx, userId); + return ps; + }); + String validFrom = request.getValidFrom() == null || request.getValidFrom().trim().isEmpty() + ? LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")) + : normalizeDateTimeString(request.getValidFrom()); + String validTo = request.getValidTo() == null || request.getValidTo().trim().isEmpty() + ? "2099-12-31 23:59:59" + : normalizeDateTimeString(request.getValidTo()); + return new SystemUser(userId, request.getUserName(), request.getPhone(), request.getEmail(), null, validFrom, validTo, "", ""); + } + + public ImportResult importUsers(List users) { + ImportResult result = new ImportResult(); + result.setTotal(users == null ? 0 : users.size()); + if (users == null) { + return result; + } + Set batchPhones = new HashSet(); + for (int i = 0; i < users.size(); i++) { + ImportUserItemRequest item = users.get(i); + int rowNo = i + 2; + try { + Long roleId = validateImportUser(item, batchPhones); + CreateUserRequest request = new CreateUserRequest(); + request.setUserName(item.getUserName() == null ? null : item.getUserName().trim()); + request.setPhone(item.getPhone() == null ? null : item.getPhone().trim()); + request.setPassword(item.getPassword() == null ? null : item.getPassword().trim()); + request.setEmail(item.getEmail() == null ? null : item.getEmail().trim()); + request.setValidFrom(item.getValidFrom()); + request.setValidTo(item.getValidTo()); + SystemUser created = createUser(request); + if (roleId != null) { + assignRole(created.getId(), roleId); + } + result.markSuccess(); + } catch (Exception ex) { + result.addError(rowNo, buildUserIdentifier(item), resolveImportMessage(ex)); + } + } + return result; + } + + public PageResult listRoles(int pageNo, int pageSize) { + int safePage = Math.max(pageNo, 1); + int safeSize = Math.min(Math.max(pageSize, 1), 100); + int offset = (safePage - 1) * safeSize; + + Integer total = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM role WHERE tenant_id=? AND is_deleted=0", + Integer.class, + tenantId() + ); + long totalCount = total == null ? 0 : total; + + List list = jdbcTemplate.query( + "SELECT * FROM role WHERE tenant_id=? AND is_deleted=0 ORDER BY id DESC LIMIT ? OFFSET ?", + ROLE_ROW_MAPPER, + tenantId(), + safeSize, + offset + ); + return new PageResult<>(list, totalCount, safePage, safeSize); + } + + public void assignRole(Long userId, Long roleId) { + Integer userExists = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM sys_user WHERE id=? AND is_deleted=0", + Integer.class, + userId + ); + if (userExists == null || userExists == 0) { + throw new BusinessException(10003, "用户不存在"); + } + Integer roleExists = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM role WHERE id=? AND is_deleted=0", + Integer.class, + roleId + ); + if (roleExists == null || roleExists == 0) { + throw new BusinessException(10003, "角色不存在"); + } + + Integer relationExists = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM user_role WHERE tenant_id=? AND user_id=? AND role_id=?", + Integer.class, + tenantId(), + userId, + roleId + ); + if (relationExists != null && relationExists > 0) { + return; + } + Long oldRoleId = currentRoleId(userId); + Long nextId = jdbcTemplate.queryForObject("SELECT IFNULL(MAX(id), 0) + 1 FROM user_role", Long.class); + jdbcTemplate.update( + "INSERT INTO user_role (id, tenant_id, user_id, role_id) VALUES (?, ?, ?, ?)", + nextId, + tenantId(), + userId, + roleId + ); + jdbcTemplate.update( + "INSERT INTO user_role_history (tenant_id, user_id, old_role_id, new_role_id, action_type, action_reason, created_by) VALUES (?, ?, ?, ?, 'ASSIGN', ?, 0)", + tenantId(), + userId, + oldRoleId, + roleId, + "用户分配角色" + ); + } + + public List getUserRoles(Long userId) { + return getUserRoles(userId, tenantId()); + } + + public List getUserRoles(Long userId, Long tenantId) { + if (tenantId == null) { + throw new BusinessException(10001, "租户信息不能为空"); + } + List roleCodes = jdbcTemplate.queryForList( + "SELECT r.role_code FROM user_role ur " + + "JOIN role r ON ur.role_id=r.id " + + "WHERE ur.user_id=? AND ur.tenant_id=? AND r.is_deleted=0", + String.class, + userId, + tenantId + ); + return roleCodes == null ? new ArrayList() : roleCodes; + } + + public void enableUser(Long userId) { + assertUserExists(userId); + jdbcTemplate.update("UPDATE sys_user SET status='ENABLED', updated_at=CURRENT_TIMESTAMP WHERE id=?", userId); + } + + public void disableUser(Long userId) { + assertUserExists(userId); + jdbcTemplate.update("UPDATE sys_user SET status='DISABLED', updated_at=CURRENT_TIMESTAMP WHERE id=?", userId); + } + + public void softDeleteUser(Long userId) { + assertUserExists(userId); + // 检查是否有待处理审核任务 + Integer pendingTasks = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM audit_task WHERE tenant_id=? AND assignee_user_id=? AND is_deleted=0 AND status='PENDING'", + Integer.class, + tenantId(), + userId + ); + if (pendingTasks != null && pendingTasks > 0) { + throw new BusinessException(10001, "该用户还有待处理的审核任务,请先转交后再删除"); + } + jdbcTemplate.update( + "UPDATE sys_user SET is_deleted=1, status='DISABLED', updated_by=?, updated_at=CURRENT_TIMESTAMP WHERE tenant_id=? AND id=?", + safeUserId(), + tenantId(), + userId + ); + } + + public void resetPassword(Long userId, ResetPasswordRequest request) { + assertUserExists(userId); + passwordPolicyService.validate(request.getNewPassword()); + jdbcTemplate.update( + "UPDATE sys_user SET password_hash=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", + passwordCodecService.encode(request.getNewPassword()), + userId + ); + } + + public void changeMyPassword(Long userId, String oldPassword, String newPassword) { + assertUserExists(userId); + passwordPolicyService.validate(newPassword); + String currentPasswordHash = jdbcTemplate.queryForObject( + "SELECT password_hash FROM sys_user WHERE tenant_id=? AND id=? AND is_deleted=0 LIMIT 1", + String.class, + tenantId(), + userId + ); + if (!passwordCodecService.matches(oldPassword, currentPasswordHash)) { + throw new BusinessException(11001, "原密码不正确"); + } + jdbcTemplate.update( + "UPDATE sys_user SET password_hash=?, updated_at=CURRENT_TIMESTAMP WHERE tenant_id=? AND id=?", + passwordCodecService.encode(newPassword), + tenantId(), + userId + ); + } + + public void onSuccessfulLogin(Long userId, Long tenantId, String phone, String rawPassword) { + upgradeStoredPasswordHash(userId, tenantId, rawPassword); + ensureTenantSwitchAccountKeyForPassword(phone, rawPassword); + } + + public ProfilePreferencesInfo getMyPreferences(Long userId) { + assertUserExists(userId); + List> rows = jdbcTemplate.queryForList( + "SELECT ui_theme_mode, ui_density, ui_theme_scheme FROM sys_user WHERE tenant_id=? AND id=? AND is_deleted=0 LIMIT 1", + tenantId(), + userId + ); + if (rows.isEmpty()) { + throw new BusinessException(10003, "用户不存在"); + } + return new ProfilePreferencesInfo( + normalizeThemeMode(rows.get(0).get("ui_theme_mode")), + normalizeDensity(rows.get(0).get("ui_density")), + normalizeThemeScheme(rows.get(0).get("ui_theme_scheme")) + ); + } + + public ProfilePreferencesInfo updateMyPreferences(Long userId, UpdateProfilePreferencesRequest request) { + assertUserExists(userId); + String themeMode = normalizeThemeMode(request == null ? null : request.getThemeMode()); + String density = normalizeDensity(request == null ? null : request.getDensity()); + String themeScheme = normalizeThemeScheme(request == null ? null : request.getThemeScheme()); + jdbcTemplate.update( + "UPDATE sys_user SET ui_theme_mode=?, ui_density=?, ui_theme_scheme=?, updated_by=?, updated_at=CURRENT_TIMESTAMP WHERE tenant_id=? AND id=?", + themeMode, + density, + themeScheme, + safeUserId(), + tenantId(), + userId + ); + return new ProfilePreferencesInfo(themeMode, density, themeScheme); + } + + public PageResult listUserRoleHistory(Long userId) { + assertUserExists(userId); + List list = jdbcTemplate.query( + "SELECT id, user_id, old_role_id, new_role_id, action_type, DATE_FORMAT(created_at, '%Y-%m-%d %H:%i:%s') AS created_at " + + "FROM user_role_history WHERE tenant_id=? AND user_id=? ORDER BY id DESC", + USER_ROLE_HISTORY_ROW_MAPPER, + tenantId(), + userId + ); + return new PageResult<>(list, list.size(), 1, 20); + } + + public RoleInfo createRole(CreateRoleRequest request) { + Integer exists = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM role WHERE tenant_id=? AND role_code=?", + Integer.class, + tenantId(), + request.getRoleCode() + ); + if (exists != null && exists > 0) { + throw new BusinessException(10001, "角色编码已存在"); + } + Long nextId = jdbcTemplate.queryForObject("SELECT IFNULL(MAX(id), 0) + 1 FROM role", Long.class); + jdbcTemplate.update( + "INSERT INTO role (id, tenant_id, role_code, role_name, status, is_deleted, created_by, updated_by) VALUES (?, ?, ?, ?, 'ENABLED', 0, 0, 0)", + nextId, + tenantId(), + request.getRoleCode(), + request.getRoleName() + ); + return new RoleInfo(nextId, request.getRoleCode(), request.getRoleName(), "ENABLED"); + } + + public RoleInfo updateRole(Long roleId, UpdateRoleRequest request) { + assertRoleExists(roleId); + jdbcTemplate.update( + "UPDATE role SET role_name=?, updated_at=CURRENT_TIMESTAMP WHERE tenant_id=? AND id=?", + request.getRoleName(), + tenantId(), + roleId + ); + return jdbcTemplate.queryForObject( + "SELECT * FROM role WHERE tenant_id=? AND id=?", + ROLE_ROW_MAPPER, + tenantId(), + roleId + ); + } + + public void enableRole(Long roleId) { + updateRoleStatus(roleId, "ENABLED"); + } + + public void disableRole(Long roleId) { + updateRoleStatus(roleId, "DISABLED"); + } + + public void softDeleteRole(Long roleId) { + assertRoleExists(roleId); + // 检查是否有活跃用户绑定 + Integer activeBindings = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM user_role ur " + + "JOIN sys_user u ON ur.tenant_id=u.tenant_id AND ur.user_id=u.id " + + "WHERE ur.tenant_id=? AND ur.role_id=? AND u.is_deleted=0 AND u.status='ENABLED'", + Integer.class, + tenantId(), + roleId + ); + if (activeBindings != null && activeBindings > 0) { + throw new BusinessException(10001, "该角色下仍有活跃用户,请先解绑后再删除"); + } + jdbcTemplate.update( + "UPDATE role SET is_deleted=1, status='DISABLED', updated_by=?, updated_at=CURRENT_TIMESTAMP WHERE tenant_id=? AND id=?", + safeUserId(), + tenantId(), + roleId + ); + } + + public PageResult listPermissions() { + List list = jdbcTemplate.query("SELECT * FROM permission ORDER BY module ASC, id ASC", PERMISSION_ROW_MAPPER); + return new PageResult<>(list, list.size(), 1, 200); + } + + public List getRolePermissionIds(Long roleId) { + assertRoleExists(roleId); + return jdbcTemplate.queryForList( + "SELECT permission_id FROM role_permission WHERE tenant_id=? AND role_id=? ORDER BY permission_id ASC", + Long.class, + tenantId(), + roleId + ); + } + + public void bindRolePermissions(Long roleId, BindRolePermissionsRequest request) { + assertRoleExists(roleId); + jdbcTemplate.update("DELETE FROM role_permission WHERE tenant_id=? AND role_id=?", tenantId(), roleId); + List ids = request.getPermissionIds(); + if (ids == null || ids.isEmpty()) { + return; + } + Long nextId = jdbcTemplate.queryForObject("SELECT IFNULL(MAX(id), 0) + 1 FROM role_permission", Long.class); + long currentId = nextId == null ? 1L : nextId; + for (Long permissionId : ids) { + jdbcTemplate.update( + "INSERT INTO role_permission (id, tenant_id, role_id, permission_id) VALUES (?, ?, ?, ?)", + currentId++, + tenantId(), + roleId, + permissionId + ); + } + } + + private void updateRoleStatus(Long roleId, String status) { + assertRoleExists(roleId); + jdbcTemplate.update("UPDATE role SET status=?, updated_at=CURRENT_TIMESTAMP WHERE tenant_id=? AND id=?", status, tenantId(), roleId); + } + + private void assertUserExists(Long userId) { + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM sys_user WHERE tenant_id=? AND id=? AND is_deleted=0", + Integer.class, + tenantId(), + userId + ); + if (count == null || count == 0) { + throw new BusinessException(10003, "用户不存在"); + } + } + + private void assertRoleExists(Long roleId) { + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM role WHERE tenant_id=? AND id=? AND is_deleted=0", + Integer.class, + tenantId(), + roleId + ); + if (count == null || count == 0) { + throw new BusinessException(10003, "角色不存在"); + } + } + + private Long currentRoleId(Long userId) { + List roleIds = jdbcTemplate.queryForList( + "SELECT role_id FROM user_role WHERE tenant_id=? AND user_id=? ORDER BY id DESC LIMIT 1", + Long.class, + tenantId(), + userId + ); + return roleIds.isEmpty() ? null : roleIds.get(0); + } + + private void autoAssignExecutorRoleWhenCreatorIsProjectExecutor(Long createdUserId) { + if (createdUserId == null || !currentUserHasRole("PROJECT_EXECUTOR")) { + return; + } + Long executorRoleId = findRoleIdByCode("EXECUTOR"); + if (executorRoleId == null) { + return; + } + assignRole(createdUserId, executorRoleId); + } + + private boolean currentUserHasRole(String roleCode) { + Long userId = AuthContext.userId(); + if (userId == null || roleCode == null || roleCode.trim().isEmpty()) { + return false; + } + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM user_role ur " + + "JOIN role r ON ur.tenant_id=r.tenant_id AND ur.role_id=r.id " + + "WHERE ur.tenant_id=? AND ur.user_id=? AND r.role_code=? AND r.is_deleted=0", + Integer.class, + tenantId(), + userId, + roleCode + ); + return count != null && count > 0; + } + + private Long findRoleIdByCode(String roleCode) { + List ids = jdbcTemplate.queryForList( + "SELECT id FROM role WHERE tenant_id=? AND role_code=? AND is_deleted=0 LIMIT 1", + Long.class, + tenantId(), + roleCode + ); + return ids.isEmpty() ? null : ids.get(0); + } + + private void assertPhoneAvailable(String phone, Long excludeUserId) { + if (phone == null || phone.trim().isEmpty()) { + throw new BusinessException(10001, "手机号不能为空"); + } + String normalizedPhone = phone.trim(); + Integer count; + if (excludeUserId == null) { + count = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM sys_user WHERE tenant_id=? AND phone=? AND is_deleted=0", + Integer.class, + tenantId(), + normalizedPhone + ); + } else { + count = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM sys_user WHERE tenant_id=? AND phone=? AND is_deleted=0 AND id<>?", + Integer.class, + tenantId(), + normalizedPhone, + excludeUserId + ); + } + if (count != null && count > 0) { + throw new BusinessException(10001, "手机号已存在"); + } + } + + private Long validateImportUser(ImportUserItemRequest item, Set batchPhones) { + if (item == null) { + throw new BusinessException(10001, "导入行不能为空"); + } + if (item.getUserName() == null || item.getUserName().trim().isEmpty()) { + throw new BusinessException(10001, "用户名不能为空"); + } + if (item.getPassword() == null || item.getPassword().trim().isEmpty()) { + throw new BusinessException(10001, "密码不能为空"); + } + ImportValidationUtils.validatePhone(item.getPhone()); + ImportValidationUtils.validateRequiredEmail(item.getEmail()); + ImportValidationUtils.validateDateRange(item.getValidFrom(), item.getValidTo()); + String phone = ImportValidationUtils.trim(item.getPhone()); + if (!batchPhones.add(phone)) { + throw new BusinessException(10001, "批次内手机号重复"); + } + passwordPolicyService.validate(item.getPassword().trim()); + String roleCode = ImportValidationUtils.trim(item.getRoleCode()); + if (roleCode.isEmpty()) { + return null; + } + Long roleId = findRoleIdByCode(roleCode); + if (roleId == null) { + throw new BusinessException(10001, "\u89d2\u8272\u7f16\u7801\u4e0d\u5b58\u5728: " + roleCode); + } + return roleId; + } + + private String buildUserIdentifier(ImportUserItemRequest item) { + if (item == null) { + return ""; + } + String userName = item.getUserName() == null ? "" : item.getUserName().trim(); + String phone = item.getPhone() == null ? "" : item.getPhone().trim(); + if (!userName.isEmpty() && !phone.isEmpty()) { + return userName + "/" + phone; + } + return !userName.isEmpty() ? userName : phone; + } + + private String resolveImportMessage(Exception ex) { + if (ex instanceof BusinessException) { + return ex.getMessage(); + } + return ex.getMessage() == null || ex.getMessage().trim().isEmpty() + ? "导入失败" + : ex.getMessage(); + } + + private void sendUserCreatedMail(Long userId, String userName, String phone, String email, String validFrom, String validTo) { + if (userId == null || userId <= 0) { + return; + } + try { + List> tenants = jdbcTemplate.queryForList( + "SELECT tenant_code, tenant_name FROM tenant WHERE id=? LIMIT 1", + tenantId() + ); + String tenantCode = tenants.isEmpty() ? "" : String.valueOf(tenants.get(0).get("tenant_code")); + String tenantName = tenants.isEmpty() ? "" : String.valueOf(tenants.get(0).get("tenant_name")); + String loginPath = "/" + tenantCode + "/login"; + Map variables = new LinkedHashMap(); + variables.put("userId", userId); + variables.put("targetUserId", userId); + variables.put("userName", userName); + variables.put("phone", phone); + variables.put("email", email); + variables.put("validFrom", validFrom); + variables.put("validTo", validTo); + variables.put("tenantCode", tenantCode); + variables.put("tenantName", tenantName); + variables.put("loginPath", loginPath); + DispatchNotificationRequest request = new DispatchNotificationRequest(); + request.setIdempotencyKey("user-created-" + tenantId() + "-" + userId); + request.setEventCode("USER_CREATED"); + request.setBizType("USER"); + request.setBizId("user-" + userId); + request.setVariablesJson(objectMapper.writeValueAsString(variables)); + notificationDispatchService.dispatch(request); + } catch (Exception ex) { + log.warn("auto trigger user created notification failed, tenantId={}, userId={}, err={}", + tenantId(), userId, ex.getMessage(), ex); + } + } + + private Long tenantId() { + return AuthContext.requireTenantId(); + } + + private void upgradeStoredPasswordHash(Long userId, Long tenantId, String rawPassword) { + List hashes = jdbcTemplate.queryForList( + "SELECT password_hash FROM sys_user WHERE tenant_id=? AND id=? AND is_deleted=0 LIMIT 1", + String.class, + tenantId, + userId + ); + if (hashes.isEmpty()) { + return; + } + String currentHash = hashes.get(0); + if (passwordCodecService.isEncoded(currentHash) || !passwordCodecService.matches(rawPassword, currentHash)) { + return; + } + jdbcTemplate.update( + "UPDATE sys_user SET password_hash=?, updated_at=CURRENT_TIMESTAMP WHERE tenant_id=? AND id=?", + passwordCodecService.encode(rawPassword), + tenantId, + userId + ); + } + + private Long safeUserId() { + Long userId = AuthContext.userId(); + return userId == null ? 0L : userId; + } + + private Map listUserCreators(List userIds) { + Map result = new LinkedHashMap<>(); + if (userIds == null || userIds.isEmpty()) { + return result; + } + String placeholders = userIds.stream().map(i -> "?").collect(Collectors.joining(",")); + List args = new ArrayList<>(); + args.add(tenantId()); + args.addAll(userIds); + List> rows = jdbcTemplate.queryForList( + "SELECT id, created_by FROM sys_user WHERE tenant_id=? AND is_deleted=0 AND id IN (" + placeholders + ")", + args.toArray() + ); + for (Map row : rows) { + result.put(((Number) row.get("id")).longValue(), ((Number) row.get("created_by")).longValue()); + } + return result; + } + + private Timestamp parseTimestamp(String raw) { + if (raw == null || raw.trim().isEmpty()) { + return null; + } + String normalized = normalizeDateTimeString(raw); + return Timestamp.valueOf(LocalDateTime.parse(normalized, DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))); + } + + private String normalizeDateTimeString(String raw) { + String value = raw.trim().replace("T", " "); + if (value.length() == 16) { + return value + ":00"; + } + return value; + } + + private String loadTenantSwitchAccountKey(Long userId) { + List keys = jdbcTemplate.queryForList( + "SELECT tenant_switch_account_key FROM sys_user WHERE tenant_id=? AND id=? AND is_deleted=0 LIMIT 1", + String.class, + tenantId(), + userId + ); + return keys.isEmpty() ? "" : String.valueOf(keys.get(0) == null ? "" : keys.get(0)).trim(); + } + + private String resolveTenantSwitchAccountKeyByPhone(String phone, Long excludeUserId) { + String normalizedPhone = phone == null ? "" : phone.trim(); + if (normalizedPhone.isEmpty()) { + return "acct_" + UUID.randomUUID().toString().replace("-", "").toUpperCase(); + } + String sql = + "SELECT tenant_switch_account_key FROM sys_user " + + "WHERE phone=? AND is_deleted=0 AND tenant_switch_account_key IS NOT NULL AND tenant_switch_account_key<>'' " + + (excludeUserId == null ? "" : "AND id<>? ") + + "ORDER BY id ASC LIMIT 1"; + List keys = excludeUserId == null + ? jdbcTemplate.queryForList(sql, String.class, normalizedPhone) + : jdbcTemplate.queryForList(sql, String.class, normalizedPhone, excludeUserId); + if (!keys.isEmpty()) { + String existing = String.valueOf(keys.get(0) == null ? "" : keys.get(0)).trim(); + if (!existing.isEmpty()) { + return existing; + } + } + return "acct_" + UUID.randomUUID().toString().replace("-", "").toUpperCase(); + } + + private void ensureTenantSwitchAccountKeyForPassword(String phone, String rawPassword) { + String normalizedPhone = phone == null ? "" : phone.trim(); + if (normalizedPhone.isEmpty() || rawPassword == null || rawPassword.isEmpty()) { + return; + } + List> rows = jdbcTemplate.queryForList( + "SELECT id, password_hash, tenant_switch_account_key FROM sys_user WHERE phone=? AND is_deleted=0", + normalizedPhone + ); + if (rows.isEmpty()) { + return; + } + List matchedUserIds = new ArrayList(); + String sharedKey = ""; + for (Map row : rows) { + String storedPassword = row.get("password_hash") == null ? null : String.valueOf(row.get("password_hash")); + if (!passwordCodecService.matches(rawPassword, storedPassword)) { + continue; + } + matchedUserIds.add(((Number) row.get("id")).longValue()); + String existingKey = row.get("tenant_switch_account_key") == null ? "" : String.valueOf(row.get("tenant_switch_account_key")).trim(); + if (sharedKey.isEmpty() && !existingKey.isEmpty()) { + sharedKey = existingKey; + } + } + if (matchedUserIds.isEmpty()) { + return; + } + if (sharedKey.isEmpty()) { + sharedKey = "acct_" + UUID.randomUUID().toString().replace("-", "").toUpperCase(); + } + for (Long matchedUserId : matchedUserIds) { + jdbcTemplate.update( + "UPDATE sys_user SET tenant_switch_account_key=?, updated_at=CURRENT_TIMESTAMP WHERE id=? AND (tenant_switch_account_key IS NULL OR tenant_switch_account_key<>?)", + sharedKey, + matchedUserId, + sharedKey + ); + } + } + + public String normalizeThemeMode(Object raw) { + String value = raw == null ? "" : String.valueOf(raw).trim().toUpperCase(); + if ("LIGHT".equals(value) || "DARK".equals(value) || "SYSTEM".equals(value)) { + return value; + } + return "SYSTEM"; + } + + public String normalizeDensity(Object raw) { + String value = raw == null ? "" : String.valueOf(raw).trim().toUpperCase(); + if ("COMPACT".equals(value) || "COMFORTABLE".equals(value)) { + return value; + } + return "COMFORTABLE"; + } + + public String normalizeThemeScheme(Object raw) { + String value = raw == null ? "" : String.valueOf(raw).trim().toUpperCase(); + if ( + "SLATE".equals(value) || + "OCEAN".equals(value) || + "FOREST".equals(value) || + "GRAPHITE".equals(value) || + "AMBER".equals(value) || + "RUBY".equals(value) || + "MIST".equals(value) || + "SAGE".equals(value) || + "DAWN".equals(value) + ) { + return value; + } + return "SLATE"; + } +} + diff --git a/backend/src/main/java/com/writeoff/module/system/service/TenantService.java b/backend/src/main/java/com/writeoff/module/system/service/TenantService.java new file mode 100644 index 0000000..025f622 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/service/TenantService.java @@ -0,0 +1,655 @@ +package com.writeoff.module.system.service; + +import com.writeoff.common.api.PageResult; +import com.writeoff.common.exception.BusinessException; +import com.writeoff.module.file.service.OssService; +import com.writeoff.module.system.dto.CreateTenantAdminRequest; +import com.writeoff.module.system.dto.CreateTenantRequest; +import com.writeoff.module.system.model.TenantInfo; +import com.writeoff.module.notification.provider.NotificationChannelProvider; +import com.writeoff.module.notification.provider.NotificationSendResult; +import com.writeoff.security.AuthContext; +import com.writeoff.security.PasswordCodecService; +import com.writeoff.security.PasswordPolicyService; +import com.writeoff.security.PasswordSetupService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Service; + +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +@Service +public class TenantService { + private final JdbcTemplate jdbcTemplate; + private final OssService ossService; + private final Map providerMap; + private final PasswordPolicyService passwordPolicyService; + private final PasswordCodecService passwordCodecService; + @Autowired + private PasswordSetupService passwordSetupService; + private final String tenantAdminMailSubjectTemplate; + private final String tenantAdminMailBodyTemplate; + + private static final RowMapper TENANT_ROW_MAPPER = (rs, n) -> new TenantInfo( + rs.getLong("id"), + rs.getString("tenant_code"), + rs.getString("tenant_name"), + rs.getString("logo_url"), + rs.getString("status"), + rs.getString("created_at") + ); + + public TenantService(JdbcTemplate jdbcTemplate, + OssService ossService, + List providers, + PasswordPolicyService passwordPolicyService, + PasswordCodecService passwordCodecService, + @Value("${app.notification.tenant-admin-mail-subject-template:租户管理员账号通知}") String tenantAdminMailSubjectTemplate, + @Value("${app.notification.tenant-admin-mail-body-template:操作类型:{actionCn}\\n租户名称:{tenantName}\\n租户编码:{tenantCode}\\n登录地址:{loginPath}\\n管理员账号:{phone}\\n管理员密码:{password}\\n请首次登录后立即修改密码。}") String tenantAdminMailBodyTemplate) { + this.jdbcTemplate = jdbcTemplate; + this.ossService = ossService; + this.providerMap = new HashMap(); + this.passwordPolicyService = passwordPolicyService; + this.passwordCodecService = passwordCodecService; + this.tenantAdminMailSubjectTemplate = tenantAdminMailSubjectTemplate; + this.tenantAdminMailBodyTemplate = tenantAdminMailBodyTemplate; + if (providers != null) { + for (NotificationChannelProvider provider : providers) { + providerMap.put(provider.channel().toUpperCase(), provider); + } + } + } + + public PageResult list() { + List list = jdbcTemplate.query( + "SELECT id, tenant_code, tenant_name, logo_url, status, DATE_FORMAT(created_at, '%Y-%m-%d %H:%i:%s') AS created_at " + + "FROM tenant WHERE is_deleted=0 ORDER BY id DESC", + TENANT_ROW_MAPPER + ); + return new PageResult(list, list.size(), 1, 50); + } + + public PageResult listCurrentTenant() { + TenantInfo currentTenant = findById(AuthContext.requireTenantId()); + return new PageResult(java.util.Collections.singletonList(currentTenant), 1, 1, 1); + } + + public TenantInfo create(CreateTenantRequest request) { + Integer exists = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM tenant WHERE tenant_code=?", + Integer.class, + request.getTenantCode().trim() + ); + if (exists != null && exists > 0) { + throw new BusinessException(10001, "租户编码已存在"); + } + jdbcTemplate.update( + "INSERT INTO tenant (tenant_code, tenant_name, logo_url, status, is_deleted, created_by, updated_by) VALUES (?, ?, ?, 'ENABLED', 0, ?, ?)", + request.getTenantCode().trim(), + request.getTenantName().trim(), + normalizeNullable(request.getLogoUrl()), + safeUserId(), + safeUserId() + ); + Long id = jdbcTemplate.queryForObject("SELECT IFNULL(MAX(id), 0) FROM tenant", Long.class); + long tenantId = id == null ? 0L : id; + initTenantBaseline(tenantId); + return findById(tenantId); + } + + public java.util.Map initTenantBaseline(Long tenantId) { + assertExists(tenantId); + int menuCount = ensureTenantMenusFromTemplate(tenantId); + int roleCount = ensureTenantRolesFromTemplate(tenantId); + int rolePermCount = 0; + int roleMenuCount = 0; + List> templateRoles = jdbcTemplate.queryForList( + "SELECT role_code FROM role WHERE tenant_id=1 AND is_deleted=0" + ); + for (Map row : templateRoles) { + String roleCode = String.valueOf(row.get("role_code")); + Long targetRoleId = ensureTenantRole(tenantId, roleCode); + rolePermCount += ensureRolePermissionsFromTemplate(tenantId, targetRoleId, roleCode); + roleMenuCount += ensureRoleMenusFromTemplate(tenantId, targetRoleId, roleCode); + } + java.util.Map data = new java.util.LinkedHashMap(); + data.put("tenantId", tenantId); + data.put("menuInitialized", menuCount); + data.put("roleInitialized", roleCount); + data.put("rolePermissionInitialized", rolePermCount); + data.put("roleMenuInitialized", roleMenuCount); + return data; + } + + public void enable(Long tenantId) { + assertExists(tenantId); + jdbcTemplate.update("UPDATE tenant SET status='ENABLED', updated_by=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", safeUserId(), tenantId); + } + + public void disable(Long tenantId) { + assertExists(tenantId); + jdbcTemplate.update("UPDATE tenant SET status='DISABLED', updated_by=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", safeUserId(), tenantId); + } + + public TenantInfo updateTenant(Long tenantId, String tenantName, String logoUrl) { + assertExists(tenantId); + if (tenantName == null || tenantName.trim().isEmpty()) { + throw new BusinessException(10001, "租户名称不能为空"); + } + jdbcTemplate.update( + "UPDATE tenant SET tenant_name=?, logo_url=?, updated_by=?, updated_at=CURRENT_TIMESTAMP WHERE id=? AND is_deleted=0", + tenantName.trim(), + normalizeNullable(logoUrl), + safeUserId(), + tenantId + ); + return findById(tenantId); + } + + public void softDelete(Long tenantId) { + assertExists(tenantId); + // 检查是否有活跃业务数据 + Integer activeProjects = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM project WHERE tenant_id=? AND is_deleted=0 AND status NOT IN ('ARCHIVED', 'TERMINATED')", + Integer.class, + tenantId + ); + if (activeProjects != null && activeProjects > 0) { + throw new BusinessException(10001, "该租户下存在活跃项目,请先归档或终止所有项目后再删除"); + } + jdbcTemplate.update( + "UPDATE tenant SET is_deleted=1, updated_by=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", + safeUserId(), + tenantId + ); + } + + public java.util.Map createTenantAdmin(Long tenantId, CreateTenantAdminRequest request) { + assertExists(tenantId); + String roleCode = request.getRoleCode() == null || request.getRoleCode().trim().isEmpty() + ? "TENANT_ADMIN" + : request.getRoleCode().trim(); + Long roleId = ensureTenantRole(tenantId, roleCode); + ensureRolePermissionsFromTemplate(tenantId, roleId, roleCode); + ensureRoleMenusFromTemplate(tenantId, roleId, roleCode); + Long existingAdminUserId = findTenantAdminUserId(tenantId, roleCode); + String passwordHash = passwordCodecService.encode(generateTemporaryPassword()); + + List> samePhoneUsers = jdbcTemplate.queryForList( + "SELECT id FROM sys_user WHERE tenant_id=? AND phone=? AND is_deleted=0 LIMIT 1", + tenantId, + request.getPhone().trim() + ); + if (!samePhoneUsers.isEmpty()) { + Long phoneUserId = ((Number) samePhoneUsers.get(0).get("id")).longValue(); + if (existingAdminUserId == null || !phoneUserId.equals(existingAdminUserId)) { + throw new BusinessException(10001, "该租户下手机号已存在"); + } + } + + long uid; + String action; + if (existingAdminUserId == null) { + String tenantSwitchAccountKey = resolveTenantSwitchAccountKeyByPhone(request.getPhone().trim(), null); + jdbcTemplate.update( + "INSERT INTO sys_user (tenant_id, user_name, phone, email, password_hash, tenant_switch_account_key, status, valid_from, valid_to, is_deleted, created_by, updated_by) " + + "VALUES (?, ?, ?, ?, ?, ?, 'ENABLED', NOW(), '2099-12-31 23:59:59', 0, ?, ?)", + tenantId, + request.getUserName().trim(), + request.getPhone().trim(), + request.getEmail().trim(), + passwordHash, + tenantSwitchAccountKey, + safeUserId(), + safeUserId() + ); + Long userId = jdbcTemplate.queryForObject( + "SELECT IFNULL(MAX(id), 0) FROM sys_user WHERE tenant_id=?", + Long.class, + tenantId + ); + uid = userId == null ? 0L : userId; + action = "CREATED"; + } else { + uid = existingAdminUserId; + String tenantSwitchAccountKey = loadTenantSwitchAccountKey(tenantId, uid); + if (tenantSwitchAccountKey == null || tenantSwitchAccountKey.trim().isEmpty()) { + tenantSwitchAccountKey = resolveTenantSwitchAccountKeyByPhone(request.getPhone().trim(), uid); + } + jdbcTemplate.update( + "UPDATE sys_user SET user_name=?, phone=?, email=?, password_hash=?, tenant_switch_account_key=?, status='ENABLED', valid_to='2099-12-31 23:59:59', updated_by=?, updated_at=CURRENT_TIMESTAMP " + + "WHERE tenant_id=? AND id=?", + request.getUserName().trim(), + request.getPhone().trim(), + request.getEmail().trim(), + passwordHash, + tenantSwitchAccountKey, + safeUserId(), + tenantId, + uid + ); + action = "UPDATED"; + } + + Integer relationExists = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM user_role WHERE tenant_id=? AND user_id=? AND role_id=?", + Integer.class, + tenantId, + uid, + roleId + ); + if (relationExists == null || relationExists == 0) { + Long nextUserRoleId = jdbcTemplate.queryForObject("SELECT IFNULL(MAX(id), 0) + 1 FROM user_role", Long.class); + jdbcTemplate.update( + "INSERT INTO user_role (id, tenant_id, user_id, role_id) VALUES (?, ?, ?, ?)", + nextUserRoleId == null ? 1L : nextUserRoleId, + tenantId, + uid, + roleId + ); + } + jdbcTemplate.update( + "INSERT INTO user_role_history (tenant_id, user_id, old_role_id, new_role_id, action_type, action_reason, created_by) VALUES (?, ?, NULL, ?, 'ASSIGN', ?, ?)", + tenantId, + uid, + roleId, + "平台设置租户管理员", + safeUserId() + ); + + String setupLink = passwordSetupService.issueTenantAdminSetupLink(tenantId, uid, safeUserId()); + sendTenantAdminMail(tenantId, request, action, setupLink); + + java.util.Map data = new java.util.LinkedHashMap(); + data.put("tenantId", tenantId); + data.put("userId", uid); + data.put("roleId", roleId); + data.put("roleCode", roleCode); + data.put("action", action); + return data; + } + + public java.util.Map getTenantAdmin(Long tenantId, String roleCode) { + assertExists(tenantId); + String normalizedRoleCode = roleCode == null || roleCode.trim().isEmpty() ? "TENANT_ADMIN" : roleCode.trim(); + List> rows = jdbcTemplate.queryForList( + "SELECT u.id, u.user_name, u.phone, u.email, r.role_code " + + "FROM user_role ur " + + "JOIN role r ON ur.role_id=r.id " + + "JOIN sys_user u ON ur.user_id=u.id " + + "WHERE ur.tenant_id=? AND r.tenant_id=? AND r.role_code=? AND r.is_deleted=0 AND u.is_deleted=0 " + + "ORDER BY ur.id DESC LIMIT 1", + tenantId, + tenantId, + normalizedRoleCode + ); + if (rows.isEmpty()) { + return null; + } + Map row = rows.get(0); + java.util.Map data = new java.util.LinkedHashMap(); + data.put("userId", ((Number) row.get("id")).longValue()); + data.put("userName", row.get("user_name")); + data.put("phone", row.get("phone")); + data.put("email", row.get("email")); + data.put("roleCode", row.get("role_code")); + return data; + } + + public Map presignLogoUpload(String fileName, String contentType) { + String name = fileName == null ? "" : fileName.trim(); + if (name.isEmpty()) { + throw new BusinessException(10001, "文件名不能为空"); + } + String ext = ""; + int idx = name.lastIndexOf('.'); + if (idx > 0 && idx < name.length() - 1) { + ext = "." + name.substring(idx + 1).toLowerCase(); + } + String normalizedContentType = normalizeContentType(contentType); + String objectKey = "tenant/logo/" + safeUserId() + "/" + UUID.randomUUID().toString().replace("-", "") + ext; + String uploadUrl = ossService.generateUploadUrl(objectKey, normalizedContentType); + Map data = new LinkedHashMap(); + data.put("objectKey", objectKey); + data.put("uploadUrl", uploadUrl); + data.put("contentType", normalizedContentType); + data.put("method", "PUT"); + return data; + } + + private Long ensureTenantRole(Long tenantId, String roleCode) { + java.util.List roleIds = jdbcTemplate.queryForList( + "SELECT id FROM role WHERE tenant_id=? AND role_code=? AND is_deleted=0 LIMIT 1", + Long.class, + tenantId, + roleCode + ); + if (!roleIds.isEmpty()) { + return roleIds.get(0); + } + java.util.List> templateRows = jdbcTemplate.queryForList( + "SELECT id, role_name FROM role WHERE tenant_id=1 AND role_code=? AND is_deleted=0 LIMIT 1", + roleCode + ); + String roleName = "单位管理员"; + if (!templateRows.isEmpty()) { + java.util.Map row = templateRows.get(0); + roleName = String.valueOf(row.get("role_name")); + } + Long nextRoleId = jdbcTemplate.queryForObject("SELECT IFNULL(MAX(id), 0) + 1 FROM role", Long.class); + long roleId = nextRoleId == null ? 1L : nextRoleId; + jdbcTemplate.update( + "INSERT INTO role (id, tenant_id, role_code, role_name, status, is_deleted, created_by, updated_by) VALUES (?, ?, ?, ?, 'ENABLED', 0, ?, ?)", + roleId, + tenantId, + roleCode, + roleName, + safeUserId(), + safeUserId() + ); + return roleId; + } + + private int ensureRolePermissionsFromTemplate(Long tenantId, Long roleId, String roleCode) { + List> templates = jdbcTemplate.queryForList( + "SELECT id FROM role WHERE tenant_id=1 AND role_code=? AND is_deleted=0 LIMIT 1", + roleCode + ); + if (templates.isEmpty()) { + return 0; + } + Long templateRoleId = ((Number) templates.get(0).get("id")).longValue(); + List permIds = jdbcTemplate.queryForList( + "SELECT permission_id FROM role_permission WHERE tenant_id=1 AND role_id=?", + Long.class, + templateRoleId + ); + int inserted = 0; + if (!permIds.isEmpty()) { + Long nextRolePermId = jdbcTemplate.queryForObject("SELECT IFNULL(MAX(id), 0) + 1 FROM role_permission", Long.class); + long currentPermId = nextRolePermId == null ? 1L : nextRolePermId; + for (Long permissionId : permIds) { + Integer exists = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM role_permission WHERE tenant_id=? AND role_id=? AND permission_id=?", + Integer.class, + tenantId, + roleId, + permissionId + ); + if (exists != null && exists > 0) { + continue; + } + jdbcTemplate.update( + "INSERT INTO role_permission (id, tenant_id, role_id, permission_id) VALUES (?, ?, ?, ?)", + currentPermId++, + tenantId, + roleId, + permissionId + ); + inserted++; + } + } + return inserted; + } + + private int ensureRoleMenusFromTemplate(Long tenantId, Long roleId, String roleCode) { + List> templates = jdbcTemplate.queryForList( + "SELECT id FROM role WHERE tenant_id=1 AND role_code=? AND is_deleted=0 LIMIT 1", + roleCode + ); + if (templates.isEmpty()) { + return 0; + } + Long templateRoleId = ((Number) templates.get(0).get("id")).longValue(); + List> templateMenus = jdbcTemplate.queryForList( + "SELECT m.menu_code FROM role_menu rm " + + "JOIN menu m ON rm.tenant_id=m.tenant_id AND rm.menu_id=m.id " + + "WHERE rm.tenant_id=1 AND rm.role_id=? AND m.is_deleted=0", + templateRoleId + ); + int inserted = 0; + if (!templateMenus.isEmpty()) { + Long nextRoleMenuId = jdbcTemplate.queryForObject("SELECT IFNULL(MAX(id), 0) + 1 FROM role_menu", Long.class); + long currentMenuId = nextRoleMenuId == null ? 1L : nextRoleMenuId; + for (Map row : templateMenus) { + String menuCode = String.valueOf(row.get("menu_code")); + List targetMenuIds = jdbcTemplate.queryForList( + "SELECT id FROM menu WHERE tenant_id=? AND menu_code=? AND is_deleted=0 LIMIT 1", + Long.class, + tenantId, + menuCode + ); + if (targetMenuIds.isEmpty()) { + continue; + } + Long targetMenuId = targetMenuIds.get(0); + Integer exists = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM role_menu WHERE tenant_id=? AND role_id=? AND menu_id=?", + Integer.class, + tenantId, + roleId, + targetMenuId + ); + if (exists != null && exists > 0) { + continue; + } + jdbcTemplate.update( + "INSERT INTO role_menu (id, tenant_id, role_id, menu_id) VALUES (?, ?, ?, ?)", + currentMenuId++, + tenantId, + roleId, + targetMenuId + ); + inserted++; + } + } + return inserted; + } + + private int ensureTenantMenusFromTemplate(Long tenantId) { + List> templateMenus = jdbcTemplate.queryForList( + "SELECT menu_code, menu_name, route_path, permission_code, sort_no, status " + + "FROM menu WHERE tenant_id=1 AND is_deleted=0" + ); + if (templateMenus.isEmpty()) { + return 0; + } + int inserted = 0; + for (Map row : templateMenus) { + String menuCode = String.valueOf(row.get("menu_code")); + Integer exists = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM menu WHERE tenant_id=? AND menu_code=? AND is_deleted=0", + Integer.class, + tenantId, + menuCode + ); + if (exists != null && exists > 0) { + continue; + } + jdbcTemplate.update( + "INSERT INTO menu (tenant_id, menu_code, menu_name, route_path, permission_code, sort_no, status, is_deleted, created_by, updated_by) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?)", + tenantId, + menuCode, + String.valueOf(row.get("menu_name")), + String.valueOf(row.get("route_path")), + row.get("permission_code"), + ((Number) row.get("sort_no")).intValue(), + String.valueOf(row.get("status")), + safeUserId(), + safeUserId() + ); + inserted++; + } + return inserted; + } + + private int ensureTenantRolesFromTemplate(Long tenantId) { + List> templateRoles = jdbcTemplate.queryForList( + "SELECT role_code FROM role WHERE tenant_id=1 AND is_deleted=0" + ); + if (templateRoles.isEmpty()) { + return 0; + } + int inserted = 0; + for (Map row : templateRoles) { + String roleCode = String.valueOf(row.get("role_code")); + List exists = jdbcTemplate.queryForList( + "SELECT id FROM role WHERE tenant_id=? AND role_code=? AND is_deleted=0 LIMIT 1", + Long.class, + tenantId, + roleCode + ); + if (exists.isEmpty()) { + ensureTenantRole(tenantId, roleCode); + inserted++; + } + } + return inserted; + } + + private Long findTenantAdminUserId(Long tenantId, String roleCode) { + List ids = jdbcTemplate.queryForList( + "SELECT u.id FROM user_role ur " + + "JOIN role r ON ur.role_id=r.id " + + "JOIN sys_user u ON ur.user_id=u.id " + + "WHERE ur.tenant_id=? AND r.tenant_id=? AND r.role_code=? AND r.is_deleted=0 AND u.is_deleted=0 " + + "ORDER BY ur.id DESC LIMIT 1", + Long.class, + tenantId, + tenantId, + roleCode + ); + return ids.isEmpty() ? null : ids.get(0); + } + + private void sendTenantAdminMail(Long tenantId, CreateTenantAdminRequest request, String action, String setupLink) { + NotificationChannelProvider provider = providerMap.get("EMAIL"); + if (provider == null) { + throw new BusinessException(10003, "邮件通道不可用"); + } + List> tenants = jdbcTemplate.queryForList( + "SELECT tenant_code, tenant_name FROM tenant WHERE id=? LIMIT 1", + tenantId + ); + String tenantCode = tenants.isEmpty() ? "" : String.valueOf(tenants.get(0).get("tenant_code")); + String tenantName = tenants.isEmpty() ? "" : String.valueOf(tenants.get(0).get("tenant_name")); + String loginPath = "/" + tenantCode + "/login"; + String actionCn = "UPDATED".equals(action) ? "修改管理员" : "新增管理员"; + String subject = renderTemplate(tenantAdminMailSubjectTemplate, tenantCode, tenantName, loginPath, request.getPhone().trim(), setupLink, action, actionCn); + String content = renderTemplate(tenantAdminMailBodyTemplate, tenantCode, tenantName, loginPath, request.getPhone().trim(), setupLink, action, actionCn); + String payload = "{\"subject\":\"" + jsonEscape(subject) + "\",\"content\":\"" + jsonEscape(content) + "\",\"action\":\"" + jsonEscape(action) + + "\",\"tenantCode\":\"" + jsonEscape(tenantCode) + "\",\"tenantName\":\"" + jsonEscape(tenantName) + "\",\"loginPath\":\"" + + jsonEscape(loginPath) + "\",\"phone\":\"" + jsonEscape(request.getPhone().trim()) + "\",\"setupLink\":\"" + jsonEscape(setupLink) + "\"}"; + NotificationSendResult result = provider.send(request.getEmail().trim(), payload, null); + if (result == null || !result.isAccepted()) { + throw new BusinessException(10001, "管理员账号邮件发送失败"); + } + } + + private String renderTemplate(String template, String tenantCode, String tenantName, String loginPath, + String phone, String setupLink, String action, String actionCn) { + String value = template == null ? "" : template; + value = value.replace("{tenantCode}", tenantCode == null ? "" : tenantCode); + value = value.replace("{tenantName}", tenantName == null ? "" : tenantName); + value = value.replace("{loginPath}", loginPath == null ? "" : loginPath); + value = value.replace("{phone}", phone == null ? "" : phone); + value = value.replace("{setupLink}", setupLink == null ? "" : setupLink); + value = value.replace("{action}", action == null ? "" : action); + value = value.replace("{actionCn}", actionCn == null ? "" : actionCn); + return value; + } + + private String jsonEscape(String raw) { + if (raw == null) { + return ""; + } + return raw + .replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\r", "") + .replace("\n", "\\n"); + } + + private void assertExists(Long tenantId) { + Integer count = jdbcTemplate.queryForObject("SELECT COUNT(1) FROM tenant WHERE id=? AND is_deleted=0", Integer.class, tenantId); + if (count == null || count == 0) { + throw new BusinessException(10003, "租户不存在"); + } + } + + private TenantInfo findById(Long id) { + List list = jdbcTemplate.query( + "SELECT id, tenant_code, tenant_name, logo_url, status, DATE_FORMAT(created_at, '%Y-%m-%d %H:%i:%s') AS created_at " + + "FROM tenant WHERE id=? AND is_deleted=0", + TENANT_ROW_MAPPER, + id + ); + if (list.isEmpty()) { + throw new BusinessException(10003, "租户不存在"); + } + return list.get(0); + } + + private Long safeUserId() { + Long userId = AuthContext.userId(); + return userId == null ? 0L : userId; + } + + private String normalizeNullable(String value) { + if (value == null) { + return null; + } + String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } + + private String normalizeContentType(String contentType) { + if (contentType == null || contentType.trim().isEmpty()) { + return "application/octet-stream"; + } + return contentType.trim(); + } + + private String loadTenantSwitchAccountKey(Long tenantId, Long userId) { + List keys = jdbcTemplate.queryForList( + "SELECT tenant_switch_account_key FROM sys_user WHERE tenant_id=? AND id=? AND is_deleted=0 LIMIT 1", + String.class, + tenantId, + userId + ); + return keys.isEmpty() ? "" : String.valueOf(keys.get(0) == null ? "" : keys.get(0)).trim(); + } + + private String resolveTenantSwitchAccountKeyByPhone(String phone, Long excludeUserId) { + String normalizedPhone = phone == null ? "" : phone.trim(); + if (normalizedPhone.isEmpty()) { + return "acct_" + UUID.randomUUID().toString().replace("-", "").toUpperCase(); + } + String sql = + "SELECT tenant_switch_account_key FROM sys_user " + + "WHERE phone=? AND is_deleted=0 AND tenant_switch_account_key IS NOT NULL AND tenant_switch_account_key<>'' " + + (excludeUserId == null ? "" : "AND id<>? ") + + "ORDER BY id ASC LIMIT 1"; + List keys = excludeUserId == null + ? jdbcTemplate.queryForList(sql, String.class, normalizedPhone) + : jdbcTemplate.queryForList(sql, String.class, normalizedPhone, excludeUserId); + if (!keys.isEmpty()) { + String existing = String.valueOf(keys.get(0) == null ? "" : keys.get(0)).trim(); + if (!existing.isEmpty()) { + return existing; + } + } + return "acct_" + UUID.randomUUID().toString().replace("-", "").toUpperCase(); + } + + private String generateTemporaryPassword() { + return "Tmp@" + UUID.randomUUID().toString().replace("-", "").substring(0, 8) + "aA1"; + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/service/UserDelegationService.java b/backend/src/main/java/com/writeoff/module/system/service/UserDelegationService.java new file mode 100644 index 0000000..8a25079 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/service/UserDelegationService.java @@ -0,0 +1,183 @@ +package com.writeoff.module.system.service; + +import com.writeoff.common.api.PageResult; +import com.writeoff.common.exception.BusinessException; +import com.writeoff.module.system.dto.CreateUserDelegationRequest; +import com.writeoff.module.system.dto.DisableDelegationRequest; +import com.writeoff.module.system.model.UserDelegationInfo; +import com.writeoff.security.AuthContext; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Service; + +import java.sql.Timestamp; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.List; + +@Service +public class UserDelegationService { + private final JdbcTemplate jdbcTemplate; + + private static final RowMapper ROW_MAPPER = (rs, n) -> new UserDelegationInfo( + rs.getLong("id"), + rs.getLong("user_id"), + rs.getLong("delegate_user_id"), + rs.getString("effective_from"), + rs.getString("effective_to"), + rs.getString("status"), + rs.getString("reason"), + rs.getString("disabled_reason"), + rs.getString("created_at") + ); + + public UserDelegationService(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + public PageResult listByUserId(Long userId) { + assertUserExists(userId); + markAutoExpiredByTenant(); + List list = jdbcTemplate.query( + "SELECT id, user_id, delegate_user_id, " + + "DATE_FORMAT(effective_from, '%Y-%m-%d %H:%i:%s') AS effective_from, " + + "DATE_FORMAT(effective_to, '%Y-%m-%d %H:%i:%s') AS effective_to, " + + "status, reason, disabled_reason, DATE_FORMAT(created_at, '%Y-%m-%d %H:%i:%s') AS created_at " + + "FROM user_delegation WHERE tenant_id=? AND user_id=? AND is_deleted=0 ORDER BY id DESC", + ROW_MAPPER, + tenantId(), + userId + ); + return new PageResult<>(list, list.size(), 1, 50); + } + + public UserDelegationInfo create(Long userId, CreateUserDelegationRequest request) { + assertUserExists(userId); + assertUserExists(request.getDelegateUserId()); + if (userId.equals(request.getDelegateUserId())) { + throw new BusinessException(10001, "代理人不能与被代理人相同"); + } + Timestamp from = parseTimestamp(request.getEffectiveFrom()); + Timestamp to = parseTimestamp(request.getEffectiveTo()); + if (!to.after(from)) { + throw new BusinessException(10001, "失效时间必须晚于生效时间"); + } + String status = from.toLocalDateTime().isAfter(LocalDateTime.now()) ? "PENDING" : "ENABLED"; + jdbcTemplate.update( + "INSERT INTO user_delegation (tenant_id, user_id, delegate_user_id, effective_from, effective_to, status, reason, is_deleted, created_by, updated_by) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?)", + tenantId(), + userId, + request.getDelegateUserId(), + from, + to, + status, + normalizeNullable(request.getReason()), + safeUserId(), + safeUserId() + ); + Long id = jdbcTemplate.queryForObject("SELECT IFNULL(MAX(id), 0) FROM user_delegation WHERE tenant_id=?", Long.class, tenantId()); + return findById(id == null ? 0L : id); + } + + public void disable(Long delegationId, DisableDelegationRequest request) { + markAutoExpiredByTenant(); + UserDelegationInfo item = findById(delegationId); + if ("DISABLED".equals(item.getStatus()) || "EXPIRED".equals(item.getStatus())) { + return; + } + jdbcTemplate.update( + "UPDATE user_delegation SET status='DISABLED', disabled_reason=?, updated_by=?, updated_at=CURRENT_TIMESTAMP " + + "WHERE tenant_id=? AND id=?", + request.getReason().trim(), + safeUserId(), + tenantId(), + delegationId + ); + } + + public void markAutoExpiredByTenant() { + jdbcTemplate.update( + "UPDATE user_delegation SET status='EXPIRED', updated_by=?, updated_at=CURRENT_TIMESTAMP " + + "WHERE tenant_id=? AND is_deleted=0 AND status IN ('PENDING','ENABLED') AND effective_to < CURRENT_TIMESTAMP", + safeUserId(), + tenantId() + ); + jdbcTemplate.update( + "UPDATE user_delegation SET status='ENABLED', updated_by=?, updated_at=CURRENT_TIMESTAMP " + + "WHERE tenant_id=? AND is_deleted=0 AND status='PENDING' AND effective_from <= CURRENT_TIMESTAMP AND effective_to >= CURRENT_TIMESTAMP", + safeUserId(), + tenantId() + ); + } + + public void markAutoExpiredAllTenants() { + jdbcTemplate.update( + "UPDATE user_delegation SET status='EXPIRED', updated_by=0, updated_at=CURRENT_TIMESTAMP " + + "WHERE is_deleted=0 AND status IN ('PENDING','ENABLED') AND effective_to < CURRENT_TIMESTAMP" + ); + jdbcTemplate.update( + "UPDATE user_delegation SET status='ENABLED', updated_by=0, updated_at=CURRENT_TIMESTAMP " + + "WHERE is_deleted=0 AND status='PENDING' AND effective_from <= CURRENT_TIMESTAMP AND effective_to >= CURRENT_TIMESTAMP" + ); + } + + private UserDelegationInfo findById(Long id) { + List list = jdbcTemplate.query( + "SELECT id, user_id, delegate_user_id, " + + "DATE_FORMAT(effective_from, '%Y-%m-%d %H:%i:%s') AS effective_from, " + + "DATE_FORMAT(effective_to, '%Y-%m-%d %H:%i:%s') AS effective_to, " + + "status, reason, disabled_reason, DATE_FORMAT(created_at, '%Y-%m-%d %H:%i:%s') AS created_at " + + "FROM user_delegation WHERE tenant_id=? AND id=? AND is_deleted=0", + ROW_MAPPER, + tenantId(), + id + ); + if (list.isEmpty()) { + throw new BusinessException(10003, "代理授权不存在"); + } + return list.get(0); + } + + private void assertUserExists(Long userId) { + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM sys_user WHERE tenant_id=? AND id=? AND is_deleted=0", + Integer.class, + tenantId(), + userId + ); + if (count == null || count == 0) { + throw new BusinessException(10003, "用户不存在"); + } + } + + private Timestamp parseTimestamp(String raw) { + String value = normalizeDateTimeString(raw); + return Timestamp.valueOf(LocalDateTime.parse(value, DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))); + } + + private String normalizeDateTimeString(String raw) { + String value = raw == null ? "" : raw.trim().replace("T", " "); + if (value.length() == 16) { + return value + ":00"; + } + return value; + } + + private String normalizeNullable(String value) { + if (value == null) { + return null; + } + String t = value.trim(); + return t.isEmpty() ? null : t; + } + + private Long tenantId() { + return AuthContext.requireTenantId(); + } + + private Long safeUserId() { + Long userId = AuthContext.userId(); + return userId == null ? 0L : userId; + } +} diff --git a/backend/src/main/java/com/writeoff/module/template/controller/TemplateController.java b/backend/src/main/java/com/writeoff/module/template/controller/TemplateController.java new file mode 100644 index 0000000..b7ce9c7 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/template/controller/TemplateController.java @@ -0,0 +1,195 @@ +package com.writeoff.module.template.controller; + +import com.writeoff.common.api.ApiResponse; +import com.writeoff.common.api.PageResult; +import com.writeoff.module.template.dto.CreateTemplateRequest; +import com.writeoff.module.template.dto.CreateTemplateVersionRequest; +import com.writeoff.module.template.dto.BindFlowTemplateRequest; +import com.writeoff.module.template.dto.RollbackTemplateRequest; +import com.writeoff.module.template.dto.TemplateUploadSignRequest; +import com.writeoff.module.template.model.TemplateDownloadLogInfo; +import com.writeoff.module.template.model.TemplateFlowLinkInfo; +import com.writeoff.module.template.model.TemplateInfo; +import com.writeoff.module.template.model.TemplateTypeOption; +import com.writeoff.module.template.model.TemplateVersionInfo; +import com.writeoff.module.template.service.TemplateService; +import com.writeoff.security.DataScopeType; +import com.writeoff.security.RequirePermission; +import org.springframework.web.bind.annotation.*; + +import javax.servlet.http.HttpServletRequest; +import javax.validation.Valid; +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/templates") +public class TemplateController { + private final TemplateService templateService; + + public TemplateController(TemplateService templateService) { + this.templateService = templateService; + } + + @GetMapping + @RequirePermission(value = "template.read", dataScope = DataScopeType.TENANT, auditAction = "TEMPLATE_LIST") + public ApiResponse> list( + @RequestParam(value = "templateName", required = false) String templateName, + @RequestParam(value = "templateType", required = false) String templateType, + @RequestParam(value = "status", required = false) String status, + @RequestParam(value = "scopeType", required = false) String scopeType, + @RequestParam(value = "bizScene", required = false) String bizScene, + @RequestParam(value = "watermarkEnabled", required = false) Boolean watermarkEnabled, + @RequestParam(value = "effectiveStatus", required = false) String effectiveStatus, + @RequestParam(value = "pageNo", defaultValue = "1") int pageNo, + @RequestParam(value = "pageSize", defaultValue = "20") int pageSize) { + return ApiResponse.success(templateService.list(templateName, templateType, status, scopeType, bizScene, watermarkEnabled, effectiveStatus, pageNo, pageSize)); + } + + @GetMapping("/published-options") + @RequirePermission(value = "template.read", dataScope = DataScopeType.TENANT, auditAction = "TEMPLATE_PUBLISHED_OPTIONS") + public ApiResponse> publishedOptions( + @RequestParam(value = "bizScene", required = false) String bizScene) { + return ApiResponse.success(templateService.listPublishedOptions(bizScene)); + } + + @GetMapping("/type-options") + @RequirePermission(value = "template.read", dataScope = DataScopeType.TENANT, auditAction = "TEMPLATE_TYPE_OPTIONS") + public ApiResponse> typeOptions() { + return ApiResponse.success(templateService.listTypeOptions()); + } + + @GetMapping("/flow-scene-options") + @RequirePermission(value = "template.read", dataScope = DataScopeType.TENANT, auditAction = "TEMPLATE_FLOW_SCENE_OPTIONS") + public ApiResponse>> flowSceneOptions() { + return ApiResponse.success(templateService.flowSceneOptions()); + } + + @GetMapping("/flow-links") + @RequirePermission(value = "template.read", dataScope = DataScopeType.TENANT, auditAction = "TEMPLATE_FLOW_LINKS") + public ApiResponse> flowLinks() { + return ApiResponse.success(templateService.listFlowLinks()); + } + + @PostMapping("/flow-links/{sceneCode}/bind") + @RequirePermission(value = "template.flow.link", dataScope = DataScopeType.TENANT, auditAction = "TEMPLATE_FLOW_LINK_BIND") + public ApiResponse bindFlowLink(@PathVariable("sceneCode") String sceneCode, + @RequestBody @Valid BindFlowTemplateRequest request) { + return ApiResponse.success(templateService.bindFlowLink(sceneCode, request.getTemplateId())); + } + + @PostMapping("/type-options/{typeCode}/enable") + @RequirePermission(value = "template.publish", dataScope = DataScopeType.TENANT, auditAction = "TEMPLATE_TYPE_ENABLE") + public ApiResponse enableTypeOption(@PathVariable("typeCode") String typeCode) { + templateService.enableTypeOption(typeCode); + return ApiResponse.success("ok"); + } + + @PostMapping("/type-options/{typeCode}/disable") + @RequirePermission(value = "template.publish", dataScope = DataScopeType.TENANT, auditAction = "TEMPLATE_TYPE_DISABLE") + public ApiResponse disableTypeOption(@PathVariable("typeCode") String typeCode) { + templateService.disableTypeOption(typeCode); + return ApiResponse.success("ok"); + } + + @PostMapping + @RequirePermission(value = "template.create", dataScope = DataScopeType.TENANT, auditAction = "TEMPLATE_CREATE") + public ApiResponse create(@RequestBody @Valid CreateTemplateRequest request) { + return ApiResponse.success(templateService.create(request)); + } + + @PostMapping("/upload-sign") + @RequirePermission(value = "template.create", dataScope = DataScopeType.TENANT, auditAction = "TEMPLATE_UPLOAD_SIGN") + public ApiResponse> uploadSign(@RequestBody @Valid TemplateUploadSignRequest request) { + return ApiResponse.success(templateService.presignUpload(request)); + } + + @GetMapping("/{id}/versions") + @RequirePermission(value = "template.read", dataScope = DataScopeType.TENANT, auditAction = "TEMPLATE_VERSIONS") + public ApiResponse> versions(@PathVariable("id") Long id) { + return ApiResponse.success(templateService.versions(id)); + } + + @PostMapping("/{id}/versions") + @RequirePermission(value = "template.create", dataScope = DataScopeType.TENANT, auditAction = "TEMPLATE_VERSION_CREATE") + public ApiResponse addVersion(@PathVariable("id") Long id, + @RequestBody @Valid CreateTemplateVersionRequest request) { + return ApiResponse.success(templateService.addVersion(id, request)); + } + + @PostMapping("/{id}/publish") + @RequirePermission(value = "template.publish", dataScope = DataScopeType.TENANT, auditAction = "TEMPLATE_PUBLISH") + public ApiResponse publish(@PathVariable("id") Long id) { + return ApiResponse.success(templateService.publish(id)); + } + + @PostMapping("/{id}/disable") + @RequirePermission(value = "template.disable", dataScope = DataScopeType.TENANT, auditAction = "TEMPLATE_DISABLE") + public ApiResponse disable(@PathVariable("id") Long id) { + return ApiResponse.success(templateService.disable(id)); + } + + @PostMapping("/{id}/archive") + @RequirePermission(value = "template.archive", dataScope = DataScopeType.TENANT, auditAction = "TEMPLATE_ARCHIVE") + public ApiResponse archive(@PathVariable("id") Long id) { + return ApiResponse.success(templateService.archive(id)); + } + + @PostMapping("/{id}/rollback") + @RequirePermission(value = "template.rollback", dataScope = DataScopeType.TENANT, auditAction = "TEMPLATE_ROLLBACK") + public ApiResponse rollback(@PathVariable("id") Long id, + @RequestBody @Valid RollbackTemplateRequest request) { + return ApiResponse.success(templateService.rollback(id, request)); + } + + @GetMapping("/{id}/download") + @RequirePermission(value = "template.download", dataScope = DataScopeType.TENANT, auditAction = "TEMPLATE_DOWNLOAD") + public ApiResponse> download(@PathVariable("id") Long id, HttpServletRequest request) { + return ApiResponse.success(templateService.download(id, request.getRemoteAddr(), request.getHeader("User-Agent"))); + } + + @GetMapping("/{id}/download-watermark") + @RequirePermission(value = "template.download", dataScope = DataScopeType.TENANT, auditAction = "TEMPLATE_DOWNLOAD_WATERMARK") + public ApiResponse> downloadWatermark(@PathVariable("id") Long id, + @RequestParam(value = "watermarkText", required = false) String watermarkText, + HttpServletRequest request) { + return ApiResponse.success(templateService.downloadWatermark(id, watermarkText, request.getRemoteAddr(), request.getHeader("User-Agent"))); + } + + @GetMapping("/{id}/versions/diff") + @RequirePermission(value = "template.read", dataScope = DataScopeType.TENANT, auditAction = "TEMPLATE_VERSION_DIFF") + public ApiResponse> versionDiff(@PathVariable("id") Long id, + @RequestParam(value = "leftVersionNo", required = false) Integer leftVersionNo, + @RequestParam(value = "rightVersionNo", required = false) Integer rightVersionNo) { + return ApiResponse.success(templateService.versionDiff(id, leftVersionNo, rightVersionNo)); + } + + @GetMapping("/download-logs") + @RequirePermission(value = "template.read", dataScope = DataScopeType.TENANT, auditAction = "TEMPLATE_DOWNLOAD_LOGS") + public ApiResponse> downloadLogs( + @RequestParam(value = "templateId", required = false) Long templateId, + @RequestParam(value = "templateName", required = false) String templateName, + @RequestParam(value = "userId", required = false) Long userId, + @RequestParam(value = "userKeyword", required = false) String userKeyword, + @RequestParam(value = "versionNo", required = false) Integer versionNo, + @RequestParam(value = "downloadType", required = false) String downloadType, + @RequestParam(value = "ip", required = false) String ip, + @RequestParam(value = "downloadedFrom", required = false) String downloadedFrom, + @RequestParam(value = "downloadedTo", required = false) String downloadedTo, + @RequestParam(value = "pageNo", defaultValue = "1") int pageNo, + @RequestParam(value = "pageSize", defaultValue = "20") int pageSize) { + return ApiResponse.success(templateService.listDownloadLogs( + templateId, + templateName, + userId, + userKeyword, + versionNo, + downloadType, + ip, + downloadedFrom, + downloadedTo, + pageNo, + pageSize + )); + } +} diff --git a/backend/src/main/java/com/writeoff/module/template/dto/BindFlowTemplateRequest.java b/backend/src/main/java/com/writeoff/module/template/dto/BindFlowTemplateRequest.java new file mode 100644 index 0000000..5d4e43f --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/template/dto/BindFlowTemplateRequest.java @@ -0,0 +1,16 @@ +package com.writeoff.module.template.dto; + +import javax.validation.constraints.NotNull; + +public class BindFlowTemplateRequest { + @NotNull(message = "模板ID不能为空") + private Long templateId; + + public Long getTemplateId() { + return templateId; + } + + public void setTemplateId(Long templateId) { + this.templateId = templateId; + } +} diff --git a/backend/src/main/java/com/writeoff/module/template/dto/CreateTemplateRequest.java b/backend/src/main/java/com/writeoff/module/template/dto/CreateTemplateRequest.java new file mode 100644 index 0000000..c4730ff --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/template/dto/CreateTemplateRequest.java @@ -0,0 +1,127 @@ +package com.writeoff.module.template.dto; + +import javax.validation.constraints.NotBlank; + +public class CreateTemplateRequest { + @NotBlank(message = "模板名称不能为空") + private String templateName; + @NotBlank(message = "模板类型不能为空") + private String templateType; + @NotBlank(message = "适用范围不能为空") + private String scopeType; + private Long projectId; + private Long meetingId; + private Long scopeId; + private String bizScene; + @NotBlank(message = "模板文件Key不能为空") + private String objectKey; + private String changeLog; + private String effectiveFrom; + private String effectiveTo; + private Boolean watermarkEnabled; + private Integer downloadRateLimitPerHour; + + public String getTemplateName() { + return templateName; + } + + public void setTemplateName(String templateName) { + this.templateName = templateName; + } + + public String getTemplateType() { + return templateType; + } + + public void setTemplateType(String templateType) { + this.templateType = templateType; + } + + public String getScopeType() { + return scopeType; + } + + public void setScopeType(String scopeType) { + this.scopeType = scopeType; + } + + public Long getProjectId() { + return projectId; + } + + public void setProjectId(Long projectId) { + this.projectId = projectId; + } + + public Long getMeetingId() { + return meetingId; + } + + public void setMeetingId(Long meetingId) { + this.meetingId = meetingId; + } + + public Long getScopeId() { + return scopeId; + } + + public void setScopeId(Long scopeId) { + this.scopeId = scopeId; + } + + public String getBizScene() { + return bizScene; + } + + public void setBizScene(String bizScene) { + this.bizScene = bizScene; + } + + public String getObjectKey() { + return objectKey; + } + + public void setObjectKey(String objectKey) { + this.objectKey = objectKey; + } + + public String getChangeLog() { + return changeLog; + } + + public void setChangeLog(String changeLog) { + this.changeLog = changeLog; + } + + public String getEffectiveFrom() { + return effectiveFrom; + } + + public void setEffectiveFrom(String effectiveFrom) { + this.effectiveFrom = effectiveFrom; + } + + public String getEffectiveTo() { + return effectiveTo; + } + + public void setEffectiveTo(String effectiveTo) { + this.effectiveTo = effectiveTo; + } + + public Boolean getWatermarkEnabled() { + return watermarkEnabled; + } + + public void setWatermarkEnabled(Boolean watermarkEnabled) { + this.watermarkEnabled = watermarkEnabled; + } + + public Integer getDownloadRateLimitPerHour() { + return downloadRateLimitPerHour; + } + + public void setDownloadRateLimitPerHour(Integer downloadRateLimitPerHour) { + this.downloadRateLimitPerHour = downloadRateLimitPerHour; + } +} diff --git a/backend/src/main/java/com/writeoff/module/template/dto/CreateTemplateVersionRequest.java b/backend/src/main/java/com/writeoff/module/template/dto/CreateTemplateVersionRequest.java new file mode 100644 index 0000000..9691ac3 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/template/dto/CreateTemplateVersionRequest.java @@ -0,0 +1,25 @@ +package com.writeoff.module.template.dto; + +import javax.validation.constraints.NotBlank; + +public class CreateTemplateVersionRequest { + @NotBlank(message = "模板文件Key不能为空") + private String objectKey; + private String changeLog; + + public String getObjectKey() { + return objectKey; + } + + public void setObjectKey(String objectKey) { + this.objectKey = objectKey; + } + + public String getChangeLog() { + return changeLog; + } + + public void setChangeLog(String changeLog) { + this.changeLog = changeLog; + } +} diff --git a/backend/src/main/java/com/writeoff/module/template/dto/RollbackTemplateRequest.java b/backend/src/main/java/com/writeoff/module/template/dto/RollbackTemplateRequest.java new file mode 100644 index 0000000..d08bfea --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/template/dto/RollbackTemplateRequest.java @@ -0,0 +1,27 @@ +package com.writeoff.module.template.dto; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; + +public class RollbackTemplateRequest { + @NotNull(message = "回滚版本号不能为空") + private Integer versionNo; + @NotBlank(message = "回滚原因不能为空") + private String rollbackReason; + + public Integer getVersionNo() { + return versionNo; + } + + public void setVersionNo(Integer versionNo) { + this.versionNo = versionNo; + } + + public String getRollbackReason() { + return rollbackReason; + } + + public void setRollbackReason(String rollbackReason) { + this.rollbackReason = rollbackReason; + } +} diff --git a/backend/src/main/java/com/writeoff/module/template/dto/TemplateUploadSignRequest.java b/backend/src/main/java/com/writeoff/module/template/dto/TemplateUploadSignRequest.java new file mode 100644 index 0000000..3deaa73 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/template/dto/TemplateUploadSignRequest.java @@ -0,0 +1,34 @@ +package com.writeoff.module.template.dto; + +import javax.validation.constraints.NotBlank; + +public class TemplateUploadSignRequest { + @NotBlank(message = "文件名不能为空") + private String fileName; + private String contentType; + private String templateType; + + public String getFileName() { + return fileName; + } + + public void setFileName(String fileName) { + this.fileName = fileName; + } + + public String getContentType() { + return contentType; + } + + public void setContentType(String contentType) { + this.contentType = contentType; + } + + public String getTemplateType() { + return templateType; + } + + public void setTemplateType(String templateType) { + this.templateType = templateType; + } +} diff --git a/backend/src/main/java/com/writeoff/module/template/model/TemplateDownloadLogInfo.java b/backend/src/main/java/com/writeoff/module/template/model/TemplateDownloadLogInfo.java new file mode 100644 index 0000000..3f663fe --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/template/model/TemplateDownloadLogInfo.java @@ -0,0 +1,99 @@ +package com.writeoff.module.template.model; + +public class TemplateDownloadLogInfo { + private Long id; + private Long templateId; + private String templateName; + private Integer versionNo; + private Long userId; + private String userName; + private String userPhone; + private String objectKey; + private String downloadType; + private String watermarkText; + private Long projectId; + private Long meetingId; + private String ip; + private String userAgent; + private String downloadedAt; + + public TemplateDownloadLogInfo(Long id, Long templateId, String templateName, Integer versionNo, Long userId, String userName, String userPhone, + String objectKey, String downloadType, String watermarkText, Long projectId, Long meetingId, + String ip, String userAgent, String downloadedAt) { + this.id = id; + this.templateId = templateId; + this.templateName = templateName; + this.versionNo = versionNo; + this.userId = userId; + this.userName = userName; + this.userPhone = userPhone; + this.objectKey = objectKey; + this.downloadType = downloadType; + this.watermarkText = watermarkText; + this.projectId = projectId; + this.meetingId = meetingId; + this.ip = ip; + this.userAgent = userAgent; + this.downloadedAt = downloadedAt; + } + + public Long getId() { + return id; + } + + public Long getTemplateId() { + return templateId; + } + + public String getTemplateName() { + return templateName; + } + + public Integer getVersionNo() { + return versionNo; + } + + public Long getUserId() { + return userId; + } + + public String getUserName() { + return userName; + } + + public String getUserPhone() { + return userPhone; + } + + public String getObjectKey() { + return objectKey; + } + + public String getDownloadType() { + return downloadType; + } + + public String getWatermarkText() { + return watermarkText; + } + + public Long getProjectId() { + return projectId; + } + + public Long getMeetingId() { + return meetingId; + } + + public String getIp() { + return ip; + } + + public String getUserAgent() { + return userAgent; + } + + public String getDownloadedAt() { + return downloadedAt; + } +} diff --git a/backend/src/main/java/com/writeoff/module/template/model/TemplateFlowLinkInfo.java b/backend/src/main/java/com/writeoff/module/template/model/TemplateFlowLinkInfo.java new file mode 100644 index 0000000..a5b4208 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/template/model/TemplateFlowLinkInfo.java @@ -0,0 +1,43 @@ +package com.writeoff.module.template.model; + +public class TemplateFlowLinkInfo { + private String sceneCode; + private String sceneName; + private Long templateId; + private String templateName; + private String templateStatus; + private Integer versionNo; + + public TemplateFlowLinkInfo(String sceneCode, String sceneName, Long templateId, String templateName, String templateStatus, Integer versionNo) { + this.sceneCode = sceneCode; + this.sceneName = sceneName; + this.templateId = templateId; + this.templateName = templateName; + this.templateStatus = templateStatus; + this.versionNo = versionNo; + } + + public String getSceneCode() { + return sceneCode; + } + + public String getSceneName() { + return sceneName; + } + + public Long getTemplateId() { + return templateId; + } + + public String getTemplateName() { + return templateName; + } + + public String getTemplateStatus() { + return templateStatus; + } + + public Integer getVersionNo() { + return versionNo; + } +} diff --git a/backend/src/main/java/com/writeoff/module/template/model/TemplateInfo.java b/backend/src/main/java/com/writeoff/module/template/model/TemplateInfo.java new file mode 100644 index 0000000..15d3c29 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/template/model/TemplateInfo.java @@ -0,0 +1,111 @@ +package com.writeoff.module.template.model; + +public class TemplateInfo { + private Long id; + private String templateName; + private String templateType; + private String scopeType; + private Long projectId; + private Long meetingId; + private Long scopeId; + private String bizScene; + private String status; + private Integer currentVersionNo; + private String currentObjectKey; + private String effectiveFrom; + private String effectiveTo; + private Boolean watermarkEnabled; + private Integer downloadRateLimitPerHour; + private String createdAt; + private String updatedAt; + + public TemplateInfo(Long id, String templateName, String templateType, String scopeType, Long projectId, Long meetingId, Long scopeId, String bizScene, + String status, Integer currentVersionNo, String currentObjectKey, String effectiveFrom, String effectiveTo, Boolean watermarkEnabled, Integer downloadRateLimitPerHour, + String createdAt, String updatedAt) { + this.id = id; + this.templateName = templateName; + this.templateType = templateType; + this.scopeType = scopeType; + this.projectId = projectId; + this.meetingId = meetingId; + this.scopeId = scopeId; + this.bizScene = bizScene; + this.status = status; + this.currentVersionNo = currentVersionNo; + this.currentObjectKey = currentObjectKey; + this.effectiveFrom = effectiveFrom; + this.effectiveTo = effectiveTo; + this.watermarkEnabled = watermarkEnabled; + this.downloadRateLimitPerHour = downloadRateLimitPerHour; + this.createdAt = createdAt; + this.updatedAt = updatedAt; + } + + public Long getId() { + return id; + } + + public String getTemplateName() { + return templateName; + } + + public String getTemplateType() { + return templateType; + } + + public String getScopeType() { + return scopeType; + } + + public Long getProjectId() { + return projectId; + } + + public Long getMeetingId() { + return meetingId; + } + + public Long getScopeId() { + return scopeId; + } + + public String getStatus() { + return status; + } + + public String getBizScene() { + return bizScene; + } + + public Integer getCurrentVersionNo() { + return currentVersionNo; + } + + public String getCurrentObjectKey() { + return currentObjectKey; + } + + public String getEffectiveFrom() { + return effectiveFrom; + } + + public String getEffectiveTo() { + return effectiveTo; + } + + public Boolean getWatermarkEnabled() { + return watermarkEnabled; + } + + public Integer getDownloadRateLimitPerHour() { + return downloadRateLimitPerHour; + } + + public String getCreatedAt() { + return createdAt; + } + + public String getUpdatedAt() { + return updatedAt; + } +} diff --git a/backend/src/main/java/com/writeoff/module/template/model/TemplateTypeOption.java b/backend/src/main/java/com/writeoff/module/template/model/TemplateTypeOption.java new file mode 100644 index 0000000..5f17b8e --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/template/model/TemplateTypeOption.java @@ -0,0 +1,31 @@ +package com.writeoff.module.template.model; + +public class TemplateTypeOption { + private String typeCode; + private String typeName; + private String status; + private Integer sortNo; + + public TemplateTypeOption(String typeCode, String typeName, String status, Integer sortNo) { + this.typeCode = typeCode; + this.typeName = typeName; + this.status = status; + this.sortNo = sortNo; + } + + public String getTypeCode() { + return typeCode; + } + + public String getTypeName() { + return typeName; + } + + public String getStatus() { + return status; + } + + public Integer getSortNo() { + return sortNo; + } +} diff --git a/backend/src/main/java/com/writeoff/module/template/model/TemplateVersionInfo.java b/backend/src/main/java/com/writeoff/module/template/model/TemplateVersionInfo.java new file mode 100644 index 0000000..df4f15c --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/template/model/TemplateVersionInfo.java @@ -0,0 +1,61 @@ +package com.writeoff.module.template.model; + +public class TemplateVersionInfo { + private Long id; + private Long templateId; + private Integer versionNo; + private String objectKey; + private String versionStatus; + private Boolean effective; + private String changeLog; + private String rollbackReason; + private String createdAt; + + public TemplateVersionInfo(Long id, Long templateId, Integer versionNo, String objectKey, String versionStatus, Boolean effective, String changeLog, String rollbackReason, String createdAt) { + this.id = id; + this.templateId = templateId; + this.versionNo = versionNo; + this.objectKey = objectKey; + this.versionStatus = versionStatus; + this.effective = effective; + this.changeLog = changeLog; + this.rollbackReason = rollbackReason; + this.createdAt = createdAt; + } + + public Long getId() { + return id; + } + + public Long getTemplateId() { + return templateId; + } + + public Integer getVersionNo() { + return versionNo; + } + + public String getObjectKey() { + return objectKey; + } + + public String getVersionStatus() { + return versionStatus; + } + + public Boolean getEffective() { + return effective; + } + + public String getChangeLog() { + return changeLog; + } + + public String getRollbackReason() { + return rollbackReason; + } + + public String getCreatedAt() { + return createdAt; + } +} diff --git a/backend/src/main/java/com/writeoff/module/template/service/TemplateService.java b/backend/src/main/java/com/writeoff/module/template/service/TemplateService.java new file mode 100644 index 0000000..305a92e --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/template/service/TemplateService.java @@ -0,0 +1,1056 @@ +package com.writeoff.module.template.service; + +import com.writeoff.common.api.PageResult; +import com.writeoff.common.exception.BusinessException; +import com.writeoff.module.file.service.OssService; +import com.writeoff.module.template.dto.CreateTemplateRequest; +import com.writeoff.module.template.dto.CreateTemplateVersionRequest; +import com.writeoff.module.template.dto.RollbackTemplateRequest; +import com.writeoff.module.template.dto.TemplateUploadSignRequest; +import com.writeoff.module.template.model.TemplateDownloadLogInfo; +import com.writeoff.module.template.model.TemplateFlowLinkInfo; +import com.writeoff.module.template.model.TemplateInfo; +import com.writeoff.module.template.model.TemplateTypeOption; +import com.writeoff.module.template.model.TemplateVersionInfo; +import com.writeoff.security.AuthContext; +import com.writeoff.security.PermissionService; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.jdbc.support.GeneratedKeyHolder; +import org.springframework.jdbc.support.KeyHolder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.sql.PreparedStatement; +import java.sql.Statement; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +@Service +public class TemplateService { + private final JdbcTemplate jdbcTemplate; + private final OssService ossService; + private final PermissionService permissionService; + private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + private static final Set SUPPORTED_TEMPLATE_TYPES = new HashSet(Arrays.asList( + "AGENDA", "SIGN_IN", "INVITATION", "OTHER" + )); + + private static final RowMapper TEMPLATE_ROW_MAPPER = (rs, n) -> new TemplateInfo( + rs.getLong("id"), + rs.getString("template_name"), + rs.getString("template_type"), + rs.getString("scope_type"), + rs.getObject("project_id") == null ? null : rs.getLong("project_id"), + rs.getObject("meeting_id") == null ? null : rs.getLong("meeting_id"), + rs.getObject("scope_id") == null ? null : rs.getLong("scope_id"), + rs.getString("biz_scene"), + rs.getString("status"), + rs.getInt("current_version_no"), + rs.getString("current_object_key"), + rs.getString("effective_from"), + rs.getString("effective_to"), + rs.getInt("watermark_enabled") == 1, + rs.getInt("download_rate_limit_per_hour"), + rs.getString("created_at"), + rs.getString("updated_at") + ); + + private static final RowMapper VERSION_ROW_MAPPER = (rs, n) -> new TemplateVersionInfo( + rs.getLong("id"), + rs.getLong("template_id"), + rs.getInt("version_no"), + rs.getString("object_key"), + rs.getString("version_status"), + rs.getInt("is_effective") == 1, + rs.getString("change_log"), + rs.getString("rollback_reason"), + rs.getString("created_at") + ); + private static final RowMapper DOWNLOAD_LOG_ROW_MAPPER = (rs, n) -> new TemplateDownloadLogInfo( + rs.getLong("id"), + rs.getLong("template_id"), + rs.getString("template_name"), + rs.getInt("version_no"), + rs.getLong("user_id"), + rs.getString("user_name"), + rs.getString("user_phone"), + rs.getString("object_key"), + rs.getString("download_type"), + rs.getString("watermark_text"), + rs.getObject("project_id") == null ? null : rs.getLong("project_id"), + rs.getObject("meeting_id") == null ? null : rs.getLong("meeting_id"), + rs.getString("ip"), + rs.getString("user_agent"), + rs.getString("downloaded_at") + ); + private static final RowMapper TYPE_OPTION_ROW_MAPPER = (rs, n) -> new TemplateTypeOption( + rs.getString("type_code"), + rs.getString("type_name"), + rs.getString("status"), + rs.getInt("sort_no") + ); + private static final Set SUPPORTED_SCENES = new HashSet(Arrays.asList( + "MEETING_RECOMMEND", "AUDIT_NOTIFY", "SETTLEMENT" + )); + + public TemplateService(JdbcTemplate jdbcTemplate, OssService ossService, PermissionService permissionService) { + this.jdbcTemplate = jdbcTemplate; + this.ossService = ossService; + this.permissionService = permissionService; + } + + public PageResult list(String templateName, + String templateType, + String status, + String scopeType, + String bizScene, + Boolean watermarkEnabled, + String effectiveStatus, + int pageNo, + int pageSize) { + int safePage = Math.max(pageNo, 1); + int safeSize = Math.min(Math.max(pageSize, 1), 100); + int offset = (safePage - 1) * safeSize; + String normalizedTemplateName = trimToNull(templateName); + String normalizedTemplateType = normalizeOptionalTemplateType(templateType); + String normalizedStatus = normalizeOptionalTemplateStatus(status); + String normalizedScopeType = normalizeOptionalScope(scopeType); + String normalizedBizScene = normalizeOptionalBizScene(bizScene); + String normalizedEffectiveStatus = normalizeOptionalEffectiveStatus(effectiveStatus); + StringBuilder whereSql = new StringBuilder(" WHERE t.tenant_id=? AND t.is_deleted=0"); + List whereArgs = new ArrayList(); + whereArgs.add(tenantId()); + if (normalizedTemplateName != null) { + whereSql.append(" AND t.template_name LIKE ?"); + whereArgs.add("%" + normalizedTemplateName + "%"); + } + if (normalizedTemplateType != null) { + whereSql.append(" AND t.template_type=?"); + whereArgs.add(normalizedTemplateType); + } + if (normalizedStatus != null) { + whereSql.append(" AND t.status=?"); + whereArgs.add(normalizedStatus); + } + if (normalizedScopeType != null) { + whereSql.append(" AND t.scope_type=?"); + whereArgs.add(normalizedScopeType); + } + if (normalizedBizScene != null) { + whereSql.append(" AND t.biz_scene=?"); + whereArgs.add(normalizedBizScene); + } + if (watermarkEnabled != null) { + whereSql.append(" AND t.watermark_enabled=?"); + whereArgs.add(Boolean.TRUE.equals(watermarkEnabled) ? 1 : 0); + } + appendEffectiveStatusFilter(whereSql, normalizedEffectiveStatus); + + Integer total = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM template t" + whereSql, + Integer.class, + whereArgs.toArray() + ); + long totalCount = total == null ? 0 : total; + + List dataArgs = new ArrayList(whereArgs); + dataArgs.add(safeSize); + dataArgs.add(offset); + List list = jdbcTemplate.query( + templateSelectSql() + + whereSql + + " ORDER BY t.updated_at DESC, t.id DESC LIMIT ? OFFSET ?", + TEMPLATE_ROW_MAPPER, + dataArgs.toArray() + ); + return new PageResult<>(list, totalCount, safePage, safeSize); + } + + public List listPublishedOptions(String bizScene) { + String normalizedBizScene = normalizeOptionalBizScene(bizScene); + StringBuilder sql = new StringBuilder(templateSelectSql()) + .append(" WHERE t.tenant_id=? AND t.is_deleted=0 AND t.status='PUBLISHED'"); + List args = new ArrayList(); + args.add(tenantId()); + if (normalizedBizScene != null) { + sql.append(" AND t.biz_scene=?"); + args.add(normalizedBizScene); + } + sql.append(" AND ").append(effectiveNowSql("t")); + sql.append(" ORDER BY t.updated_at DESC, t.id DESC"); + return jdbcTemplate.query(sql.toString(), TEMPLATE_ROW_MAPPER, args.toArray()); + } + + public List listMatchedForMeeting(Long meetingId, Long projectId) { + return jdbcTemplate.query( + "SELECT t.*, tv.object_key AS current_object_key " + + "FROM template t " + + "LEFT JOIN template_version tv ON tv.tenant_id=t.tenant_id AND tv.template_id=t.id AND tv.version_no=t.current_version_no " + + "WHERE t.tenant_id=? AND t.is_deleted=0 AND t.status='PUBLISHED' AND t.biz_scene='MEETING_RECOMMEND' " + + "AND " + effectiveNowSql("t") + " AND (" + + "t.scope_type='ALL' OR (t.scope_type='PROJECT' AND t.project_id=?) OR (t.scope_type='MEETING' AND t.meeting_id=?)) " + + "ORDER BY CASE t.scope_type WHEN 'MEETING' THEN 1 WHEN 'PROJECT' THEN 2 ELSE 3 END, t.id DESC", + TEMPLATE_ROW_MAPPER, + tenantId(), + projectId, + meetingId + ); + } + + public List listTypeOptions() { + return jdbcTemplate.query( + "SELECT type_code, type_name, status, sort_no FROM template_type_option WHERE tenant_id=? ORDER BY sort_no ASC", + TYPE_OPTION_ROW_MAPPER, + tenantId() + ); + } + + public void enableTypeOption(String typeCode) { + String normalized = normalizeTemplateType(typeCode); + assertTypeOptionExists(normalized); + jdbcTemplate.update( + "UPDATE template_type_option SET status='ENABLED', updated_at=CURRENT_TIMESTAMP WHERE tenant_id=? AND type_code=?", + tenantId(), + normalized + ); + } + + public void disableTypeOption(String typeCode) { + String normalized = normalizeTemplateType(typeCode); + assertTypeOptionExists(normalized); + jdbcTemplate.update( + "UPDATE template_type_option SET status='DISABLED', updated_at=CURRENT_TIMESTAMP WHERE tenant_id=? AND type_code=?", + tenantId(), + normalized + ); + } + + @Transactional(rollbackFor = Exception.class) + public TemplateInfo create(CreateTemplateRequest request) { + String scopeType = normalizeScope(request.getScopeType()); + String templateType = normalizeTemplateType(request.getTemplateType()); + String effectiveFrom = normalizeOptionalDateTime(request.getEffectiveFrom(), "effectiveFrom"); + String effectiveTo = normalizeOptionalDateTime(request.getEffectiveTo(), "effectiveTo"); + Integer downloadRateLimitPerHour = normalizeDownloadRateLimit(request.getDownloadRateLimitPerHour()); + assertEffectiveRangeValid(effectiveFrom, effectiveTo); + assertTypeOptionEnabled(templateType); + Long userId = safeUserId(); + KeyHolder keyHolder = new GeneratedKeyHolder(); + jdbcTemplate.update(connection -> { + PreparedStatement ps = connection.prepareStatement( + "INSERT INTO template (tenant_id, template_name, template_type, scope_type, project_id, meeting_id, scope_id, biz_scene, status, current_version_no, effective_from, effective_to, watermark_enabled, download_rate_limit_per_hour, created_by, updated_by) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'DRAFT', 1, STR_TO_DATE(?, '%Y-%m-%d %H:%i:%s'), STR_TO_DATE(?, '%Y-%m-%d %H:%i:%s'), ?, ?, ?, ?)", + Statement.RETURN_GENERATED_KEYS + ); + ps.setLong(1, tenantId()); + ps.setString(2, request.getTemplateName()); + ps.setString(3, templateType); + ps.setString(4, scopeType); + ps.setObject(5, request.getProjectId()); + ps.setObject(6, request.getMeetingId()); + ps.setObject(7, request.getScopeId()); + ps.setString(8, normalizeBizScene(request.getBizScene())); + ps.setString(9, effectiveFrom); + ps.setString(10, effectiveTo); + ps.setInt(11, Boolean.TRUE.equals(request.getWatermarkEnabled()) ? 1 : 0); + ps.setInt(12, downloadRateLimitPerHour); + ps.setLong(13, userId); + ps.setLong(14, userId); + return ps; + }, keyHolder); + Long templateId = keyHolder.getKey() == null ? null : keyHolder.getKey().longValue(); + Long validTemplateId = templateId == null ? 0L : templateId; + jdbcTemplate.update( + "INSERT INTO template_version (tenant_id, template_id, version_no, object_key, version_status, is_effective, change_log, rollback_reason, created_by) " + + "VALUES (?, ?, 1, ?, 'DRAFT', 0, ?, NULL, ?)", + tenantId(), + validTemplateId, + request.getObjectKey(), + request.getChangeLog(), + userId + ); + return findById(validTemplateId); + } + + public List versions(Long templateId) { + assertTemplateExists(templateId); + return jdbcTemplate.query( + "SELECT * FROM template_version WHERE tenant_id=? AND template_id=? ORDER BY version_no DESC", + VERSION_ROW_MAPPER, + tenantId(), + templateId + ); + } + + public Map presignUpload(TemplateUploadSignRequest request) { + String fileName = request.getFileName().trim(); + String ext = ""; + int idx = fileName.lastIndexOf('.'); + if (idx > 0 && idx < fileName.length() - 1) { + ext = "." + fileName.substring(idx + 1).toLowerCase(); + } + String templateType = normalizeTemplateType(request.getTemplateType()); + assertTypeOptionEnabled(templateType); + String typeFolder = templateType.toLowerCase(); + Long userId = safeUserId(); + String objectKey = "template/" + typeFolder + "/" + userId + "/" + UUID.randomUUID().toString().replace("-", "") + ext; + String contentType = normalizeContentType(request.getContentType()); + String putUrl = ossService.generateUploadUrl(objectKey, contentType); + Map result = new LinkedHashMap(); + result.put("objectKey", objectKey); + result.put("uploadUrl", putUrl); + result.put("contentType", contentType); + result.put("method", "PUT"); + result.put("templateType", templateType); + return result; + } + + @Transactional(rollbackFor = Exception.class) + public TemplateInfo addVersion(Long templateId, CreateTemplateVersionRequest request) { + TemplateInfo template = findById(templateId); + assertTemplateEditable(template); + Integer nextVersionNo = jdbcTemplate.queryForObject( + "SELECT IFNULL(MAX(version_no), 0) + 1 FROM template_version WHERE tenant_id=? AND template_id=? FOR UPDATE", + Integer.class, + tenantId(), + templateId + ); + int versionNo = nextVersionNo == null ? 1 : nextVersionNo; + jdbcTemplate.update( + "INSERT INTO template_version (tenant_id, template_id, version_no, object_key, version_status, is_effective, change_log, rollback_reason, created_by) " + + "VALUES (?, ?, ?, ?, 'DRAFT', 0, ?, NULL, ?)", + tenantId(), + templateId, + versionNo, + request.getObjectKey(), + request.getChangeLog(), + safeUserId() + ); + jdbcTemplate.update( + "UPDATE template SET current_version_no=?, status='DRAFT', updated_by=?, updated_at=CURRENT_TIMESTAMP WHERE tenant_id=? AND id=?", + versionNo, + safeUserId(), + tenantId(), + templateId + ); + return findById(templateId); + } + + @Transactional(rollbackFor = Exception.class) + public TemplateInfo publish(Long templateId) { + TemplateInfo template = findById(templateId); + assertEffectiveRangeValid(template.getEffectiveFrom(), template.getEffectiveTo()); + if ("ARCHIVED".equalsIgnoreCase(template.getStatus())) { + throw new BusinessException(10003, "模板已归档,不能再次发布"); + } + assertTypeOptionEnabled(template.getTemplateType()); + assertTemplateCurrentVersionReady(template); + jdbcTemplate.update( + "UPDATE template SET status='PUBLISHED', updated_by=?, updated_at=CURRENT_TIMESTAMP WHERE tenant_id=? AND id=?", + safeUserId(), + tenantId(), + templateId + ); + jdbcTemplate.update( + "UPDATE template_version SET version_status='HISTORY', is_effective=0 WHERE tenant_id=? AND template_id=?", + tenantId(), + templateId + ); + jdbcTemplate.update( + "UPDATE template_version SET version_status='PUBLISHED', is_effective=1 WHERE tenant_id=? AND template_id=? AND version_no=?", + tenantId(), + templateId, + template.getCurrentVersionNo() + ); + return findById(templateId); + } + + public TemplateInfo disable(Long templateId) { + assertTemplateExists(templateId); + jdbcTemplate.update( + "UPDATE template SET status='DISABLED', updated_by=?, updated_at=CURRENT_TIMESTAMP WHERE tenant_id=? AND id=?", + safeUserId(), + tenantId(), + templateId + ); + return findById(templateId); + } + + public TemplateInfo archive(Long templateId) { + assertTemplateExists(templateId); + jdbcTemplate.update( + "UPDATE template SET status='ARCHIVED', updated_by=?, updated_at=CURRENT_TIMESTAMP WHERE tenant_id=? AND id=?", + safeUserId(), + tenantId(), + templateId + ); + return findById(templateId); + } + + @Transactional(rollbackFor = Exception.class) + public TemplateInfo rollback(Long templateId, RollbackTemplateRequest request) { + TemplateInfo template = findById(templateId); + if ("ARCHIVED".equalsIgnoreCase(template.getStatus())) { + throw new BusinessException(10003, "已归档模板不允许回滚"); + } + Integer versionCount = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM template_version WHERE tenant_id=? AND template_id=? AND version_no=?", + Integer.class, + tenantId(), + templateId, + request.getVersionNo() + ); + if (versionCount == null || versionCount == 0) { + throw new BusinessException(10003, "目标版本不存在"); + } + String rollbackReason = normalizeRequiredText(request.getRollbackReason(), "rollbackReason"); + jdbcTemplate.update( + "UPDATE template SET current_version_no=?, status='PUBLISHED', updated_by=?, updated_at=CURRENT_TIMESTAMP WHERE tenant_id=? AND id=?", + request.getVersionNo(), + safeUserId(), + tenantId(), + templateId + ); + jdbcTemplate.update( + "UPDATE template_version SET version_status='HISTORY', is_effective=0 WHERE tenant_id=? AND template_id=?", + tenantId(), + templateId + ); + jdbcTemplate.update( + "UPDATE template_version SET version_status='PUBLISHED', is_effective=1, rollback_reason=? WHERE tenant_id=? AND template_id=? AND version_no=?", + rollbackReason, + tenantId(), + templateId, + request.getVersionNo() + ); + return findById(templateId); + } + + @Transactional(rollbackFor = Exception.class) + public Map download(Long templateId, String ip, String userAgent) { + TemplateInfo template = findById(templateId); + if ("DISABLED".equalsIgnoreCase(template.getStatus()) || "ARCHIVED".equalsIgnoreCase(template.getStatus())) { + throw new BusinessException(10003, "模板已停用,无法下载"); + } + if (template.getCurrentObjectKey() == null || template.getCurrentObjectKey().trim().isEmpty()) { + throw new BusinessException(10003, "模板当前版本文件不存在"); + } + String signedUrl = ossService.generateDownloadUrl(template.getCurrentObjectKey()); + Long userId = safeUserId(); + assertTemplateDownloadAllowed(template); + assertTemplateEffectiveNow(template, "下载"); + assertDownloadRateLimit(template, userId); + jdbcTemplate.update( + "INSERT INTO template_download_log (tenant_id, template_id, version_no, user_id, object_key, download_type, watermark_text, project_id, meeting_id, ip, user_agent) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + tenantId(), + template.getId(), + template.getCurrentVersionNo(), + userId, + template.getCurrentObjectKey(), + "NORMAL", + null, + template.getProjectId(), + template.getMeetingId(), + ip, + userAgent + ); + Map result = new LinkedHashMap(); + result.put("templateId", template.getId()); + result.put("versionNo", template.getCurrentVersionNo()); + result.put("objectKey", template.getCurrentObjectKey()); + result.put("signedUrl", signedUrl); + return result; + } + + @Transactional(rollbackFor = Exception.class) + public Map downloadWatermark(Long templateId, String watermarkText, String ip, String userAgent) { + TemplateInfo template = findById(templateId); + if ("DISABLED".equalsIgnoreCase(template.getStatus()) || "ARCHIVED".equalsIgnoreCase(template.getStatus())) { + throw new BusinessException(10003, "模板已停用,无法下载"); + } + if (template.getCurrentObjectKey() == null || template.getCurrentObjectKey().trim().isEmpty()) { + throw new BusinessException(10003, "模板当前版本文件不存在"); + } + String signedUrl = ossService.generateDownloadUrl(template.getCurrentObjectKey()); + Long userId = safeUserId(); + assertTemplateDownloadAllowed(template); + if (!template.getWatermarkEnabled()) { + throw new BusinessException(10003, "模板未启用水印下载"); + } + assertTemplateEffectiveNow(template, "水印下载"); + assertDownloadRateLimit(template, userId); + jdbcTemplate.update( + "INSERT INTO template_download_log (tenant_id, template_id, version_no, user_id, object_key, download_type, watermark_text, project_id, meeting_id, ip, user_agent) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + tenantId(), + template.getId(), + template.getCurrentVersionNo(), + userId, + template.getCurrentObjectKey(), + "WATERMARK", + watermarkText == null ? null : watermarkText.trim(), + template.getProjectId(), + template.getMeetingId(), + ip, + userAgent + ); + Map result = new LinkedHashMap(); + result.put("templateId", template.getId()); + result.put("versionNo", template.getCurrentVersionNo()); + result.put("objectKey", template.getCurrentObjectKey()); + result.put("signedUrl", signedUrl); + result.put("watermarkText", watermarkText == null ? "" : watermarkText.trim()); + return result; + } + + public Map versionDiff(Long templateId, Integer leftVersionNo, Integer rightVersionNo) { + assertTemplateExists(templateId); + Integer latest = jdbcTemplate.queryForObject( + "SELECT IFNULL(MAX(version_no), 0) FROM template_version WHERE tenant_id=? AND template_id=?", + Integer.class, + tenantId(), + templateId + ); + int right = rightVersionNo == null ? (latest == null ? 1 : latest) : rightVersionNo; + int left = leftVersionNo == null ? Math.max(1, right - 1) : leftVersionNo; + TemplateVersionInfo leftV = findVersion(templateId, left); + TemplateVersionInfo rightV = findVersion(templateId, right); + + Map diff = new LinkedHashMap(); + diff.put("templateId", templateId); + diff.put("leftVersionNo", left); + diff.put("rightVersionNo", right); + diff.put("leftObjectKey", leftV.getObjectKey()); + diff.put("rightObjectKey", rightV.getObjectKey()); + diff.put("leftStatus", leftV.getVersionStatus()); + diff.put("rightStatus", rightV.getVersionStatus()); + diff.put("leftChangeLog", leftV.getChangeLog()); + diff.put("rightChangeLog", rightV.getChangeLog()); + diff.put("objectKeyChanged", !safeEquals(leftV.getObjectKey(), rightV.getObjectKey())); + diff.put("statusChanged", !safeEquals(leftV.getVersionStatus(), rightV.getVersionStatus())); + diff.put("changeLogChanged", !safeEquals(leftV.getChangeLog(), rightV.getChangeLog())); + return diff; + } + + public PageResult listDownloadLogs(Long templateId, + String templateName, + Long userId, + String userKeyword, + Integer versionNo, + String downloadType, + String ip, + String downloadedFrom, + String downloadedTo, + int pageNo, + int pageSize) { + int safePage = Math.max(pageNo, 1); + int safeSize = Math.min(Math.max(pageSize, 1), 100); + int offset = (safePage - 1) * safeSize; + String normalizedTemplateName = trimToNull(templateName); + String normalizedUserKeyword = trimToNull(userKeyword); + String normalizedDownloadType = normalizeOptionalDownloadType(downloadType); + String normalizedIp = trimToNull(ip); + String normalizedDownloadedFrom = trimToNull(downloadedFrom); + String normalizedDownloadedTo = trimToNull(downloadedTo); + Long currentUserId = userId(); + boolean canReadAllLogs = currentUserId != null + && permissionService.hasPermission(currentUserId, "template.download.log.read.all"); + Long scopedUserId = canReadAllLogs ? userId : currentUserId; + String scopedUserKeyword = canReadAllLogs ? normalizedUserKeyword : null; + StringBuilder whereSql = new StringBuilder( + " FROM template_download_log l " + + "LEFT JOIN template t ON t.tenant_id=l.tenant_id AND t.id=l.template_id " + + "LEFT JOIN sys_user u ON u.tenant_id=l.tenant_id AND u.id=l.user_id " + + "WHERE l.tenant_id=?" + ); + List whereArgs = new ArrayList(); + whereArgs.add(tenantId()); + if (templateId != null) { + whereSql.append(" AND l.template_id=?"); + whereArgs.add(templateId); + } + if (normalizedTemplateName != null) { + whereSql.append(" AND t.template_name LIKE ?"); + whereArgs.add("%" + normalizedTemplateName + "%"); + } + if (scopedUserId != null) { + whereSql.append(" AND l.user_id=?"); + whereArgs.add(scopedUserId); + } + if (scopedUserKeyword != null) { + whereSql.append(" AND (u.user_name LIKE ? OR u.phone LIKE ?)"); + String like = "%" + scopedUserKeyword + "%"; + whereArgs.add(like); + whereArgs.add(like); + } + if (versionNo != null) { + whereSql.append(" AND l.version_no=?"); + whereArgs.add(versionNo); + } + if (normalizedDownloadType != null) { + whereSql.append(" AND l.download_type=?"); + whereArgs.add(normalizedDownloadType); + } + if (normalizedIp != null) { + whereSql.append(" AND l.ip LIKE ?"); + whereArgs.add("%" + normalizedIp + "%"); + } + if (normalizedDownloadedFrom != null) { + whereSql.append(" AND l.downloaded_at >= STR_TO_DATE(?, '%Y-%m-%d %H:%i:%s')"); + whereArgs.add(normalizedDownloadedFrom); + } + if (normalizedDownloadedTo != null) { + whereSql.append(" AND l.downloaded_at <= STR_TO_DATE(?, '%Y-%m-%d %H:%i:%s')"); + whereArgs.add(normalizedDownloadedTo); + } + + Integer total = jdbcTemplate.queryForObject( + "SELECT COUNT(1)" + whereSql, + Integer.class, + whereArgs.toArray() + ); + long totalCount = total == null ? 0 : total; + + List dataArgs = new ArrayList(whereArgs); + dataArgs.add(safeSize); + dataArgs.add(offset); + List list = jdbcTemplate.query( + "SELECT l.id, l.template_id, COALESCE(t.template_name, '') AS template_name, " + + "l.version_no, l.user_id, COALESCE(u.user_name, '') AS user_name, COALESCE(u.phone, '') AS user_phone, " + + "l.object_key, l.download_type, COALESCE(l.watermark_text, '') AS watermark_text, " + + "l.project_id, l.meeting_id, l.ip, l.user_agent, DATE_FORMAT(l.downloaded_at, '%Y-%m-%d %H:%i:%s') AS downloaded_at" + + whereSql + + " ORDER BY l.downloaded_at DESC, l.id DESC LIMIT ? OFFSET ?", + DOWNLOAD_LOG_ROW_MAPPER, + dataArgs.toArray() + ); + return new PageResult(list, totalCount, safePage, safeSize); + } + + public List> flowSceneOptions() { + return Arrays.asList( + scene("MEETING_RECOMMEND", "会议推荐模板"), + scene("AUDIT_NOTIFY", "审核通知模板"), + scene("SETTLEMENT", "结算模板") + ); + } + + public List listFlowLinks() { + return jdbcTemplate.query( + "SELECT fl.scene_code, fl.template_id, t.template_name, t.status, t.current_version_no " + + "FROM template_flow_link fl " + + "LEFT JOIN template t ON t.tenant_id=fl.tenant_id AND t.id=fl.template_id " + + "WHERE fl.tenant_id=? ORDER BY fl.scene_code ASC", + (rs, n) -> new TemplateFlowLinkInfo( + rs.getString("scene_code"), + sceneName(rs.getString("scene_code")), + rs.getLong("template_id"), + rs.getString("template_name"), + rs.getString("status"), + rs.getInt("current_version_no") + ), + tenantId() + ); + } + + public TemplateFlowLinkInfo bindFlowLink(String sceneCode, Long templateId) { + String scene = normalizeBizScene(sceneCode); + TemplateInfo template = findById(templateId); + assertPublishedTemplate(template, "流程绑定"); + assertTemplateSceneMatches(scene, template); + assertTemplateEffectiveNow(template, "流程绑定"); + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM template_flow_link WHERE tenant_id=? AND scene_code=?", + Integer.class, + tenantId(), + scene + ); + if (count == null || count == 0) { + jdbcTemplate.update( + "INSERT INTO template_flow_link (tenant_id, scene_code, template_id, created_by, updated_by) VALUES (?, ?, ?, ?, ?)", + tenantId(), + scene, + templateId, + safeUserId(), + safeUserId() + ); + } else { + jdbcTemplate.update( + "UPDATE template_flow_link SET template_id=?, updated_by=?, updated_at=CURRENT_TIMESTAMP WHERE tenant_id=? AND scene_code=?", + templateId, + safeUserId(), + tenantId(), + scene + ); + } + return new TemplateFlowLinkInfo( + scene, + sceneName(scene), + template.getId(), + template.getTemplateName(), + template.getStatus(), + template.getCurrentVersionNo() + ); + } + + private String templateSelectSql() { + return "SELECT t.id, t.template_name, t.template_type, t.scope_type, t.project_id, t.meeting_id, t.scope_id, t.biz_scene, t.status, " + + "t.current_version_no, tv.object_key AS current_object_key, " + + "DATE_FORMAT(t.effective_from, '%Y-%m-%d %H:%i:%s') AS effective_from, " + + "DATE_FORMAT(t.effective_to, '%Y-%m-%d %H:%i:%s') AS effective_to, " + + "t.watermark_enabled, t.download_rate_limit_per_hour, " + + "DATE_FORMAT(t.created_at, '%Y-%m-%d %H:%i:%s') AS created_at, " + + "DATE_FORMAT(t.updated_at, '%Y-%m-%d %H:%i:%s') AS updated_at " + + "FROM template t " + + "LEFT JOIN template_version tv ON tv.tenant_id=t.tenant_id AND tv.template_id=t.id AND tv.version_no=t.current_version_no "; + } + + private void appendEffectiveStatusFilter(StringBuilder sql, String effectiveStatus) { + if (effectiveStatus == null) { + return; + } + if ("ACTIVE".equals(effectiveStatus)) { + sql.append(" AND (t.effective_from IS NULL OR t.effective_from<=CURRENT_TIMESTAMP)"); + sql.append(" AND (t.effective_to IS NULL OR t.effective_to>=CURRENT_TIMESTAMP)"); + return; + } + if ("UPCOMING".equals(effectiveStatus)) { + sql.append(" AND t.effective_from IS NOT NULL AND t.effective_from>CURRENT_TIMESTAMP"); + return; + } + sql.append(" AND t.effective_to IS NOT NULL AND t.effective_to=CURRENT_TIMESTAMP)"; + } + + private String normalizeScope(String scopeType) { + String value = scopeType == null ? "ALL" : scopeType.trim().toUpperCase(); + if (!"ALL".equals(value) && !"PROJECT".equals(value) && !"MEETING".equals(value)) { + throw new BusinessException(10003, "scopeType仅支持ALL/PROJECT/MEETING"); + } + return value; + } + + private String normalizeTemplateType(String templateType) { + String value = templateType == null ? "OTHER" : templateType.trim().toUpperCase(); + if ("SIGN".equals(value)) { + value = "SIGN_IN"; + } else if ("INVIT".equals(value) || "INVITATION_LETTER".equals(value)) { + value = "INVITATION"; + } + if (!SUPPORTED_TEMPLATE_TYPES.contains(value)) { + throw new BusinessException(10003, "templateType仅支持AGENDA/SIGN_IN/INVITATION/OTHER"); + } + return value; + } + + private String normalizeBizScene(String sceneCode) { + String value = sceneCode == null ? "MEETING_RECOMMEND" : sceneCode.trim().toUpperCase(); + if (!SUPPORTED_SCENES.contains(value)) { + throw new BusinessException(10003, "bizScene仅支持MEETING_RECOMMEND/AUDIT_NOTIFY/SETTLEMENT"); + } + return value; + } + + private String normalizeOptionalScope(String scopeType) { + String value = trimToNull(scopeType); + return value == null ? null : normalizeScope(value); + } + + private String normalizeOptionalTemplateType(String templateType) { + String value = trimToNull(templateType); + return value == null ? null : normalizeTemplateType(value); + } + + private String normalizeOptionalBizScene(String bizScene) { + String value = trimToNull(bizScene); + return value == null ? null : normalizeBizScene(value); + } + + private String normalizeOptionalTemplateStatus(String status) { + String value = trimToNull(status); + if (value == null) { + return null; + } + String normalized = value.toUpperCase(); + if (!"DRAFT".equals(normalized) && !"PUBLISHED".equals(normalized) + && !"DISABLED".equals(normalized) && !"ARCHIVED".equals(normalized)) { + throw new BusinessException(10003, "status浠呮敮鎸丏RAFT/PUBLISHED/DISABLED/ARCHIVED"); + } + return normalized; + } + + private String normalizeOptionalEffectiveStatus(String effectiveStatus) { + String value = trimToNull(effectiveStatus); + if (value == null) { + return null; + } + String normalized = value.toUpperCase(); + if (!"ACTIVE".equals(normalized) && !"UPCOMING".equals(normalized) && !"EXPIRED".equals(normalized)) { + throw new BusinessException(10003, "effectiveStatus仅支持ACTIVE/UPCOMING/EXPIRED"); + } + return normalized; + } + + private String normalizeOptionalDownloadType(String downloadType) { + String value = trimToNull(downloadType); + if (value == null) { + return null; + } + String normalized = value.toUpperCase(); + if (!"NORMAL".equals(normalized) && !"WATERMARK".equals(normalized)) { + throw new BusinessException(10003, "downloadType仅支持NORMAL/WATERMARK"); + } + return normalized; + } + + private String normalizeContentType(String contentType) { + if (contentType == null || contentType.trim().isEmpty()) { + return "application/octet-stream"; + } + return contentType.trim(); + } + + private String normalizeRequiredText(String value, String fieldName) { + String normalized = trimToNull(value); + if (normalized == null) { + throw new BusinessException(10003, fieldName + "不能为空"); + } + return normalized; + } + + private String normalizeOptionalDateTime(String value, String fieldName) { + String normalized = trimToNull(value); + if (normalized == null) { + return null; + } + try { + return LocalDateTime.parse(normalized, DATE_TIME_FORMATTER).format(DATE_TIME_FORMATTER); + } catch (DateTimeParseException ex) { + throw new BusinessException(10003, fieldName + "格式应为yyyy-MM-dd HH:mm:ss"); + } + } + + private Integer normalizeDownloadRateLimit(Integer downloadRateLimitPerHour) { + if (downloadRateLimitPerHour == null) { + return 100; + } + if (downloadRateLimitPerHour <= 0) { + throw new BusinessException(10003, "downloadRateLimitPerHour必须大于0"); + } + return downloadRateLimitPerHour; + } + + private String trimToNull(String value) { + if (value == null) { + return null; + } + String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } + + private void assertTypeOptionExists(String typeCode) { + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM template_type_option WHERE tenant_id=? AND type_code=?", + Integer.class, + tenantId(), + typeCode + ); + if (count == null || count == 0) { + throw new BusinessException(10003, "模板类型不存在"); + } + } + + private void assertTypeOptionEnabled(String typeCode) { + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM template_type_option WHERE tenant_id=? AND type_code=? AND status='ENABLED'", + Integer.class, + tenantId(), + typeCode + ); + if (count == null || count == 0) { + throw new BusinessException(10003, "模板类型已停用: " + typeCode); + } + } + + private void assertTemplateExists(Long templateId) { + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM template WHERE tenant_id=? AND id=? AND is_deleted=0", + Integer.class, + tenantId(), + templateId + ); + if (count == null || count == 0) { + throw new BusinessException(10003, "模板不存在"); + } + } + + private TemplateVersionInfo findVersion(Long templateId, Integer versionNo) { + List list = jdbcTemplate.query( + "SELECT * FROM template_version WHERE tenant_id=? AND template_id=? AND version_no=? LIMIT 1", + VERSION_ROW_MAPPER, + tenantId(), + templateId, + versionNo + ); + if (list.isEmpty()) { + throw new BusinessException(10003, "模板版本不存在"); + } + return list.get(0); + } + + private boolean safeEquals(String a, String b) { + return (a == null && b == null) || (a != null && a.equals(b)); + } + + private void assertEffectiveRangeValid(String effectiveFrom, String effectiveTo) { + if (effectiveFrom == null || effectiveTo == null) { + return; + } + LocalDateTime start = LocalDateTime.parse(effectiveFrom, DATE_TIME_FORMATTER); + LocalDateTime end = LocalDateTime.parse(effectiveTo, DATE_TIME_FORMATTER); + if (start.isAfter(end)) { + throw new BusinessException(10003, "生效开始时间不能晚于生效结束时间"); + } + } + + private void assertPublishedTemplate(TemplateInfo template, String actionName) { + if (!"PUBLISHED".equalsIgnoreCase(template.getStatus())) { + throw new BusinessException(10003, actionName + "仅支持已发布模板"); + } + } + + private void assertTemplateEditable(TemplateInfo template) { + if ("ARCHIVED".equalsIgnoreCase(template.getStatus())) { + throw new BusinessException(10003, "已归档模板不允许新增版本"); + } + } + + private void assertTemplateCurrentVersionReady(TemplateInfo template) { + if (template.getCurrentObjectKey() == null || template.getCurrentObjectKey().trim().isEmpty()) { + throw new BusinessException(10003, "当前版本文件不存在,不能发布"); + } + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM template_version WHERE tenant_id=? AND template_id=? AND version_no=? AND object_key IS NOT NULL AND TRIM(object_key)<>''", + Integer.class, + tenantId(), + template.getId(), + template.getCurrentVersionNo() + ); + if (count == null || count == 0) { + throw new BusinessException(10003, "当前版本未找到可发布文件"); + } + } + + private void assertTemplateSceneMatches(String expectedScene, TemplateInfo template) { + if (!expectedScene.equalsIgnoreCase(template.getBizScene())) { + throw new BusinessException(10003, "模板业务场景与绑定场景不一致"); + } + } + + private void assertTemplateEffectiveNow(TemplateInfo template, String actionName) { + LocalDateTime now = LocalDateTime.now(); + String effectiveFrom = trimToNull(template.getEffectiveFrom()); + if (effectiveFrom != null) { + LocalDateTime start = LocalDateTime.parse(effectiveFrom, DATE_TIME_FORMATTER); + if (start.isAfter(now)) { + throw new BusinessException(10003, actionName + "失败,模板尚未生效"); + } + } + String effectiveTo = trimToNull(template.getEffectiveTo()); + if (effectiveTo != null) { + LocalDateTime end = LocalDateTime.parse(effectiveTo, DATE_TIME_FORMATTER); + if (end.isBefore(now)) { + throw new BusinessException(10003, actionName + "失败,模板已过期"); + } + } + } + + private void assertTemplateDownloadAllowed(TemplateInfo template) { + if ("DISABLED".equalsIgnoreCase(template.getStatus()) || "ARCHIVED".equalsIgnoreCase(template.getStatus())) { + throw new BusinessException(10003, "模板已停用或归档,无法下载"); + } + if (template.getCurrentObjectKey() == null || template.getCurrentObjectKey().trim().isEmpty()) { + throw new BusinessException(10003, "模板当前版本文件不存在"); + } + } + + private void assertDownloadRateLimit(TemplateInfo template, Long userId) { + Integer limit = template.getDownloadRateLimitPerHour(); + if (limit == null || limit <= 0) { + return; + } + Integer downloadedCount = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM template_download_log " + + "WHERE tenant_id=? AND template_id=? AND user_id=? AND downloaded_at>=DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 1 HOUR)", + Integer.class, + tenantId(), + template.getId(), + userId + ); + int currentCount = downloadedCount == null ? 0 : downloadedCount; + if (currentCount >= limit) { + throw new BusinessException(10003, "当前小时下载次数已达上限"); + } + } + + private TemplateInfo findById(Long templateId) { + List list = jdbcTemplate.query( + "SELECT t.*, tv.object_key AS current_object_key " + + "FROM template t " + + "LEFT JOIN template_version tv ON tv.tenant_id=t.tenant_id AND tv.template_id=t.id AND tv.version_no=t.current_version_no " + + "WHERE t.tenant_id=? AND t.id=? AND t.is_deleted=0", + TEMPLATE_ROW_MAPPER, + tenantId(), + templateId + ); + if (list.isEmpty()) { + throw new BusinessException(10003, "模板不存在"); + } + return list.get(0); + } + + private Map scene(String code, String name) { + Map data = new LinkedHashMap(); + data.put("sceneCode", code); + data.put("sceneName", name); + return data; + } + + private String sceneName(String sceneCode) { + if ("AUDIT_NOTIFY".equalsIgnoreCase(sceneCode)) { + return "审核通知模板"; + } + if ("SETTLEMENT".equalsIgnoreCase(sceneCode)) { + return "结算模板"; + } + return "会议推荐模板"; + } + + private Long safeUserId() { + Long userId = AuthContext.userId(); + return userId == null ? 0L : userId; + } + + private Long userId() { + return AuthContext.userId(); + } + + private Long tenantId() { + return AuthContext.requireTenantId(); + } +} diff --git a/backend/src/main/java/com/writeoff/security/AuthContext.java b/backend/src/main/java/com/writeoff/security/AuthContext.java new file mode 100644 index 0000000..50388dd --- /dev/null +++ b/backend/src/main/java/com/writeoff/security/AuthContext.java @@ -0,0 +1,47 @@ +package com.writeoff.security; + +public class AuthContext { + private static final ThreadLocal USER_ID_HOLDER = new ThreadLocal<>(); + private static final ThreadLocal TENANT_ID_HOLDER = new ThreadLocal<>(); + private static final ThreadLocal SCOPE_HOLDER = new ThreadLocal<>(); + + private AuthContext() { + } + + public static void set(Long userId, Long tenantId, AuthScope scope) { + USER_ID_HOLDER.set(userId); + TENANT_ID_HOLDER.set(tenantId); + SCOPE_HOLDER.set(scope); + } + + public static void set(Long userId, Long tenantId) { + set(userId, tenantId, AuthScope.TENANT); + } + + public static Long userId() { + return USER_ID_HOLDER.get(); + } + + public static Long tenantId() { + return TENANT_ID_HOLDER.get(); + } + + public static Long requireTenantId() { + Long tenantId = TENANT_ID_HOLDER.get(); + if (tenantId == null) { + throw new IllegalStateException("tenant context required"); + } + return tenantId; + } + + public static AuthScope scope() { + AuthScope scope = SCOPE_HOLDER.get(); + return scope == null ? AuthScope.TENANT : scope; + } + + public static void clear() { + USER_ID_HOLDER.remove(); + TENANT_ID_HOLDER.remove(); + SCOPE_HOLDER.remove(); + } +} diff --git a/backend/src/main/java/com/writeoff/security/AuthInterceptor.java b/backend/src/main/java/com/writeoff/security/AuthInterceptor.java new file mode 100644 index 0000000..fc7bd19 --- /dev/null +++ b/backend/src/main/java/com/writeoff/security/AuthInterceptor.java @@ -0,0 +1,379 @@ +package com.writeoff.security; + +import com.writeoff.common.exception.BusinessException; +import com.writeoff.common.exception.ErrorCodes; +import com.writeoff.common.web.RequestIdContext; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.JwtException; +import com.writeoff.module.observability.service.ObservabilityService; +import com.writeoff.module.system.service.OperationAuditLogService; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; +import org.springframework.web.method.HandlerMethod; +import org.springframework.web.servlet.HandlerInterceptor; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.time.LocalDateTime; +import java.util.UUID; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +@Component +public class AuthInterceptor implements HandlerInterceptor { + private static final long SESSION_ACTIVITY_TOUCH_INTERVAL_SECONDS = 60; + private final JwtTokenService jwtTokenService; + private final PermissionService permissionService; + private final OperationAuditLogService operationAuditLogService; + private final ObservabilityService observabilityService; + private final JdbcTemplate jdbcTemplate; + private final long idleTimeoutMinutes; + private static final Pattern BIZ_ID_PATTERN = Pattern.compile("/(\\d+)(/|$)"); + + public AuthInterceptor(JwtTokenService jwtTokenService, + PermissionService permissionService, + OperationAuditLogService operationAuditLogService, + ObservabilityService observabilityService, + JdbcTemplate jdbcTemplate, + @Value("${app.security.idle-timeout-minutes:60}") long idleTimeoutMinutes) { + this.jwtTokenService = jwtTokenService; + this.permissionService = permissionService; + this.operationAuditLogService = operationAuditLogService; + this.observabilityService = observabilityService; + this.jdbcTemplate = jdbcTemplate; + this.idleTimeoutMinutes = idleTimeoutMinutes <= 0 ? 60 : idleTimeoutMinutes; + } + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { + request.setAttribute("_startAtMs", System.currentTimeMillis()); + String requestId = resolveRequestId(request.getHeader("X-Request-Id")); + RequestIdContext.set(requestId); + response.setHeader("X-Request-Id", requestId); + String path = request.getRequestURI(); + if (path.startsWith("/api/auth/login") + || path.startsWith("/api/auth/platform-login") + || path.startsWith("/api/auth/password-public-key") + || path.startsWith("/api/auth/password-setup") + || path.startsWith("/api/auth/refresh") + || path.startsWith("/api/auth/logout") + || path.startsWith("/api/system/health") + || path.startsWith("/api/health") + || path.startsWith("/api/captcha")) { + return true; + } + String auth = request.getHeader("Authorization"); + if (auth == null || !auth.startsWith("Bearer ")) { + throw new BusinessException(ErrorCodes.UNAUTHORIZED, "未登录或Token无效"); + } + String token = auth.substring("Bearer ".length()); + try { + Claims claims = jwtTokenService.parse(token); + Long userId = claims.get("uid", Number.class).longValue(); + AuthScope scope = AuthScope.fromClaim(claims.get("scope", String.class)); + Number sidNum = claims.get("sid", Number.class); + Long sessionId = sidNum == null ? null : sidNum.longValue(); + Long tenantId = null; + if (scope == AuthScope.TENANT) { + Number tidNum = claims.get("tid", Number.class); + if (tidNum == null) { + throw new BusinessException(ErrorCodes.UNAUTHORIZED, "租户会话缺少租户信息"); + } + tenantId = tidNum.longValue(); + ensureTenantSessionValid(userId, tenantId); + ensureAccessSessionValid(sessionId, userId, tenantId, scope); + } else { + ensurePlatformSessionValid(userId); + ensureAccessSessionValid(sessionId, userId, null, scope); + } + AuthContext.set(userId, tenantId, scope); + + if (handler instanceof HandlerMethod) { + HandlerMethod hm = (HandlerMethod) handler; + RequirePermission rp = hm.getMethodAnnotation(RequirePermission.class); + if (rp != null) { + if (rp.domain() == PermissionDomain.PLATFORM) { + if (scope != AuthScope.PLATFORM || !permissionService.hasPlatformPermission(userId, rp.value())) { + throw new BusinessException(ErrorCodes.NO_PERMISSION, "无操作权限"); + } + } else { + if (scope != AuthScope.TENANT || !permissionService.hasPermission(userId, rp.value())) { + throw new BusinessException(ErrorCodes.NO_PERMISSION, "无操作权限"); + } + } + } + } + return true; + } catch (ExpiredJwtException e) { + throw new BusinessException(ErrorCodes.TOKEN_EXPIRED, "Token已过期"); + } catch (JwtException e) { + throw new BusinessException(ErrorCodes.UNAUTHORIZED, "未登录或Token无效"); + } + } + + @Override + public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { + try { + String path = request.getRequestURI(); + if (path != null && path.startsWith("/api/")) { + Object startAtObj = request.getAttribute("_startAtMs"); + long startAt = startAtObj instanceof Long ? (Long) startAtObj : System.currentTimeMillis(); + long durationMs = Math.max(0L, System.currentTimeMillis() - startAt); + observabilityService.recordApiMetric(path, response.getStatus(), durationMs); + String method = request.getMethod(); + boolean shouldLog = !"GET".equalsIgnoreCase(method) + || path.startsWith("/api/platform/") + || path.contains("/export") + || path.contains("/download") + || path.contains("/login"); + if (shouldLog) { + if (path.contains("/export") || path.contains("/download")) { + observabilityService.recordExportMetric(path, ex == null && response.getStatus() < 400 ? "SUCCESS" : "FAILED"); + } + String actionCode = "API_CALL"; + if (handler instanceof HandlerMethod) { + HandlerMethod hm = (HandlerMethod) handler; + RequirePermission rp = hm.getMethodAnnotation(RequirePermission.class); + if (rp != null) { + actionCode = rp.auditAction() == null || rp.auditAction().trim().isEmpty() + ? rp.value() + : rp.auditAction().trim(); + } else { + actionCode = hm.getMethod().getName(); + } + } + AuthScope scope = AuthContext.scope(); + if (scope == null) { + if (path.contains("/platform-login")) { + scope = AuthScope.PLATFORM; + } else if (path.contains("/auth/login")) { + scope = AuthScope.TENANT; + } + } + operationAuditLogService.log( + AuthContext.tenantId(), + AuthContext.userId(), + scope, + actionCode, + resolveBizType(path), + resolveBizId(path), + method, + path, + request.getQueryString(), + RequestIdContext.get(), + response.getStatus(), + ex == null && response.getStatus() < 400, + ex == null ? null : ex.getMessage(), + request.getRemoteAddr(), + request.getHeader("User-Agent") + ); + } + } + } catch (Exception ignored) { + } + AuthContext.clear(); + RequestIdContext.clear(); + } + + private String resolveBizType(String path) { + if (path == null) { + return "unknown"; + } + String p = path.replace("/api/", ""); + int idx = p.indexOf("/"); + if (idx <= 0) { + return p; + } + return p.substring(0, idx); + } + + private String resolveBizId(String path) { + if (path == null) { + return null; + } + Matcher matcher = BIZ_ID_PATTERN.matcher(path); + if (matcher.find()) { + return matcher.group(1); + } + return null; + } + + private void ensureTenantSessionValid(Long userId, Long tenantId) { + java.util.List> rows = jdbcTemplate.queryForList( + "SELECT u.status, u.valid_from, u.valid_to, t.status AS tenant_status " + + "FROM sys_user u JOIN tenant t ON t.id=u.tenant_id " + + "WHERE u.id=? AND u.tenant_id=? AND u.is_deleted=0 AND t.is_deleted=0 LIMIT 1", + userId, + tenantId + ); + if (rows.isEmpty()) { + throw new BusinessException(ErrorCodes.SESSION_INVALID, "会话失效"); + } + java.util.Map row = rows.get(0); + String userStatus = String.valueOf(row.get("status")); + String tenantStatus = String.valueOf(row.get("tenant_status")); + if (!"ENABLED".equals(userStatus) || !"ENABLED".equals(tenantStatus)) { + throw new BusinessException(ErrorCodes.SESSION_INVALID, "会话失效"); + } + LocalDateTime now = LocalDateTime.now(); + LocalDateTime validFrom = toLocalDateTime(row.get("valid_from")); + LocalDateTime validTo = toLocalDateTime(row.get("valid_to")); + if (validFrom != null && now.isBefore(validFrom)) { + throw new BusinessException(ErrorCodes.ACCOUNT_EXPIRED, "账号尚未生效"); + } + if (validTo != null && now.isAfter(validTo)) { + throw new BusinessException(ErrorCodes.ACCOUNT_EXPIRED, "账号已过有效期"); + } + } + + private void ensurePlatformSessionValid(Long userId) { + java.util.List> rows = jdbcTemplate.queryForList( + "SELECT status, valid_from, valid_to FROM platform_user WHERE id=? AND is_deleted=0 LIMIT 1", + userId + ); + if (rows.isEmpty()) { + throw new BusinessException(ErrorCodes.SESSION_INVALID, "会话失效"); + } + java.util.Map row = rows.get(0); + String userStatus = String.valueOf(row.get("status")); + if (!"ENABLED".equals(userStatus)) { + throw new BusinessException(ErrorCodes.SESSION_INVALID, "会话失效"); + } + LocalDateTime now = LocalDateTime.now(); + LocalDateTime validFrom = toLocalDateTime(row.get("valid_from")); + LocalDateTime validTo = toLocalDateTime(row.get("valid_to")); + if (validFrom != null && now.isBefore(validFrom)) { + throw new BusinessException(ErrorCodes.ACCOUNT_EXPIRED, "账号尚未生效"); + } + if (validTo != null && now.isAfter(validTo)) { + throw new BusinessException(ErrorCodes.ACCOUNT_EXPIRED, "账号已过有效期"); + } + } + + private LocalDateTime toLocalDateTime(Object value) { + if (value == null) { + return null; + } + if (value instanceof LocalDateTime) { + return (LocalDateTime) value; + } + if (value instanceof java.sql.Timestamp) { + return ((java.sql.Timestamp) value).toLocalDateTime(); + } + if (value instanceof java.util.Date) { + return new java.sql.Timestamp(((java.util.Date) value).getTime()).toLocalDateTime(); + } + if (value instanceof String) { + String text = String.valueOf(value).trim(); + if (text.isEmpty()) { + return null; + } + try { + return LocalDateTime.parse(text.replace(' ', 'T')); + } catch (Exception ignored) { + } + } + return null; + } + + private void ensureAccessSessionValid(Long sessionId, Long userId, Long tenantId, AuthScope scope) { + // Require session binding so admin revoke can take effect immediately. + if (sessionId == null || sessionId <= 0) { + throw new BusinessException(ErrorCodes.SESSION_INVALID, "会话失效"); + } + java.util.List> rows = jdbcTemplate.queryForList( + "SELECT user_id, tenant_id, scope, status, issued_at, expires_at, last_used_at, is_deleted FROM auth_refresh_token WHERE id=? LIMIT 1", + sessionId + ); + if (rows.isEmpty()) { + throw new BusinessException(ErrorCodes.SESSION_INVALID, "会话失效"); + } + java.util.Map row = rows.get(0); + int isDeleted = toFlagInt(row.get("is_deleted")); + if (isDeleted == 1) { + throw new BusinessException(ErrorCodes.SESSION_INVALID, "会话失效"); + } + Long rowUserId = ((Number) row.get("user_id")).longValue(); + Long rowTenantId = row.get("tenant_id") == null ? null : ((Number) row.get("tenant_id")).longValue(); + String rowScope = String.valueOf(row.get("scope")); + String rowStatus = String.valueOf(row.get("status")); + LocalDateTime issuedAt = toLocalDateTime(row.get("issued_at")); + LocalDateTime expiresAt = toLocalDateTime(row.get("expires_at")); + LocalDateTime lastUsedAt = toLocalDateTime(row.get("last_used_at")); + LocalDateTime now = LocalDateTime.now(); + if (!rowUserId.equals(userId) || !scope.name().equals(rowScope)) { + throw new BusinessException(ErrorCodes.SESSION_INVALID, "会话失效"); + } + if (scope == AuthScope.TENANT && (rowTenantId == null || !rowTenantId.equals(tenantId))) { + throw new BusinessException(ErrorCodes.SESSION_INVALID, "会话失效"); + } + if (scope == AuthScope.PLATFORM && rowTenantId != null) { + throw new BusinessException(ErrorCodes.SESSION_INVALID, "会话失效"); + } + if (!"ACTIVE".equals(rowStatus)) { + throw new BusinessException(ErrorCodes.SESSION_INVALID, "会话失效"); + } + LocalDateTime lastActivityAt = lastUsedAt == null ? issuedAt : lastUsedAt; + if (lastActivityAt == null || now.isAfter(lastActivityAt.plusMinutes(idleTimeoutMinutes))) { + revokeIdleSession(sessionId); + throw new BusinessException(ErrorCodes.SESSION_INVALID, "浼氳瘽澶辨晥"); + } + if (expiresAt == null || now.isAfter(expiresAt)) { + throw new BusinessException(ErrorCodes.SESSION_INVALID, "会话失效"); + } + touchSessionActivity(sessionId, lastUsedAt, now); + } + + private String resolveRequestId(String requestIdHeader) { + String requestId = requestIdHeader == null ? "" : requestIdHeader.trim(); + if (!requestId.isEmpty()) { + return requestId; + } + return UUID.randomUUID().toString().replace("-", "").substring(0, 16); + } + + private int toFlagInt(Object value) { + if (value == null) { + return 0; + } + if (value instanceof Number) { + return ((Number) value).intValue(); + } + if (value instanceof Boolean) { + return ((Boolean) value) ? 1 : 0; + } + String text = String.valueOf(value).trim(); + if ("true".equalsIgnoreCase(text)) { + return 1; + } + if ("false".equalsIgnoreCase(text)) { + return 0; + } + try { + return Integer.parseInt(text); + } catch (NumberFormatException ex) { + return 0; + } + } + + private void revokeIdleSession(Long sessionId) { + jdbcTemplate.update( + "UPDATE auth_refresh_token SET status='REVOKED', revoked_at=CURRENT_TIMESTAMP, revoked_reason='IDLE_TIMEOUT', updated_at=CURRENT_TIMESTAMP " + + "WHERE id=? AND status='ACTIVE' AND is_deleted=0", + sessionId + ); + } + + private void touchSessionActivity(Long sessionId, LocalDateTime lastUsedAt, LocalDateTime now) { + if (lastUsedAt != null && java.time.Duration.between(lastUsedAt, now).getSeconds() < SESSION_ACTIVITY_TOUCH_INTERVAL_SECONDS) { + return; + } + jdbcTemplate.update( + "UPDATE auth_refresh_token SET last_used_at=CURRENT_TIMESTAMP, updated_at=CURRENT_TIMESTAMP " + + "WHERE id=? AND status='ACTIVE' AND is_deleted=0", + sessionId + ); + } +} diff --git a/backend/src/main/java/com/writeoff/security/AuthScope.java b/backend/src/main/java/com/writeoff/security/AuthScope.java new file mode 100644 index 0000000..116e409 --- /dev/null +++ b/backend/src/main/java/com/writeoff/security/AuthScope.java @@ -0,0 +1,19 @@ +package com.writeoff.security; + +public enum AuthScope { + TENANT, + PLATFORM; + + public static AuthScope fromClaim(String raw) { + if (raw == null || raw.trim().isEmpty()) { + return TENANT; + } + String normalized = raw.trim().toUpperCase(); + for (AuthScope value : values()) { + if (value.name().equals(normalized)) { + return value; + } + } + throw new IllegalArgumentException("invalid auth scope: " + raw); + } +} diff --git a/backend/src/main/java/com/writeoff/security/CaptchaService.java b/backend/src/main/java/com/writeoff/security/CaptchaService.java new file mode 100644 index 0000000..2424a79 --- /dev/null +++ b/backend/src/main/java/com/writeoff/security/CaptchaService.java @@ -0,0 +1,144 @@ +package com.writeoff.security; + +import org.springframework.stereotype.Service; + +import javax.imageio.ImageIO; +import java.awt.*; +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; +import java.time.Instant; +import java.util.Base64; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Random; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +/** + * 图形验证码服务 —— 基于 Java 2D 生成 4 位字母数字混合验证码。 + * 存储于内存 ConcurrentHashMap,单实例场景使用。 + */ +@Service +public class CaptchaService { + + private static final int CODE_LENGTH = 4; + private static final int WIDTH = 130; + private static final int HEIGHT = 40; + private static final long EXPIRE_SECONDS = 5 * 60; // 5 分钟 + private static final String CHARS = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; + + private final ConcurrentHashMap store = new ConcurrentHashMap<>(); + + /** + * 生成验证码。 + * + * @return map 包含 captchaId 和 image (Base64 PNG) + */ + public Map generate() { + cleanExpired(); + String captchaId = UUID.randomUUID().toString().replace("-", "").substring(0, 16); + Random random = new Random(); + StringBuilder code = new StringBuilder(); + for (int i = 0; i < CODE_LENGTH; i++) { + code.append(CHARS.charAt(random.nextInt(CHARS.length()))); + } + String codeStr = code.toString(); + store.put(captchaId, new CaptchaRecord(codeStr, Instant.now())); + + String base64 = renderImage(codeStr, random); + + Map result = new LinkedHashMap<>(); + result.put("captchaId", captchaId); + result.put("image", "data:image/png;base64," + base64); + return result; + } + + /** + * 校验验证码(一次性,校验后即删除)。 + */ + public boolean verify(String captchaId, String code) { + if (captchaId == null || code == null) { + return false; + } + CaptchaRecord record = store.remove(captchaId); + if (record == null) { + return false; + } + if (record.isExpired()) { + return false; + } + return record.code.equalsIgnoreCase(code.trim()); + } + + private String renderImage(String code, Random random) { + BufferedImage image = new BufferedImage(WIDTH, HEIGHT, BufferedImage.TYPE_INT_RGB); + Graphics2D g = image.createGraphics(); + + // 启用抗锯齿 + g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + + // 背景 + g.setColor(new Color(240, 243, 248)); + g.fillRect(0, 0, WIDTH, HEIGHT); + + // 干扰线 + for (int i = 0; i < 5; i++) { + g.setColor(new Color(180 + random.nextInt(50), 180 + random.nextInt(50), 200 + random.nextInt(50))); + g.setStroke(new BasicStroke(1.2f)); + g.drawLine(random.nextInt(WIDTH), random.nextInt(HEIGHT), random.nextInt(WIDTH), random.nextInt(HEIGHT)); + } + + // 干扰点 + for (int i = 0; i < 30; i++) { + g.setColor(new Color(150 + random.nextInt(80), 150 + random.nextInt(80), 180 + random.nextInt(60))); + g.fillOval(random.nextInt(WIDTH), random.nextInt(HEIGHT), 2, 2); + } + + // 绘制字符 + Color[] charColors = { + new Color(59, 130, 246), + new Color(99, 102, 241), + new Color(139, 92, 246), + new Color(14, 165, 233), + }; + g.setFont(new Font("SansSerif", Font.BOLD, 26)); + int charSpacing = (WIDTH - 20) / CODE_LENGTH; + for (int i = 0; i < code.length(); i++) { + g.setColor(charColors[i % charColors.length]); + double angle = (random.nextDouble() - 0.5) * 0.4; + int x = 12 + i * charSpacing; + int y = 28 + random.nextInt(6); + g.rotate(angle, x, y); + g.drawString(String.valueOf(code.charAt(i)), x, y); + g.rotate(-angle, x, y); + } + + g.dispose(); + + try { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + ImageIO.write(image, "png", baos); + return Base64.getEncoder().encodeToString(baos.toByteArray()); + } catch (Exception e) { + return ""; + } + } + + private void cleanExpired() { + store.entrySet().removeIf(entry -> entry.getValue().isExpired()); + } + + private static class CaptchaRecord { + final String code; + final Instant createdAt; + + CaptchaRecord(String code, Instant createdAt) { + this.code = code; + this.createdAt = createdAt; + } + + boolean isExpired() { + return Instant.now().getEpochSecond() - createdAt.getEpochSecond() > EXPIRE_SECONDS; + } + } +} diff --git a/backend/src/main/java/com/writeoff/security/DataScopeType.java b/backend/src/main/java/com/writeoff/security/DataScopeType.java new file mode 100644 index 0000000..38a45e0 --- /dev/null +++ b/backend/src/main/java/com/writeoff/security/DataScopeType.java @@ -0,0 +1,9 @@ +package com.writeoff.security; + +public enum DataScopeType { + TENANT, + PROJECT, + MEETING, + MEETING_MODULE, + GLOBAL_READONLY +} diff --git a/backend/src/main/java/com/writeoff/security/JwtTokenService.java b/backend/src/main/java/com/writeoff/security/JwtTokenService.java new file mode 100644 index 0000000..3f3a71a --- /dev/null +++ b/backend/src/main/java/com/writeoff/security/JwtTokenService.java @@ -0,0 +1,87 @@ +package com.writeoff.security; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import io.jsonwebtoken.security.Keys; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.nio.charset.StandardCharsets; +import java.security.Key; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; + +@Service +public class JwtTokenService { + private final Key key; + private final long accessExpireMs; + + public JwtTokenService(@Value("${app.security.jwt-secret}") String secret, + @Value("${app.security.access-expire-minutes:${app.security.jwt-expire-minutes:120}}") long accessExpireMinutes) { + byte[] bytes = secret.getBytes(StandardCharsets.UTF_8); + if (bytes.length < 32) { + byte[] padded = new byte[32]; + System.arraycopy(bytes, 0, padded, 0, bytes.length); + bytes = padded; + } + this.key = Keys.hmacShaKeyFor(bytes); + this.accessExpireMs = accessExpireMinutes * 60 * 1000; + } + + public String createTenantToken(Long userId, Long tenantId, String phone) { + return createTenantToken(userId, tenantId, phone, null); + } + + public String createTenantToken(Long userId, Long tenantId, String phone, Long sessionId) { + Date now = new Date(); + Date expiry = new Date(now.getTime() + accessExpireMs); + Map claims = new HashMap<>(); + claims.put("uid", userId); + claims.put("tid", tenantId); + claims.put("scope", AuthScope.TENANT.name()); + claims.put("phone", phone); + if (sessionId != null && sessionId > 0) { + claims.put("sid", sessionId); + } + return Jwts.builder() + .setClaims(claims) + .setSubject("writeoff-user") + .setIssuedAt(now) + .setExpiration(expiry) + .signWith(key, SignatureAlgorithm.HS256) + .compact(); + } + + public String createPlatformToken(Long userId, String phone) { + return createPlatformToken(userId, phone, null); + } + + public String createPlatformToken(Long userId, String phone, Long sessionId) { + Date now = new Date(); + Date expiry = new Date(now.getTime() + accessExpireMs); + Map claims = new HashMap<>(); + claims.put("uid", userId); + claims.put("scope", AuthScope.PLATFORM.name()); + claims.put("phone", phone); + if (sessionId != null && sessionId > 0) { + claims.put("sid", sessionId); + } + return Jwts.builder() + .setClaims(claims) + .setSubject("writeoff-user") + .setIssuedAt(now) + .setExpiration(expiry) + .signWith(key, SignatureAlgorithm.HS256) + .compact(); + } + + public Claims parse(String token) { + return Jwts.parserBuilder() + .setSigningKey(key) + .build() + .parseClaimsJws(token) + .getBody(); + } +} diff --git a/backend/src/main/java/com/writeoff/security/LoginAttemptService.java b/backend/src/main/java/com/writeoff/security/LoginAttemptService.java new file mode 100644 index 0000000..01023fb --- /dev/null +++ b/backend/src/main/java/com/writeoff/security/LoginAttemptService.java @@ -0,0 +1,195 @@ +package com.writeoff.security; + +import org.springframework.dao.DuplicateKeyException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Timestamp; +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.List; + +@Service +public class LoginAttemptService { + + private static final int MAX_ATTEMPTS = 5; + private static final long FAILURE_WINDOW_MINUTES = 30; + private static final long LOCK_DURATION_MINUTES = 15; + + private final JdbcTemplate jdbcTemplate; + + public LoginAttemptService(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + @Transactional + public LoginAttemptStatus recordFailure(String key) { + for (int retry = 0; retry < 2; retry++) { + try { + return recordFailureInternal(key); + } catch (DuplicateKeyException ex) { + if (retry == 1) { + throw ex; + } + } + } + throw new IllegalStateException("Unable to record login failure"); + } + + public void clearFailures(String key) { + jdbcTemplate.update("DELETE FROM auth_login_attempt WHERE attempt_key = ?", key); + } + + public boolean isLocked(String key) { + return getStatus(key).isLocked(); + } + + public long getRemainingLockSeconds(String key) { + return getStatus(key).getRemainingLockSeconds(); + } + + public int getFailureCount(String key) { + return getStatus(key).getFailureCount(); + } + + public LoginAttemptStatus getStatus(String key) { + return toStatus(findRecord(key), LocalDateTime.now()); + } + + private LoginAttemptStatus recordFailureInternal(String key) { + LocalDateTime now = LocalDateTime.now(); + AttemptRecord existing = findRecordForUpdate(key); + if (existing == null) { + jdbcTemplate.update( + "INSERT INTO auth_login_attempt (attempt_key, failure_count, window_started_at, last_failed_at, locked_until) " + + "VALUES (?, ?, ?, ?, ?)", + key, + 1, + Timestamp.valueOf(now), + Timestamp.valueOf(now), + null + ); + return new LoginAttemptStatus(1, 0); + } + + LoginAttemptStatus current = toStatus(existing, now); + if (current.isLocked()) { + return current; + } + + boolean windowExpired = isWindowExpired(existing.getWindowStartedAt(), now); + int nextFailureCount = windowExpired ? 1 : existing.getFailureCount() + 1; + LocalDateTime nextWindowStartedAt = windowExpired ? now : existing.getWindowStartedAt(); + LocalDateTime nextLockedUntil = nextFailureCount >= MAX_ATTEMPTS ? now.plusMinutes(LOCK_DURATION_MINUTES) : null; + + jdbcTemplate.update( + "UPDATE auth_login_attempt " + + "SET failure_count = ?, window_started_at = ?, last_failed_at = ?, locked_until = ?, updated_at = CURRENT_TIMESTAMP " + + "WHERE attempt_key = ?", + nextFailureCount, + Timestamp.valueOf(nextWindowStartedAt), + Timestamp.valueOf(now), + nextLockedUntil == null ? null : Timestamp.valueOf(nextLockedUntil), + key + ); + + return toStatus(new AttemptRecord(nextFailureCount, nextWindowStartedAt, nextLockedUntil), now); + } + + private AttemptRecord findRecord(String key) { + List records = jdbcTemplate.query( + "SELECT failure_count, window_started_at, locked_until FROM auth_login_attempt WHERE attempt_key = ?", + (rs, rowNum) -> mapAttemptRecord(rs), + key + ); + return records.isEmpty() ? null : records.get(0); + } + + private AttemptRecord findRecordForUpdate(String key) { + List records = jdbcTemplate.query( + "SELECT failure_count, window_started_at, locked_until FROM auth_login_attempt WHERE attempt_key = ? FOR UPDATE", + (rs, rowNum) -> mapAttemptRecord(rs), + key + ); + return records.isEmpty() ? null : records.get(0); + } + + private AttemptRecord mapAttemptRecord(ResultSet rs) throws SQLException { + Timestamp windowStartedAt = rs.getTimestamp("window_started_at"); + Timestamp lockedUntil = rs.getTimestamp("locked_until"); + return new AttemptRecord( + rs.getInt("failure_count"), + windowStartedAt == null ? null : windowStartedAt.toLocalDateTime(), + lockedUntil == null ? null : lockedUntil.toLocalDateTime() + ); + } + + private LoginAttemptStatus toStatus(AttemptRecord record, LocalDateTime now) { + if (record == null) { + return new LoginAttemptStatus(0, 0); + } + int failureCount = isWindowExpired(record.getWindowStartedAt(), now) ? 0 : record.getFailureCount(); + long remainingLockSeconds = 0; + if (record.getLockedUntil() != null && record.getLockedUntil().isAfter(now)) { + remainingLockSeconds = Math.max(1, Duration.between(now, record.getLockedUntil()).getSeconds()); + } + return new LoginAttemptStatus(failureCount, remainingLockSeconds); + } + + private boolean isWindowExpired(LocalDateTime windowStartedAt, LocalDateTime now) { + return windowStartedAt == null || !now.isBefore(windowStartedAt.plusMinutes(FAILURE_WINDOW_MINUTES)); + } + + public static class LoginAttemptStatus { + private final int failureCount; + private final long remainingLockSeconds; + + LoginAttemptStatus(int failureCount, long remainingLockSeconds) { + this.failureCount = failureCount; + this.remainingLockSeconds = remainingLockSeconds; + } + + public int getFailureCount() { + return failureCount; + } + + public long getRemainingLockSeconds() { + return remainingLockSeconds; + } + + public boolean isLocked() { + return remainingLockSeconds > 0; + } + + public int getRemainingAttempts() { + return Math.max(0, MAX_ATTEMPTS - failureCount); + } + } + + private static class AttemptRecord { + private final int failureCount; + private final LocalDateTime windowStartedAt; + private final LocalDateTime lockedUntil; + + AttemptRecord(int failureCount, LocalDateTime windowStartedAt, LocalDateTime lockedUntil) { + this.failureCount = failureCount; + this.windowStartedAt = windowStartedAt; + this.lockedUntil = lockedUntil; + } + + int getFailureCount() { + return failureCount; + } + + LocalDateTime getWindowStartedAt() { + return windowStartedAt; + } + + LocalDateTime getLockedUntil() { + return lockedUntil; + } + } +} diff --git a/backend/src/main/java/com/writeoff/security/LoginPasswordCryptoService.java b/backend/src/main/java/com/writeoff/security/LoginPasswordCryptoService.java new file mode 100644 index 0000000..4fd77ea --- /dev/null +++ b/backend/src/main/java/com/writeoff/security/LoginPasswordCryptoService.java @@ -0,0 +1,71 @@ +package com.writeoff.security; + +import org.springframework.stereotype.Service; + +import javax.annotation.PostConstruct; +import javax.crypto.Cipher; +import javax.crypto.spec.OAEPParameterSpec; +import javax.crypto.spec.PSource; +import java.nio.charset.StandardCharsets; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.interfaces.RSAPrivateKey; +import java.security.interfaces.RSAPublicKey; +import java.security.spec.MGF1ParameterSpec; +import java.util.Base64; + +@Service +public class LoginPasswordCryptoService { + public static final String PASSWORD_PREFIX = "rsa:"; + private static final OAEPParameterSpec OAEP_SHA256_MGF1_SHA256 = new OAEPParameterSpec( + "SHA-256", + "MGF1", + MGF1ParameterSpec.SHA256, + PSource.PSpecified.DEFAULT + ); + + private volatile RSAPublicKey publicKey; + private volatile RSAPrivateKey privateKey; + + @PostConstruct + public void init() { + try { + KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA"); + generator.initialize(2048); + KeyPair keyPair = generator.generateKeyPair(); + this.publicKey = (RSAPublicKey) keyPair.getPublic(); + this.privateKey = (RSAPrivateKey) keyPair.getPrivate(); + } catch (Exception ex) { + throw new IllegalStateException("Unable to initialize login password crypto service", ex); + } + } + + public String getEncodedPublicKey() { + RSAPublicKey key = publicKey; + if (key == null) { + throw new IllegalStateException("Login password public key is not ready"); + } + return Base64.getEncoder().encodeToString(key.getEncoded()); + } + + public String unwrapPassword(String password) { + if (password == null || password.trim().isEmpty()) { + return password; + } + if (!password.startsWith(PASSWORD_PREFIX)) { + return password; + } + String cipherText = password.substring(PASSWORD_PREFIX.length()).trim(); + if (cipherText.isEmpty()) { + throw new IllegalArgumentException("Encrypted password cannot be empty"); + } + try { + Cipher cipher = Cipher.getInstance("RSA/ECB/OAEPWithSHA-256AndMGF1Padding"); + cipher.init(Cipher.DECRYPT_MODE, privateKey, OAEP_SHA256_MGF1_SHA256); + byte[] decrypted = cipher.doFinal(Base64.getDecoder().decode(cipherText)); + return new String(decrypted, StandardCharsets.UTF_8); + } catch (Exception ex) { + throw new IllegalArgumentException("Unable to decrypt login password", ex); + } + } +} diff --git a/backend/src/main/java/com/writeoff/security/PasswordCodecService.java b/backend/src/main/java/com/writeoff/security/PasswordCodecService.java new file mode 100644 index 0000000..256d42c --- /dev/null +++ b/backend/src/main/java/com/writeoff/security/PasswordCodecService.java @@ -0,0 +1,90 @@ +package com.writeoff.security; + +import org.springframework.stereotype.Service; + +import javax.crypto.SecretKeyFactory; +import javax.crypto.spec.PBEKeySpec; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.SecureRandom; + +@Service +public class PasswordCodecService { + private static final String PREFIX = "pbkdf2"; + private static final String ALGORITHM = "sha1"; + private static final int ITERATIONS = 150000; + private static final int SALT_BYTES = 16; + private static final int KEY_BYTES = 32; + + private final SecureRandom secureRandom = new SecureRandom(); + + public String encode(String rawPassword) { + if (rawPassword == null) { + throw new IllegalArgumentException("Password cannot be null"); + } + byte[] salt = new byte[SALT_BYTES]; + secureRandom.nextBytes(salt); + byte[] hash = derive(rawPassword, salt, ITERATIONS, KEY_BYTES); + return PREFIX + "$" + ALGORITHM + "$" + ITERATIONS + "$" + toHex(salt) + "$" + toHex(hash); + } + + public boolean matches(String rawPassword, String storedPassword) { + if (rawPassword == null || storedPassword == null || storedPassword.trim().isEmpty()) { + return false; + } + if (!isEncoded(storedPassword)) { + return MessageDigest.isEqual( + rawPassword.getBytes(StandardCharsets.UTF_8), + storedPassword.getBytes(StandardCharsets.UTF_8) + ); + } + String[] parts = storedPassword.split("\\$"); + if (parts.length != 5) { + return false; + } + if (!PREFIX.equals(parts[0]) || !ALGORITHM.equals(parts[1])) { + return false; + } + try { + int iterations = Integer.parseInt(parts[2]); + byte[] salt = fromHex(parts[3]); + byte[] expected = fromHex(parts[4]); + byte[] actual = derive(rawPassword, salt, iterations, expected.length); + return MessageDigest.isEqual(actual, expected); + } catch (RuntimeException ex) { + return false; + } + } + + public boolean isEncoded(String storedPassword) { + return storedPassword != null && storedPassword.startsWith(PREFIX + "$"); + } + + private byte[] derive(String rawPassword, byte[] salt, int iterations, int keyBytes) { + try { + PBEKeySpec spec = new PBEKeySpec(rawPassword.toCharArray(), salt, iterations, keyBytes * 8); + return SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1").generateSecret(spec).getEncoded(); + } catch (Exception ex) { + throw new IllegalStateException("Unable to encode password", ex); + } + } + + private String toHex(byte[] bytes) { + StringBuilder sb = new StringBuilder(bytes.length * 2); + for (byte value : bytes) { + sb.append(String.format("%02x", value & 0xff)); + } + return sb.toString(); + } + + private byte[] fromHex(String hex) { + if (hex == null || (hex.length() % 2) != 0) { + throw new IllegalArgumentException("Invalid hex value"); + } + byte[] bytes = new byte[hex.length() / 2]; + for (int i = 0; i < hex.length(); i += 2) { + bytes[i / 2] = (byte) Integer.parseInt(hex.substring(i, i + 2), 16); + } + return bytes; + } +} diff --git a/backend/src/main/java/com/writeoff/security/PasswordPolicyService.java b/backend/src/main/java/com/writeoff/security/PasswordPolicyService.java new file mode 100644 index 0000000..2539369 --- /dev/null +++ b/backend/src/main/java/com/writeoff/security/PasswordPolicyService.java @@ -0,0 +1,60 @@ +package com.writeoff.security; + +import com.writeoff.common.exception.BusinessException; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Pattern; + +/** + * 密码策略校验服务。 + * 规则: + * - 最少8位 + * - 必须包含大写字母 + * - 必须包含小写字母 + * - 必须包含数字 + * - 必须包含特殊字符 + */ +@Service +public class PasswordPolicyService { + + private static final int MIN_LENGTH = 8; + private static final Pattern UPPER = Pattern.compile("[A-Z]"); + private static final Pattern LOWER = Pattern.compile("[a-z]"); + private static final Pattern DIGIT = Pattern.compile("[0-9]"); + private static final Pattern SPECIAL = Pattern.compile("[!@#$%^&*()_+\\-=\\[\\]{};':\"\\\\|,.<>/?`~]"); + + /** + * 校验密码强度,不满足则抛 BusinessException。 + */ + public void validate(String password) { + List violations = check(password); + if (!violations.isEmpty()) { + throw new BusinessException(10001, "密码强度不足:" + String.join(";", violations)); + } + } + + /** + * 检查密码强度,返回不满足的规则列表(空列表表示通过)。 + */ + public List check(String password) { + List violations = new ArrayList<>(); + if (password == null || password.length() < MIN_LENGTH) { + violations.add("密码长度至少" + MIN_LENGTH + "位"); + } + if (password == null || !UPPER.matcher(password).find()) { + violations.add("需包含大写字母"); + } + if (password == null || !LOWER.matcher(password).find()) { + violations.add("需包含小写字母"); + } + if (password == null || !DIGIT.matcher(password).find()) { + violations.add("需包含数字"); + } + if (password == null || !SPECIAL.matcher(password).find()) { + violations.add("需包含特殊字符"); + } + return violations; + } +} diff --git a/backend/src/main/java/com/writeoff/security/PasswordSetupService.java b/backend/src/main/java/com/writeoff/security/PasswordSetupService.java new file mode 100644 index 0000000..fcbc751 --- /dev/null +++ b/backend/src/main/java/com/writeoff/security/PasswordSetupService.java @@ -0,0 +1,277 @@ +package com.writeoff.security; + +import com.writeoff.common.exception.BusinessException; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.net.URLEncoder; +import java.security.MessageDigest; +import java.security.SecureRandom; +import java.sql.Timestamp; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.Base64; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@Service +public class PasswordSetupService { + private static final String SCENARIO_TENANT_ADMIN_SETUP = "TENANT_ADMIN_SETUP"; + private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + + private final JdbcTemplate jdbcTemplate; + private final PasswordPolicyService passwordPolicyService; + private final PasswordCodecService passwordCodecService; + private final String frontendBaseUrl; + private final long passwordSetupExpireMinutes; + private final SecureRandom secureRandom = new SecureRandom(); + + public PasswordSetupService(JdbcTemplate jdbcTemplate, + PasswordPolicyService passwordPolicyService, + PasswordCodecService passwordCodecService, + @Value("${app.frontend-base-url:http://localhost:5173}") String frontendBaseUrl, + @Value("${app.security.password-setup-expire-minutes:1440}") long passwordSetupExpireMinutes) { + this.jdbcTemplate = jdbcTemplate; + this.passwordPolicyService = passwordPolicyService; + this.passwordCodecService = passwordCodecService; + this.frontendBaseUrl = frontendBaseUrl; + this.passwordSetupExpireMinutes = passwordSetupExpireMinutes; + } + + @Transactional + public String issueTenantAdminSetupLink(Long tenantId, Long userId, Long operatorUserId) { + Map user = loadTenantAdminUser(tenantId, userId); + String tenantCode = String.valueOf(user.get("tenant_code")); + jdbcTemplate.update( + "UPDATE auth_password_setup_token " + + "SET is_deleted=1, updated_by=?, updated_at=CURRENT_TIMESTAMP " + + "WHERE tenant_id=? AND user_id=? AND scenario=? AND is_deleted=0 AND used_at IS NULL", + safeOperator(operatorUserId), + tenantId, + userId, + SCENARIO_TENANT_ADMIN_SETUP + ); + + String rawToken = generateToken(); + LocalDateTime expiresAt = LocalDateTime.now().plusMinutes(Math.max(passwordSetupExpireMinutes, 10L)); + jdbcTemplate.update( + "INSERT INTO auth_password_setup_token (tenant_id, user_id, scenario, token_hash, expires_at, created_by, updated_by) " + + "VALUES (?, ?, ?, ?, ?, ?, ?)", + tenantId, + userId, + SCENARIO_TENANT_ADMIN_SETUP, + hashToken(rawToken), + Timestamp.valueOf(expiresAt), + safeOperator(operatorUserId), + safeOperator(operatorUserId) + ); + return buildSetupLink(tenantCode, rawToken); + } + + public Map verifyTenantPasswordSetupToken(String tenantCode, String rawToken) { + Map tokenRecord = loadAvailableTokenRecord(tenantCode, rawToken); + Map data = new LinkedHashMap(); + data.put("tenantCode", tokenRecord.get("tenant_code")); + data.put("tenantName", tokenRecord.get("tenant_name")); + data.put("userName", tokenRecord.get("user_name")); + data.put("phone", maskPhone(tokenRecord.get("phone"))); + data.put("expiresAt", formatDateTime(tokenRecord.get("expires_at"))); + return data; + } + + @Transactional + public Map completeTenantPasswordSetup(String tenantCode, String rawToken, String newPassword) { + passwordPolicyService.validate(newPassword); + Map tokenRecord = loadAvailableTokenRecord(tenantCode, rawToken); + Long tokenId = ((Number) tokenRecord.get("id")).longValue(); + Long tenantId = ((Number) tokenRecord.get("tenant_id")).longValue(); + Long userId = ((Number) tokenRecord.get("user_id")).longValue(); + + int consumed = jdbcTemplate.update( + "UPDATE auth_password_setup_token " + + "SET used_at=CURRENT_TIMESTAMP, updated_by=?, updated_at=CURRENT_TIMESTAMP " + + "WHERE id=? AND used_at IS NULL AND is_deleted=0 AND expires_at>=CURRENT_TIMESTAMP", + userId, + tokenId + ); + if (consumed <= 0) { + throw new BusinessException(10001, "设置链接无效或已过期"); + } + + jdbcTemplate.update( + "UPDATE sys_user SET password_hash=?, status='ENABLED', updated_by=?, updated_at=CURRENT_TIMESTAMP " + + "WHERE tenant_id=? AND id=? AND is_deleted=0", + passwordCodecService.encode(newPassword), + userId, + tenantId, + userId + ); + jdbcTemplate.update( + "UPDATE auth_password_setup_token " + + "SET is_deleted=1, updated_by=?, updated_at=CURRENT_TIMESTAMP " + + "WHERE tenant_id=? AND user_id=? AND scenario=? AND id<>? AND is_deleted=0 AND used_at IS NULL", + userId, + tenantId, + userId, + SCENARIO_TENANT_ADMIN_SETUP, + tokenId + ); + + Map data = new LinkedHashMap(); + data.put("ok", Boolean.TRUE); + data.put("tenantCode", tokenRecord.get("tenant_code")); + return data; + } + + private Map loadAvailableTokenRecord(String tenantCode, String rawToken) { + String normalizedTenantCode = normalizeText(tenantCode); + String normalizedToken = normalizeText(rawToken); + if (normalizedTenantCode.isEmpty() || normalizedToken.isEmpty()) { + throw new BusinessException(10001, "设置链接无效或已过期"); + } + + List> rows = jdbcTemplate.queryForList( + "SELECT tkn.id, tkn.tenant_id, tkn.user_id, tkn.expires_at, tkn.used_at, " + + "t.tenant_code, t.tenant_name, u.user_name, u.phone " + + "FROM auth_password_setup_token tkn " + + "JOIN tenant t ON tkn.tenant_id=t.id " + + "JOIN sys_user u ON tkn.user_id=u.id AND tkn.tenant_id=u.tenant_id " + + "WHERE tkn.token_hash=? AND tkn.scenario=? AND tkn.is_deleted=0 " + + "AND t.is_deleted=0 AND u.is_deleted=0 " + + "LIMIT 1", + hashToken(normalizedToken), + SCENARIO_TENANT_ADMIN_SETUP + ); + if (rows.isEmpty()) { + throw new BusinessException(10001, "设置链接无效或已过期"); + } + + Map row = rows.get(0); + String rowTenantCode = normalizeText(row.get("tenant_code")); + if (!normalizedTenantCode.equalsIgnoreCase(rowTenantCode)) { + throw new BusinessException(10001, "设置链接无效或已过期"); + } + if (row.get("used_at") != null) { + throw new BusinessException(10001, "设置链接已失效,请联系平台重新发送"); + } + + LocalDateTime expiresAt = toLocalDateTime(row.get("expires_at")); + if (expiresAt == null || LocalDateTime.now().isAfter(expiresAt)) { + throw new BusinessException(10001, "设置链接无效或已过期"); + } + return row; + } + + private Map loadTenantAdminUser(Long tenantId, Long userId) { + List> rows = jdbcTemplate.queryForList( + "SELECT u.id, u.tenant_id, t.tenant_code " + + "FROM sys_user u " + + "JOIN tenant t ON u.tenant_id=t.id " + + "WHERE u.tenant_id=? AND u.id=? AND u.is_deleted=0 AND t.is_deleted=0 " + + "LIMIT 1", + tenantId, + userId + ); + if (rows.isEmpty()) { + throw new BusinessException(10003, "租户管理员不存在"); + } + return rows.get(0); + } + + private String buildSetupLink(String tenantCode, String rawToken) { + String baseUrl = normalizeBaseUrl(frontendBaseUrl); + String path = "/" + tenantCode + "/setup-password?token=" + urlEncode(rawToken); + return baseUrl.isEmpty() ? path : baseUrl + path; + } + + private String generateToken() { + byte[] bytes = new byte[32]; + secureRandom.nextBytes(bytes); + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + } + + private String hashToken(String rawToken) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest(rawToken.getBytes("UTF-8")); + StringBuilder sb = new StringBuilder(); + for (byte item : hash) { + String hex = Integer.toHexString(item & 0xff); + if (hex.length() == 1) { + sb.append('0'); + } + sb.append(hex); + } + return sb.toString(); + } catch (Exception ex) { + throw new IllegalStateException("Failed to hash password setup token", ex); + } + } + + private String normalizeBaseUrl(String rawBaseUrl) { + String value = normalizeText(rawBaseUrl); + while (value.endsWith("/")) { + value = value.substring(0, value.length() - 1); + } + return value; + } + + private String urlEncode(String raw) { + try { + return URLEncoder.encode(raw, "UTF-8"); + } catch (Exception ex) { + throw new IllegalStateException("Failed to encode password setup link", ex); + } + } + + private String normalizeText(Object raw) { + return raw == null ? "" : String.valueOf(raw).trim(); + } + + private String formatDateTime(Object value) { + LocalDateTime dateTime = toLocalDateTime(value); + return dateTime == null ? "" : dateTime.format(DATE_TIME_FORMATTER); + } + + private LocalDateTime toLocalDateTime(Object value) { + if (value == null) { + return null; + } + if (value instanceof LocalDateTime) { + return (LocalDateTime) value; + } + if (value instanceof Timestamp) { + return ((Timestamp) value).toLocalDateTime(); + } + if (value instanceof java.util.Date) { + return new Timestamp(((java.util.Date) value).getTime()).toLocalDateTime(); + } + if (value instanceof String) { + String text = String.valueOf(value).trim(); + if (text.isEmpty()) { + return null; + } + try { + return LocalDateTime.parse(text.replace(' ', 'T')); + } catch (Exception ignored) { + return null; + } + } + return null; + } + + private String maskPhone(Object rawPhone) { + String phone = normalizeText(rawPhone); + if (phone.length() < 7) { + return phone; + } + return phone.substring(0, 3) + "****" + phone.substring(phone.length() - 4); + } + + private Long safeOperator(Long operatorUserId) { + return operatorUserId == null ? 0L : operatorUserId; + } +} diff --git a/backend/src/main/java/com/writeoff/security/PasswordStorageMigrationRunner.java b/backend/src/main/java/com/writeoff/security/PasswordStorageMigrationRunner.java new file mode 100644 index 0000000..d679c5a --- /dev/null +++ b/backend/src/main/java/com/writeoff/security/PasswordStorageMigrationRunner.java @@ -0,0 +1,74 @@ +package com.writeoff.security; + +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +@Component +public class PasswordStorageMigrationRunner implements ApplicationRunner { + private final JdbcTemplate jdbcTemplate; + private final PasswordCodecService passwordCodecService; + + public PasswordStorageMigrationRunner(JdbcTemplate jdbcTemplate, PasswordCodecService passwordCodecService) { + this.jdbcTemplate = jdbcTemplate; + this.passwordCodecService = passwordCodecService; + } + + @Override + public void run(ApplicationArguments args) { + migrateTenantUsers(); + migratePlatformUsers(); + } + + private void migrateTenantUsers() { + List> rows = jdbcTemplate.queryForList( + "SELECT id, phone, password_hash, tenant_switch_account_key FROM sys_user WHERE is_deleted=0" + ); + Map sharedKeys = new LinkedHashMap(); + for (Map row : rows) { + String currentPassword = row.get("password_hash") == null ? "" : String.valueOf(row.get("password_hash")); + if (passwordCodecService.isEncoded(currentPassword)) { + continue; + } + String phone = row.get("phone") == null ? "" : String.valueOf(row.get("phone")).trim(); + String legacyKey = phone + "|" + currentPassword; + String existingSwitchKey = row.get("tenant_switch_account_key") == null ? "" : String.valueOf(row.get("tenant_switch_account_key")).trim(); + String sharedKey = sharedKeys.get(legacyKey); + if (sharedKey == null || sharedKey.isEmpty()) { + sharedKey = existingSwitchKey.isEmpty() + ? "acct_" + UUID.randomUUID().toString().replace("-", "").toUpperCase() + : existingSwitchKey; + sharedKeys.put(legacyKey, sharedKey); + } + jdbcTemplate.update( + "UPDATE sys_user SET password_hash=?, tenant_switch_account_key=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", + passwordCodecService.encode(currentPassword), + sharedKey, + ((Number) row.get("id")).longValue() + ); + } + } + + private void migratePlatformUsers() { + List> rows = jdbcTemplate.queryForList( + "SELECT id, password_hash FROM platform_user WHERE is_deleted=0" + ); + for (Map row : rows) { + String currentPassword = row.get("password_hash") == null ? "" : String.valueOf(row.get("password_hash")); + if (passwordCodecService.isEncoded(currentPassword)) { + continue; + } + jdbcTemplate.update( + "UPDATE platform_user SET password_hash=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", + passwordCodecService.encode(currentPassword), + ((Number) row.get("id")).longValue() + ); + } + } +} diff --git a/backend/src/main/java/com/writeoff/security/PermissionDomain.java b/backend/src/main/java/com/writeoff/security/PermissionDomain.java new file mode 100644 index 0000000..59bdaef --- /dev/null +++ b/backend/src/main/java/com/writeoff/security/PermissionDomain.java @@ -0,0 +1,6 @@ +package com.writeoff.security; + +public enum PermissionDomain { + TENANT, + PLATFORM +} diff --git a/backend/src/main/java/com/writeoff/security/PermissionMetadataGuard.java b/backend/src/main/java/com/writeoff/security/PermissionMetadataGuard.java new file mode 100644 index 0000000..c1801e7 --- /dev/null +++ b/backend/src/main/java/com/writeoff/security/PermissionMetadataGuard.java @@ -0,0 +1,96 @@ +package com.writeoff.security; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.SmartInitializingSingleton; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.method.HandlerMethod; +import org.springframework.web.servlet.mvc.method.RequestMappingInfo; +import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; + +@Component +public class PermissionMetadataGuard implements SmartInitializingSingleton { + private static final Logger log = LoggerFactory.getLogger(PermissionMetadataGuard.class); + + private final RequestMappingHandlerMapping handlerMapping; + + @Value("${writeoff.permission-metadata.strict:false}") + private boolean strictMode; + + public PermissionMetadataGuard(RequestMappingHandlerMapping handlerMapping) { + this.handlerMapping = handlerMapping; + } + + @Override + public void afterSingletonsInstantiated() { + List violations = new ArrayList<>(); + Map mapping = handlerMapping.getHandlerMethods(); + for (Map.Entry entry : mapping.entrySet()) { + HandlerMethod handlerMethod = entry.getValue(); + if (!isApiHandler(entry.getKey(), handlerMethod)) { + continue; + } + RequirePermission requirePermission = handlerMethod.getMethodAnnotation(RequirePermission.class); + if (requirePermission == null) { + violations.add(describe(entry.getKey(), handlerMethod) + " missing @RequirePermission"); + continue; + } + if (isBlank(requirePermission.value())) { + violations.add(describe(entry.getKey(), handlerMethod) + " missing permission code"); + } + if (isBlank(requirePermission.auditAction())) { + violations.add(describe(entry.getKey(), handlerMethod) + " missing auditAction"); + } + if (requirePermission.dataScope() == null) { + violations.add(describe(entry.getKey(), handlerMethod) + " missing dataScope"); + } + } + if (violations.isEmpty()) { + log.info("Permission metadata guard passed: all API handlers are compliant."); + return; + } + String message = "Permission metadata guard found " + violations.size() + " violation(s): " + String.join("; ", violations); + if (strictMode) { + throw new IllegalStateException(message); + } + log.warn(message); + } + + private boolean isApiHandler(RequestMappingInfo info, HandlerMethod method) { + String packageName = method.getBeanType().getPackage().getName(); + if (!packageName.startsWith("com.writeoff.module")) { + return false; + } + Set patterns = info.getPatternsCondition() == null ? java.util.Collections.emptySet() : info.getPatternsCondition().getPatterns(); + for (String pattern : patterns) { + if (!pattern.startsWith("/api/")) { + continue; + } + if (pattern.startsWith("/api/auth/login") + || pattern.startsWith("/api/auth/platform-login") + || pattern.startsWith("/api/auth/password-public-key") + || pattern.startsWith("/api/system/health") + || pattern.startsWith("/api/health") + || pattern.startsWith("/api/platform/menus/current")) { + continue; + } + return true; + } + return false; + } + + private String describe(RequestMappingInfo info, HandlerMethod method) { + Set patterns = info.getPatternsCondition() == null ? java.util.Collections.emptySet() : info.getPatternsCondition().getPatterns(); + return method.getBeanType().getSimpleName() + "#" + method.getMethod().getName() + patterns; + } + + private boolean isBlank(String value) { + return value == null || value.trim().isEmpty(); + } +} diff --git a/backend/src/main/java/com/writeoff/security/PermissionService.java b/backend/src/main/java/com/writeoff/security/PermissionService.java new file mode 100644 index 0000000..e8e5aa0 --- /dev/null +++ b/backend/src/main/java/com/writeoff/security/PermissionService.java @@ -0,0 +1,126 @@ +package com.writeoff.security; + +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; + +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +@Service +public class PermissionService { + private final JdbcTemplate jdbcTemplate; + + public PermissionService(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + public boolean hasPermission(Long userId, String permissionCode) { + Long tenantId = tenantId(); + if (hasPermissionDirect(userId, tenantId, permissionCode)) { + return true; + } + List principalUserIds = jdbcTemplate.queryForList( + "SELECT user_id FROM user_delegation " + + "WHERE tenant_id=? AND delegate_user_id=? AND is_deleted=0 AND status='ENABLED' " + + "AND effective_from<=CURRENT_TIMESTAMP AND effective_to>=CURRENT_TIMESTAMP", + Long.class, + tenantId, + userId + ); + for (Long principalUserId : principalUserIds) { + if (hasPermissionDirect(principalUserId, tenantId, permissionCode)) { + return true; + } + } + return false; + } + + public List getPermissions(Long userId) { + return getPermissions(userId, tenantId()); + } + + public List getPermissions(Long userId, Long tenantId) { + if (tenantId == null) { + throw new IllegalStateException("tenant context required"); + } + Long safeTenantId = tenantId; + Set result = new LinkedHashSet<>(getPermissionsDirect(userId, safeTenantId)); + List principalUserIds = jdbcTemplate.queryForList( + "SELECT user_id FROM user_delegation " + + "WHERE tenant_id=? AND delegate_user_id=? AND is_deleted=0 AND status='ENABLED' " + + "AND effective_from<=CURRENT_TIMESTAMP AND effective_to>=CURRENT_TIMESTAMP", + Long.class, + safeTenantId, + userId + ); + for (Long principalUserId : principalUserIds) { + result.addAll(getPermissionsDirect(principalUserId, safeTenantId)); + } + return new java.util.ArrayList<>(result); + } + + public boolean hasPlatformPermission(Long userId, String permissionCode) { + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM platform_user_role ur " + + "JOIN platform_role_permission rp ON ur.role_id=rp.role_id " + + "JOIN platform_permission p ON rp.permission_id=p.id " + + "WHERE ur.user_id=? AND p.permission_code=?", + Integer.class, + userId, + permissionCode + ); + return count != null && count > 0; + } + + public List getPlatformPermissions(Long userId) { + return jdbcTemplate.queryForList( + "SELECT DISTINCT p.permission_code FROM platform_user_role ur " + + "JOIN platform_role_permission rp ON ur.role_id=rp.role_id " + + "JOIN platform_permission p ON rp.permission_id=p.id " + + "WHERE ur.user_id=?", + String.class, + userId + ); + } + + public List getPlatformRoles(Long userId) { + return jdbcTemplate.queryForList( + "SELECT DISTINCT r.role_code FROM platform_user_role ur " + + "JOIN platform_role r ON ur.role_id=r.id " + + "WHERE ur.user_id=? AND r.is_deleted=0", + String.class, + userId + ); + } + + private boolean hasPermissionDirect(Long userId, Long tenantId, String permissionCode) { + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM user_role ur " + + "JOIN role_permission rp ON ur.role_id=rp.role_id AND ur.tenant_id=rp.tenant_id " + + "JOIN permission p ON rp.permission_id=p.id " + + "WHERE ur.user_id=? AND ur.tenant_id=? AND p.permission_code=?", + Integer.class, + userId, + tenantId, + permissionCode + ); + return count != null && count > 0; + } + + private List getPermissionsDirect(Long userId, Long tenantId) { + return jdbcTemplate.queryForList( + "SELECT DISTINCT p.permission_code FROM user_role ur " + + "JOIN role_permission rp ON ur.role_id=rp.role_id AND ur.tenant_id=rp.tenant_id " + + "JOIN permission p ON rp.permission_id=p.id " + + "WHERE ur.user_id=? AND ur.tenant_id=?", + String.class, + userId, + tenantId + ); + } + + private Long tenantId() { + return AuthContext.requireTenantId(); + } +} diff --git a/backend/src/main/java/com/writeoff/security/RateLimitFilter.java b/backend/src/main/java/com/writeoff/security/RateLimitFilter.java new file mode 100644 index 0000000..0d6394b --- /dev/null +++ b/backend/src/main/java/com/writeoff/security/RateLimitFilter.java @@ -0,0 +1,86 @@ +package com.writeoff.security; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.writeoff.common.api.ApiErrorResponse; +import com.writeoff.common.exception.ErrorCodes; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; + +import javax.servlet.Filter; +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.ServletRequest; +import javax.servlet.ServletResponse; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.LinkedHashMap; +import java.util.Map; + +@Component +@Order(1) +public class RateLimitFilter implements Filter { + + private static final Logger log = LoggerFactory.getLogger(RateLimitFilter.class); + + private final RateLimitService rateLimitService; + private final ObjectMapper objectMapper = new ObjectMapper(); + + public RateLimitFilter(RateLimitService rateLimitService) { + this.rateLimitService = rateLimitService; + } + + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) + throws IOException, ServletException { + if (!(request instanceof HttpServletRequest) || !(response instanceof HttpServletResponse)) { + chain.doFilter(request, response); + return; + } + HttpServletRequest httpRequest = (HttpServletRequest) request; + String path = httpRequest.getRequestURI(); + if (!path.startsWith("/api/")) { + chain.doFilter(request, response); + return; + } + + boolean isAuthPath = path.startsWith("/api/auth/login") || path.startsWith("/api/auth/platform-login"); + RateLimitService.RateLimitDecision decision; + try { + decision = rateLimitService.tryAcquire(getClientIp(httpRequest), isAuthPath); + } catch (Exception ex) { + log.warn("Rate limit degraded to allow request for path {}", path, ex); + chain.doFilter(request, response); + return; + } + + if (!decision.isAllowed()) { + HttpServletResponse httpResponse = (HttpServletResponse) response; + httpResponse.setStatus(429); + httpResponse.setContentType("application/json;charset=utf-8"); + httpResponse.setHeader("Retry-After", String.valueOf(decision.getRetryAfterSeconds())); + Map errors = new LinkedHashMap<>(); + errors.put("scope", decision.getScope()); + errors.put("limit", String.valueOf(decision.getLimit())); + errors.put("retryAfterSeconds", String.valueOf(decision.getRetryAfterSeconds())); + ApiErrorResponse body = ApiErrorResponse.of(ErrorCodes.RATE_LIMITED, "请求过于频繁,请稍后重试", errors); + httpResponse.getWriter().write(objectMapper.writeValueAsString(body)); + return; + } + chain.doFilter(request, response); + } + + private String getClientIp(HttpServletRequest request) { + String xff = request.getHeader("X-Forwarded-For"); + if (xff != null && !xff.isEmpty()) { + return xff.split(",")[0].trim(); + } + String realIp = request.getHeader("X-Real-IP"); + if (realIp != null && !realIp.isEmpty()) { + return realIp.trim(); + } + return request.getRemoteAddr(); + } +} diff --git a/backend/src/main/java/com/writeoff/security/RateLimitService.java b/backend/src/main/java/com/writeoff/security/RateLimitService.java new file mode 100644 index 0000000..f496dce --- /dev/null +++ b/backend/src/main/java/com/writeoff/security/RateLimitService.java @@ -0,0 +1,91 @@ +package com.writeoff.security; + +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.sql.Timestamp; +import java.time.Duration; +import java.time.LocalDateTime; + +@Service +public class RateLimitService { + + private static final int GLOBAL_LIMIT_PER_MINUTE = 300; + private static final int LOGIN_LIMIT_PER_MINUTE = 20; + + private final JdbcTemplate jdbcTemplate; + + public RateLimitService(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + @Transactional + public RateLimitDecision tryAcquire(String clientIp, boolean loginRequest) { + String scope = loginRequest ? "LOGIN" : "API"; + int limit = loginRequest ? LOGIN_LIMIT_PER_MINUTE : GLOBAL_LIMIT_PER_MINUTE; + LocalDateTime now = LocalDateTime.now(); + LocalDateTime windowStartAt = now.withSecond(0).withNano(0); + LocalDateTime expiresAt = windowStartAt.plusMinutes(2); + String bucketKey = scope + ":" + clientIp; + + jdbcTemplate.update( + "INSERT INTO api_rate_limit_counter " + + "(bucket_key, scope, client_ip, window_start_at, expires_at, request_count, created_at, updated_at) " + + "VALUES (?, ?, ?, ?, ?, LAST_INSERT_ID(1), CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) " + + "ON DUPLICATE KEY UPDATE " + + "scope = VALUES(scope), " + + "client_ip = VALUES(client_ip), " + + "request_count = LAST_INSERT_ID(IF(window_start_at = VALUES(window_start_at), request_count + 1, 1)), " + + "window_start_at = VALUES(window_start_at), " + + "expires_at = VALUES(expires_at), " + + "updated_at = CURRENT_TIMESTAMP", + bucketKey, + scope, + clientIp, + Timestamp.valueOf(windowStartAt), + Timestamp.valueOf(expiresAt) + ); + + Integer currentCount = jdbcTemplate.queryForObject("SELECT LAST_INSERT_ID()", Integer.class); + int requestCount = currentCount == null ? 1 : currentCount.intValue(); + long retryAfterSeconds = Math.max(1, Duration.between(now, windowStartAt.plusMinutes(1)).getSeconds()); + return new RateLimitDecision(scope, limit, requestCount <= limit, requestCount, retryAfterSeconds); + } + + public static class RateLimitDecision { + private final String scope; + private final int limit; + private final boolean allowed; + private final int currentCount; + private final long retryAfterSeconds; + + RateLimitDecision(String scope, int limit, boolean allowed, int currentCount, long retryAfterSeconds) { + this.scope = scope; + this.limit = limit; + this.allowed = allowed; + this.currentCount = currentCount; + this.retryAfterSeconds = retryAfterSeconds; + } + + public String getScope() { + return scope; + } + + public int getLimit() { + return limit; + } + + public boolean isAllowed() { + return allowed; + } + + public int getCurrentCount() { + return currentCount; + } + + public long getRetryAfterSeconds() { + return retryAfterSeconds; + } + } +} diff --git a/backend/src/main/java/com/writeoff/security/RequirePermission.java b/backend/src/main/java/com/writeoff/security/RequirePermission.java new file mode 100644 index 0000000..407ad46 --- /dev/null +++ b/backend/src/main/java/com/writeoff/security/RequirePermission.java @@ -0,0 +1,13 @@ +package com.writeoff.security; + +import java.lang.annotation.*; + +@Target({ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface RequirePermission { + String value(); + DataScopeType dataScope() default DataScopeType.TENANT; + PermissionDomain domain() default PermissionDomain.TENANT; + String auditAction() default ""; +} diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml new file mode 100644 index 0000000..1fef7ae --- /dev/null +++ b/backend/src/main/resources/application.yml @@ -0,0 +1,71 @@ +server: + port: ${SERVER_PORT:8080} + +spring: + application: + name: writeoff-backend + datasource: + url: ${DB_URL:jdbc:mysql://localhost:3306/writeoff?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai} + username: ${DB_USERNAME:root} + password: ${DB_PASSWORD:root} + driver-class-name: com.mysql.cj.jdbc.Driver + flyway: + enabled: true + locations: classpath:db/migration + +logging: + level: + root: ${LOG_LEVEL:INFO} + org.springframework.jdbc.core.JdbcTemplate: INFO + org.springframework.jdbc.core.StatementCreatorUtils: INFO + org.springframework.jdbc.datasource: INFO + +app: + repository: + mode: ${APP_REPOSITORY_MODE:jdbc} + security: + jwt-secret: ${JWT_SECRET:replace-with-your-jwt-secret} + jwt-expire-minutes: ${JWT_EXPIRE_MINUTES:120} + access-expire-minutes: ${ACCESS_EXPIRE_MINUTES:${JWT_EXPIRE_MINUTES:120}} + idle-timeout-minutes: ${IDLE_TIMEOUT_MINUTES:60} + refresh-expire-days: ${REFRESH_EXPIRE_DAYS:14} + password-setup-expire-minutes: ${PASSWORD_SETUP_EXPIRE_MINUTES:1440} + refresh-cookie-name: ${REFRESH_COOKIE_NAME:refreshToken} + refresh-cookie-secure: ${REFRESH_COOKIE_SECURE:false} + refresh-cookie-same-site: ${REFRESH_COOKIE_SAME_SITE:Lax} + frontend-base-url: ${FRONTEND_BASE_URL:http://localhost:5173} + oss: + endpoint: ${OSS_ENDPOINT:https://oss-cn-beijing.aliyuncs.com} + bucket: ${OSS_BUCKET:write-off} + access-key-id: ${OSS_ACCESS_KEY_ID:LTAI5tAkZSLF5xbFGPVqmYeq} + access-key-secret: ${OSS_ACCESS_KEY_SECRET:ETrDjr35Ty2uMSqulOuk2Yky5R1R0Y} + sign-expire-seconds: ${OSS_SIGN_EXPIRE_SECONDS:600} + scheduler: + enabled: ${SCHEDULER_ENABLED:true} + poll-interval-ms: ${SCHEDULER_POLL_INTERVAL_MS:3000} + batch-size: ${SCHEDULER_BATCH_SIZE:100} + job-timeout-seconds: ${JOB_TIMEOUT_SECONDS:120} + max-retry: ${JOB_MAX_RETRY:3} + notification: + webhook-secret: ${NOTIFICATION_WEBHOOK_SECRET:change-me} + gateway-crypto-secret: ${NOTIFICATION_GATEWAY_CRYPTO_SECRET:${JWT_SECRET:replace-with-your-jwt-secret}} + tenant-admin-mail-subject-template: ${TENANT_ADMIN_MAIL_SUBJECT_TEMPLATE:Tenant admin account notification} + tenant-admin-mail-body-template: '${TENANT_ADMIN_MAIL_BODY_TEMPLATE:Action: {actionCn}\nTenant name: {tenantName}\nTenant code: {tenantCode}\nLogin path: {loginPath}\nAdmin phone: {phone}\nPassword setup link: {setupLink}\nPlease use the setup link to complete your password configuration before logging in.}' + mail: + default-subject: ${MAIL_DEFAULT_SUBJECT:绯荤粺閫氱煡} + runtime: + version: ${APP_VERSION:0.0.1-SNAPSHOT} + build-time: ${BUILD_TIME:unknown} + baidu-ocr: + api-key: ${BAIDU_OCR_API_KEY:VDOgQBXtfMH5YYudCIhUULge} + secret-key: ${BAIDU_OCR_SECRET_KEY:as6QiY79TOYm2kKTvdA3aEah1WawNGtT} + token-url: ${BAIDU_OCR_TOKEN_URL:https://aip.baidubce.com/oauth/2.0/token} + multiple-invoice-url: ${BAIDU_OCR_MULTIPLE_INVOICE_URL:https://aip.baidubce.com/rest/2.0/ocr/v1/multiple_invoice} + id-card-url: ${BAIDU_OCR_ID_CARD_URL:https://aip.baidubce.com/rest/2.0/ocr/v1/idcard} + bank-card-url: ${BAIDU_OCR_BANK_CARD_URL:https://aip.baidubce.com/rest/2.0/ocr/v1/bankcard} + document-extract-task-url: ${BAIDU_OCR_DOCUMENT_EXTRACT_TASK_URL:https://aip.baidubce.com/rest/2.0/brain/online/v1/extract/task} + document-extract-query-url: ${BAIDU_OCR_DOCUMENT_EXTRACT_QUERY_URL:https://aip.baidubce.com/rest/2.0/brain/online/v1/extract/query_task} + connect-timeout-ms: ${BAIDU_OCR_CONNECT_TIMEOUT_MS:5000} + read-timeout-ms: ${BAIDU_OCR_READ_TIMEOUT_MS:20000} + max-bytes: ${BAIDU_OCR_MAX_BYTES:3145728} + document-extract-max-bytes: ${BAIDU_OCR_DOCUMENT_EXTRACT_MAX_BYTES:52428800} diff --git a/backend/src/main/resources/db/data.sql b/backend/src/main/resources/db/data.sql new file mode 100644 index 0000000..423e7de --- /dev/null +++ b/backend/src/main/resources/db/data.sql @@ -0,0 +1,40 @@ +-- 初始租户与角色 +INSERT INTO tenant (id, tenant_name, status, created_by, updated_by) +VALUES (1, '默认单位', 'ACTIVE', 0, 0) +ON DUPLICATE KEY UPDATE updated_at = CURRENT_TIMESTAMP; + +INSERT INTO role (id, tenant_id, role_code, role_name, status, created_by, updated_by) +VALUES + (101, 1, 'TENANT_ADMIN', '单位管理员', 'ENABLED', 0, 0), + (102, 1, 'PROJECT_OWNER', '项目负责人', 'ENABLED', 0, 0), + (103, 1, 'EXECUTOR', '项目执行人', 'ENABLED', 0, 0), + (104, 1, 'AUDITOR', '审核人', 'ENABLED', 0, 0), + (105, 1, 'FINANCE', '财务', 'ENABLED', 0, 0) +ON DUPLICATE KEY UPDATE updated_at = CURRENT_TIMESTAMP; + +-- 权限字典 +INSERT INTO permission (id, permission_code, permission_name, module) +VALUES + (1001, 'project.create', '创建项目', 'project'), + (1002, 'project.freeze', '冻结项目', 'project'), + (1003, 'meeting.create', '创建会议', 'meeting'), + (1004, 'meeting.submit', '会议级提交', 'meeting'), + (1005, 'audit.approve', '审核通过', 'audit'), + (1006, 'audit.reject', '审核拒绝', 'audit'), + (1007, 'audit.return', '审核退回', 'audit'), + (1008, 'finance.payment.confirm', '支付确认', 'finance'), + (1009, 'meeting.read', '查看会议', 'meeting') +ON DUPLICATE KEY UPDATE permission_name = VALUES(permission_name); + +-- 错误码字典 +INSERT INTO error_code_dict (id, code, message, category) +VALUES + (1, 10001, '参数校验失败', 'COMMON'), + (2, 10002, '请求幂等冲突', 'COMMON'), + (3, 10003, '资源不存在', 'COMMON'), + (4, 20001, '无操作权限', 'PERMISSION'), + (5, 30001, '状态不允许流转', 'STATE'), + (6, 30003, '审核任务已处理', 'STATE'), + (7, 40003, '支付状态不允许', 'FINANCE'), + (8, 90001, '系统内部异常', 'SYSTEM') +ON DUPLICATE KEY UPDATE message = VALUES(message); diff --git a/backend/src/main/resources/db/migration/V100__project_meeting_change_log_permissions.sql b/backend/src/main/resources/db/migration/V100__project_meeting_change_log_permissions.sql new file mode 100644 index 0000000..b155bfc --- /dev/null +++ b/backend/src/main/resources/db/migration/V100__project_meeting_change_log_permissions.sql @@ -0,0 +1,56 @@ +SET @next_permission_id := (SELECT IFNULL(MAX(id), 0) + 1 FROM permission); +INSERT INTO permission (id, permission_code, permission_name, module) +SELECT @next_permission_id, 'project.key-change-log.read', '查看项目关键变更日志', 'project' +FROM dual +WHERE NOT EXISTS ( + SELECT 1 + FROM permission + WHERE permission_code = 'project.key-change-log.read' +); + +SET @next_permission_id := (SELECT IFNULL(MAX(id), 0) + 1 FROM permission); +INSERT INTO permission (id, permission_code, permission_name, module) +SELECT @next_permission_id, 'meeting.change-log.read', '查看会议变更记录', 'meeting' +FROM dual +WHERE NOT EXISTS ( + SELECT 1 + FROM permission + WHERE permission_code = 'meeting.change-log.read' +); + +SET @next_role_permission_id := (SELECT IFNULL(MAX(id), 0) FROM role_permission); +INSERT INTO role_permission (id, tenant_id, role_id, permission_id) +SELECT + (@next_role_permission_id := @next_role_permission_id + 1) AS id, + r.tenant_id, + r.id AS role_id, + p.id AS permission_id +FROM role r +JOIN permission p ON p.permission_code = 'project.key-change-log.read' +WHERE r.role_code IN ('TENANT_ADMIN', 'PROJECT_OWNER', 'EXECUTOR', 'AUDITOR', 'FINANCE') + AND r.is_deleted = 0 + AND NOT EXISTS ( + SELECT 1 + FROM role_permission rp + WHERE rp.tenant_id = r.tenant_id + AND rp.role_id = r.id + AND rp.permission_id = p.id + ); + +INSERT INTO role_permission (id, tenant_id, role_id, permission_id) +SELECT + (@next_role_permission_id := @next_role_permission_id + 1) AS id, + r.tenant_id, + r.id AS role_id, + p.id AS permission_id +FROM role r +JOIN permission p ON p.permission_code = 'meeting.change-log.read' +WHERE r.role_code IN ('TENANT_ADMIN', 'PROJECT_OWNER', 'EXECUTOR', 'AUDITOR', 'FINANCE') + AND r.is_deleted = 0 + AND NOT EXISTS ( + SELECT 1 + FROM role_permission rp + WHERE rp.tenant_id = r.tenant_id + AND rp.role_id = r.id + AND rp.permission_id = p.id + ); diff --git a/backend/src/main/resources/db/migration/V10__data_permission_policy.sql b/backend/src/main/resources/db/migration/V10__data_permission_policy.sql new file mode 100644 index 0000000..7a00c98 --- /dev/null +++ b/backend/src/main/resources/db/migration/V10__data_permission_policy.sql @@ -0,0 +1,52 @@ +CREATE TABLE IF NOT EXISTS data_permission_policy ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + policy_name VARCHAR(128) NOT NULL, + project_scope VARCHAR(32) NOT NULL DEFAULT 'ALL' COMMENT 'ALL/IDS', + project_ids_csv VARCHAR(2000) DEFAULT NULL, + meeting_scope VARCHAR(32) NOT NULL DEFAULT 'ALL' COMMENT 'ALL/IDS', + meeting_ids_csv VARCHAR(2000) DEFAULT NULL, + module_scope VARCHAR(255) DEFAULT NULL, + export_allowed TINYINT(1) NOT NULL DEFAULT 0, + status VARCHAR(32) NOT NULL DEFAULT 'ENABLED', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + KEY idx_tenant_status (tenant_id, status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS role_data_permission ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + role_id BIGINT UNSIGNED NOT NULL, + policy_id BIGINT UNSIGNED NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uk_tenant_role_policy (tenant_id, role_id, policy_id), + KEY idx_policy (policy_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +INSERT INTO data_permission_policy ( + id, tenant_id, policy_name, project_scope, meeting_scope, module_scope, export_allowed, status +) +VALUES + (1, 1, '默认全量策略', 'ALL', 'ALL', 'ALL', 1, 'ENABLED') +ON DUPLICATE KEY UPDATE + policy_name = VALUES(policy_name), + project_scope = VALUES(project_scope), + meeting_scope = VALUES(meeting_scope), + status = VALUES(status); + +INSERT INTO role_data_permission (id, tenant_id, role_id, policy_id) +VALUES (1, 1, 101, 1) +ON DUPLICATE KEY UPDATE policy_id = VALUES(policy_id); + +INSERT INTO permission (id, permission_code, permission_name, module) +VALUES + (1025, 'data.permission.read', '查看数据权限策略', 'system'), + (1026, 'data.permission.manage', '管理数据权限策略', 'system') +ON DUPLICATE KEY UPDATE permission_name = VALUES(permission_name), module = VALUES(module); + +INSERT INTO role_permission (id, tenant_id, role_id, permission_id) +VALUES + (25, 1, 101, 1025), + (26, 1, 101, 1026) +ON DUPLICATE KEY UPDATE permission_id = VALUES(permission_id); diff --git a/backend/src/main/resources/db/migration/V11__export_permission_seed.sql b/backend/src/main/resources/db/migration/V11__export_permission_seed.sql new file mode 100644 index 0000000..f3c6a0a --- /dev/null +++ b/backend/src/main/resources/db/migration/V11__export_permission_seed.sql @@ -0,0 +1,13 @@ +INSERT INTO permission (id, permission_code, permission_name, module) +VALUES + (1027, 'file.download', '下载文件', 'file'), + (1028, 'audit.export.opinions', '导出审核意见', 'audit'), + (1029, 'finance.ledger.export', '导出财务台账', 'finance') +ON DUPLICATE KEY UPDATE permission_name = VALUES(permission_name), module = VALUES(module); + +INSERT INTO role_permission (id, tenant_id, role_id, permission_id) +VALUES + (27, 1, 101, 1027), + (28, 1, 101, 1028), + (29, 1, 101, 1029) +ON DUPLICATE KEY UPDATE permission_id = VALUES(permission_id); diff --git a/backend/src/main/resources/db/migration/V12__meeting_material_module.sql b/backend/src/main/resources/db/migration/V12__meeting_material_module.sql new file mode 100644 index 0000000..9ebae01 --- /dev/null +++ b/backend/src/main/resources/db/migration/V12__meeting_material_module.sql @@ -0,0 +1,31 @@ +CREATE TABLE IF NOT EXISTS meeting_material ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + meeting_id BIGINT UNSIGNED NOT NULL, + module_code VARCHAR(64) NOT NULL, + content_json TEXT NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'DRAFT', + version_no INT NOT NULL DEFAULT 1, + is_deleted TINYINT(1) NOT NULL DEFAULT 0, + created_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_tenant_meeting_module (tenant_id, meeting_id, module_code), + KEY idx_tenant_meeting (tenant_id, meeting_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS meeting_material_history ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + meeting_id BIGINT UNSIGNED NOT NULL, + module_code VARCHAR(64) NOT NULL, + version_no INT NOT NULL, + action_type VARCHAR(32) NOT NULL COMMENT 'SAVE/SUBMIT', + content_json TEXT NOT NULL, + remark VARCHAR(500) DEFAULT NULL, + created_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY idx_tenant_meeting_module (tenant_id, meeting_id, module_code), + KEY idx_tenant_meeting_ver (tenant_id, meeting_id, version_no) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/backend/src/main/resources/db/migration/V13__meeting_material_permission_seed.sql b/backend/src/main/resources/db/migration/V13__meeting_material_permission_seed.sql new file mode 100644 index 0000000..9870902 --- /dev/null +++ b/backend/src/main/resources/db/migration/V13__meeting_material_permission_seed.sql @@ -0,0 +1,15 @@ +INSERT INTO permission (id, permission_code, permission_name, module) +VALUES + (1030, 'meeting.material.read', '查看会议资料模块', 'meeting'), + (1031, 'meeting.material.save', '保存会议资料模块', 'meeting'), + (1032, 'meeting.material.submit', '提交会议资料模块', 'meeting'), + (1033, 'meeting.material.history.read', '查看会议资料历史', 'meeting') +ON DUPLICATE KEY UPDATE permission_name = VALUES(permission_name), module = VALUES(module); + +INSERT INTO role_permission (id, tenant_id, role_id, permission_id) +VALUES + (30, 1, 101, 1030), + (31, 1, 101, 1031), + (32, 1, 101, 1032), + (33, 1, 101, 1033) +ON DUPLICATE KEY UPDATE permission_id = VALUES(permission_id); diff --git a/backend/src/main/resources/db/migration/V14__audit_material_read_permission.sql b/backend/src/main/resources/db/migration/V14__audit_material_read_permission.sql new file mode 100644 index 0000000..baead56 --- /dev/null +++ b/backend/src/main/resources/db/migration/V14__audit_material_read_permission.sql @@ -0,0 +1,10 @@ +INSERT INTO permission (id, permission_code, permission_name, module) +VALUES + (1034, 'audit.material.read', '审核端查看会议资料', 'audit') +ON DUPLICATE KEY UPDATE permission_name = VALUES(permission_name), module = VALUES(module); + +INSERT INTO role_permission (id, tenant_id, role_id, permission_id) +VALUES + (34, 1, 101, 1034), + (35, 1, 104, 1034) +ON DUPLICATE KEY UPDATE permission_id = VALUES(permission_id); diff --git a/backend/src/main/resources/db/migration/V15__template_module.sql b/backend/src/main/resources/db/migration/V15__template_module.sql new file mode 100644 index 0000000..b3503c3 --- /dev/null +++ b/backend/src/main/resources/db/migration/V15__template_module.sql @@ -0,0 +1,46 @@ +CREATE TABLE IF NOT EXISTS template ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + template_name VARCHAR(128) NOT NULL, + template_type VARCHAR(64) NOT NULL, + scope_type VARCHAR(32) NOT NULL DEFAULT 'ALL', + project_id BIGINT UNSIGNED DEFAULT NULL, + meeting_id BIGINT UNSIGNED DEFAULT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'DRAFT', + current_version_no INT NOT NULL DEFAULT 1, + is_deleted TINYINT(1) NOT NULL DEFAULT 0, + created_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + KEY idx_tenant_status (tenant_id, status), + KEY idx_tenant_type (tenant_id, template_type) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS template_version ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + template_id BIGINT UNSIGNED NOT NULL, + version_no INT NOT NULL, + object_key VARCHAR(512) NOT NULL, + version_status VARCHAR(32) NOT NULL DEFAULT 'DRAFT', + change_log VARCHAR(500) DEFAULT NULL, + created_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uk_template_version (tenant_id, template_id, version_no), + KEY idx_template (template_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS template_download_log ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + template_id BIGINT UNSIGNED NOT NULL, + version_no INT NOT NULL, + user_id BIGINT UNSIGNED NOT NULL, + object_key VARCHAR(512) NOT NULL, + downloaded_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + ip VARCHAR(64) DEFAULT NULL, + user_agent VARCHAR(255) DEFAULT NULL, + KEY idx_template_time (template_id, downloaded_at), + KEY idx_user_time (user_id, downloaded_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/backend/src/main/resources/db/migration/V16__template_permission_seed.sql b/backend/src/main/resources/db/migration/V16__template_permission_seed.sql new file mode 100644 index 0000000..4291ad7 --- /dev/null +++ b/backend/src/main/resources/db/migration/V16__template_permission_seed.sql @@ -0,0 +1,19 @@ +INSERT INTO permission (id, permission_code, permission_name, module) +VALUES + (1035, 'template.read', '模板查询', 'template'), + (1036, 'template.create', '模板上传', 'template'), + (1037, 'template.publish', '模板发布', 'template'), + (1038, 'template.disable', '模板停用', 'template'), + (1039, 'template.rollback', '模板版本回滚', 'template'), + (1040, 'template.download', '模板下载', 'template') +ON DUPLICATE KEY UPDATE permission_name=VALUES(permission_name), module=VALUES(module); + +INSERT INTO role_permission (id, tenant_id, role_id, permission_id) +VALUES + (36, 1, 101, 1035), + (37, 1, 101, 1036), + (38, 1, 101, 1037), + (39, 1, 101, 1038), + (40, 1, 101, 1039), + (41, 1, 101, 1040) +ON DUPLICATE KEY UPDATE permission_id = VALUES(permission_id); diff --git a/backend/src/main/resources/db/migration/V17__template_type_standardize.sql b/backend/src/main/resources/db/migration/V17__template_type_standardize.sql new file mode 100644 index 0000000..7b006cc --- /dev/null +++ b/backend/src/main/resources/db/migration/V17__template_type_standardize.sql @@ -0,0 +1,11 @@ +UPDATE template +SET template_type = CASE + WHEN UPPER(template_type) LIKE '%AGENDA%' THEN 'AGENDA' + WHEN UPPER(template_type) LIKE '%SIGN%' THEN 'SIGN_IN' + WHEN UPPER(template_type) LIKE '%INVIT%' THEN 'INVITATION' + WHEN UPPER(template_type) = 'SIGN' THEN 'SIGN_IN' + WHEN UPPER(template_type) IN ('INVIT', 'INVITATION_LETTER') THEN 'INVITATION' + WHEN UPPER(template_type) IN ('AGENDA', 'SIGN_IN', 'INVITATION', 'OTHER') THEN UPPER(template_type) + ELSE 'OTHER' +END +WHERE tenant_id = 1; diff --git a/backend/src/main/resources/db/migration/V18__template_type_option.sql b/backend/src/main/resources/db/migration/V18__template_type_option.sql new file mode 100644 index 0000000..f7fff10 --- /dev/null +++ b/backend/src/main/resources/db/migration/V18__template_type_option.sql @@ -0,0 +1,20 @@ +CREATE TABLE IF NOT EXISTS template_type_option ( + type_code VARCHAR(64) NOT NULL PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL DEFAULT 1, + type_name VARCHAR(128) NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'ENABLED', + sort_no INT NOT NULL DEFAULT 99, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + KEY idx_tenant_status (tenant_id, status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +INSERT INTO template_type_option (type_code, tenant_id, type_name, status, sort_no) +VALUES + ('AGENDA', 1, '会议日程', 'ENABLED', 1), + ('SIGN_IN', 1, '签到表', 'ENABLED', 2), + ('INVITATION', 1, '邀请函', 'ENABLED', 3), + ('OTHER', 1, '其他', 'ENABLED', 99) +ON DUPLICATE KEY UPDATE + type_name = VALUES(type_name), + sort_no = VALUES(sort_no); diff --git a/backend/src/main/resources/db/migration/V19__tenant_table.sql b/backend/src/main/resources/db/migration/V19__tenant_table.sql new file mode 100644 index 0000000..8df1f33 --- /dev/null +++ b/backend/src/main/resources/db/migration/V19__tenant_table.sql @@ -0,0 +1,59 @@ +CREATE TABLE IF NOT EXISTS tenant ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_code VARCHAR(64) NOT NULL, + tenant_name VARCHAR(128) NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'ENABLED', + is_deleted TINYINT(1) NOT NULL DEFAULT 0, + created_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_tenant_code (tenant_code) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +SET @tenant_code_column_exists = ( + SELECT COUNT(1) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'tenant' + AND COLUMN_NAME = 'tenant_code' +); +SET @add_tenant_code_sql = IF( + @tenant_code_column_exists = 0, + 'ALTER TABLE tenant ADD COLUMN tenant_code VARCHAR(64) NULL AFTER id', + 'SELECT 1' +); +PREPARE stmt_add_tenant_code FROM @add_tenant_code_sql; +EXECUTE stmt_add_tenant_code; +DEALLOCATE PREPARE stmt_add_tenant_code; + +UPDATE tenant +SET tenant_code = CONCAT('TENANT_', LPAD(id, 6, '0')) +WHERE tenant_code IS NULL OR tenant_code = ''; + +UPDATE tenant +SET tenant_code = 'TENANT_DEFAULT' +WHERE id = 1; + +SET @tenant_code_index_exists = ( + SELECT COUNT(1) + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'tenant' + AND INDEX_NAME = 'uk_tenant_code' +); +SET @add_tenant_code_index_sql = IF( + @tenant_code_index_exists = 0, + 'ALTER TABLE tenant ADD UNIQUE KEY uk_tenant_code (tenant_code)', + 'SELECT 1' +); +PREPARE stmt_add_tenant_code_index FROM @add_tenant_code_index_sql; +EXECUTE stmt_add_tenant_code_index; +DEALLOCATE PREPARE stmt_add_tenant_code_index; + +INSERT INTO tenant (id, tenant_code, tenant_name, status, is_deleted, created_by, updated_by) +VALUES (1, 'TENANT_DEFAULT', '默认单位主体', 'ENABLED', 0, 0, 0) +ON DUPLICATE KEY UPDATE + tenant_code = VALUES(tenant_code), + tenant_name = VALUES(tenant_name), + status = VALUES(status); diff --git a/backend/src/main/resources/db/migration/V1__init_schema.sql b/backend/src/main/resources/db/migration/V1__init_schema.sql new file mode 100644 index 0000000..a2173c2 --- /dev/null +++ b/backend/src/main/resources/db/migration/V1__init_schema.sql @@ -0,0 +1,104 @@ +CREATE TABLE IF NOT EXISTS tenant ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_name VARCHAR(128) NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE', + is_deleted TINYINT(1) NOT NULL DEFAULT 0, + created_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_tenant_name (tenant_name) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS project ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + project_name VARCHAR(128) NOT NULL, + budget_cent BIGINT NOT NULL, + meeting_total INT NOT NULL, + status VARCHAR(32) NOT NULL, + is_deleted TINYINT(1) NOT NULL DEFAULT 0, + created_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + KEY idx_tenant_status (tenant_id, status), + KEY idx_tenant_updated (tenant_id, updated_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS meeting ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + project_id BIGINT UNSIGNED NOT NULL, + topic VARCHAR(256) NOT NULL, + budget_cent BIGINT NOT NULL, + meeting_status VARCHAR(32) NOT NULL, + audit_status VARCHAR(32) NOT NULL, + is_deleted TINYINT(1) NOT NULL DEFAULT 0, + created_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + KEY idx_tenant_project (tenant_id, project_id), + KEY idx_tenant_audit (tenant_id, audit_status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS audit_task ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + meeting_id BIGINT UNSIGNED NOT NULL, + audit_node VARCHAR(32) NOT NULL, + status VARCHAR(32) NOT NULL, + opinion VARCHAR(500) DEFAULT NULL, + is_deleted TINYINT(1) NOT NULL DEFAULT 0, + created_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + KEY idx_tenant_node_status (tenant_id, audit_node, status), + KEY idx_tenant_meeting (tenant_id, meeting_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS finance_payment ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + project_id BIGINT UNSIGNED NOT NULL, + meeting_id BIGINT UNSIGNED NOT NULL, + amount_cent BIGINT NOT NULL, + payment_status VARCHAR(32) NOT NULL, + voucher_oss_key VARCHAR(512) DEFAULT NULL, + is_deleted TINYINT(1) NOT NULL DEFAULT 0, + created_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + KEY idx_tenant_project_status (tenant_id, project_id, payment_status), + KEY idx_tenant_meeting (tenant_id, meeting_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS async_job ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL DEFAULT 0, + job_type VARCHAR(64) NOT NULL, + payload TEXT, + status VARCHAR(32) NOT NULL DEFAULT 'READY', + next_run_at DATETIME NOT NULL, + retry_count INT NOT NULL DEFAULT 0, + max_retry INT NOT NULL DEFAULT 3, + idempotency_key VARCHAR(128) DEFAULT NULL, + locked_by VARCHAR(128) DEFAULT NULL, + locked_at DATETIME DEFAULT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + KEY idx_status_next_run (status, next_run_at), + UNIQUE KEY uk_idempotency_key (idempotency_key) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS async_job_log ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + job_id BIGINT UNSIGNED NOT NULL, + execute_status VARCHAR(32) NOT NULL, + message VARCHAR(500) DEFAULT NULL, + executed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY idx_job_time (job_id, executed_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/backend/src/main/resources/db/migration/V20__tenant_permission_seed.sql b/backend/src/main/resources/db/migration/V20__tenant_permission_seed.sql new file mode 100644 index 0000000..9326ab6 --- /dev/null +++ b/backend/src/main/resources/db/migration/V20__tenant_permission_seed.sql @@ -0,0 +1,9 @@ +INSERT INTO permission (id, permission_code, permission_name, module) +VALUES + (1041, 'tenant.manage', '租户管理', 'system') +ON DUPLICATE KEY UPDATE permission_name = VALUES(permission_name), module = VALUES(module); + +INSERT INTO role_permission (id, tenant_id, role_id, permission_id) +VALUES + (42, 1, 101, 1041) +ON DUPLICATE KEY UPDATE permission_id = VALUES(permission_id); diff --git a/backend/src/main/resources/db/migration/V21__operation_audit_log.sql b/backend/src/main/resources/db/migration/V21__operation_audit_log.sql new file mode 100644 index 0000000..93f48b6 --- /dev/null +++ b/backend/src/main/resources/db/migration/V21__operation_audit_log.sql @@ -0,0 +1,30 @@ +CREATE TABLE IF NOT EXISTS operation_audit_log ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + user_id BIGINT UNSIGNED NOT NULL DEFAULT 0, + action_code VARCHAR(128) NOT NULL, + biz_type VARCHAR(64) DEFAULT NULL, + biz_id VARCHAR(64) DEFAULT NULL, + http_method VARCHAR(16) NOT NULL, + request_uri VARCHAR(255) NOT NULL, + request_query VARCHAR(1000) DEFAULT NULL, + status_code INT NOT NULL DEFAULT 200, + success TINYINT(1) NOT NULL DEFAULT 1, + error_message VARCHAR(500) DEFAULT NULL, + ip VARCHAR(64) DEFAULT NULL, + user_agent VARCHAR(255) DEFAULT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY idx_tenant_time (tenant_id, created_at), + KEY idx_user_time (user_id, created_at), + KEY idx_action_time (action_code, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +INSERT INTO permission (id, permission_code, permission_name, module) +VALUES + (1042, 'audit.log.read', '查看审计日志', 'system') +ON DUPLICATE KEY UPDATE permission_name = VALUES(permission_name), module = VALUES(module); + +INSERT INTO role_permission (id, tenant_id, role_id, permission_id) +VALUES + (43, 1, 101, 1042) +ON DUPLICATE KEY UPDATE permission_id = VALUES(permission_id); diff --git a/backend/src/main/resources/db/migration/V22__audit_sla_transfer_enhance.sql b/backend/src/main/resources/db/migration/V22__audit_sla_transfer_enhance.sql new file mode 100644 index 0000000..68f3161 --- /dev/null +++ b/backend/src/main/resources/db/migration/V22__audit_sla_transfer_enhance.sql @@ -0,0 +1,41 @@ +ALTER TABLE audit_task + ADD COLUMN sla_deadline_at DATETIME DEFAULT NULL AFTER opinion, + ADD COLUMN timeout_level TINYINT NOT NULL DEFAULT 0 AFTER sla_deadline_at; + +CREATE TABLE IF NOT EXISTS audit_transfer_log ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + task_id BIGINT UNSIGNED NOT NULL, + from_user_id BIGINT UNSIGNED DEFAULT NULL, + to_user_id BIGINT UNSIGNED NOT NULL, + reason VARCHAR(500) DEFAULT NULL, + created_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY idx_tenant_task_time (tenant_id, task_id, created_at), + KEY idx_tenant_to_user_time (tenant_id, to_user_id, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +UPDATE audit_task +SET sla_deadline_at = DATE_ADD(created_at, INTERVAL 24 HOUR) +WHERE sla_deadline_at IS NULL; + +UPDATE audit_task +SET timeout_level = CASE + WHEN status='PENDING' AND sla_deadline_at IS NOT NULL AND TIMESTAMPDIFF(HOUR, sla_deadline_at, NOW()) >= 24 THEN 3 + WHEN status='PENDING' AND sla_deadline_at IS NOT NULL AND TIMESTAMPDIFF(HOUR, sla_deadline_at, NOW()) >= 12 THEN 2 + WHEN status='PENDING' AND sla_deadline_at IS NOT NULL AND TIMESTAMPDIFF(HOUR, sla_deadline_at, NOW()) >= 4 THEN 1 + ELSE 0 END; + +INSERT INTO permission (id, permission_code, permission_name, module) +VALUES + (1043, 'audit.transfer', '审核转审', 'audit'), + (1044, 'audit.remind', '审核催办', 'audit'), + (1045, 'audit.sla.read', '查看审核SLA统计', 'audit') +ON DUPLICATE KEY UPDATE permission_name = VALUES(permission_name), module = VALUES(module); + +INSERT INTO role_permission (id, tenant_id, role_id, permission_id) +VALUES + (44, 1, 101, 1043), + (45, 1, 101, 1044), + (46, 1, 101, 1045) +ON DUPLICATE KEY UPDATE permission_id = VALUES(permission_id); diff --git a/backend/src/main/resources/db/migration/V23__finance_reconciliation_lock.sql b/backend/src/main/resources/db/migration/V23__finance_reconciliation_lock.sql new file mode 100644 index 0000000..7a857dd --- /dev/null +++ b/backend/src/main/resources/db/migration/V23__finance_reconciliation_lock.sql @@ -0,0 +1,44 @@ +CREATE TABLE IF NOT EXISTS finance_reconciliation ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + project_id BIGINT UNSIGNED NOT NULL, + expected_amount_cent BIGINT NOT NULL DEFAULT 0, + actual_amount_cent BIGINT NOT NULL DEFAULT 0, + diff_amount_cent BIGINT NOT NULL DEFAULT 0, + result_status VARCHAR(32) NOT NULL DEFAULT 'MATCH', + is_deleted TINYINT(1) NOT NULL DEFAULT 0, + created_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + KEY idx_tenant_project_time (tenant_id, project_id, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS finance_lock_log ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + project_id BIGINT UNSIGNED NOT NULL, + lock_status VARCHAR(32) NOT NULL, + reason VARCHAR(500) DEFAULT NULL, + is_deleted TINYINT(1) NOT NULL DEFAULT 0, + created_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + KEY idx_tenant_project_status (tenant_id, project_id, lock_status), + KEY idx_tenant_time (tenant_id, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +INSERT INTO permission (id, permission_code, permission_name, module) +VALUES + (1046, 'finance.reconciliation', '财务对账', 'finance'), + (1047, 'finance.lock', '财务锁账', 'finance'), + (1048, 'finance.unlock', '财务解锁', 'finance') +ON DUPLICATE KEY UPDATE permission_name = VALUES(permission_name), module = VALUES(module); + +INSERT INTO role_permission (id, tenant_id, role_id, permission_id) +VALUES + (47, 1, 101, 1046), + (48, 1, 101, 1047), + (49, 1, 101, 1048) +ON DUPLICATE KEY UPDATE permission_id = VALUES(permission_id); diff --git a/backend/src/main/resources/db/migration/V24__template_archive_diff_permission.sql b/backend/src/main/resources/db/migration/V24__template_archive_diff_permission.sql new file mode 100644 index 0000000..4593339 --- /dev/null +++ b/backend/src/main/resources/db/migration/V24__template_archive_diff_permission.sql @@ -0,0 +1,9 @@ +INSERT INTO permission (id, permission_code, permission_name, module) +VALUES + (1049, 'template.archive', '模板归档', 'template') +ON DUPLICATE KEY UPDATE permission_name = VALUES(permission_name), module = VALUES(module); + +INSERT INTO role_permission (id, tenant_id, role_id, permission_id) +VALUES + (50, 1, 101, 1049) +ON DUPLICATE KEY UPDATE permission_id = VALUES(permission_id); diff --git a/backend/src/main/resources/db/migration/V25__template_flow_linkage.sql b/backend/src/main/resources/db/migration/V25__template_flow_linkage.sql new file mode 100644 index 0000000..65fa518 --- /dev/null +++ b/backend/src/main/resources/db/migration/V25__template_flow_linkage.sql @@ -0,0 +1,34 @@ +ALTER TABLE template + ADD COLUMN biz_scene VARCHAR(64) NOT NULL DEFAULT 'MEETING_RECOMMEND' AFTER meeting_id; + +CREATE TABLE IF NOT EXISTS template_flow_link ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + scene_code VARCHAR(64) NOT NULL, + template_id BIGINT UNSIGNED NOT NULL, + is_deleted TINYINT(1) NOT NULL DEFAULT 0, + created_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_tenant_scene (tenant_id, scene_code), + KEY idx_tenant_template (tenant_id, template_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +INSERT INTO template_flow_link (tenant_id, scene_code, template_id, created_by, updated_by) +SELECT t.tenant_id, 'MEETING_RECOMMEND', t.id, 0, 0 +FROM template t +WHERE t.tenant_id = 1 AND t.status='PUBLISHED' +ORDER BY t.id DESC +LIMIT 1 +ON DUPLICATE KEY UPDATE template_id = VALUES(template_id), updated_at = CURRENT_TIMESTAMP; + +INSERT INTO permission (id, permission_code, permission_name, module) +VALUES + (1050, 'template.flow.link', '模板流程联动绑定', 'template') +ON DUPLICATE KEY UPDATE permission_name = VALUES(permission_name), module = VALUES(module); + +INSERT INTO role_permission (id, tenant_id, role_id, permission_id) +VALUES + (51, 1, 101, 1050) +ON DUPLICATE KEY UPDATE permission_id = VALUES(permission_id); diff --git a/backend/src/main/resources/db/migration/V26__expert_module.sql b/backend/src/main/resources/db/migration/V26__expert_module.sql new file mode 100644 index 0000000..2e4810f --- /dev/null +++ b/backend/src/main/resources/db/migration/V26__expert_module.sql @@ -0,0 +1,78 @@ +CREATE TABLE IF NOT EXISTS expert ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + expert_name VARCHAR(128) NOT NULL, + id_no VARCHAR(64) NOT NULL, + phone VARCHAR(32) NOT NULL, + title VARCHAR(128) DEFAULT NULL, + organization VARCHAR(255) DEFAULT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'ENABLED', + is_deleted TINYINT(1) NOT NULL DEFAULT 0, + created_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_tenant_id_no (tenant_id, id_no), + KEY idx_tenant_name (tenant_id, expert_name) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS expert_bank_card ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + expert_id BIGINT UNSIGNED NOT NULL, + bank_name VARCHAR(128) NOT NULL, + bank_card_no VARCHAR(128) NOT NULL, + account_name VARCHAR(128) NOT NULL, + is_default CHAR(1) NOT NULL DEFAULT 'N', + is_deleted TINYINT(1) NOT NULL DEFAULT 0, + created_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + KEY idx_tenant_expert (tenant_id, expert_id), + KEY idx_tenant_card (tenant_id, bank_card_no) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS expert_merge_log ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + target_expert_id BIGINT UNSIGNED NOT NULL, + source_expert_id BIGINT UNSIGNED NOT NULL, + reason VARCHAR(500) DEFAULT NULL, + created_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY idx_tenant_target (tenant_id, target_expert_id), + KEY idx_tenant_source (tenant_id, source_expert_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS meeting_expert_snapshot ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + meeting_id BIGINT UNSIGNED NOT NULL, + expert_id BIGINT UNSIGNED NOT NULL, + snapshot_json TEXT NOT NULL, + created_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY idx_tenant_meeting (tenant_id, meeting_id), + KEY idx_tenant_expert (tenant_id, expert_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +INSERT INTO permission (id, permission_code, permission_name, module) +VALUES + (1051, 'expert.read', '查看专家', 'expert'), + (1052, 'expert.create', '创建专家', 'expert'), + (1053, 'expert.merge', '专家合并', 'expert'), + (1054, 'expert.import', '导入专家', 'expert'), + (1055, 'expert.export', '导出专家', 'expert'), + (1056, 'expert.card.manage', '管理专家银行卡', 'expert') +ON DUPLICATE KEY UPDATE permission_name = VALUES(permission_name), module = VALUES(module); + +INSERT INTO role_permission (id, tenant_id, role_id, permission_id) +VALUES + (52, 1, 101, 1051), + (53, 1, 101, 1052), + (54, 1, 101, 1053), + (55, 1, 101, 1054), + (56, 1, 101, 1055), + (57, 1, 101, 1056) +ON DUPLICATE KEY UPDATE permission_id = VALUES(permission_id); diff --git a/backend/src/main/resources/db/migration/V27__notification_policy.sql b/backend/src/main/resources/db/migration/V27__notification_policy.sql new file mode 100644 index 0000000..2da898d --- /dev/null +++ b/backend/src/main/resources/db/migration/V27__notification_policy.sql @@ -0,0 +1,44 @@ +CREATE TABLE IF NOT EXISTS notification_policy ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + policy_name VARCHAR(128) NOT NULL, + event_code VARCHAR(64) NOT NULL, + channel VARCHAR(32) NOT NULL, + receiver_type VARCHAR(64) NOT NULL, + template_id BIGINT UNSIGNED NOT NULL, + variables_json TEXT DEFAULT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'ENABLED', + is_deleted TINYINT(1) NOT NULL DEFAULT 0, + created_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + KEY idx_tenant_event_status (tenant_id, event_code, status), + KEY idx_tenant_template (tenant_id, template_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS notification_policy_event ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + policy_id BIGINT UNSIGNED NOT NULL, + event_code VARCHAR(64) NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'ENABLED', + created_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_tenant_policy (tenant_id, policy_id), + KEY idx_tenant_event (tenant_id, event_code) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +INSERT INTO permission (id, permission_code, permission_name, module) +VALUES + (1057, 'notification.policy.read', '查看通知策略', 'notification'), + (1058, 'notification.policy.manage', '管理通知策略', 'notification') +ON DUPLICATE KEY UPDATE permission_name = VALUES(permission_name), module = VALUES(module); + +INSERT INTO role_permission (id, tenant_id, role_id, permission_id) +VALUES + (58, 1, 101, 1057), + (59, 1, 101, 1058) +ON DUPLICATE KEY UPDATE permission_id = VALUES(permission_id); diff --git a/backend/src/main/resources/db/migration/V28__observability_alerting.sql b/backend/src/main/resources/db/migration/V28__observability_alerting.sql new file mode 100644 index 0000000..94c13f0 --- /dev/null +++ b/backend/src/main/resources/db/migration/V28__observability_alerting.sql @@ -0,0 +1,63 @@ +CREATE TABLE IF NOT EXISTS observability_metric ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + metric_code VARCHAR(64) NOT NULL, + label_key VARCHAR(64) DEFAULT NULL, + label_value VARCHAR(255) DEFAULT NULL, + metric_value DOUBLE NOT NULL DEFAULT 0, + created_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY idx_tenant_metric_time (tenant_id, metric_code, created_at), + KEY idx_tenant_label (tenant_id, label_key, label_value) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS alert_rule ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + rule_code VARCHAR(64) NOT NULL, + rule_name VARCHAR(128) NOT NULL, + compare_op VARCHAR(8) NOT NULL DEFAULT '>=', + threshold_value DOUBLE NOT NULL DEFAULT 0, + window_minute INT NOT NULL DEFAULT 5, + status VARCHAR(32) NOT NULL DEFAULT 'ENABLED', + is_deleted TINYINT(1) NOT NULL DEFAULT 0, + created_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_tenant_rule_code (tenant_id, rule_code) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS alert_event ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + rule_code VARCHAR(64) NOT NULL, + metric_code VARCHAR(64) NOT NULL, + current_value DOUBLE NOT NULL DEFAULT 0, + threshold_value DOUBLE NOT NULL DEFAULT 0, + alert_level VARCHAR(32) NOT NULL DEFAULT 'WARN', + message VARCHAR(500) DEFAULT NULL, + created_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY idx_tenant_rule_time (tenant_id, rule_code, created_at), + KEY idx_tenant_metric_time (tenant_id, metric_code, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +INSERT INTO alert_rule (tenant_id, rule_code, rule_name, compare_op, threshold_value, window_minute, status, created_by, updated_by) +VALUES + (1, 'API_5XX_RATE', 'API 5xx错误率', '>=', 5, 5, 'ENABLED', 0, 0), + (1, 'ASYNC_BACKLOG', '异步任务积压量', '>=', 50, 5, 'ENABLED', 0, 0), + (1, 'ASYNC_FAILED_RATE', '异步任务失败率', '>=', 10, 10, 'ENABLED', 0, 0) +ON DUPLICATE KEY UPDATE threshold_value = VALUES(threshold_value), window_minute = VALUES(window_minute), updated_at = CURRENT_TIMESTAMP; + +INSERT INTO permission (id, permission_code, permission_name, module) +VALUES + (1059, 'observability.read', '查看可观测性指标', 'observability'), + (1060, 'observability.manage', '管理可观测性告警', 'observability') +ON DUPLICATE KEY UPDATE permission_name = VALUES(permission_name), module = VALUES(module); + +INSERT INTO role_permission (id, tenant_id, role_id, permission_id) +VALUES + (60, 1, 101, 1059), + (61, 1, 101, 1060) +ON DUPLICATE KEY UPDATE permission_id = VALUES(permission_id); diff --git a/backend/src/main/resources/db/migration/V29__observability_recovery_suppress.sql b/backend/src/main/resources/db/migration/V29__observability_recovery_suppress.sql new file mode 100644 index 0000000..1aeb3bd --- /dev/null +++ b/backend/src/main/resources/db/migration/V29__observability_recovery_suppress.sql @@ -0,0 +1,10 @@ +ALTER TABLE alert_rule + ADD COLUMN suppress_window_minute INT NOT NULL DEFAULT 0 AFTER window_minute; + +ALTER TABLE alert_event + ADD COLUMN status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE' AFTER alert_level, + ADD COLUMN recovered_at DATETIME DEFAULT NULL AFTER created_at; + +UPDATE alert_rule +SET suppress_window_minute = 5 +WHERE rule_code IN ('API_5XX_RATE', 'ASYNC_FAILED_RATE') AND suppress_window_minute = 0; diff --git a/backend/src/main/resources/db/migration/V2__seed_data.sql b/backend/src/main/resources/db/migration/V2__seed_data.sql new file mode 100644 index 0000000..4c1ee1d --- /dev/null +++ b/backend/src/main/resources/db/migration/V2__seed_data.sql @@ -0,0 +1,7 @@ +INSERT INTO tenant (id, tenant_name, status, created_by, updated_by) +VALUES (1, '默认单位', 'ACTIVE', 0, 0) +ON DUPLICATE KEY UPDATE updated_at = CURRENT_TIMESTAMP; + +INSERT INTO project (id, tenant_id, project_name, budget_cent, meeting_total, status, created_by, updated_by) +VALUES (1001, 1, 'MVP默认项目', 1000000, 3, 'WAITING', 0, 0) +ON DUPLICATE KEY UPDATE updated_at = CURRENT_TIMESTAMP; diff --git a/backend/src/main/resources/db/migration/V30__meeting_withdraw_field_invoice.sql b/backend/src/main/resources/db/migration/V30__meeting_withdraw_field_invoice.sql new file mode 100644 index 0000000..8e7c223 --- /dev/null +++ b/backend/src/main/resources/db/migration/V30__meeting_withdraw_field_invoice.sql @@ -0,0 +1,56 @@ +CREATE TABLE IF NOT EXISTS meeting_field ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + field_code VARCHAR(64) NOT NULL, + field_name VARCHAR(128) NOT NULL, + field_values TEXT NOT NULL, + scope_type VARCHAR(32) NOT NULL DEFAULT 'GLOBAL', + project_id BIGINT UNSIGNED DEFAULT NULL, + sort_no INT NOT NULL DEFAULT 0, + status VARCHAR(32) NOT NULL DEFAULT 'ENABLED', + is_deleted TINYINT(1) NOT NULL DEFAULT 0, + created_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_tenant_field_code (tenant_id, field_code), + KEY idx_tenant_status_sort (tenant_id, status, sort_no) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS invoice_profile ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + company_name VARCHAR(200) NOT NULL, + tax_no VARCHAR(64) NOT NULL, + bank_name VARCHAR(128) NOT NULL, + account_no VARCHAR(128) NOT NULL, + address VARCHAR(255) DEFAULT NULL, + phone VARCHAR(64) DEFAULT NULL, + default_project_id BIGINT UNSIGNED DEFAULT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'ENABLED', + is_deleted TINYINT(1) NOT NULL DEFAULT 0, + created_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_tenant_tax_account (tenant_id, tax_no, account_no), + KEY idx_tenant_status_project (tenant_id, status, default_project_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +INSERT INTO permission (id, permission_code, permission_name, module) +VALUES + (1061, 'meeting.withdraw', '撤回会议提交', 'meeting'), + (1062, 'meeting.field.read', '查看会议字段配置', 'meeting'), + (1063, 'meeting.field.manage', '管理会议字段配置', 'meeting'), + (1064, 'invoice.profile.read', '查看发票抬头', 'invoice'), + (1065, 'invoice.profile.manage', '管理发票抬头', 'invoice') +ON DUPLICATE KEY UPDATE permission_name = VALUES(permission_name), module = VALUES(module); + +INSERT INTO role_permission (id, tenant_id, role_id, permission_id) +VALUES + (70, 1, 101, 1061), + (71, 1, 101, 1062), + (72, 1, 101, 1063), + (73, 1, 101, 1064), + (74, 1, 101, 1065) +ON DUPLICATE KEY UPDATE permission_id = VALUES(permission_id); diff --git a/backend/src/main/resources/db/migration/V31__notification_dispatch_export_task_auto_eval.sql b/backend/src/main/resources/db/migration/V31__notification_dispatch_export_task_auto_eval.sql new file mode 100644 index 0000000..7fd9e6e --- /dev/null +++ b/backend/src/main/resources/db/migration/V31__notification_dispatch_export_task_auto_eval.sql @@ -0,0 +1,68 @@ +CREATE TABLE IF NOT EXISTS notification_task ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + policy_id BIGINT UNSIGNED NOT NULL, + event_code VARCHAR(64) NOT NULL, + channel VARCHAR(32) NOT NULL, + receiver_type VARCHAR(32) NOT NULL, + receiver_ref VARCHAR(128) DEFAULT NULL, + payload_json TEXT, + status VARCHAR(32) NOT NULL DEFAULT 'PENDING', + retry_count INT NOT NULL DEFAULT 0, + max_retry INT NOT NULL DEFAULT 3, + error_message VARCHAR(500) DEFAULT NULL, + idempotency_key VARCHAR(128) DEFAULT NULL, + sent_at DATETIME DEFAULT NULL, + is_deleted TINYINT(1) NOT NULL DEFAULT 0, + created_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_tenant_idempotency (tenant_id, idempotency_key), + KEY idx_tenant_event_status (tenant_id, event_code, status), + KEY idx_tenant_created (tenant_id, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS export_task ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + task_code VARCHAR(64) NOT NULL, + biz_type VARCHAR(64) NOT NULL, + biz_id VARCHAR(128) DEFAULT NULL, + filters_json TEXT, + file_name VARCHAR(255) NOT NULL, + file_oss_key VARCHAR(512) DEFAULT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'PENDING', + retry_count INT NOT NULL DEFAULT 0, + max_retry INT NOT NULL DEFAULT 3, + error_message VARCHAR(500) DEFAULT NULL, + idempotency_key VARCHAR(128) DEFAULT NULL, + requested_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + finished_at DATETIME DEFAULT NULL, + is_deleted TINYINT(1) NOT NULL DEFAULT 0, + created_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_tenant_export_idempotency (tenant_id, idempotency_key), + KEY idx_tenant_status_time (tenant_id, status, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +ALTER TABLE alert_event + ADD COLUMN recover_candidate_at DATETIME DEFAULT NULL AFTER recovered_at; + +INSERT INTO permission (id, permission_code, permission_name, module) +VALUES + (1066, 'notification.dispatch', '触发通知发送', 'notification'), + (1067, 'notification.task.read', '查看通知发送任务', 'notification'), + (1068, 'export.task.read', '查看导出任务', 'export'), + (1069, 'export.task.manage', '创建导出任务', 'export') +ON DUPLICATE KEY UPDATE permission_name = VALUES(permission_name), module = VALUES(module); + +INSERT INTO role_permission (id, tenant_id, role_id, permission_id) +VALUES + (75, 1, 101, 1066), + (76, 1, 101, 1067), + (77, 1, 101, 1068), + (78, 1, 101, 1069) +ON DUPLICATE KEY UPDATE permission_id = VALUES(permission_id); diff --git a/backend/src/main/resources/db/migration/V32__deepen_receipt_export_download_dashboard.sql b/backend/src/main/resources/db/migration/V32__deepen_receipt_export_download_dashboard.sql new file mode 100644 index 0000000..68667ec --- /dev/null +++ b/backend/src/main/resources/db/migration/V32__deepen_receipt_export_download_dashboard.sql @@ -0,0 +1,35 @@ +ALTER TABLE notification_task + ADD COLUMN provider_message_id VARCHAR(128) DEFAULT NULL AFTER status, + ADD COLUMN receipt_code VARCHAR(64) DEFAULT NULL AFTER provider_message_id, + ADD COLUMN receipt_message VARCHAR(500) DEFAULT NULL AFTER receipt_code, + ADD COLUMN receipt_at DATETIME DEFAULT NULL AFTER sent_at; + +CREATE TABLE IF NOT EXISTS notification_receipt_log ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + task_id BIGINT UNSIGNED NOT NULL, + provider_message_id VARCHAR(128) NOT NULL, + receipt_code VARCHAR(64) NOT NULL, + receipt_message VARCHAR(500) DEFAULT NULL, + receipt_status VARCHAR(32) NOT NULL, + created_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY idx_tenant_task_time (tenant_id, task_id, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +ALTER TABLE export_task + ADD COLUMN download_token VARCHAR(128) DEFAULT NULL AFTER file_oss_key, + ADD COLUMN download_token_expire_at DATETIME DEFAULT NULL AFTER download_token, + ADD COLUMN download_count INT NOT NULL DEFAULT 0 AFTER download_token_expire_at; + +INSERT INTO permission (id, permission_code, permission_name, module) +VALUES + (1070, 'export.task.download', '下载导出结果', 'export'), + (1071, 'dashboard.read', '查看运营看板', 'dashboard') +ON DUPLICATE KEY UPDATE permission_name = VALUES(permission_name), module = VALUES(module); + +INSERT INTO role_permission (id, tenant_id, role_id, permission_id) +VALUES + (79, 1, 101, 1070), + (80, 1, 101, 1071) +ON DUPLICATE KEY UPDATE permission_id = VALUES(permission_id); diff --git a/backend/src/main/resources/db/migration/V33__user_account_validity.sql b/backend/src/main/resources/db/migration/V33__user_account_validity.sql new file mode 100644 index 0000000..6f24e10 --- /dev/null +++ b/backend/src/main/resources/db/migration/V33__user_account_validity.sql @@ -0,0 +1,36 @@ +SET @valid_from_exists = ( + SELECT COUNT(1) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'sys_user' + AND COLUMN_NAME = 'valid_from' +); +SET @add_valid_from_sql = IF( + @valid_from_exists = 0, + 'ALTER TABLE sys_user ADD COLUMN valid_from DATETIME NULL AFTER status', + 'SELECT 1' +); +PREPARE stmt_add_valid_from FROM @add_valid_from_sql; +EXECUTE stmt_add_valid_from; +DEALLOCATE PREPARE stmt_add_valid_from; + +SET @valid_to_exists = ( + SELECT COUNT(1) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'sys_user' + AND COLUMN_NAME = 'valid_to' +); +SET @add_valid_to_sql = IF( + @valid_to_exists = 0, + 'ALTER TABLE sys_user ADD COLUMN valid_to DATETIME NULL AFTER valid_from', + 'SELECT 1' +); +PREPARE stmt_add_valid_to FROM @add_valid_to_sql; +EXECUTE stmt_add_valid_to; +DEALLOCATE PREPARE stmt_add_valid_to; + +UPDATE sys_user +SET valid_from = IFNULL(valid_from, created_at), + valid_to = IFNULL(valid_to, '2099-12-31 23:59:59') +WHERE is_deleted = 0; diff --git a/backend/src/main/resources/db/migration/V34__enterprise_and_project_enterprise_ref.sql b/backend/src/main/resources/db/migration/V34__enterprise_and_project_enterprise_ref.sql new file mode 100644 index 0000000..12c758d --- /dev/null +++ b/backend/src/main/resources/db/migration/V34__enterprise_and_project_enterprise_ref.sql @@ -0,0 +1,47 @@ +CREATE TABLE IF NOT EXISTS enterprise ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + enterprise_name VARCHAR(128) NOT NULL, + enterprise_url VARCHAR(255) DEFAULT NULL, + logo_url VARCHAR(512) DEFAULT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'ENABLED', + is_deleted TINYINT(1) NOT NULL DEFAULT 0, + created_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_tenant_enterprise_name (tenant_id, enterprise_name), + KEY idx_tenant_status (tenant_id, status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +SET @project_enterprise_id_exists = ( + SELECT COUNT(1) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'project' + AND COLUMN_NAME = 'enterprise_id' +); +SET @add_project_enterprise_id_sql = IF( + @project_enterprise_id_exists = 0, + 'ALTER TABLE project ADD COLUMN enterprise_id BIGINT UNSIGNED NULL AFTER project_name', + 'SELECT 1' +); +PREPARE stmt_add_project_enterprise_id FROM @add_project_enterprise_id_sql; +EXECUTE stmt_add_project_enterprise_id; +DEALLOCATE PREPARE stmt_add_project_enterprise_id; + +SET @project_enterprise_idx_exists = ( + SELECT COUNT(1) + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'project' + AND INDEX_NAME = 'idx_tenant_enterprise' +); +SET @add_project_enterprise_idx_sql = IF( + @project_enterprise_idx_exists = 0, + 'ALTER TABLE project ADD KEY idx_tenant_enterprise (tenant_id, enterprise_id)', + 'SELECT 1' +); +PREPARE stmt_add_project_enterprise_idx FROM @add_project_enterprise_idx_sql; +EXECUTE stmt_add_project_enterprise_idx; +DEALLOCATE PREPARE stmt_add_project_enterprise_idx; diff --git a/backend/src/main/resources/db/migration/V35__menu_management.sql b/backend/src/main/resources/db/migration/V35__menu_management.sql new file mode 100644 index 0000000..000e335 --- /dev/null +++ b/backend/src/main/resources/db/migration/V35__menu_management.sql @@ -0,0 +1,67 @@ +CREATE TABLE IF NOT EXISTS menu ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + menu_code VARCHAR(64) NOT NULL, + menu_name VARCHAR(64) NOT NULL, + route_path VARCHAR(128) NOT NULL, + sort_no INT NOT NULL DEFAULT 100, + status VARCHAR(32) NOT NULL DEFAULT 'ENABLED', + is_deleted TINYINT(1) NOT NULL DEFAULT 0, + created_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_tenant_menu_code (tenant_id, menu_code), + KEY idx_tenant_status_sort (tenant_id, status, sort_no) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS role_menu ( + id BIGINT UNSIGNED NOT NULL PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + role_id BIGINT UNSIGNED NOT NULL, + menu_id BIGINT UNSIGNED NOT NULL, + UNIQUE KEY uk_tenant_role_menu (tenant_id, role_id, menu_id), + KEY idx_tenant_role (tenant_id, role_id), + KEY idx_tenant_menu (tenant_id, menu_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +INSERT INTO menu (tenant_id, menu_code, menu_name, route_path, sort_no, status, is_deleted, created_by, updated_by) +VALUES + (1, 'project', '项目管理', '/projects', 10, 'ENABLED', 0, 0, 0), + (1, 'meeting', '会议管理', '/meetings', 20, 'ENABLED', 0, 0, 0), + (1, 'audit', '审核管理', '/audits', 30, 'ENABLED', 0, 0, 0), + (1, 'finance', '财务管理', '/finance', 40, 'ENABLED', 0, 0, 0), + (1, 'user', '用户管理', '/users', 50, 'ENABLED', 0, 0, 0), + (1, 'tenant', '租户管理', '/tenants', 60, 'ENABLED', 0, 0, 0), + (1, 'enterprise', '企业管理', '/enterprises', 70, 'ENABLED', 0, 0, 0), + (1, 'role', '角色管理', '/roles', 80, 'ENABLED', 0, 0, 0), + (1, 'menu', '菜单管理', '/menus', 90, 'ENABLED', 0, 0, 0), + (1, 'audit_flow', '审核流配置', '/audit-flows', 100, 'ENABLED', 0, 0, 0), + (1, 'data_permission', '数据权限管理', '/data-permissions', 110, 'ENABLED', 0, 0, 0), + (1, 'template', '模板管理', '/templates', 120, 'ENABLED', 0, 0, 0), + (1, 'template_download_log', '模板下载日志', '/template-download-logs', 130, 'ENABLED', 0, 0, 0), + (1, 'audit_log', '审计日志', '/audit-logs', 140, 'ENABLED', 0, 0, 0), + (1, 'expert', '专家管理', '/experts', 150, 'ENABLED', 0, 0, 0), + (1, 'meeting_field', '会议字段管理', '/meeting-fields', 160, 'ENABLED', 0, 0, 0), + (1, 'invoice_profile', '发票管理', '/invoice-profiles', 170, 'ENABLED', 0, 0, 0), + (1, 'notification_policy', '通知策略', '/notification-policies', 180, 'ENABLED', 0, 0, 0), + (1, 'export_task', '导出任务中心', '/export-tasks', 190, 'ENABLED', 0, 0, 0), + (1, 'observability', '可观测性', '/observability', 200, 'ENABLED', 0, 0, 0), + (1, 'operations_dashboard', '运营看板', '/operations-dashboard', 210, 'ENABLED', 0, 0, 0) +ON DUPLICATE KEY UPDATE + menu_name = VALUES(menu_name), + route_path = VALUES(route_path), + sort_no = VALUES(sort_no), + status = VALUES(status), + is_deleted = VALUES(is_deleted), + updated_at = CURRENT_TIMESTAMP; + +INSERT INTO role_menu (id, tenant_id, role_id, menu_id) +SELECT + (1000 + m.id) AS id, + 1 AS tenant_id, + 101 AS role_id, + m.id AS menu_id +FROM menu m +WHERE m.tenant_id = 1 +ON DUPLICATE KEY UPDATE menu_id = VALUES(menu_id); diff --git a/backend/src/main/resources/db/migration/V36__menu_permission_code.sql b/backend/src/main/resources/db/migration/V36__menu_permission_code.sql new file mode 100644 index 0000000..6a4ef62 --- /dev/null +++ b/backend/src/main/resources/db/migration/V36__menu_permission_code.sql @@ -0,0 +1,37 @@ +SET @menu_permission_code_exists = ( + SELECT COUNT(1) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'menu' + AND COLUMN_NAME = 'permission_code' +); +SET @add_menu_permission_code_sql = IF( + @menu_permission_code_exists = 0, + 'ALTER TABLE menu ADD COLUMN permission_code VARCHAR(128) NULL AFTER route_path', + 'SELECT 1' +); +PREPARE stmt_add_menu_permission_code FROM @add_menu_permission_code_sql; +EXECUTE stmt_add_menu_permission_code; +DEALLOCATE PREPARE stmt_add_menu_permission_code; + +UPDATE menu SET permission_code='project.create' WHERE tenant_id=1 AND route_path='/projects'; +UPDATE menu SET permission_code='meeting.create' WHERE tenant_id=1 AND route_path='/meetings'; +UPDATE menu SET permission_code='audit.approve' WHERE tenant_id=1 AND route_path='/audits'; +UPDATE menu SET permission_code='finance.payment.confirm' WHERE tenant_id=1 AND route_path='/finance'; +UPDATE menu SET permission_code='user.read' WHERE tenant_id=1 AND route_path='/users'; +UPDATE menu SET permission_code='tenant.manage' WHERE tenant_id=1 AND route_path='/tenants'; +UPDATE menu SET permission_code='tenant.manage' WHERE tenant_id=1 AND route_path='/enterprises'; +UPDATE menu SET permission_code='role.read' WHERE tenant_id=1 AND route_path='/roles'; +UPDATE menu SET permission_code='role.permission.bind' WHERE tenant_id=1 AND route_path='/menus'; +UPDATE menu SET permission_code='audit.flow.read' WHERE tenant_id=1 AND route_path='/audit-flows'; +UPDATE menu SET permission_code='data.permission.read' WHERE tenant_id=1 AND route_path='/data-permissions'; +UPDATE menu SET permission_code='template.read' WHERE tenant_id=1 AND route_path='/templates'; +UPDATE menu SET permission_code='template.read' WHERE tenant_id=1 AND route_path='/template-download-logs'; +UPDATE menu SET permission_code='audit.log.read' WHERE tenant_id=1 AND route_path='/audit-logs'; +UPDATE menu SET permission_code='expert.read' WHERE tenant_id=1 AND route_path='/experts'; +UPDATE menu SET permission_code='meeting.field.read' WHERE tenant_id=1 AND route_path='/meeting-fields'; +UPDATE menu SET permission_code='invoice.profile.read' WHERE tenant_id=1 AND route_path='/invoice-profiles'; +UPDATE menu SET permission_code='notification.policy.read' WHERE tenant_id=1 AND route_path='/notification-policies'; +UPDATE menu SET permission_code='export.task.read' WHERE tenant_id=1 AND route_path='/export-tasks'; +UPDATE menu SET permission_code='observability.read' WHERE tenant_id=1 AND route_path='/observability'; +UPDATE menu SET permission_code='dashboard.read' WHERE tenant_id=1 AND route_path='/operations-dashboard'; diff --git a/backend/src/main/resources/db/migration/V37__user_delegation.sql b/backend/src/main/resources/db/migration/V37__user_delegation.sql new file mode 100644 index 0000000..d5ab70f --- /dev/null +++ b/backend/src/main/resources/db/migration/V37__user_delegation.sql @@ -0,0 +1,29 @@ +CREATE TABLE IF NOT EXISTS user_delegation ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + user_id BIGINT UNSIGNED NOT NULL, + delegate_user_id BIGINT UNSIGNED NOT NULL, + effective_from DATETIME NOT NULL, + effective_to DATETIME NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'PENDING', + reason VARCHAR(255) DEFAULT NULL, + disabled_reason VARCHAR(255) DEFAULT NULL, + is_deleted TINYINT(1) NOT NULL DEFAULT 0, + created_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + KEY idx_tenant_user_status (tenant_id, user_id, status), + KEY idx_tenant_delegate_status (tenant_id, delegate_user_id, status), + KEY idx_tenant_effective_to (tenant_id, effective_to) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +INSERT INTO permission (id, permission_code, permission_name, module) +VALUES + (1052, 'user.delegation.manage', '代理授权管理', 'system') +ON DUPLICATE KEY UPDATE permission_name = VALUES(permission_name), module = VALUES(module); + +INSERT INTO role_permission (id, tenant_id, role_id, permission_id) +VALUES + (53, 1, 101, 1052) +ON DUPLICATE KEY UPDATE permission_id = VALUES(permission_id); diff --git a/backend/src/main/resources/db/migration/V38__v2a_field_dictionary.sql b/backend/src/main/resources/db/migration/V38__v2a_field_dictionary.sql new file mode 100644 index 0000000..8f559f6 --- /dev/null +++ b/backend/src/main/resources/db/migration/V38__v2a_field_dictionary.sql @@ -0,0 +1,372 @@ +-- V2A-DB-01: 字段级数据字典落库(6.10) +-- MySQL 5.7:通过 information_schema + PREPARE 方式做幂等加列 + +-- ========================= +-- project 扩展字段 +-- ========================= +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'project' AND COLUMN_NAME = 'host_enterprise_id'); +SET @sql := IF(@c = 0, 'ALTER TABLE project ADD COLUMN host_enterprise_id BIGINT UNSIGNED NULL AFTER enterprise_id', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'project' AND COLUMN_NAME = 'partner_enterprise_id'); +SET @sql := IF(@c = 0, 'ALTER TABLE project ADD COLUMN partner_enterprise_id BIGINT UNSIGNED NULL AFTER host_enterprise_id', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'project' AND COLUMN_NAME = 'host_owner_user_id'); +SET @sql := IF(@c = 0, 'ALTER TABLE project ADD COLUMN host_owner_user_id BIGINT UNSIGNED NULL AFTER partner_enterprise_id', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'project' AND COLUMN_NAME = 'host_executor_user_id'); +SET @sql := IF(@c = 0, 'ALTER TABLE project ADD COLUMN host_executor_user_id BIGINT UNSIGNED NULL AFTER host_owner_user_id', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'project' AND COLUMN_NAME = 'partner_owner_user_id'); +SET @sql := IF(@c = 0, 'ALTER TABLE project ADD COLUMN partner_owner_user_id BIGINT UNSIGNED NULL AFTER host_executor_user_id', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'project' AND COLUMN_NAME = 'partner_executor_user_id'); +SET @sql := IF(@c = 0, 'ALTER TABLE project ADD COLUMN partner_executor_user_id BIGINT UNSIGNED NULL AFTER partner_owner_user_id', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'project' AND COLUMN_NAME = 'allow_meeting_over_budget'); +SET @sql := IF(@c = 0, 'ALTER TABLE project ADD COLUMN allow_meeting_over_budget TINYINT(1) NOT NULL DEFAULT 0 AFTER partner_executor_user_id', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'project' AND COLUMN_NAME = 'over_budget_threshold_ratio'); +SET @sql := IF(@c = 0, 'ALTER TABLE project ADD COLUMN over_budget_threshold_ratio DECIMAL(8,6) NOT NULL DEFAULT 0.100000 AFTER allow_meeting_over_budget', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'project' AND COLUMN_NAME = 'over_budget_approval_chain_json'); +SET @sql := IF(@c = 0, 'ALTER TABLE project ADD COLUMN over_budget_approval_chain_json TEXT NULL AFTER over_budget_threshold_ratio', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'project' AND COLUMN_NAME = 'meeting_completed_count'); +SET @sql := IF(@c = 0, 'ALTER TABLE project ADD COLUMN meeting_completed_count INT NOT NULL DEFAULT 0 AFTER meeting_total', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'project' AND COLUMN_NAME = 'budget_execution_ratio'); +SET @sql := IF(@c = 0, 'ALTER TABLE project ADD COLUMN budget_execution_ratio DECIMAL(8,6) NOT NULL DEFAULT 0.000000 AFTER meeting_completed_count', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'project' AND COLUMN_NAME = 'risk_flags_json'); +SET @sql := IF(@c = 0, 'ALTER TABLE project ADD COLUMN risk_flags_json TEXT NULL AFTER budget_execution_ratio', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @idx := (SELECT COUNT(1) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'project' AND INDEX_NAME = 'idx_tenant_host_partner'); +SET @sql := IF(@idx = 0, 'ALTER TABLE project ADD KEY idx_tenant_host_partner (tenant_id, host_enterprise_id, partner_enterprise_id)', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- ========================= +-- meeting 扩展字段 +-- ========================= +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'meeting' AND COLUMN_NAME = 'sub_project_name'); +SET @sql := IF(@c = 0, 'ALTER TABLE meeting ADD COLUMN sub_project_name VARCHAR(128) NULL AFTER topic', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'meeting' AND COLUMN_NAME = 'meeting_category'); +SET @sql := IF(@c = 0, 'ALTER TABLE meeting ADD COLUMN meeting_category VARCHAR(64) NULL AFTER sub_project_name', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'meeting' AND COLUMN_NAME = 'meeting_form'); +SET @sql := IF(@c = 0, 'ALTER TABLE meeting ADD COLUMN meeting_form VARCHAR(64) NULL AFTER meeting_category', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'meeting' AND COLUMN_NAME = 'location'); +SET @sql := IF(@c = 0, 'ALTER TABLE meeting ADD COLUMN location VARCHAR(255) NULL AFTER meeting_form', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'meeting' AND COLUMN_NAME = 'start_time'); +SET @sql := IF(@c = 0, 'ALTER TABLE meeting ADD COLUMN start_time DATETIME NULL AFTER location', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'meeting' AND COLUMN_NAME = 'end_time'); +SET @sql := IF(@c = 0, 'ALTER TABLE meeting ADD COLUMN end_time DATETIME NULL AFTER start_time', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'meeting' AND COLUMN_NAME = 'labor_ratio'); +SET @sql := IF(@c = 0, 'ALTER TABLE meeting ADD COLUMN labor_ratio DECIMAL(8,6) NOT NULL DEFAULT 0.000000 AFTER budget_cent', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'meeting' AND COLUMN_NAME = 'catering_ratio'); +SET @sql := IF(@c = 0, 'ALTER TABLE meeting ADD COLUMN catering_ratio DECIMAL(8,6) NOT NULL DEFAULT 0.000000 AFTER labor_ratio', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'meeting' AND COLUMN_NAME = 'current_audit_node'); +SET @sql := IF(@c = 0, 'ALTER TABLE meeting ADD COLUMN current_audit_node VARCHAR(64) NULL AFTER audit_status', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'meeting' AND COLUMN_NAME = 'last_submit_at'); +SET @sql := IF(@c = 0, 'ALTER TABLE meeting ADD COLUMN last_submit_at DATETIME NULL AFTER current_audit_node', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'meeting' AND COLUMN_NAME = 'last_reject_reason'); +SET @sql := IF(@c = 0, 'ALTER TABLE meeting ADD COLUMN last_reject_reason VARCHAR(500) NULL AFTER last_submit_at', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'meeting' AND COLUMN_NAME = 'overdue_days'); +SET @sql := IF(@c = 0, 'ALTER TABLE meeting ADD COLUMN overdue_days INT NOT NULL DEFAULT 0 AFTER last_reject_reason', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'meeting' AND COLUMN_NAME = 'risk_flags_json'); +SET @sql := IF(@c = 0, 'ALTER TABLE meeting ADD COLUMN risk_flags_json TEXT NULL AFTER overdue_days', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'meeting' AND COLUMN_NAME = 'is_frozen'); +SET @sql := IF(@c = 0, 'ALTER TABLE meeting ADD COLUMN is_frozen TINYINT(1) NOT NULL DEFAULT 0 AFTER risk_flags_json', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'meeting' AND COLUMN_NAME = 'freeze_reason'); +SET @sql := IF(@c = 0, 'ALTER TABLE meeting ADD COLUMN freeze_reason VARCHAR(500) NULL AFTER is_frozen', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @idx := (SELECT COUNT(1) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'meeting' AND INDEX_NAME = 'idx_tenant_time'); +SET @sql := IF(@idx = 0, 'ALTER TABLE meeting ADD KEY idx_tenant_time (tenant_id, start_time, end_time)', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- ========================= +-- meeting_material 扩展字段 +-- ========================= +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'meeting_material' AND COLUMN_NAME = 'is_latest_version'); +SET @sql := IF(@c = 0, 'ALTER TABLE meeting_material ADD COLUMN is_latest_version TINYINT(1) NOT NULL DEFAULT 1 AFTER version_no', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'meeting_material' AND COLUMN_NAME = 'audit_node_status'); +SET @sql := IF(@c = 0, 'ALTER TABLE meeting_material ADD COLUMN audit_node_status VARCHAR(32) NULL AFTER status', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'meeting_material' AND COLUMN_NAME = 'audit_aggregate_status'); +SET @sql := IF(@c = 0, 'ALTER TABLE meeting_material ADD COLUMN audit_aggregate_status VARCHAR(32) NULL AFTER audit_node_status', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'meeting_material' AND COLUMN_NAME = 'submit_remark'); +SET @sql := IF(@c = 0, 'ALTER TABLE meeting_material ADD COLUMN submit_remark VARCHAR(500) NULL AFTER audit_aggregate_status', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'meeting_material' AND COLUMN_NAME = 'reject_count'); +SET @sql := IF(@c = 0, 'ALTER TABLE meeting_material ADD COLUMN reject_count INT NOT NULL DEFAULT 0 AFTER submit_remark', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'meeting_material' AND COLUMN_NAME = 'last_reject_reason'); +SET @sql := IF(@c = 0, 'ALTER TABLE meeting_material ADD COLUMN last_reject_reason VARCHAR(500) NULL AFTER reject_count', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'meeting_material' AND COLUMN_NAME = 'resubmit_at'); +SET @sql := IF(@c = 0, 'ALTER TABLE meeting_material ADD COLUMN resubmit_at DATETIME NULL AFTER last_reject_reason', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- ========================= +-- 发票结构化新表 +-- ========================= +CREATE TABLE IF NOT EXISTS meeting_material_invoice_item ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + meeting_id BIGINT UNSIGNED NOT NULL, + material_id BIGINT UNSIGNED DEFAULT NULL, + expense_type VARCHAR(64) NOT NULL, + invoice_no VARCHAR(128) DEFAULT NULL, + invoice_amount_cent BIGINT NOT NULL DEFAULT 0, + tax_amount_cent BIGINT NOT NULL DEFAULT 0, + detail_amount_cent BIGINT NOT NULL DEFAULT 0, + vendor_name VARCHAR(255) DEFAULT NULL, + occur_date DATE DEFAULT NULL, + remark VARCHAR(500) DEFAULT NULL, + is_deleted TINYINT(1) NOT NULL DEFAULT 0, + created_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + KEY idx_tenant_meeting_expense_date (tenant_id, meeting_id, expense_type, occur_date), + KEY idx_tenant_invoice_no (tenant_id, invoice_no) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS meeting_material_invoice_file ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + meeting_id BIGINT UNSIGNED NOT NULL, + invoice_item_id BIGINT UNSIGNED DEFAULT NULL, + file_type VARCHAR(32) NOT NULL COMMENT 'INVOICE/DETAIL/PHOTO/TICKET/OTHER', + file_name VARCHAR(255) NOT NULL, + oss_key VARCHAR(512) NOT NULL, + content_type VARCHAR(128) DEFAULT NULL, + size BIGINT UNSIGNED NOT NULL DEFAULT 0, + is_deleted TINYINT(1) NOT NULL DEFAULT 0, + created_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY idx_tenant_meeting_file_type (tenant_id, meeting_id, file_type), + KEY idx_tenant_invoice_item (tenant_id, invoice_item_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS meeting_invoice_summary ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + meeting_id BIGINT UNSIGNED NOT NULL, + category_amount_cent_json TEXT NOT NULL, + meeting_total_amount_cent BIGINT NOT NULL DEFAULT 0, + is_over_budget TINYINT(1) NOT NULL DEFAULT 0, + is_deleted TINYINT(1) NOT NULL DEFAULT 0, + created_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_tenant_meeting (tenant_id, meeting_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- ========================= +-- audit_task 扩展字段 +-- ========================= +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'audit_task' AND COLUMN_NAME = 'overtime_hours'); +SET @sql := IF(@c = 0, 'ALTER TABLE audit_task ADD COLUMN overtime_hours INT NOT NULL DEFAULT 0 AFTER timeout_level', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'audit_task' AND COLUMN_NAME = 'is_overtime'); +SET @sql := IF(@c = 0, 'ALTER TABLE audit_task ADD COLUMN is_overtime TINYINT(1) NOT NULL DEFAULT 0 AFTER overtime_hours', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'audit_task' AND COLUMN_NAME = 'transfer_from_user_id'); +SET @sql := IF(@c = 0, 'ALTER TABLE audit_task ADD COLUMN transfer_from_user_id BIGINT UNSIGNED NULL AFTER assignee_user_id', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'audit_task' AND COLUMN_NAME = 'transfer_reason'); +SET @sql := IF(@c = 0, 'ALTER TABLE audit_task ADD COLUMN transfer_reason VARCHAR(500) NULL AFTER transfer_from_user_id', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'audit_task' AND COLUMN_NAME = 'return_reason'); +SET @sql := IF(@c = 0, 'ALTER TABLE audit_task ADD COLUMN return_reason VARCHAR(500) NULL AFTER transfer_reason', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'audit_task' AND COLUMN_NAME = 'reject_count'); +SET @sql := IF(@c = 0, 'ALTER TABLE audit_task ADD COLUMN reject_count INT NOT NULL DEFAULT 0 AFTER return_reason', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'audit_task' AND COLUMN_NAME = 'last_reject_reason'); +SET @sql := IF(@c = 0, 'ALTER TABLE audit_task ADD COLUMN last_reject_reason VARCHAR(500) NULL AFTER reject_count', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'audit_task' AND COLUMN_NAME = 'last_action_at'); +SET @sql := IF(@c = 0, 'ALTER TABLE audit_task ADD COLUMN last_action_at DATETIME NULL AFTER last_reject_reason', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- ========================= +-- expert / expert_bank_card 扩展字段 +-- ========================= +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'expert' AND COLUMN_NAME = 'gender'); +SET @sql := IF(@c = 0, 'ALTER TABLE expert ADD COLUMN gender VARCHAR(16) NULL AFTER expert_name', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'expert' AND COLUMN_NAME = 'birthday'); +SET @sql := IF(@c = 0, 'ALTER TABLE expert ADD COLUMN birthday DATE NULL AFTER gender', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'expert' AND COLUMN_NAME = 'id_card_valid_until'); +SET @sql := IF(@c = 0, 'ALTER TABLE expert ADD COLUMN id_card_valid_until DATE NULL AFTER id_no', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'expert' AND COLUMN_NAME = 'status_reason'); +SET @sql := IF(@c = 0, 'ALTER TABLE expert ADD COLUMN status_reason VARCHAR(500) NULL AFTER status', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'expert' AND COLUMN_NAME = 'status_changed_by'); +SET @sql := IF(@c = 0, 'ALTER TABLE expert ADD COLUMN status_changed_by BIGINT UNSIGNED NULL AFTER status_reason', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'expert' AND COLUMN_NAME = 'status_changed_at'); +SET @sql := IF(@c = 0, 'ALTER TABLE expert ADD COLUMN status_changed_at DATETIME NULL AFTER status_changed_by', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'expert' AND COLUMN_NAME = 'export_restricted'); +SET @sql := IF(@c = 0, 'ALTER TABLE expert ADD COLUMN export_restricted TINYINT(1) NOT NULL DEFAULT 1 AFTER status_changed_at', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'expert_bank_card' AND COLUMN_NAME = 'bank_province'); +SET @sql := IF(@c = 0, 'ALTER TABLE expert_bank_card ADD COLUMN bank_province VARCHAR(64) NULL AFTER bank_name', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'expert_bank_card' AND COLUMN_NAME = 'bank_city'); +SET @sql := IF(@c = 0, 'ALTER TABLE expert_bank_card ADD COLUMN bank_city VARCHAR(64) NULL AFTER bank_province', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'expert_bank_card' AND COLUMN_NAME = 'bank_branch_name'); +SET @sql := IF(@c = 0, 'ALTER TABLE expert_bank_card ADD COLUMN bank_branch_name VARCHAR(255) NULL AFTER bank_city', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'expert_bank_card' AND COLUMN_NAME = 'card_status'); +SET @sql := IF(@c = 0, 'ALTER TABLE expert_bank_card ADD COLUMN card_status VARCHAR(32) NOT NULL DEFAULT ''ENABLED'' AFTER is_default', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'expert_bank_card' AND COLUMN_NAME = 'inconsistent_name_approved'); +SET @sql := IF(@c = 0, 'ALTER TABLE expert_bank_card ADD COLUMN inconsistent_name_approved TINYINT(1) NOT NULL DEFAULT 0 AFTER card_status', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'expert_bank_card' AND COLUMN_NAME = 'change_reason'); +SET @sql := IF(@c = 0, 'ALTER TABLE expert_bank_card ADD COLUMN change_reason VARCHAR(500) NULL AFTER inconsistent_name_approved', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- ========================= +-- template / template_version 扩展字段 +-- ========================= +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'template' AND COLUMN_NAME = 'scope_id'); +SET @sql := IF(@c = 0, 'ALTER TABLE template ADD COLUMN scope_id BIGINT UNSIGNED NULL AFTER scope_type', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'template' AND COLUMN_NAME = 'effective_from'); +SET @sql := IF(@c = 0, 'ALTER TABLE template ADD COLUMN effective_from DATETIME NULL AFTER current_version_no', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'template' AND COLUMN_NAME = 'effective_to'); +SET @sql := IF(@c = 0, 'ALTER TABLE template ADD COLUMN effective_to DATETIME NULL AFTER effective_from', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'template' AND COLUMN_NAME = 'watermark_enabled'); +SET @sql := IF(@c = 0, 'ALTER TABLE template ADD COLUMN watermark_enabled TINYINT(1) NOT NULL DEFAULT 0 AFTER effective_to', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'template' AND COLUMN_NAME = 'download_rate_limit_per_hour'); +SET @sql := IF(@c = 0, 'ALTER TABLE template ADD COLUMN download_rate_limit_per_hour INT NOT NULL DEFAULT 100 AFTER watermark_enabled', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'template_version' AND COLUMN_NAME = 'is_effective'); +SET @sql := IF(@c = 0, 'ALTER TABLE template_version ADD COLUMN is_effective TINYINT(1) NOT NULL DEFAULT 0 AFTER version_status', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'template_version' AND COLUMN_NAME = 'rollback_reason'); +SET @sql := IF(@c = 0, 'ALTER TABLE template_version ADD COLUMN rollback_reason VARCHAR(500) NULL AFTER change_log', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- ========================= +-- finance_meeting_bill 新表 +-- ========================= +CREATE TABLE IF NOT EXISTS finance_meeting_bill ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + project_id BIGINT UNSIGNED NOT NULL, + meeting_id BIGINT UNSIGNED NOT NULL, + venue_amount_cent BIGINT NOT NULL DEFAULT 0, + build_amount_cent BIGINT NOT NULL DEFAULT 0, + hotel_amount_cent BIGINT NOT NULL DEFAULT 0, + catering_amount_cent BIGINT NOT NULL DEFAULT 0, + local_traffic_amount_cent BIGINT NOT NULL DEFAULT 0, + long_distance_traffic_amount_cent BIGINT NOT NULL DEFAULT 0, + material_amount_cent BIGINT NOT NULL DEFAULT 0, + design_amount_cent BIGINT NOT NULL DEFAULT 0, + labor_payable_amount_cent BIGINT NOT NULL DEFAULT 0, + labor_actual_amount_cent BIGINT NOT NULL DEFAULT 0, + finance_review_fee_cent BIGINT NOT NULL DEFAULT 0, + management_fee_cent BIGINT NOT NULL DEFAULT 0, + tax_fee_cent BIGINT NOT NULL DEFAULT 0, + custom_fee_json TEXT NULL, + paid_amount_cent BIGINT NOT NULL DEFAULT 0, + unpaid_amount_cent BIGINT NOT NULL DEFAULT 0, + reconciliation_result VARCHAR(32) DEFAULT NULL, + reconciliation_diff_amount_cent BIGINT NOT NULL DEFAULT 0, + reconciliation_diff_reason VARCHAR(500) DEFAULT NULL, + settlement_no VARCHAR(64) DEFAULT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'DRAFT', + is_deleted TINYINT(1) NOT NULL DEFAULT 0, + created_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_tenant_meeting (tenant_id, meeting_id), + UNIQUE KEY uk_tenant_settlement_no (tenant_id, settlement_no), + KEY idx_tenant_project_status (tenant_id, project_id, status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/backend/src/main/resources/db/migration/V39__operation_audit_log_scope.sql b/backend/src/main/resources/db/migration/V39__operation_audit_log_scope.sql new file mode 100644 index 0000000..3db59b8 --- /dev/null +++ b/backend/src/main/resources/db/migration/V39__operation_audit_log_scope.sql @@ -0,0 +1,36 @@ +SET @scope_col_exists = ( + SELECT COUNT(1) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'operation_audit_log' + AND COLUMN_NAME = 'scope' +); + +SET @add_scope_col_sql = IF( + @scope_col_exists = 0, + 'ALTER TABLE operation_audit_log ADD COLUMN scope VARCHAR(16) NOT NULL DEFAULT ''TENANT'' AFTER user_id', + 'SELECT 1' +); +PREPARE stmt_add_scope_col FROM @add_scope_col_sql; +EXECUTE stmt_add_scope_col; +DEALLOCATE PREPARE stmt_add_scope_col; + +UPDATE operation_audit_log +SET scope = CASE WHEN tenant_id = 0 THEN 'PLATFORM' ELSE 'TENANT' END +WHERE scope IS NULL OR scope = ''; + +SET @scope_idx_exists = ( + SELECT COUNT(1) + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'operation_audit_log' + AND INDEX_NAME = 'idx_scope_time' +); +SET @add_scope_idx_sql = IF( + @scope_idx_exists = 0, + 'ALTER TABLE operation_audit_log ADD KEY idx_scope_time (scope, created_at)', + 'SELECT 1' +); +PREPARE stmt_add_scope_idx FROM @add_scope_idx_sql; +EXECUTE stmt_add_scope_idx; +DEALLOCATE PREPARE stmt_add_scope_idx; diff --git a/backend/src/main/resources/db/migration/V3__auth_rbac_seed.sql b/backend/src/main/resources/db/migration/V3__auth_rbac_seed.sql new file mode 100644 index 0000000..3aeefe3 --- /dev/null +++ b/backend/src/main/resources/db/migration/V3__auth_rbac_seed.sql @@ -0,0 +1,94 @@ +CREATE TABLE IF NOT EXISTS sys_user ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + user_name VARCHAR(64) NOT NULL, + phone VARCHAR(32) NOT NULL, + email VARCHAR(128) DEFAULT NULL, + password_hash VARCHAR(255) NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'ENABLED', + is_deleted TINYINT(1) NOT NULL DEFAULT 0, + created_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_tenant_phone (tenant_id, phone) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS role ( + id BIGINT UNSIGNED NOT NULL PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + role_code VARCHAR(64) NOT NULL, + role_name VARCHAR(64) NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'ENABLED', + is_deleted TINYINT(1) NOT NULL DEFAULT 0, + created_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_tenant_role_code (tenant_id, role_code) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS permission ( + id BIGINT UNSIGNED NOT NULL PRIMARY KEY, + permission_code VARCHAR(128) NOT NULL, + permission_name VARCHAR(128) NOT NULL, + module VARCHAR(64) NOT NULL, + UNIQUE KEY uk_permission_code (permission_code) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS role_permission ( + id BIGINT UNSIGNED NOT NULL PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + role_id BIGINT UNSIGNED NOT NULL, + permission_id BIGINT UNSIGNED NOT NULL, + UNIQUE KEY uk_tenant_role_perm (tenant_id, role_id, permission_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS user_role ( + id BIGINT UNSIGNED NOT NULL PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + user_id BIGINT UNSIGNED NOT NULL, + role_id BIGINT UNSIGNED NOT NULL, + UNIQUE KEY uk_tenant_user_role (tenant_id, user_id, role_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +INSERT INTO role (id, tenant_id, role_code, role_name, status, created_by, updated_by) +VALUES + (101, 1, 'TENANT_ADMIN', '閸楁洑缍呯粻锛勬倞閸?, 'ENABLED', 0, 0), + (102, 1, 'PROJECT_OWNER', '妞ゅ湱娲扮拹鐔荤煑娴?, 'ENABLED', 0, 0), + (103, 1, 'EXECUTOR', '妞ゅ湱娲伴幍褑顢戞禍?, 'ENABLED', 0, 0), + (104, 1, 'AUDITOR', '鐎光剝鐗虫禍?, 'ENABLED', 0, 0), + (105, 1, 'FINANCE', '鐠愩垹濮?, 'ENABLED', 0, 0) +ON DUPLICATE KEY UPDATE updated_at = CURRENT_TIMESTAMP; + +INSERT INTO permission (id, permission_code, permission_name, module) +VALUES + (1001, 'project.create', '閸掓稑缂撴い鍦窗', 'project'), + (1002, 'project.freeze', '閸愯崵绮ㄦい鍦窗', 'project'), + (1003, 'meeting.create', '閸掓稑缂撴导姘愁唴', 'meeting'), + (1004, 'meeting.submit', '娴兼俺顔呯痪褎褰佹禍?, 'meeting'), + (1005, 'audit.approve', '鐎光剝鐗抽柅姘崇箖', 'audit'), + (1006, 'audit.reject', '鐎光剝鐗抽幏鎺旂卜', 'audit'), + (1007, 'audit.return', '鐎光剝鐗抽柅鈧崶?, 'audit'), + (1008, 'finance.payment.confirm', '閺€顖欑帛绾喛顓?, 'finance') +ON DUPLICATE KEY UPDATE permission_name = VALUES(permission_name); + +INSERT INTO sys_user (id, tenant_id, user_name, phone, email, password_hash, status, created_by, updated_by) +VALUES (1, 1, '缁狅紕鎮婇崨?, '13800000000', 'admin@writeoff.local', '123456', 'ENABLED', 0, 0) +ON DUPLICATE KEY UPDATE updated_at = CURRENT_TIMESTAMP; + +INSERT INTO user_role (id, tenant_id, user_id, role_id) +VALUES (1, 1, 1, 101) +ON DUPLICATE KEY UPDATE role_id = VALUES(role_id); + +INSERT INTO role_permission (id, tenant_id, role_id, permission_id) +VALUES + (1, 1, 101, 1001), + (2, 1, 101, 1002), + (3, 1, 101, 1003), + (4, 1, 101, 1004), + (5, 1, 101, 1005), + (6, 1, 101, 1006), + (7, 1, 101, 1007), + (8, 1, 101, 1008) +ON DUPLICATE KEY UPDATE permission_id = VALUES(permission_id); diff --git a/backend/src/main/resources/db/migration/V40__platform_admin_rbac.sql b/backend/src/main/resources/db/migration/V40__platform_admin_rbac.sql new file mode 100644 index 0000000..f16ff6f --- /dev/null +++ b/backend/src/main/resources/db/migration/V40__platform_admin_rbac.sql @@ -0,0 +1,77 @@ +CREATE TABLE IF NOT EXISTS platform_user ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + user_name VARCHAR(64) NOT NULL, + phone VARCHAR(32) NOT NULL, + email VARCHAR(128) DEFAULT NULL, + password_hash VARCHAR(255) NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'ENABLED', + valid_from DATETIME DEFAULT NULL, + valid_to DATETIME DEFAULT NULL, + is_deleted TINYINT(1) NOT NULL DEFAULT 0, + created_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_platform_phone (phone) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS platform_role ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + role_code VARCHAR(64) NOT NULL, + role_name VARCHAR(64) NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'ENABLED', + is_deleted TINYINT(1) NOT NULL DEFAULT 0, + created_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_platform_role_code (role_code) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS platform_permission ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + permission_code VARCHAR(128) NOT NULL, + permission_name VARCHAR(128) NOT NULL, + module VARCHAR(64) NOT NULL, + UNIQUE KEY uk_platform_permission_code (permission_code) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS platform_user_role ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT UNSIGNED NOT NULL, + role_id BIGINT UNSIGNED NOT NULL, + UNIQUE KEY uk_platform_user_role (user_id, role_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS platform_role_permission ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + role_id BIGINT UNSIGNED NOT NULL, + permission_id BIGINT UNSIGNED NOT NULL, + UNIQUE KEY uk_platform_role_permission (role_id, permission_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +INSERT INTO platform_role (id, role_code, role_name, status, is_deleted, created_by, updated_by) +VALUES (1, 'PLATFORM_SUPER_ADMIN', '缁崵绮虹搾鍛獓缁狅紕鎮婇崨?, 'ENABLED', 0, 0, 0) +ON DUPLICATE KEY UPDATE role_name = VALUES(role_name), status = VALUES(status), updated_at = CURRENT_TIMESTAMP; + +INSERT INTO platform_permission (id, permission_code, permission_name, module) +VALUES + (1, 'platform.tenant.manage', '楠炲啿褰寸粔鐔稿煕缁狅紕鎮?, 'platform'), + (2, 'platform.user.manage', '楠炲啿褰寸拹锕€褰跨粻锛勬倞', 'platform'), + (3, 'platform.audit.read', '楠炲啿褰寸€孤ゎ吀閺屻儳婀?, 'platform') +ON DUPLICATE KEY UPDATE permission_name = VALUES(permission_name), module = VALUES(module); + +INSERT INTO platform_role_permission (id, role_id, permission_id) +VALUES + (1, 1, 1), + (2, 1, 2), + (3, 1, 3) +ON DUPLICATE KEY UPDATE permission_id = VALUES(permission_id); + +INSERT INTO platform_user (id, user_name, phone, email, password_hash, status, valid_from, valid_to, is_deleted, created_by, updated_by) +VALUES (1, '缁崵绮虹搾鍛獓缁狅紕鎮婇崨?, '13900000000', 'platform-admin@writeoff.local', '123456', 'ENABLED', NOW(), '2099-12-31 23:59:59', 0, 0, 0) +ON DUPLICATE KEY UPDATE user_name = VALUES(user_name), status = VALUES(status), updated_at = CURRENT_TIMESTAMP; + +INSERT INTO platform_user_role (id, user_id, role_id) +VALUES (1, 1, 1) +ON DUPLICATE KEY UPDATE role_id = VALUES(role_id); diff --git a/backend/src/main/resources/db/migration/V41__platform_menu_management.sql b/backend/src/main/resources/db/migration/V41__platform_menu_management.sql new file mode 100644 index 0000000..6a7f18e --- /dev/null +++ b/backend/src/main/resources/db/migration/V41__platform_menu_management.sql @@ -0,0 +1,44 @@ +CREATE TABLE IF NOT EXISTS platform_menu ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + menu_code VARCHAR(64) NOT NULL, + menu_name VARCHAR(64) NOT NULL, + route_path VARCHAR(128) NOT NULL, + permission_code VARCHAR(128) DEFAULT NULL, + sort_no INT NOT NULL DEFAULT 100, + status VARCHAR(32) NOT NULL DEFAULT 'ENABLED', + is_deleted TINYINT(1) NOT NULL DEFAULT 0, + created_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_platform_menu_code (menu_code), + KEY idx_platform_menu_status_sort (status, sort_no) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS platform_role_menu ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + role_id BIGINT UNSIGNED NOT NULL, + menu_id BIGINT UNSIGNED NOT NULL, + UNIQUE KEY uk_platform_role_menu (role_id, menu_id), + KEY idx_platform_role_menu_role (role_id), + KEY idx_platform_role_menu_menu (menu_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +INSERT INTO platform_menu (id, menu_code, menu_name, route_path, permission_code, sort_no, status, is_deleted, created_by, updated_by) +VALUES + (1, 'platform_tenant', '租户管理', '/platform/tenants', 'platform.tenant.manage', 10, 'ENABLED', 0, 0, 0), + (2, 'platform_audit_log', '平台审计日志', '/platform/audit-logs', 'platform.audit.read', 20, 'ENABLED', 0, 0, 0) +ON DUPLICATE KEY UPDATE + menu_name = VALUES(menu_name), + route_path = VALUES(route_path), + permission_code = VALUES(permission_code), + sort_no = VALUES(sort_no), + status = VALUES(status), + is_deleted = VALUES(is_deleted), + updated_at = CURRENT_TIMESTAMP; + +INSERT INTO platform_role_menu (id, role_id, menu_id) +VALUES + (1, 1, 1), + (2, 1, 2) +ON DUPLICATE KEY UPDATE menu_id = VALUES(menu_id); diff --git a/backend/src/main/resources/db/migration/V42__platform_menu_permission_seed.sql b/backend/src/main/resources/db/migration/V42__platform_menu_permission_seed.sql new file mode 100644 index 0000000..0c9590b --- /dev/null +++ b/backend/src/main/resources/db/migration/V42__platform_menu_permission_seed.sql @@ -0,0 +1,11 @@ +INSERT INTO platform_permission (id, permission_code, permission_name, module) +VALUES + (4, 'platform.menu.manage', '平台菜单管理', 'platform'), + (5, 'platform.role.read', '平台角色查看', 'platform') +ON DUPLICATE KEY UPDATE permission_name = VALUES(permission_name), module = VALUES(module); + +INSERT INTO platform_role_permission (id, role_id, permission_id) +VALUES + (4, 1, 4), + (5, 1, 5) +ON DUPLICATE KEY UPDATE permission_id = VALUES(permission_id); diff --git a/backend/src/main/resources/db/migration/V43__platform_menu_manage_seed.sql b/backend/src/main/resources/db/migration/V43__platform_menu_manage_seed.sql new file mode 100644 index 0000000..07863f0 --- /dev/null +++ b/backend/src/main/resources/db/migration/V43__platform_menu_manage_seed.sql @@ -0,0 +1,16 @@ +INSERT INTO platform_menu (id, menu_code, menu_name, route_path, permission_code, sort_no, status, is_deleted, created_by, updated_by) +VALUES + (3, 'platform_menu_manage', '平台菜单管理', '/platform/menus', 'platform.menu.manage', 30, 'ENABLED', 0, 0, 0) +ON DUPLICATE KEY UPDATE + menu_name = VALUES(menu_name), + route_path = VALUES(route_path), + permission_code = VALUES(permission_code), + sort_no = VALUES(sort_no), + status = VALUES(status), + is_deleted = VALUES(is_deleted), + updated_at = CURRENT_TIMESTAMP; + +INSERT INTO platform_role_menu (id, role_id, menu_id) +VALUES + (3, 1, 3) +ON DUPLICATE KEY UPDATE menu_id = VALUES(menu_id); diff --git a/backend/src/main/resources/db/migration/V44__platform_iam_menu_seed.sql b/backend/src/main/resources/db/migration/V44__platform_iam_menu_seed.sql new file mode 100644 index 0000000..5f47947 --- /dev/null +++ b/backend/src/main/resources/db/migration/V44__platform_iam_menu_seed.sql @@ -0,0 +1,32 @@ +INSERT INTO platform_permission (id, permission_code, permission_name, module) +VALUES + (6, 'platform.role.manage', '平台角色管理', 'platform'), + (7, 'platform.permission.read', '平台权限查看', 'platform') +ON DUPLICATE KEY UPDATE permission_name = VALUES(permission_name), module = VALUES(module); + +INSERT INTO platform_role_permission (id, role_id, permission_id) +VALUES + (6, 1, 6), + (7, 1, 7) +ON DUPLICATE KEY UPDATE permission_id = VALUES(permission_id); + +INSERT INTO platform_menu (id, menu_code, menu_name, route_path, permission_code, sort_no, status, is_deleted, created_by, updated_by) +VALUES + (4, 'platform_user_manage', '平台用户管理', '/platform/users', 'platform.user.manage', 15, 'ENABLED', 0, 0, 0), + (5, 'platform_role_manage', '平台角色管理', '/platform/roles', 'platform.role.read', 16, 'ENABLED', 0, 0, 0), + (6, 'platform_permission_manage', '平台权限管理', '/platform/permissions', 'platform.permission.read', 17, 'ENABLED', 0, 0, 0) +ON DUPLICATE KEY UPDATE + menu_name = VALUES(menu_name), + route_path = VALUES(route_path), + permission_code = VALUES(permission_code), + sort_no = VALUES(sort_no), + status = VALUES(status), + is_deleted = VALUES(is_deleted), + updated_at = CURRENT_TIMESTAMP; + +INSERT INTO platform_role_menu (id, role_id, menu_id) +VALUES + (4, 1, 4), + (5, 1, 5), + (6, 1, 6) +ON DUPLICATE KEY UPDATE menu_id = VALUES(menu_id); diff --git a/backend/src/main/resources/db/migration/V45__migrate_auditlog_expert_to_platform.sql b/backend/src/main/resources/db/migration/V45__migrate_auditlog_expert_to_platform.sql new file mode 100644 index 0000000..3e2ae34 --- /dev/null +++ b/backend/src/main/resources/db/migration/V45__migrate_auditlog_expert_to_platform.sql @@ -0,0 +1,32 @@ +INSERT INTO platform_permission (id, permission_code, permission_name, module) +VALUES + (8, 'platform.expert.read', '平台专家查看', 'platform'), + (9, 'platform.expert.manage', '平台专家管理', 'platform') +ON DUPLICATE KEY UPDATE permission_name = VALUES(permission_name), module = VALUES(module); + +INSERT INTO platform_role_permission (id, role_id, permission_id) +VALUES + (8, 1, 8), + (9, 1, 9) +ON DUPLICATE KEY UPDATE permission_id = VALUES(permission_id); + +INSERT INTO platform_menu (id, menu_code, menu_name, route_path, permission_code, sort_no, status, is_deleted, created_by, updated_by) +VALUES + (7, 'platform_expert', '专家管理', '/platform/experts', 'platform.expert.read', 18, 'ENABLED', 0, 0, 0) +ON DUPLICATE KEY UPDATE + menu_name = VALUES(menu_name), + route_path = VALUES(route_path), + permission_code = VALUES(permission_code), + sort_no = VALUES(sort_no), + status = VALUES(status), + is_deleted = VALUES(is_deleted), + updated_at = CURRENT_TIMESTAMP; + +INSERT INTO platform_role_menu (id, role_id, menu_id) +VALUES + (7, 1, 7) +ON DUPLICATE KEY UPDATE menu_id = VALUES(menu_id); + +UPDATE menu +SET status='DISABLED', is_deleted=1, updated_at=CURRENT_TIMESTAMP +WHERE tenant_id=1 AND route_path IN ('/audit-logs', '/experts'); diff --git a/backend/src/main/resources/db/migration/V46__init_tenant_admin_role_menu_permission.sql b/backend/src/main/resources/db/migration/V46__init_tenant_admin_role_menu_permission.sql new file mode 100644 index 0000000..f5f5333 --- /dev/null +++ b/backend/src/main/resources/db/migration/V46__init_tenant_admin_role_menu_permission.sql @@ -0,0 +1,57 @@ +SET @next_role_id := (SELECT IFNULL(MAX(id), 0) FROM role); + +INSERT INTO role (id, tenant_id, role_code, role_name, status, is_deleted, created_by, updated_by) +SELECT + (@next_role_id := @next_role_id + 1) AS id, + t.id AS tenant_id, + 'TENANT_ADMIN' AS role_code, + '单位管理员' AS role_name, + 'ENABLED' AS status, + 0 AS is_deleted, + 0 AS created_by, + 0 AS updated_by +FROM tenant t +LEFT JOIN role r ON r.tenant_id=t.id AND r.role_code='TENANT_ADMIN' AND r.is_deleted=0 +WHERE t.is_deleted=0 AND r.id IS NULL; + +SET @template_role_id := ( + SELECT id FROM role + WHERE tenant_id=1 AND role_code='TENANT_ADMIN' AND is_deleted=0 + LIMIT 1 +); + +SET @next_role_permission_id := (SELECT IFNULL(MAX(id), 0) FROM role_permission); + +INSERT INTO role_permission (id, tenant_id, role_id, permission_id) +SELECT + (@next_role_permission_id := @next_role_permission_id + 1) AS id, + target_role.tenant_id, + target_role.id AS role_id, + template_perm.permission_id +FROM role target_role +JOIN role_permission template_perm ON template_perm.tenant_id=1 AND template_perm.role_id=@template_role_id +LEFT JOIN role_permission existing_perm + ON existing_perm.tenant_id=target_role.tenant_id + AND existing_perm.role_id=target_role.id + AND existing_perm.permission_id=template_perm.permission_id +WHERE target_role.is_deleted=0 + AND target_role.role_code='TENANT_ADMIN' + AND existing_perm.id IS NULL; + +SET @next_role_menu_id := (SELECT IFNULL(MAX(id), 0) FROM role_menu); + +INSERT INTO role_menu (id, tenant_id, role_id, menu_id) +SELECT + (@next_role_menu_id := @next_role_menu_id + 1) AS id, + target_role.tenant_id, + target_role.id AS role_id, + template_menu.menu_id +FROM role target_role +JOIN role_menu template_menu ON template_menu.tenant_id=1 AND template_menu.role_id=@template_role_id +LEFT JOIN role_menu existing_menu + ON existing_menu.tenant_id=target_role.tenant_id + AND existing_menu.role_id=target_role.id + AND existing_menu.menu_id=template_menu.menu_id +WHERE target_role.is_deleted=0 + AND target_role.role_code='TENANT_ADMIN' + AND existing_menu.id IS NULL; diff --git a/backend/src/main/resources/db/migration/V47__tenant_permission_menu_seed.sql b/backend/src/main/resources/db/migration/V47__tenant_permission_menu_seed.sql new file mode 100644 index 0000000..55c23cc --- /dev/null +++ b/backend/src/main/resources/db/migration/V47__tenant_permission_menu_seed.sql @@ -0,0 +1,41 @@ +INSERT INTO menu (tenant_id, menu_code, menu_name, route_path, permission_code, sort_no, status, is_deleted, created_by, updated_by) +VALUES + (1, 'permission', '权限管理', '/permissions', 'permission.read', 85, 'ENABLED', 0, 0, 0) +ON DUPLICATE KEY UPDATE + menu_name = VALUES(menu_name), + route_path = VALUES(route_path), + permission_code = VALUES(permission_code), + sort_no = VALUES(sort_no), + status = VALUES(status), + is_deleted = VALUES(is_deleted), + updated_at = CURRENT_TIMESTAMP; + +INSERT INTO role_menu (id, tenant_id, role_id, menu_id) +SELECT + (1000 + m.id) AS id, + 1 AS tenant_id, + 101 AS role_id, + m.id AS menu_id +FROM menu m +WHERE m.tenant_id = 1 + AND m.menu_code = 'permission' +ON DUPLICATE KEY UPDATE menu_id = VALUES(menu_id); + +SET @next_role_menu_id = (SELECT IFNULL(MAX(id), 0) FROM role_menu); +INSERT INTO role_menu (id, tenant_id, role_id, menu_id) +SELECT + (@next_role_menu_id := @next_role_menu_id + 1) AS id, + r.tenant_id, + r.id, + m.id +FROM role r +JOIN menu m ON m.tenant_id = r.tenant_id AND m.menu_code = 'permission' AND m.is_deleted = 0 +WHERE r.role_code = 'TENANT_ADMIN' + AND r.is_deleted = 0 + AND NOT EXISTS ( + SELECT 1 + FROM role_menu rm + WHERE rm.tenant_id = r.tenant_id + AND rm.role_id = r.id + AND rm.menu_id = m.id + ); diff --git a/backend/src/main/resources/db/migration/V48__project_user_binding.sql b/backend/src/main/resources/db/migration/V48__project_user_binding.sql new file mode 100644 index 0000000..118306f --- /dev/null +++ b/backend/src/main/resources/db/migration/V48__project_user_binding.sql @@ -0,0 +1,24 @@ +CREATE TABLE IF NOT EXISTS project_user_binding ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + project_id BIGINT UNSIGNED NOT NULL, + user_id BIGINT UNSIGNED NOT NULL, + bind_role_code VARCHAR(64) NOT NULL COMMENT 'PROJECT_OWNER/PROJECT_EXECUTOR', + is_deleted TINYINT(1) NOT NULL DEFAULT 0, + created_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_project_user_role (tenant_id, project_id, user_id, bind_role_code), + KEY idx_user_role (tenant_id, user_id, bind_role_code, is_deleted) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +INSERT INTO permission (id, permission_code, permission_name, module) +VALUES (20001, 'project.bind.user', '绑定项目人员', 'project') +ON DUPLICATE KEY UPDATE permission_name = VALUES(permission_name), module = VALUES(module); + +INSERT INTO role_permission (id, tenant_id, role_id, permission_id) +SELECT 20001, 1, r.id, 20001 +FROM role r +WHERE r.tenant_id = 1 AND r.role_code = 'TENANT_ADMIN' +ON DUPLICATE KEY UPDATE permission_id = VALUES(permission_id); diff --git a/backend/src/main/resources/db/migration/V49__data_permission_user_scope_owner.sql b/backend/src/main/resources/db/migration/V49__data_permission_user_scope_owner.sql new file mode 100644 index 0000000..0e62dc8 --- /dev/null +++ b/backend/src/main/resources/db/migration/V49__data_permission_user_scope_owner.sql @@ -0,0 +1,7 @@ +ALTER TABLE data_permission_policy + ADD COLUMN user_scope VARCHAR(32) NOT NULL DEFAULT 'ALL' COMMENT 'ALL/IDS/OWNER' AFTER meeting_ids_csv, + ADD COLUMN user_ids_csv VARCHAR(2000) DEFAULT NULL AFTER user_scope; + +ALTER TABLE data_permission_policy + MODIFY COLUMN project_scope VARCHAR(32) NOT NULL DEFAULT 'ALL' COMMENT 'ALL/IDS/OWNER', + MODIFY COLUMN meeting_scope VARCHAR(32) NOT NULL DEFAULT 'ALL' COMMENT 'ALL/IDS/OWNER'; diff --git a/backend/src/main/resources/db/migration/V4__audit_flow_config.sql b/backend/src/main/resources/db/migration/V4__audit_flow_config.sql new file mode 100644 index 0000000..1567e54 --- /dev/null +++ b/backend/src/main/resources/db/migration/V4__audit_flow_config.sql @@ -0,0 +1,34 @@ +CREATE TABLE IF NOT EXISTS audit_flow ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + flow_code VARCHAR(64) NOT NULL, + flow_name VARCHAR(128) NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'ENABLED', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_tenant_flow_code (tenant_id, flow_code) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS audit_flow_node ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + flow_id BIGINT UNSIGNED NOT NULL, + node_code VARCHAR(32) NOT NULL, + node_name VARCHAR(64) NOT NULL, + sort_no INT NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'ENABLED', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_flow_node_code (flow_id, node_code), + KEY idx_flow_sort (flow_id, sort_no) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +INSERT INTO audit_flow (id, tenant_id, flow_code, flow_name, status) +VALUES (1, 1, 'DEFAULT', '默认三审流程', 'ENABLED') +ON DUPLICATE KEY UPDATE flow_name = VALUES(flow_name), status = VALUES(status); + +INSERT INTO audit_flow_node (id, flow_id, node_code, node_name, sort_no, status) +VALUES + (1, 1, 'INIT_REVIEW', '初审', 1, 'ENABLED'), + (2, 1, 'RE_REVIEW', '复审', 2, 'ENABLED'), + (3, 1, 'FINAL_REVIEW', '终审', 3, 'ENABLED') +ON DUPLICATE KEY UPDATE node_name = VALUES(node_name), sort_no = VALUES(sort_no), status = VALUES(status); diff --git a/backend/src/main/resources/db/migration/V50__project_bind_executor_permission.sql b/backend/src/main/resources/db/migration/V50__project_bind_executor_permission.sql new file mode 100644 index 0000000..2cb8f4d --- /dev/null +++ b/backend/src/main/resources/db/migration/V50__project_bind_executor_permission.sql @@ -0,0 +1,9 @@ +INSERT INTO permission (id, permission_code, permission_name, module) +VALUES (20002, 'project.bind.executor_user', '绑定EXECUTOR项目人员', 'project') +ON DUPLICATE KEY UPDATE permission_name = VALUES(permission_name), module = VALUES(module); + +INSERT INTO role_permission (id, tenant_id, role_id, permission_id) +SELECT 20002, 1, r.id, 20002 +FROM role r +WHERE r.tenant_id = 1 AND r.role_code = 'TENANT_ADMIN' +ON DUPLICATE KEY UPDATE permission_id = VALUES(permission_id); diff --git a/backend/src/main/resources/db/migration/V51__meeting_expert_binding_table.sql b/backend/src/main/resources/db/migration/V51__meeting_expert_binding_table.sql new file mode 100644 index 0000000..251e9cb --- /dev/null +++ b/backend/src/main/resources/db/migration/V51__meeting_expert_binding_table.sql @@ -0,0 +1,15 @@ +CREATE TABLE IF NOT EXISTS meeting_expert_binding ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + meeting_id BIGINT UNSIGNED NOT NULL, + expert_id BIGINT UNSIGNED NOT NULL, + expert_name VARCHAR(128) NOT NULL, + phone VARCHAR(32) DEFAULT NULL, + title VARCHAR(128) DEFAULT NULL, + organization VARCHAR(255) DEFAULT NULL, + created_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY idx_tenant_meeting (tenant_id, meeting_id), + KEY idx_tenant_expert (tenant_id, expert_id), + UNIQUE KEY uk_tenant_meeting_expert (tenant_id, meeting_id, expert_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/backend/src/main/resources/db/migration/V52__project_fields_and_comments.sql b/backend/src/main/resources/db/migration/V52__project_fields_and_comments.sql new file mode 100644 index 0000000..d282766 --- /dev/null +++ b/backend/src/main/resources/db/migration/V52__project_fields_and_comments.sql @@ -0,0 +1,55 @@ +-- 项目模块字段补齐(对齐产品文档)并补充字段注释 + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'project' AND COLUMN_NAME = 'sub_project_name'); +SET @sql := IF(@c = 0, 'ALTER TABLE project ADD COLUMN sub_project_name VARCHAR(128) NULL AFTER project_name', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'project' AND COLUMN_NAME = 'start_date'); +SET @sql := IF(@c = 0, 'ALTER TABLE project ADD COLUMN start_date DATE NULL AFTER sub_project_name', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'project' AND COLUMN_NAME = 'end_date'); +SET @sql := IF(@c = 0, 'ALTER TABLE project ADD COLUMN end_date DATE NULL AFTER start_date', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'project' AND COLUMN_NAME = 'payment_status'); +SET @sql := IF(@c = 0, 'ALTER TABLE project ADD COLUMN payment_status VARCHAR(32) NOT NULL DEFAULT ''WAIT_SUBMIT'' AFTER risk_flags_json', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'project' AND COLUMN_NAME = 'labor_fee_ratio'); +SET @sql := IF(@c = 0, 'ALTER TABLE project ADD COLUMN labor_fee_ratio DECIMAL(8,6) NOT NULL DEFAULT 0.000000 AFTER payment_status', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'project' AND COLUMN_NAME = 'key_change_log_json'); +SET @sql := IF(@c = 0, 'ALTER TABLE project ADD COLUMN key_change_log_json TEXT NULL AFTER labor_fee_ratio', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @idx := (SELECT COUNT(1) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'project' AND INDEX_NAME = 'idx_tenant_payment_status'); +SET @sql := IF(@idx = 0, 'ALTER TABLE project ADD KEY idx_tenant_payment_status (tenant_id, payment_status)', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- 字段注释(统一补齐) +ALTER TABLE project + MODIFY COLUMN project_name VARCHAR(128) NOT NULL COMMENT '项目名称', + MODIFY COLUMN sub_project_name VARCHAR(128) NULL COMMENT '子项目名称', + MODIFY COLUMN enterprise_id BIGINT UNSIGNED NULL COMMENT '合作企业ID(历史兼容)', + MODIFY COLUMN host_enterprise_id BIGINT UNSIGNED NULL COMMENT '主办单位ID', + MODIFY COLUMN partner_enterprise_id BIGINT UNSIGNED NULL COMMENT '合作企业ID', + MODIFY COLUMN host_owner_user_id BIGINT UNSIGNED NULL COMMENT '主办单位负责人用户ID', + MODIFY COLUMN host_executor_user_id BIGINT UNSIGNED NULL COMMENT '主办单位项目执行人用户ID', + MODIFY COLUMN partner_owner_user_id BIGINT UNSIGNED NULL COMMENT '合作企业负责人用户ID', + MODIFY COLUMN partner_executor_user_id BIGINT UNSIGNED NULL COMMENT '合作企业项目执行人用户ID', + MODIFY COLUMN start_date DATE NULL COMMENT '项目开始日期', + MODIFY COLUMN end_date DATE NULL COMMENT '项目结束日期', + MODIFY COLUMN budget_cent BIGINT NOT NULL COMMENT '项目总预算(分)', + MODIFY COLUMN meeting_total INT NOT NULL COMMENT '会议总期数', + MODIFY COLUMN meeting_completed_count INT NOT NULL DEFAULT 0 COMMENT '已完成核销期数', + MODIFY COLUMN allow_meeting_over_budget TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否允许单场超支', + MODIFY COLUMN over_budget_threshold_ratio DECIMAL(8,6) NOT NULL DEFAULT 0.100000 COMMENT '超支阈值比例', + MODIFY COLUMN over_budget_approval_chain_json TEXT NULL COMMENT '超支审批链(JSON)', + MODIFY COLUMN budget_execution_ratio DECIMAL(8,6) NOT NULL DEFAULT 0.000000 COMMENT '预算执行率', + MODIFY COLUMN risk_flags_json TEXT NULL COMMENT '风险标记(JSON)', + MODIFY COLUMN payment_status VARCHAR(32) NOT NULL DEFAULT 'WAIT_SUBMIT' COMMENT '支付状态', + MODIFY COLUMN labor_fee_ratio DECIMAL(8,6) NOT NULL DEFAULT 0.000000 COMMENT '劳务费用占比', + MODIFY COLUMN key_change_log_json TEXT NULL COMMENT '关键变更日志(JSON)', + MODIFY COLUMN status VARCHAR(32) NOT NULL COMMENT '项目状态'; diff --git a/backend/src/main/resources/db/migration/V53__project_more_fields_from_prd.sql b/backend/src/main/resources/db/migration/V53__project_more_fields_from_prd.sql new file mode 100644 index 0000000..76917f8 --- /dev/null +++ b/backend/src/main/resources/db/migration/V53__project_more_fields_from_prd.sql @@ -0,0 +1,79 @@ +-- 项目模块二期字段补齐(核销状态/进度、审批人、备份执行人、中止冻结归档等) + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'project' AND COLUMN_NAME = 'write_off_status'); +SET @sql := IF(@c = 0, 'ALTER TABLE project ADD COLUMN write_off_status VARCHAR(32) NOT NULL DEFAULT ''WAITING'' AFTER payment_status', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'project' AND COLUMN_NAME = 'write_off_not_started_count'); +SET @sql := IF(@c = 0, 'ALTER TABLE project ADD COLUMN write_off_not_started_count INT NOT NULL DEFAULT 0 AFTER write_off_status', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'project' AND COLUMN_NAME = 'write_off_in_progress_count'); +SET @sql := IF(@c = 0, 'ALTER TABLE project ADD COLUMN write_off_in_progress_count INT NOT NULL DEFAULT 0 AFTER write_off_not_started_count', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'project' AND COLUMN_NAME = 'write_off_completed_count'); +SET @sql := IF(@c = 0, 'ALTER TABLE project ADD COLUMN write_off_completed_count INT NOT NULL DEFAULT 0 AFTER write_off_in_progress_count', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'project' AND COLUMN_NAME = 'allow_project_over_budget'); +SET @sql := IF(@c = 0, 'ALTER TABLE project ADD COLUMN allow_project_over_budget TINYINT(1) NOT NULL DEFAULT 0 AFTER labor_fee_ratio', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'project' AND COLUMN_NAME = 'invoice_info'); +SET @sql := IF(@c = 0, 'ALTER TABLE project ADD COLUMN invoice_info TEXT NULL AFTER allow_project_over_budget', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'project' AND COLUMN_NAME = 'expense_ratio_json'); +SET @sql := IF(@c = 0, 'ALTER TABLE project ADD COLUMN expense_ratio_json TEXT NULL AFTER invoice_info', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'project' AND COLUMN_NAME = 'host_backup_executor_user_id'); +SET @sql := IF(@c = 0, 'ALTER TABLE project ADD COLUMN host_backup_executor_user_id BIGINT UNSIGNED NULL AFTER host_executor_user_id', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'project' AND COLUMN_NAME = 'partner_backup_executor_user_id'); +SET @sql := IF(@c = 0, 'ALTER TABLE project ADD COLUMN partner_backup_executor_user_id BIGINT UNSIGNED NULL AFTER partner_executor_user_id', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'project' AND COLUMN_NAME = 'project_owner_approver_user_id'); +SET @sql := IF(@c = 0, 'ALTER TABLE project ADD COLUMN project_owner_approver_user_id BIGINT UNSIGNED NULL AFTER over_budget_approval_chain_json', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'project' AND COLUMN_NAME = 'finance_approver_user_id'); +SET @sql := IF(@c = 0, 'ALTER TABLE project ADD COLUMN finance_approver_user_id BIGINT UNSIGNED NULL AFTER project_owner_approver_user_id', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'project' AND COLUMN_NAME = 'terminated_reason'); +SET @sql := IF(@c = 0, 'ALTER TABLE project ADD COLUMN terminated_reason VARCHAR(500) NULL AFTER status', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'project' AND COLUMN_NAME = 'freeze_reason'); +SET @sql := IF(@c = 0, 'ALTER TABLE project ADD COLUMN freeze_reason VARCHAR(500) NULL AFTER terminated_reason', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'project' AND COLUMN_NAME = 'archived_at'); +SET @sql := IF(@c = 0, 'ALTER TABLE project ADD COLUMN archived_at DATETIME NULL AFTER freeze_reason', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- 补充索引 +SET @idx := (SELECT COUNT(1) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'project' AND INDEX_NAME = 'idx_tenant_write_off_status'); +SET @sql := IF(@idx = 0, 'ALTER TABLE project ADD KEY idx_tenant_write_off_status (tenant_id, write_off_status)', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- 字段注释 +ALTER TABLE project + MODIFY COLUMN write_off_status VARCHAR(32) NOT NULL DEFAULT 'WAITING' COMMENT '核销状态', + MODIFY COLUMN write_off_not_started_count INT NOT NULL DEFAULT 0 COMMENT '核销进度-未开始场次', + MODIFY COLUMN write_off_in_progress_count INT NOT NULL DEFAULT 0 COMMENT '核销进度-核销中场次', + MODIFY COLUMN write_off_completed_count INT NOT NULL DEFAULT 0 COMMENT '核销进度-核销完成场次', + MODIFY COLUMN allow_project_over_budget TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否允许超过项目总费用', + MODIFY COLUMN invoice_info TEXT NULL COMMENT '发票信息快照(用于一键复制)', + MODIFY COLUMN expense_ratio_json TEXT NULL COMMENT '费用占比(JSON)', + MODIFY COLUMN host_backup_executor_user_id BIGINT UNSIGNED NULL COMMENT '主办单位备份执行人用户ID', + MODIFY COLUMN partner_backup_executor_user_id BIGINT UNSIGNED NULL COMMENT '合作企业备份执行人用户ID', + MODIFY COLUMN project_owner_approver_user_id BIGINT UNSIGNED NULL COMMENT '项目负责人审批人用户ID', + MODIFY COLUMN finance_approver_user_id BIGINT UNSIGNED NULL COMMENT '财务审批人用户ID', + MODIFY COLUMN terminated_reason VARCHAR(500) NULL COMMENT '项目中止原因', + MODIFY COLUMN freeze_reason VARCHAR(500) NULL COMMENT '项目冻结原因', + MODIFY COLUMN archived_at DATETIME NULL COMMENT '归档时间'; diff --git a/backend/src/main/resources/db/migration/V54__project_key_change_log_table.sql b/backend/src/main/resources/db/migration/V54__project_key_change_log_table.sql new file mode 100644 index 0000000..63a5d59 --- /dev/null +++ b/backend/src/main/resources/db/migration/V54__project_key_change_log_table.sql @@ -0,0 +1,20 @@ +-- 项目关键变更日志表:记录预算/周期/负责人/执行人/会议期数等字段前后值 + +CREATE TABLE IF NOT EXISTS project_key_change_log ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY COMMENT '主键', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '租户ID', + project_id BIGINT UNSIGNED NOT NULL COMMENT '项目ID', + field_code VARCHAR(64) NOT NULL COMMENT '字段编码', + field_name VARCHAR(128) NOT NULL COMMENT '字段名称', + before_value VARCHAR(1000) NULL COMMENT '变更前值', + after_value VARCHAR(1000) NULL COMMENT '变更后值', + change_reason VARCHAR(500) NULL COMMENT '变更原因', + handover_at DATETIME NULL COMMENT '交接时间(负责人/执行人变更时)', + is_deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除', + created_by BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '创建人', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_by BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '更新人', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + KEY idx_tenant_project_time (tenant_id, project_id, created_at), + KEY idx_tenant_field_time (tenant_id, field_code, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='项目关键变更日志'; diff --git a/backend/src/main/resources/db/migration/V55__project_subproject_count_and_host_name.sql b/backend/src/main/resources/db/migration/V55__project_subproject_count_and_host_name.sql new file mode 100644 index 0000000..ee37528 --- /dev/null +++ b/backend/src/main/resources/db/migration/V55__project_subproject_count_and_host_name.sql @@ -0,0 +1,9 @@ +-- 直接按最新需求调整项目字段(不做兼容) + +ALTER TABLE project + ADD COLUMN sub_project_count INT NOT NULL DEFAULT 0 COMMENT '子项目数量', + ADD COLUMN host_enterprise_name VARCHAR(128) NULL COMMENT '主办单位(当前租户名称)'; + +ALTER TABLE project + MODIFY COLUMN sub_project_count INT NOT NULL DEFAULT 0 COMMENT '子项目数量', + MODIFY COLUMN host_enterprise_name VARCHAR(128) NULL COMMENT '主办单位(当前租户名称)'; diff --git a/backend/src/main/resources/db/migration/V56__project_remove_user_id_columns.sql b/backend/src/main/resources/db/migration/V56__project_remove_user_id_columns.sql new file mode 100644 index 0000000..7d1ffd0 --- /dev/null +++ b/backend/src/main/resources/db/migration/V56__project_remove_user_id_columns.sql @@ -0,0 +1,12 @@ +-- 彻底改为通过 project_user_binding 管理项目人员,project 表不再存人员ID字段 + +ALTER TABLE project + DROP COLUMN host_enterprise_id, + DROP COLUMN host_owner_user_id, + DROP COLUMN host_executor_user_id, + DROP COLUMN partner_owner_user_id, + DROP COLUMN partner_executor_user_id, + DROP COLUMN host_backup_executor_user_id, + DROP COLUMN partner_backup_executor_user_id, + DROP COLUMN project_owner_approver_user_id, + DROP COLUMN finance_approver_user_id; diff --git a/backend/src/main/resources/db/migration/V57__project_hierarchy_parent_id.sql b/backend/src/main/resources/db/migration/V57__project_hierarchy_parent_id.sql new file mode 100644 index 0000000..1ac8ceb --- /dev/null +++ b/backend/src/main/resources/db/migration/V57__project_hierarchy_parent_id.sql @@ -0,0 +1,8 @@ +-- 子项目层级化:不存子项目数量,改为基于 parent_project_id 动态统计 + +ALTER TABLE project + ADD COLUMN parent_project_id BIGINT UNSIGNED NULL COMMENT '上级项目ID(为空表示一级项目)', + DROP COLUMN sub_project_count; + +ALTER TABLE project + ADD KEY idx_tenant_parent_project (tenant_id, parent_project_id); diff --git a/backend/src/main/resources/db/migration/V58__project_drop_enterprise_id.sql b/backend/src/main/resources/db/migration/V58__project_drop_enterprise_id.sql new file mode 100644 index 0000000..1a28514 --- /dev/null +++ b/backend/src/main/resources/db/migration/V58__project_drop_enterprise_id.sql @@ -0,0 +1,4 @@ +-- 项目表仅保留 partner_enterprise_id 作为合作企业ID + +ALTER TABLE project + DROP COLUMN enterprise_id; diff --git a/backend/src/main/resources/db/migration/V59__meeting_field_attributes_and_comments.sql b/backend/src/main/resources/db/migration/V59__meeting_field_attributes_and_comments.sql new file mode 100644 index 0000000..564b0d1 --- /dev/null +++ b/backend/src/main/resources/db/migration/V59__meeting_field_attributes_and_comments.sql @@ -0,0 +1,93 @@ +-- 会议模块字段属性补齐:流程追溯、状态元数据、并发锁字段 + 字段注释统一 + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'meeting' AND COLUMN_NAME = 'current_auditor_user_id'); +SET @sql := IF(@c = 0, 'ALTER TABLE meeting ADD COLUMN current_auditor_user_id BIGINT UNSIGNED NULL AFTER freeze_reason', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'meeting' AND COLUMN_NAME = 'node_deadline_at'); +SET @sql := IF(@c = 0, 'ALTER TABLE meeting ADD COLUMN node_deadline_at DATETIME NULL AFTER current_auditor_user_id', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'meeting' AND COLUMN_NAME = 'reject_count'); +SET @sql := IF(@c = 0, 'ALTER TABLE meeting ADD COLUMN reject_count INT NOT NULL DEFAULT 0 AFTER node_deadline_at', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'meeting' AND COLUMN_NAME = 'last_action_at'); +SET @sql := IF(@c = 0, 'ALTER TABLE meeting ADD COLUMN last_action_at DATETIME NULL AFTER reject_count', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'meeting' AND COLUMN_NAME = 'status_changed_at'); +SET @sql := IF(@c = 0, 'ALTER TABLE meeting ADD COLUMN status_changed_at DATETIME NULL AFTER last_action_at', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'meeting' AND COLUMN_NAME = 'status_changed_by'); +SET @sql := IF(@c = 0, 'ALTER TABLE meeting ADD COLUMN status_changed_by BIGINT UNSIGNED NULL AFTER status_changed_at', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'meeting' AND COLUMN_NAME = 'cancel_reason'); +SET @sql := IF(@c = 0, 'ALTER TABLE meeting ADD COLUMN cancel_reason VARCHAR(500) NULL AFTER status_changed_by', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'meeting' AND COLUMN_NAME = 'postpone_reason'); +SET @sql := IF(@c = 0, 'ALTER TABLE meeting ADD COLUMN postpone_reason VARCHAR(500) NULL AFTER cancel_reason', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'meeting' AND COLUMN_NAME = 'withdraw_reason'); +SET @sql := IF(@c = 0, 'ALTER TABLE meeting ADD COLUMN withdraw_reason VARCHAR(500) NULL AFTER postpone_reason', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'meeting' AND COLUMN_NAME = 'lock_version'); +SET @sql := IF(@c = 0, 'ALTER TABLE meeting ADD COLUMN lock_version INT NOT NULL DEFAULT 0 AFTER withdraw_reason', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'meeting' AND COLUMN_NAME = 'lock_at'); +SET @sql := IF(@c = 0, 'ALTER TABLE meeting ADD COLUMN lock_at DATETIME NULL AFTER lock_version', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'meeting' AND COLUMN_NAME = 'locked_by'); +SET @sql := IF(@c = 0, 'ALTER TABLE meeting ADD COLUMN locked_by BIGINT UNSIGNED NULL AFTER lock_at', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- 查询优化:当前审核人和流程追溯字段索引 +SET @idx := (SELECT COUNT(1) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'meeting' AND INDEX_NAME = 'idx_tenant_auditor_node'); +SET @sql := IF(@idx = 0, 'ALTER TABLE meeting ADD KEY idx_tenant_auditor_node (tenant_id, current_auditor_user_id, current_audit_node)', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @idx := (SELECT COUNT(1) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'meeting' AND INDEX_NAME = 'idx_tenant_last_action'); +SET @sql := IF(@idx = 0, 'ALTER TABLE meeting ADD KEY idx_tenant_last_action (tenant_id, last_action_at)', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- 会议表字段注释统一补齐 +ALTER TABLE meeting + MODIFY COLUMN project_id BIGINT UNSIGNED NOT NULL COMMENT '所属项目ID', + MODIFY COLUMN topic VARCHAR(256) NOT NULL COMMENT '会议主题', + MODIFY COLUMN sub_project_name VARCHAR(128) NULL COMMENT '子项目名称', + MODIFY COLUMN meeting_category VARCHAR(64) NULL COMMENT '会议类别', + MODIFY COLUMN meeting_form VARCHAR(64) NULL COMMENT '会议形式', + MODIFY COLUMN location VARCHAR(255) NULL COMMENT '会议地点', + MODIFY COLUMN start_time DATETIME NULL COMMENT '会议开始时间', + MODIFY COLUMN end_time DATETIME NULL COMMENT '会议结束时间', + MODIFY COLUMN budget_cent BIGINT NOT NULL COMMENT '会议预算(分)', + MODIFY COLUMN labor_ratio DECIMAL(8,6) NOT NULL DEFAULT 0.000000 COMMENT '劳务费用占比(0~1)', + MODIFY COLUMN catering_ratio DECIMAL(8,6) NOT NULL DEFAULT 0.000000 COMMENT '餐费占比(0~1)', + MODIFY COLUMN meeting_status VARCHAR(32) NOT NULL COMMENT '会议状态', + MODIFY COLUMN audit_status VARCHAR(32) NOT NULL COMMENT '会议审核状态', + MODIFY COLUMN current_audit_node VARCHAR(64) NULL COMMENT '当前审核节点', + MODIFY COLUMN last_submit_at DATETIME NULL COMMENT '最后提交时间', + MODIFY COLUMN last_reject_reason VARCHAR(500) NULL COMMENT '最后驳回原因摘要', + MODIFY COLUMN overdue_days INT NOT NULL DEFAULT 0 COMMENT '逾期天数', + MODIFY COLUMN risk_flags_json TEXT NULL COMMENT '风险标记JSON', + MODIFY COLUMN is_frozen TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否冻结', + MODIFY COLUMN freeze_reason VARCHAR(500) NULL COMMENT '冻结原因', + MODIFY COLUMN current_auditor_user_id BIGINT UNSIGNED NULL COMMENT '当前审核人用户ID', + MODIFY COLUMN node_deadline_at DATETIME NULL COMMENT '当前节点SLA截止时间', + MODIFY COLUMN reject_count INT NOT NULL DEFAULT 0 COMMENT '累计驳回次数', + MODIFY COLUMN last_action_at DATETIME NULL COMMENT '最后一次流程动作时间', + MODIFY COLUMN status_changed_at DATETIME NULL COMMENT '状态最近变更时间', + MODIFY COLUMN status_changed_by BIGINT UNSIGNED NULL COMMENT '状态最近变更人用户ID', + MODIFY COLUMN cancel_reason VARCHAR(500) NULL COMMENT '取消原因', + MODIFY COLUMN postpone_reason VARCHAR(500) NULL COMMENT '延期原因', + MODIFY COLUMN withdraw_reason VARCHAR(500) NULL COMMENT '撤回原因', + MODIFY COLUMN lock_version INT NOT NULL DEFAULT 0 COMMENT '字段锁版本号', + MODIFY COLUMN lock_at DATETIME NULL COMMENT '字段锁定时间', + MODIFY COLUMN locked_by BIGINT UNSIGNED NULL COMMENT '字段锁定操作人用户ID'; diff --git a/backend/src/main/resources/db/migration/V5__user_role_permission_seed.sql b/backend/src/main/resources/db/migration/V5__user_role_permission_seed.sql new file mode 100644 index 0000000..737ae73 --- /dev/null +++ b/backend/src/main/resources/db/migration/V5__user_role_permission_seed.sql @@ -0,0 +1,15 @@ +INSERT INTO permission (id, permission_code, permission_name, module) +VALUES + (1009, 'user.read', '查看用户', 'system'), + (1010, 'user.create', '创建用户', 'system'), + (1011, 'role.read', '查看角色', 'system'), + (1012, 'user.role.assign', '用户分配角色', 'system') +ON DUPLICATE KEY UPDATE permission_name = VALUES(permission_name), module = VALUES(module); + +INSERT INTO role_permission (id, tenant_id, role_id, permission_id) +VALUES + (9, 1, 101, 1009), + (10, 1, 101, 1010), + (11, 1, 101, 1011), + (12, 1, 101, 1012) +ON DUPLICATE KEY UPDATE permission_id = VALUES(permission_id); diff --git a/backend/src/main/resources/db/migration/V60__remove_meeting_field_module.sql b/backend/src/main/resources/db/migration/V60__remove_meeting_field_module.sql new file mode 100644 index 0000000..4c065db --- /dev/null +++ b/backend/src/main/resources/db/migration/V60__remove_meeting_field_module.sql @@ -0,0 +1,38 @@ +-- 全面下线会议字段管理模块:权限、菜单、关联关系与数据表 + +-- 1) 移除角色权限绑定 +DELETE rp +FROM role_permission rp +INNER JOIN permission p ON rp.permission_id = p.id +WHERE p.permission_code IN ('meeting.field.read', 'meeting.field.manage'); + +-- 2) 逻辑删除权限定义 +-- permission 表未定义 is_deleted,直接物理删除对应权限码 +DELETE FROM permission +WHERE permission_code IN ('meeting.field.read', 'meeting.field.manage'); + +-- 3) 移除菜单角色绑定 +DELETE rm +FROM role_menu rm +INNER JOIN menu m ON rm.menu_id = m.id +WHERE m.menu_code = 'meeting_field' OR m.route_path = '/meeting-fields'; + +-- 4) 逻辑删除菜单 +SET @menu_has_is_deleted := ( + SELECT COUNT(1) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'menu' + AND COLUMN_NAME = 'is_deleted' +); +SET @sql := IF( + @menu_has_is_deleted > 0, + 'UPDATE menu SET is_deleted = 1 WHERE menu_code = ''meeting_field'' OR route_path = ''/meeting-fields''', + 'DELETE FROM menu WHERE menu_code = ''meeting_field'' OR route_path = ''/meeting-fields''' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- 5) 删除业务表 +DROP TABLE IF EXISTS meeting_field; diff --git a/backend/src/main/resources/db/migration/V61__meeting_remove_subproject_and_comment_update.sql b/backend/src/main/resources/db/migration/V61__meeting_remove_subproject_and_comment_update.sql new file mode 100644 index 0000000..c8e88c5 --- /dev/null +++ b/backend/src/main/resources/db/migration/V61__meeting_remove_subproject_and_comment_update.sql @@ -0,0 +1,19 @@ +-- 会议模块口径调整: +-- 1) 去除 sub_project_name 字段(会议归属以 project_id 为准) +-- 2) 补充地点/预算字段注释 + +SET @c := ( + SELECT COUNT(1) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'meeting' + AND COLUMN_NAME = 'sub_project_name' +); +SET @sql := IF(@c > 0, 'ALTER TABLE meeting DROP COLUMN sub_project_name', 'SELECT 1'); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +ALTER TABLE meeting + MODIFY COLUMN location VARCHAR(255) NULL COMMENT '会议地点(线上/线下/线上+线下)', + MODIFY COLUMN budget_cent BIGINT NOT NULL COMMENT '会议预算(分)'; diff --git a/backend/src/main/resources/db/migration/V62__platform_dictionary_and_expert_dict_ref.sql b/backend/src/main/resources/db/migration/V62__platform_dictionary_and_expert_dict_ref.sql new file mode 100644 index 0000000..521cd7a --- /dev/null +++ b/backend/src/main/resources/db/migration/V62__platform_dictionary_and_expert_dict_ref.sql @@ -0,0 +1,98 @@ +-- 平台共享字典(专家职称/医院)+ 专家表字典编码字段 + +CREATE TABLE IF NOT EXISTS platform_dictionary_item ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID', + dict_type VARCHAR(64) NOT NULL COMMENT '字典类型:EXPERT_TITLE/EXPERT_HOSPITAL', + dict_code VARCHAR(64) NOT NULL COMMENT '字典编码', + dict_name VARCHAR(128) NOT NULL COMMENT '字典名称', + sort_no INT NOT NULL DEFAULT 100 COMMENT '排序', + status VARCHAR(32) NOT NULL DEFAULT 'ENABLED' COMMENT '状态:ENABLED/DISABLED', + remark VARCHAR(500) DEFAULT NULL COMMENT '备注', + is_deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除', + created_by BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '创建人', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_by BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '更新人', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + UNIQUE KEY uk_dict_type_code (dict_type, dict_code), + KEY idx_dict_type_status_sort (dict_type, status, sort_no) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='平台共享字典项'; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'expert' AND COLUMN_NAME = 'title_code'); +SET @sql := IF(@c = 0, 'ALTER TABLE expert ADD COLUMN title_code VARCHAR(64) NULL AFTER phone', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'expert' AND COLUMN_NAME = 'hospital_code'); +SET @sql := IF(@c = 0, 'ALTER TABLE expert ADD COLUMN hospital_code VARCHAR(64) NULL AFTER title', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @idx := (SELECT COUNT(1) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'expert' AND INDEX_NAME = 'idx_expert_title_code'); +SET @sql := IF(@idx = 0, 'ALTER TABLE expert ADD KEY idx_expert_title_code (title_code)', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @idx := (SELECT COUNT(1) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'expert' AND INDEX_NAME = 'idx_expert_hospital_code'); +SET @sql := IF(@idx = 0, 'ALTER TABLE expert ADD KEY idx_expert_hospital_code (hospital_code)', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +ALTER TABLE expert + COMMENT = '平台共享专家主数据(tenant_id=0)'; + +ALTER TABLE expert + MODIFY COLUMN expert_name VARCHAR(128) NOT NULL COMMENT '专家姓名', + MODIFY COLUMN id_no VARCHAR(64) NOT NULL COMMENT '身份证号(敏感)', + MODIFY COLUMN phone VARCHAR(32) NOT NULL COMMENT '手机号(敏感)', + MODIFY COLUMN title_code VARCHAR(64) NULL COMMENT '职称字典编码', + MODIFY COLUMN title VARCHAR(128) DEFAULT NULL COMMENT '职称名称快照', + MODIFY COLUMN hospital_code VARCHAR(64) NULL COMMENT '医院字典编码', + MODIFY COLUMN organization VARCHAR(255) DEFAULT NULL COMMENT '医院名称快照', + MODIFY COLUMN status VARCHAR(32) NOT NULL DEFAULT 'ENABLED' COMMENT '状态:DRAFT/ENABLED/DISABLED/BLACKLISTED'; + +ALTER TABLE expert_bank_card + COMMENT = '专家银行卡信息'; + +ALTER TABLE expert_bank_card + MODIFY COLUMN bank_name VARCHAR(128) NOT NULL COMMENT '开户银行', + MODIFY COLUMN bank_card_no VARCHAR(128) NOT NULL COMMENT '银行卡号(敏感)', + MODIFY COLUMN account_name VARCHAR(128) NOT NULL COMMENT '开户名'; + +INSERT INTO platform_permission (id, permission_code, permission_name, module) +VALUES + (10, 'platform.dictionary.read', '平台字典查看', 'platform'), + (11, 'platform.dictionary.manage', '平台字典管理', 'platform') +ON DUPLICATE KEY UPDATE permission_name = VALUES(permission_name), module = VALUES(module); + +INSERT INTO platform_role_permission (id, role_id, permission_id) +VALUES + (10, 1, 10), + (11, 1, 11) +ON DUPLICATE KEY UPDATE permission_id = VALUES(permission_id); + +INSERT INTO platform_menu (id, menu_code, menu_name, route_path, permission_code, sort_no, status, is_deleted, created_by, updated_by) +VALUES + (8, 'platform_dictionary_manage', '平台字典管理', '/platform/dictionaries', 'platform.dictionary.read', 19, 'ENABLED', 0, 0, 0) +ON DUPLICATE KEY UPDATE + menu_name = VALUES(menu_name), + route_path = VALUES(route_path), + permission_code = VALUES(permission_code), + sort_no = VALUES(sort_no), + status = VALUES(status), + is_deleted = VALUES(is_deleted), + updated_at = CURRENT_TIMESTAMP; + +INSERT INTO platform_role_menu (id, role_id, menu_id) +VALUES + (8, 1, 8) +ON DUPLICATE KEY UPDATE menu_id = VALUES(menu_id); + +INSERT INTO platform_dictionary_item (dict_type, dict_code, dict_name, sort_no, status, remark, created_by, updated_by) +VALUES + ('EXPERT_TITLE', 'CHIEF_PHYSICIAN', '主任医师', 10, 'ENABLED', '默认种子', 0, 0), + ('EXPERT_TITLE', 'ASSOCIATE_CHIEF_PHYSICIAN', '副主任医师', 20, 'ENABLED', '默认种子', 0, 0), + ('EXPERT_TITLE', 'ATTENDING_PHYSICIAN', '主治医师', 30, 'ENABLED', '默认种子', 0, 0), + ('EXPERT_HOSPITAL', 'PUMCH', '北京协和医院', 10, 'ENABLED', '默认种子', 0, 0), + ('EXPERT_HOSPITAL', 'PKUH', '北京大学第一医院', 20, 'ENABLED', '默认种子', 0, 0) +ON DUPLICATE KEY UPDATE + dict_name = VALUES(dict_name), + sort_no = VALUES(sort_no), + status = VALUES(status), + remark = VALUES(remark), + updated_at = CURRENT_TIMESTAMP; diff --git a/backend/src/main/resources/db/migration/V63__expert_identity_and_bank_card_image_columns.sql b/backend/src/main/resources/db/migration/V63__expert_identity_and_bank_card_image_columns.sql new file mode 100644 index 0000000..61c9097 --- /dev/null +++ b/backend/src/main/resources/db/migration/V63__expert_identity_and_bank_card_image_columns.sql @@ -0,0 +1,25 @@ +-- 专家证件与银行卡图片字段 + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'expert' AND COLUMN_NAME = 'id_card_front_oss_key'); +SET @sql := IF(@c = 0, 'ALTER TABLE expert ADD COLUMN id_card_front_oss_key VARCHAR(512) NULL AFTER id_card_valid_until', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'expert' AND COLUMN_NAME = 'id_card_back_oss_key'); +SET @sql := IF(@c = 0, 'ALTER TABLE expert ADD COLUMN id_card_back_oss_key VARCHAR(512) NULL AFTER id_card_front_oss_key', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'expert_bank_card' AND COLUMN_NAME = 'bank_card_front_oss_key'); +SET @sql := IF(@c = 0, 'ALTER TABLE expert_bank_card ADD COLUMN bank_card_front_oss_key VARCHAR(512) NULL AFTER bank_card_no', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'expert_bank_card' AND COLUMN_NAME = 'bank_card_back_oss_key'); +SET @sql := IF(@c = 0, 'ALTER TABLE expert_bank_card ADD COLUMN bank_card_back_oss_key VARCHAR(512) NULL AFTER bank_card_front_oss_key', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +ALTER TABLE expert + MODIFY COLUMN id_card_front_oss_key VARCHAR(512) NULL COMMENT '身份证正面图片OSSKey', + MODIFY COLUMN id_card_back_oss_key VARCHAR(512) NULL COMMENT '身份证反面图片OSSKey'; + +ALTER TABLE expert_bank_card + MODIFY COLUMN bank_card_front_oss_key VARCHAR(512) NULL COMMENT '银行卡正面图片OSSKey', + MODIFY COLUMN bank_card_back_oss_key VARCHAR(512) NULL COMMENT '银行卡反面图片OSSKey'; diff --git a/backend/src/main/resources/db/migration/V64__tenant_logo_url.sql b/backend/src/main/resources/db/migration/V64__tenant_logo_url.sql new file mode 100644 index 0000000..e4d11c0 --- /dev/null +++ b/backend/src/main/resources/db/migration/V64__tenant_logo_url.sql @@ -0,0 +1,20 @@ +-- 租户Logo字段 + +SET @c := ( + SELECT COUNT(1) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'tenant' + AND COLUMN_NAME = 'logo_url' +); +SET @sql := IF( + @c = 0, + 'ALTER TABLE tenant ADD COLUMN logo_url VARCHAR(512) NULL AFTER tenant_name', + 'SELECT 1' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +ALTER TABLE tenant + MODIFY COLUMN logo_url VARCHAR(512) NULL COMMENT '租户Logo OSSKey/URL'; diff --git a/backend/src/main/resources/db/migration/V65__platform_file_download_permission.sql b/backend/src/main/resources/db/migration/V65__platform_file_download_permission.sql new file mode 100644 index 0000000..ad1b53b --- /dev/null +++ b/backend/src/main/resources/db/migration/V65__platform_file_download_permission.sql @@ -0,0 +1,19 @@ +-- 平台域补充文件下载权限(用于Logo等资源预览) + +INSERT INTO platform_permission (permission_code, permission_name, module) +VALUES ('file.download', '下载文件', 'file') +ON DUPLICATE KEY UPDATE + permission_name = VALUES(permission_name), + module = VALUES(module); + +INSERT INTO platform_role_permission (role_id, permission_id) +SELECT r.id, p.id +FROM platform_role r +JOIN platform_permission p ON p.permission_code = 'file.download' +WHERE r.role_code = 'PLATFORM_SUPER_ADMIN' + AND NOT EXISTS ( + SELECT 1 + FROM platform_role_permission rp + WHERE rp.role_id = r.id + AND rp.permission_id = p.id + ); diff --git a/backend/src/main/resources/db/migration/V66__operation_audit_log_request_id.sql b/backend/src/main/resources/db/migration/V66__operation_audit_log_request_id.sql new file mode 100644 index 0000000..358918c --- /dev/null +++ b/backend/src/main/resources/db/migration/V66__operation_audit_log_request_id.sql @@ -0,0 +1,35 @@ +SET @request_id_col_exists = ( + SELECT COUNT(1) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'operation_audit_log' + AND COLUMN_NAME = 'request_id' +); + +SET @add_request_id_col_sql = IF( + @request_id_col_exists = 0, + 'ALTER TABLE operation_audit_log ADD COLUMN request_id VARCHAR(64) DEFAULT NULL AFTER request_query', + 'SELECT 1' +); + +PREPARE stmt_add_request_id_col FROM @add_request_id_col_sql; +EXECUTE stmt_add_request_id_col; +DEALLOCATE PREPARE stmt_add_request_id_col; + +SET @request_id_idx_exists = ( + SELECT COUNT(1) + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'operation_audit_log' + AND INDEX_NAME = 'idx_request_id_time' +); + +SET @add_request_id_idx_sql = IF( + @request_id_idx_exists = 0, + 'ALTER TABLE operation_audit_log ADD KEY idx_request_id_time (request_id, created_at)', + 'SELECT 1' +); + +PREPARE stmt_add_request_id_idx FROM @add_request_id_idx_sql; +EXECUTE stmt_add_request_id_idx; +DEALLOCATE PREPARE stmt_add_request_id_idx; diff --git a/backend/src/main/resources/db/migration/V67__audit_material_item_review_record.sql b/backend/src/main/resources/db/migration/V67__audit_material_item_review_record.sql new file mode 100644 index 0000000..251b3b1 --- /dev/null +++ b/backend/src/main/resources/db/migration/V67__audit_material_item_review_record.sql @@ -0,0 +1,18 @@ +CREATE TABLE IF NOT EXISTS audit_material_item_review ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + meeting_id BIGINT UNSIGNED NOT NULL, + task_id BIGINT UNSIGNED NOT NULL, + review_node VARCHAR(32) NOT NULL, + module_code VARCHAR(32) NOT NULL, + item_key VARCHAR(128) NOT NULL, + item_label VARCHAR(255) NOT NULL, + review_result VARCHAR(16) NOT NULL, + review_reason VARCHAR(500) DEFAULT NULL, + reviewer_user_id BIGINT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_task_node_module_item (tenant_id, task_id, review_node, module_code, item_key), + KEY idx_tenant_meeting_module (tenant_id, meeting_id, module_code), + KEY idx_tenant_task_node (tenant_id, task_id, review_node) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/backend/src/main/resources/db/migration/V68__in_app_notification_center.sql b/backend/src/main/resources/db/migration/V68__in_app_notification_center.sql new file mode 100644 index 0000000..d71c2e8 --- /dev/null +++ b/backend/src/main/resources/db/migration/V68__in_app_notification_center.sql @@ -0,0 +1,56 @@ +CREATE TABLE IF NOT EXISTS in_app_notification ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + receiver_ref VARCHAR(128) NOT NULL, + receiver_user_id BIGINT UNSIGNED DEFAULT NULL, + title VARCHAR(200) NOT NULL, + content TEXT, + payload_json TEXT, + status VARCHAR(32) NOT NULL DEFAULT 'UNREAD', + read_at DATETIME DEFAULT NULL, + is_deleted TINYINT(1) NOT NULL DEFAULT 0, + created_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + KEY idx_tenant_receiver_status_time (tenant_id, receiver_user_id, status, created_at), + KEY idx_tenant_receiver_ref_time (tenant_id, receiver_ref, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +INSERT INTO permission (id, permission_code, permission_name, module) +SELECT t.next_id, 'notification.inapp.read', '查看站内通知', 'notification' +FROM (SELECT IFNULL(MAX(id), 0) + 1 AS next_id FROM permission) t +WHERE NOT EXISTS ( + SELECT 1 FROM permission WHERE permission_code = 'notification.inapp.read' +); + +INSERT INTO permission (id, permission_code, permission_name, module) +SELECT t.next_id, 'notification.inapp.mark-read', '标记站内通知已读', 'notification' +FROM (SELECT IFNULL(MAX(id), 0) + 1 AS next_id FROM permission) t +WHERE NOT EXISTS ( + SELECT 1 FROM permission WHERE permission_code = 'notification.inapp.mark-read' +); + +UPDATE permission +SET permission_name = '查看站内通知', module = 'notification' +WHERE permission_code = 'notification.inapp.read'; + +UPDATE permission +SET permission_name = '标记站内通知已读', module = 'notification' +WHERE permission_code = 'notification.inapp.mark-read'; + +INSERT INTO role_permission (id, tenant_id, role_id, permission_id) +SELECT t.next_id, 1, 101, p.id +FROM (SELECT IFNULL(MAX(id), 0) + 1 AS next_id FROM role_permission) t +JOIN permission p ON p.permission_code = 'notification.inapp.read' +WHERE NOT EXISTS ( + SELECT 1 FROM role_permission rp WHERE rp.tenant_id = 1 AND rp.role_id = 101 AND rp.permission_id = p.id +); + +INSERT INTO role_permission (id, tenant_id, role_id, permission_id) +SELECT t.next_id, 1, 101, p.id +FROM (SELECT IFNULL(MAX(id), 0) + 1 AS next_id FROM role_permission) t +JOIN permission p ON p.permission_code = 'notification.inapp.mark-read' +WHERE NOT EXISTS ( + SELECT 1 FROM role_permission rp WHERE rp.tenant_id = 1 AND rp.role_id = 101 AND rp.permission_id = p.id +); diff --git a/backend/src/main/resources/db/migration/V69__in_app_notification_menu_seed.sql b/backend/src/main/resources/db/migration/V69__in_app_notification_menu_seed.sql new file mode 100644 index 0000000..893e2b0 --- /dev/null +++ b/backend/src/main/resources/db/migration/V69__in_app_notification_menu_seed.sql @@ -0,0 +1,30 @@ +INSERT INTO menu (tenant_id, menu_code, menu_name, route_path, permission_code, sort_no, status, is_deleted, created_by, updated_by) +VALUES + (1, 'in_app_notification', '站内通知', '/in-app-notifications', 'notification.inapp.read', 181, 'ENABLED', 0, 0, 0) +ON DUPLICATE KEY UPDATE + menu_name = VALUES(menu_name), + route_path = VALUES(route_path), + permission_code = VALUES(permission_code), + sort_no = VALUES(sort_no), + status = VALUES(status), + is_deleted = VALUES(is_deleted), + updated_at = CURRENT_TIMESTAMP; + +SET @next_role_menu_id = (SELECT IFNULL(MAX(id), 0) FROM role_menu); +INSERT INTO role_menu (id, tenant_id, role_id, menu_id) +SELECT + (@next_role_menu_id := @next_role_menu_id + 1) AS id, + r.tenant_id, + r.id, + m.id +FROM role r +JOIN menu m ON m.tenant_id = r.tenant_id AND m.menu_code = 'in_app_notification' AND m.is_deleted = 0 +WHERE r.role_code = 'TENANT_ADMIN' + AND r.is_deleted = 0 + AND NOT EXISTS ( + SELECT 1 + FROM role_menu rm + WHERE rm.tenant_id = r.tenant_id + AND rm.role_id = r.id + AND rm.menu_id = m.id + ); diff --git a/backend/src/main/resources/db/migration/V6__audit_flow_manage_enhance.sql b/backend/src/main/resources/db/migration/V6__audit_flow_manage_enhance.sql new file mode 100644 index 0000000..060102c --- /dev/null +++ b/backend/src/main/resources/db/migration/V6__audit_flow_manage_enhance.sql @@ -0,0 +1,18 @@ +ALTER TABLE audit_flow + ADD COLUMN is_default TINYINT(1) NOT NULL DEFAULT 0 AFTER status, + ADD COLUMN effective_start_at DATETIME DEFAULT NULL AFTER is_default, + ADD COLUMN effective_end_at DATETIME DEFAULT NULL AFTER effective_start_at; + +CREATE TABLE IF NOT EXISTS audit_flow_node_assignee ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + flow_node_id BIGINT UNSIGNED NOT NULL, + assignee_type VARCHAR(32) NOT NULL COMMENT 'USER/ROLE/PROJECT_OWNER', + assignee_ref_id BIGINT UNSIGNED DEFAULT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + KEY idx_flow_node (flow_node_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +UPDATE audit_flow +SET is_default = 1 +WHERE tenant_id = 1 AND flow_code = 'DEFAULT'; diff --git a/backend/src/main/resources/db/migration/V70__notification_text_template_module.sql b/backend/src/main/resources/db/migration/V70__notification_text_template_module.sql new file mode 100644 index 0000000..4d5d59d --- /dev/null +++ b/backend/src/main/resources/db/migration/V70__notification_text_template_module.sql @@ -0,0 +1,82 @@ +CREATE TABLE IF NOT EXISTS notification_text_template ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + template_name VARCHAR(128) NOT NULL, + subject_template VARCHAR(255) DEFAULT NULL, + title_template VARCHAR(255) DEFAULT NULL, + content_template TEXT NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'ENABLED', + is_deleted TINYINT(1) NOT NULL DEFAULT 0, + created_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_tenant_template_name (tenant_id, template_name), + KEY idx_tenant_status (tenant_id, status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +INSERT INTO notification_text_template (tenant_id, template_name, subject_template, title_template, content_template, status, is_deleted, created_by, updated_by) +SELECT 1, '默认通知文案', '系统通知', '系统通知', '您有一条新的系统通知,请及时处理。', 'ENABLED', 0, 0, 0 +WHERE NOT EXISTS ( + SELECT 1 FROM notification_text_template WHERE tenant_id=1 AND template_name='默认通知文案' AND is_deleted=0 +); + +INSERT INTO permission (id, permission_code, permission_name, module) +SELECT t.next_id, 'notification.text-template.read', '查看通知文案模板', 'notification' +FROM (SELECT IFNULL(MAX(id), 0) + 1 AS next_id FROM permission) t +WHERE NOT EXISTS ( + SELECT 1 FROM permission WHERE permission_code = 'notification.text-template.read' +); + +INSERT INTO permission (id, permission_code, permission_name, module) +SELECT t.next_id, 'notification.text-template.manage', '管理通知文案模板', 'notification' +FROM (SELECT IFNULL(MAX(id), 0) + 1 AS next_id FROM permission) t +WHERE NOT EXISTS ( + SELECT 1 FROM permission WHERE permission_code = 'notification.text-template.manage' +); + +UPDATE permission SET permission_name='查看通知文案模板', module='notification' WHERE permission_code='notification.text-template.read'; +UPDATE permission SET permission_name='管理通知文案模板', module='notification' WHERE permission_code='notification.text-template.manage'; + +INSERT INTO menu (tenant_id, menu_code, menu_name, route_path, permission_code, sort_no, status, is_deleted, created_by, updated_by) +VALUES + (1, 'notification_text_template', '通知文案模板', '/notification-text-templates', 'notification.text-template.read', 179, 'ENABLED', 0, 0, 0) +ON DUPLICATE KEY UPDATE + menu_name = VALUES(menu_name), + route_path = VALUES(route_path), + permission_code = VALUES(permission_code), + sort_no = VALUES(sort_no), + status = VALUES(status), + is_deleted = VALUES(is_deleted), + updated_at = CURRENT_TIMESTAMP; + +INSERT INTO role_permission (id, tenant_id, role_id, permission_id) +SELECT t.next_id, 1, 101, p.id +FROM (SELECT IFNULL(MAX(id), 0) + 1 AS next_id FROM role_permission) t +JOIN permission p ON p.permission_code = 'notification.text-template.read' +WHERE NOT EXISTS ( + SELECT 1 FROM role_permission rp WHERE rp.tenant_id=1 AND rp.role_id=101 AND rp.permission_id=p.id +); + +INSERT INTO role_permission (id, tenant_id, role_id, permission_id) +SELECT t.next_id, 1, 101, p.id +FROM (SELECT IFNULL(MAX(id), 0) + 1 AS next_id FROM role_permission) t +JOIN permission p ON p.permission_code = 'notification.text-template.manage' +WHERE NOT EXISTS ( + SELECT 1 FROM role_permission rp WHERE rp.tenant_id=1 AND rp.role_id=101 AND rp.permission_id=p.id +); + +SET @next_role_menu_id = (SELECT IFNULL(MAX(id), 0) FROM role_menu); +INSERT INTO role_menu (id, tenant_id, role_id, menu_id) +SELECT + (@next_role_menu_id := @next_role_menu_id + 1) AS id, + r.tenant_id, + r.id, + m.id +FROM role r +JOIN menu m ON m.tenant_id=r.tenant_id AND m.menu_code='notification_text_template' AND m.is_deleted=0 +WHERE r.role_code='TENANT_ADMIN' + AND r.is_deleted=0 + AND NOT EXISTS ( + SELECT 1 FROM role_menu rm WHERE rm.tenant_id=r.tenant_id AND rm.role_id=r.id AND rm.menu_id=m.id + ); diff --git a/backend/src/main/resources/db/migration/V71__auth_refresh_token.sql b/backend/src/main/resources/db/migration/V71__auth_refresh_token.sql new file mode 100644 index 0000000..318e9d6 --- /dev/null +++ b/backend/src/main/resources/db/migration/V71__auth_refresh_token.sql @@ -0,0 +1,25 @@ +CREATE TABLE IF NOT EXISTS auth_refresh_token ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT UNSIGNED NOT NULL, + tenant_id BIGINT UNSIGNED NULL, + scope VARCHAR(32) NOT NULL, + token_hash CHAR(64) NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE', + issued_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + expires_at DATETIME NOT NULL, + last_used_at DATETIME NULL, + rotated_to_id BIGINT UNSIGNED NULL, + revoked_at DATETIME NULL, + revoked_reason VARCHAR(128) NULL, + device_id VARCHAR(128) NULL, + ip_hash CHAR(64) NULL, + ua_hash CHAR(64) NULL, + is_deleted TINYINT(1) NOT NULL DEFAULT 0, + created_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_token_hash (token_hash), + KEY idx_user_scope_status (user_id, scope, status), + KEY idx_expires_at (expires_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/backend/src/main/resources/db/migration/V72__platform_auth_session_permission.sql b/backend/src/main/resources/db/migration/V72__platform_auth_session_permission.sql new file mode 100644 index 0000000..85a5eb1 --- /dev/null +++ b/backend/src/main/resources/db/migration/V72__platform_auth_session_permission.sql @@ -0,0 +1,54 @@ +INSERT INTO platform_permission (permission_code, permission_name, module) +VALUES + ('platform.session.read', '平台会话查看', 'platform'), + ('platform.session.manage', '平台会话管理', 'platform') +ON DUPLICATE KEY UPDATE + permission_name = VALUES(permission_name), + module = VALUES(module); + +SET @next_platform_role_permission_id = (SELECT IFNULL(MAX(id), 0) FROM platform_role_permission); +INSERT INTO platform_role_permission (id, role_id, permission_id) +SELECT + (@next_platform_role_permission_id := @next_platform_role_permission_id + 1) AS id, + 1 AS role_id, + p.id AS permission_id +FROM platform_permission p +WHERE p.permission_code = 'platform.session.read' + AND NOT EXISTS ( + SELECT 1 FROM platform_role_permission rp WHERE rp.role_id = 1 AND rp.permission_id = p.id + ); + +INSERT INTO platform_role_permission (id, role_id, permission_id) +SELECT + (@next_platform_role_permission_id := @next_platform_role_permission_id + 1) AS id, + 1 AS role_id, + p.id AS permission_id +FROM platform_permission p +WHERE p.permission_code = 'platform.session.manage' + AND NOT EXISTS ( + SELECT 1 FROM platform_role_permission rp WHERE rp.role_id = 1 AND rp.permission_id = p.id + ); + +INSERT INTO platform_menu (menu_code, menu_name, route_path, permission_code, sort_no, status, is_deleted, created_by, updated_by) +VALUES + ('platform_auth_session_manage', '平台会话管理', '/platform/auth-sessions', 'platform.session.read', 20, 'ENABLED', 0, 0, 0) +ON DUPLICATE KEY UPDATE + menu_name = VALUES(menu_name), + route_path = VALUES(route_path), + permission_code = VALUES(permission_code), + sort_no = VALUES(sort_no), + status = VALUES(status), + is_deleted = VALUES(is_deleted), + updated_at = CURRENT_TIMESTAMP; + +SET @next_platform_role_menu_id = (SELECT IFNULL(MAX(id), 0) FROM platform_role_menu); +INSERT INTO platform_role_menu (id, role_id, menu_id) +SELECT + (@next_platform_role_menu_id := @next_platform_role_menu_id + 1) AS id, + 1 AS role_id, + m.id AS menu_id +FROM platform_menu m +WHERE m.menu_code = 'platform_auth_session_manage' + AND NOT EXISTS ( + SELECT 1 FROM platform_role_menu rm WHERE rm.role_id = 1 AND rm.menu_id = m.id + ); diff --git a/backend/src/main/resources/db/migration/V73__project_fee_json.sql b/backend/src/main/resources/db/migration/V73__project_fee_json.sql new file mode 100644 index 0000000..ae87ec9 --- /dev/null +++ b/backend/src/main/resources/db/migration/V73__project_fee_json.sql @@ -0,0 +1,14 @@ +-- 项目费用设置 JSON(管理费/税费/到款金额/自定义费用) + +SET @c := ( + SELECT COUNT(1) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'project' + AND COLUMN_NAME = 'project_fee_json' +); +SET @sql := IF(@c = 0, 'ALTER TABLE project ADD COLUMN project_fee_json TEXT NULL AFTER expense_ratio_json', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +ALTER TABLE project + MODIFY COLUMN project_fee_json TEXT NULL COMMENT '项目费用设置(JSON)'; diff --git a/backend/src/main/resources/db/migration/V74__ocr_id_card_permission_seed.sql b/backend/src/main/resources/db/migration/V74__ocr_id_card_permission_seed.sql new file mode 100644 index 0000000..1461636 --- /dev/null +++ b/backend/src/main/resources/db/migration/V74__ocr_id_card_permission_seed.sql @@ -0,0 +1,49 @@ +-- 身份证OCR权限:租户域 + 平台域 + +SET @next_permission_id := (SELECT IFNULL(MAX(id), 0) + 1 FROM permission); +INSERT INTO permission (id, permission_code, permission_name, module) +SELECT @next_permission_id, 'ocr.idcard', '身份证OCR识别', 'ocr' +FROM dual +WHERE NOT EXISTS (SELECT 1 FROM permission WHERE permission_code = 'ocr.idcard'); + +UPDATE permission +SET permission_name = '身份证OCR识别', + module = 'ocr' +WHERE permission_code = 'ocr.idcard'; + +INSERT INTO role_permission (id, tenant_id, role_id, permission_id) +SELECT + (SELECT IFNULL(MAX(rp.id), 0) + 1 FROM role_permission rp) AS id, + r.tenant_id, + r.id, + p.id +FROM role r +JOIN permission p ON p.permission_code = 'ocr.idcard' +WHERE r.tenant_id = 1 + AND r.role_code = 'TENANT_ADMIN' + AND NOT EXISTS ( + SELECT 1 + FROM role_permission rp + WHERE rp.tenant_id = r.tenant_id + AND rp.role_id = r.id + AND rp.permission_id = p.id + ); + +INSERT INTO platform_permission (permission_code, permission_name, module) +VALUES ('platform.ocr.idcard', '平台身份证OCR识别', 'platform') +ON DUPLICATE KEY UPDATE + permission_name = VALUES(permission_name), + module = VALUES(module); + +INSERT INTO platform_role_permission (role_id, permission_id) +SELECT r.id, p.id +FROM platform_role r +JOIN platform_permission p ON p.permission_code = 'platform.ocr.idcard' +WHERE r.role_code = 'PLATFORM_SUPER_ADMIN' + AND NOT EXISTS ( + SELECT 1 + FROM platform_role_permission rp + WHERE rp.role_id = r.id + AND rp.permission_id = p.id + ); + diff --git a/backend/src/main/resources/db/migration/V75__ocr_bank_card_permission_seed.sql b/backend/src/main/resources/db/migration/V75__ocr_bank_card_permission_seed.sql new file mode 100644 index 0000000..06645f7 --- /dev/null +++ b/backend/src/main/resources/db/migration/V75__ocr_bank_card_permission_seed.sql @@ -0,0 +1,49 @@ +-- 银行卡OCR权限:租户域 + 平台域 + +SET @next_permission_id := (SELECT IFNULL(MAX(id), 0) + 1 FROM permission); +INSERT INTO permission (id, permission_code, permission_name, module) +SELECT @next_permission_id, 'ocr.bankcard', '银行卡OCR识别', 'ocr' +FROM dual +WHERE NOT EXISTS (SELECT 1 FROM permission WHERE permission_code = 'ocr.bankcard'); + +UPDATE permission +SET permission_name = '银行卡OCR识别', + module = 'ocr' +WHERE permission_code = 'ocr.bankcard'; + +INSERT INTO role_permission (id, tenant_id, role_id, permission_id) +SELECT + (SELECT IFNULL(MAX(rp.id), 0) + 1 FROM role_permission rp) AS id, + r.tenant_id, + r.id, + p.id +FROM role r +JOIN permission p ON p.permission_code = 'ocr.bankcard' +WHERE r.tenant_id = 1 + AND r.role_code = 'TENANT_ADMIN' + AND NOT EXISTS ( + SELECT 1 + FROM role_permission rp + WHERE rp.tenant_id = r.tenant_id + AND rp.role_id = r.id + AND rp.permission_id = p.id + ); + +INSERT INTO platform_permission (permission_code, permission_name, module) +VALUES ('platform.ocr.bankcard', '平台银行卡OCR识别', 'platform') +ON DUPLICATE KEY UPDATE + permission_name = VALUES(permission_name), + module = VALUES(module); + +INSERT INTO platform_role_permission (role_id, permission_id) +SELECT r.id, p.id +FROM platform_role r +JOIN platform_permission p ON p.permission_code = 'platform.ocr.bankcard' +WHERE r.role_code = 'PLATFORM_SUPER_ADMIN' + AND NOT EXISTS ( + SELECT 1 + FROM platform_role_permission rp + WHERE rp.role_id = r.id + AND rp.permission_id = p.id + ); + diff --git a/backend/src/main/resources/db/migration/V76__platform_dictionary_type_management.sql b/backend/src/main/resources/db/migration/V76__platform_dictionary_type_management.sql new file mode 100644 index 0000000..c2c0486 --- /dev/null +++ b/backend/src/main/resources/db/migration/V76__platform_dictionary_type_management.sql @@ -0,0 +1,39 @@ +-- 平台字典类型管理:支持新增类型并维护字典项 + +CREATE TABLE IF NOT EXISTS platform_dictionary_type ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID', + dict_type VARCHAR(64) NOT NULL COMMENT '字典类型编码', + dict_name VARCHAR(128) NOT NULL COMMENT '字典类型名称', + sort_no INT NOT NULL DEFAULT 100 COMMENT '排序', + status VARCHAR(32) NOT NULL DEFAULT 'ENABLED' COMMENT '状态:ENABLED/DISABLED', + remark VARCHAR(500) DEFAULT NULL COMMENT '备注', + is_deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除', + created_by BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '创建人', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_by BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '更新人', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + UNIQUE KEY uk_platform_dictionary_type (dict_type), + KEY idx_platform_dictionary_type_status_sort (status, sort_no) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='平台共享字典类型'; + +-- 将已有字典项中的类型补齐到类型表(避免升级后历史数据丢失类型) +INSERT INTO platform_dictionary_type (dict_type, dict_name, sort_no, status, remark, created_by, updated_by) +SELECT t.dict_type, t.dict_type, 100, 'ENABLED', '从历史字典项自动补齐', 0, 0 +FROM ( + SELECT DISTINCT dict_type + FROM platform_dictionary_item + WHERE is_deleted = 0 +) t +ON DUPLICATE KEY UPDATE updated_at = CURRENT_TIMESTAMP; + +-- 覆盖默认种子类型展示名与排序 +INSERT INTO platform_dictionary_type (dict_type, dict_name, sort_no, status, remark, created_by, updated_by) +VALUES + ('EXPERT_TITLE', '专家职称', 10, 'ENABLED', '默认种子', 0, 0), + ('EXPERT_HOSPITAL', '专家医院', 20, 'ENABLED', '默认种子', 0, 0) +ON DUPLICATE KEY UPDATE + dict_name = VALUES(dict_name), + sort_no = VALUES(sort_no), + status = VALUES(status), + remark = VALUES(remark), + updated_at = CURRENT_TIMESTAMP; diff --git a/backend/src/main/resources/db/migration/V77__tenant_expert_menu_permission_restore.sql b/backend/src/main/resources/db/migration/V77__tenant_expert_menu_permission_restore.sql new file mode 100644 index 0000000..b148ac5 --- /dev/null +++ b/backend/src/main/resources/db/migration/V77__tenant_expert_menu_permission_restore.sql @@ -0,0 +1,77 @@ +-- 租户域恢复“专家列表”菜单,并为租户管理员补齐菜单与权限 + +SET @next_permission_id := (SELECT IFNULL(MAX(id), 0) + 1 FROM permission); +INSERT INTO permission (id, permission_code, permission_name, module) +SELECT @next_permission_id, 'expert.read', '查看专家列表', 'expert' +FROM dual +WHERE NOT EXISTS (SELECT 1 FROM permission WHERE permission_code = 'expert.read'); + +UPDATE permission +SET permission_name = '查看专家列表', + module = 'expert' +WHERE permission_code = 'expert.read'; + +UPDATE menu +SET menu_name = '专家列表', + route_path = '/experts', + permission_code = 'expert.read', + sort_no = 150, + status = 'ENABLED', + is_deleted = 0, + updated_at = CURRENT_TIMESTAMP +WHERE menu_code = 'expert'; + +INSERT INTO menu (tenant_id, menu_code, menu_name, route_path, permission_code, sort_no, status, is_deleted, created_by, updated_by) +SELECT + t.id, + 'expert', + '专家列表', + '/experts', + 'expert.read', + 150, + 'ENABLED', + 0, + 0, + 0 +FROM tenant t +LEFT JOIN menu m ON m.tenant_id = t.id AND m.menu_code = 'expert' +WHERE t.is_deleted = 0 + AND m.id IS NULL; + +SET @next_role_permission_id := (SELECT IFNULL(MAX(id), 0) FROM role_permission); +INSERT INTO role_permission (id, tenant_id, role_id, permission_id) +SELECT + (@next_role_permission_id := @next_role_permission_id + 1) AS id, + r.tenant_id, + r.id AS role_id, + p.id AS permission_id +FROM role r +JOIN permission p ON p.permission_code = 'expert.read' +WHERE r.role_code = 'TENANT_ADMIN' + AND r.is_deleted = 0 + AND NOT EXISTS ( + SELECT 1 + FROM role_permission rp + WHERE rp.tenant_id = r.tenant_id + AND rp.role_id = r.id + AND rp.permission_id = p.id + ); + +SET @next_role_menu_id := (SELECT IFNULL(MAX(id), 0) FROM role_menu); +INSERT INTO role_menu (id, tenant_id, role_id, menu_id) +SELECT + (@next_role_menu_id := @next_role_menu_id + 1) AS id, + r.tenant_id, + r.id AS role_id, + m.id AS menu_id +FROM role r +JOIN menu m ON m.tenant_id = r.tenant_id AND m.menu_code = 'expert' AND m.is_deleted = 0 +WHERE r.role_code = 'TENANT_ADMIN' + AND r.is_deleted = 0 + AND NOT EXISTS ( + SELECT 1 + FROM role_menu rm + WHERE rm.tenant_id = r.tenant_id + AND rm.role_id = r.id + AND rm.menu_id = m.id + ); diff --git a/backend/src/main/resources/db/migration/V78__data_permission_expert_scope.sql b/backend/src/main/resources/db/migration/V78__data_permission_expert_scope.sql new file mode 100644 index 0000000..3816546 --- /dev/null +++ b/backend/src/main/resources/db/migration/V78__data_permission_expert_scope.sql @@ -0,0 +1,3 @@ +ALTER TABLE data_permission_policy + ADD COLUMN expert_scope VARCHAR(32) NOT NULL DEFAULT 'ALL' COMMENT 'ALL/IDS/OWNER' AFTER user_ids_csv, + ADD COLUMN expert_ids_csv VARCHAR(2000) DEFAULT NULL AFTER expert_scope; diff --git a/backend/src/main/resources/db/migration/V79__meeting_invoice_config.sql b/backend/src/main/resources/db/migration/V79__meeting_invoice_config.sql new file mode 100644 index 0000000..e7a098d --- /dev/null +++ b/backend/src/main/resources/db/migration/V79__meeting_invoice_config.sql @@ -0,0 +1,28 @@ +ALTER TABLE meeting ADD COLUMN invoice_config_json VARCHAR(1000) DEFAULT NULL COMMENT '会议发票动态模块配置JSON'; + +-- 增加发票动态配置权限 +SET @next_permission_id := (SELECT IFNULL(MAX(id), 0) + 1 FROM permission); +INSERT INTO permission (id, permission_code, permission_name, module) +SELECT @next_permission_id, 'meeting.invoice.config', '配置会议发票模块', 'meeting' +FROM dual +WHERE NOT EXISTS (SELECT 1 FROM permission WHERE permission_code = 'meeting.invoice.config'); + +-- 为单位管理员(TENANT_ADMIN)默认绑定该权限 +SET @next_role_permission_id := (SELECT IFNULL(MAX(id), 0) FROM role_permission); +INSERT INTO role_permission (id, tenant_id, role_id, permission_id) +SELECT + (@next_role_permission_id := @next_role_permission_id + 1) AS id, + r.tenant_id, + r.id AS role_id, + p.id AS permission_id +FROM role r +JOIN permission p ON p.permission_code = 'meeting.invoice.config' +WHERE r.role_code = 'TENANT_ADMIN' + AND r.is_deleted = 0 + AND NOT EXISTS ( + SELECT 1 + FROM role_permission rp + WHERE rp.tenant_id = r.tenant_id + AND rp.role_id = r.id + AND rp.permission_id = p.id + ); diff --git a/backend/src/main/resources/db/migration/V7__user_role_manage_enhance.sql b/backend/src/main/resources/db/migration/V7__user_role_manage_enhance.sql new file mode 100644 index 0000000..95a5074 --- /dev/null +++ b/backend/src/main/resources/db/migration/V7__user_role_manage_enhance.sql @@ -0,0 +1,16 @@ +ALTER TABLE sys_user + ADD COLUMN effective_start_at DATETIME DEFAULT NULL AFTER status, + ADD COLUMN effective_end_at DATETIME DEFAULT NULL AFTER effective_start_at; + +CREATE TABLE IF NOT EXISTS user_role_history ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + user_id BIGINT UNSIGNED NOT NULL, + old_role_id BIGINT UNSIGNED DEFAULT NULL, + new_role_id BIGINT UNSIGNED NOT NULL, + action_type VARCHAR(32) NOT NULL COMMENT 'ASSIGN/REMOVE/REPLACE', + action_reason VARCHAR(255) DEFAULT NULL, + created_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY idx_user_time (user_id, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/backend/src/main/resources/db/migration/V80__meeting_read_permission.sql b/backend/src/main/resources/db/migration/V80__meeting_read_permission.sql new file mode 100644 index 0000000..564591f --- /dev/null +++ b/backend/src/main/resources/db/migration/V80__meeting_read_permission.sql @@ -0,0 +1,26 @@ +-- 增加查看会议权限 +SET @next_permission_id := (SELECT IFNULL(MAX(id), 0) + 1 FROM permission); +INSERT INTO permission (id, permission_code, permission_name, module) +SELECT @next_permission_id, 'meeting.read', '查看会议', 'meeting' +FROM dual +WHERE NOT EXISTS (SELECT 1 FROM permission WHERE permission_code = 'meeting.read'); + +-- 为各个常用角色默认绑定该权限 +SET @next_role_permission_id := (SELECT IFNULL(MAX(id), 0) FROM role_permission); +INSERT INTO role_permission (id, tenant_id, role_id, permission_id) +SELECT + (@next_role_permission_id := @next_role_permission_id + 1) AS id, + r.tenant_id, + r.id AS role_id, + p.id AS permission_id +FROM role r +JOIN permission p ON p.permission_code = 'meeting.read' +WHERE r.role_code IN ('TENANT_ADMIN', 'PROJECT_OWNER', 'EXECUTOR', 'AUDITOR', 'FINANCE') + AND r.is_deleted = 0 + AND NOT EXISTS ( + SELECT 1 + FROM role_permission rp + WHERE rp.tenant_id = r.tenant_id + AND rp.role_id = r.id + AND rp.permission_id = p.id + ); diff --git a/backend/src/main/resources/db/migration/V81__meeting_read_permission.sql b/backend/src/main/resources/db/migration/V81__meeting_read_permission.sql new file mode 100644 index 0000000..d5187ae --- /dev/null +++ b/backend/src/main/resources/db/migration/V81__meeting_read_permission.sql @@ -0,0 +1 @@ +UPDATE menu SET permission_code='meeting.read' WHERE tenant_id=1 AND route_path='/meetings'; \ No newline at end of file diff --git a/backend/src/main/resources/db/migration/V82__project_remove_status_fields.sql b/backend/src/main/resources/db/migration/V82__project_remove_status_fields.sql new file mode 100644 index 0000000..68dab46 --- /dev/null +++ b/backend/src/main/resources/db/migration/V82__project_remove_status_fields.sql @@ -0,0 +1,2 @@ +ALTER TABLE project DROP COLUMN payment_status; +ALTER TABLE project DROP COLUMN write_off_status; diff --git a/backend/src/main/resources/db/migration/V83__audit_flow_soft_delete.sql b/backend/src/main/resources/db/migration/V83__audit_flow_soft_delete.sql new file mode 100644 index 0000000..8760a4d --- /dev/null +++ b/backend/src/main/resources/db/migration/V83__audit_flow_soft_delete.sql @@ -0,0 +1,2 @@ +ALTER TABLE audit_flow + ADD COLUMN is_deleted TINYINT(1) NOT NULL DEFAULT 0 AFTER effective_end_at; diff --git a/backend/src/main/resources/db/migration/V84__security_state_mysql.sql b/backend/src/main/resources/db/migration/V84__security_state_mysql.sql new file mode 100644 index 0000000..5b79393 --- /dev/null +++ b/backend/src/main/resources/db/migration/V84__security_state_mysql.sql @@ -0,0 +1,28 @@ +CREATE TABLE IF NOT EXISTS auth_login_attempt ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + attempt_key VARCHAR(191) NOT NULL, + failure_count INT NOT NULL DEFAULT 0, + window_started_at DATETIME NOT NULL, + last_failed_at DATETIME NOT NULL, + locked_until DATETIME NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_attempt_key (attempt_key), + KEY idx_locked_until (locked_until), + KEY idx_last_failed_at (last_failed_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS api_rate_limit_counter ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + bucket_key VARCHAR(191) NOT NULL, + scope VARCHAR(32) NOT NULL, + client_ip VARCHAR(64) NOT NULL, + window_start_at DATETIME NOT NULL, + expires_at DATETIME NOT NULL, + request_count INT NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_bucket_key (bucket_key), + KEY idx_scope_client_ip (scope, client_ip), + KEY idx_expires_at (expires_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/backend/src/main/resources/db/migration/V85__user_import_permission.sql b/backend/src/main/resources/db/migration/V85__user_import_permission.sql new file mode 100644 index 0000000..6e7b9c3 --- /dev/null +++ b/backend/src/main/resources/db/migration/V85__user_import_permission.sql @@ -0,0 +1,9 @@ +INSERT INTO permission (id, permission_code, permission_name, module) +VALUES + (1201, 'user.import', '导入用户', 'user') +ON DUPLICATE KEY UPDATE permission_name = VALUES(permission_name), module = VALUES(module); + +INSERT INTO role_permission (id, tenant_id, role_id, permission_id) +VALUES + (1202, 1, 101, 1201) +ON DUPLICATE KEY UPDATE permission_id = VALUES(permission_id); diff --git a/backend/src/main/resources/db/migration/V86__tenant_switch_permission.sql b/backend/src/main/resources/db/migration/V86__tenant_switch_permission.sql new file mode 100644 index 0000000..f3948f2 --- /dev/null +++ b/backend/src/main/resources/db/migration/V86__tenant_switch_permission.sql @@ -0,0 +1,22 @@ +INSERT INTO permission (id, permission_code, permission_name, module) +VALUES + (1203, 'tenant.switch', '切换租户', 'system') +ON DUPLICATE KEY UPDATE permission_name = VALUES(permission_name), module = VALUES(module); + +SET @next_role_permission_id := (SELECT IFNULL(MAX(id), 0) FROM role_permission); + +INSERT INTO role_permission (id, tenant_id, role_id, permission_id) +SELECT + (@next_role_permission_id := @next_role_permission_id + 1) AS id, + r.tenant_id, + r.id AS role_id, + p.id AS permission_id +FROM role r +JOIN permission p ON p.permission_code = 'tenant.switch' +LEFT JOIN role_permission rp + ON rp.tenant_id = r.tenant_id + AND rp.role_id = r.id + AND rp.permission_id = p.id +WHERE r.role_code = 'TENANT_ADMIN' + AND r.is_deleted = 0 + AND rp.id IS NULL; diff --git a/backend/src/main/resources/db/migration/V87__platform_notify_gateway.sql b/backend/src/main/resources/db/migration/V87__platform_notify_gateway.sql new file mode 100644 index 0000000..932b7a1 --- /dev/null +++ b/backend/src/main/resources/db/migration/V87__platform_notify_gateway.sql @@ -0,0 +1,113 @@ +CREATE TABLE IF NOT EXISTS platform_notify_gateway ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + channel_code VARCHAR(32) NOT NULL, + gateway_name VARCHAR(64) NOT NULL, + provider_code VARCHAR(64) NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'DISABLED', + config_json TEXT DEFAULT NULL, + secret_config_cipher TEXT DEFAULT NULL, + remark VARCHAR(255) DEFAULT NULL, + is_deleted TINYINT(1) NOT NULL DEFAULT 0, + created_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_platform_notify_gateway_channel (channel_code) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +INSERT INTO platform_notify_gateway ( + channel_code, + gateway_name, + provider_code, + status, + config_json, + secret_config_cipher, + remark, + is_deleted, + created_by, + updated_by +) +SELECT 'EMAIL', '邮件网关', 'SMTP', 'DISABLED', '{}', '', '平台统一邮件网关配置', 0, 0, 0 +WHERE NOT EXISTS ( + SELECT 1 FROM platform_notify_gateway WHERE channel_code = 'EMAIL' +); + +INSERT INTO platform_notify_gateway ( + channel_code, + gateway_name, + provider_code, + status, + config_json, + secret_config_cipher, + remark, + is_deleted, + created_by, + updated_by +) +SELECT 'SMS', '短信网关', 'MOCK', 'DISABLED', '{}', '', '平台统一短信网关配置', 0, 0, 0 +WHERE NOT EXISTS ( + SELECT 1 FROM platform_notify_gateway WHERE channel_code = 'SMS' +); + +INSERT INTO platform_permission (permission_code, permission_name, module) +SELECT 'platform.notify-gateway.read', '平台通知网关查看', 'platform' +WHERE NOT EXISTS ( + SELECT 1 FROM platform_permission WHERE permission_code = 'platform.notify-gateway.read' +); + +INSERT INTO platform_permission (permission_code, permission_name, module) +SELECT 'platform.notify-gateway.manage', '平台通知网关管理', 'platform' +WHERE NOT EXISTS ( + SELECT 1 FROM platform_permission WHERE permission_code = 'platform.notify-gateway.manage' +); + +UPDATE platform_permission +SET permission_name = '平台通知网关查看', module = 'platform' +WHERE permission_code = 'platform.notify-gateway.read'; + +UPDATE platform_permission +SET permission_name = '平台通知网关管理', module = 'platform' +WHERE permission_code = 'platform.notify-gateway.manage'; + +INSERT INTO platform_role_permission (role_id, permission_id) +SELECT r.id, p.id +FROM platform_role r +JOIN platform_permission p ON p.permission_code = 'platform.notify-gateway.read' +LEFT JOIN platform_role_permission rp ON rp.role_id = r.id AND rp.permission_id = p.id +WHERE r.role_code = 'PLATFORM_SUPER_ADMIN' + AND r.is_deleted = 0 + AND rp.id IS NULL; + +INSERT INTO platform_role_permission (role_id, permission_id) +SELECT r.id, p.id +FROM platform_role r +JOIN platform_permission p ON p.permission_code = 'platform.notify-gateway.manage' +LEFT JOIN platform_role_permission rp ON rp.role_id = r.id AND rp.permission_id = p.id +WHERE r.role_code = 'PLATFORM_SUPER_ADMIN' + AND r.is_deleted = 0 + AND rp.id IS NULL; + +INSERT INTO platform_menu ( + menu_code, + menu_name, + route_path, + permission_code, + sort_no, + status, + is_deleted, + created_by, + updated_by +) +SELECT 'platform_notify_gateway', '通知网关配置', '/platform/notify-gateways', 'platform.notify-gateway.read', 65, 'ENABLED', 0, 0, 0 +WHERE NOT EXISTS ( + SELECT 1 FROM platform_menu WHERE menu_code = 'platform_notify_gateway' +); + +INSERT INTO platform_role_menu (role_id, menu_id) +SELECT r.id, m.id +FROM platform_role r +JOIN platform_menu m ON m.menu_code = 'platform_notify_gateway' +LEFT JOIN platform_role_menu rm ON rm.role_id = r.id AND rm.menu_id = m.id +WHERE r.role_code = 'PLATFORM_SUPER_ADMIN' + AND r.is_deleted = 0 + AND rm.id IS NULL; diff --git a/backend/src/main/resources/db/migration/V88__notification_delivery_protection.sql b/backend/src/main/resources/db/migration/V88__notification_delivery_protection.sql new file mode 100644 index 0000000..8bb715f --- /dev/null +++ b/backend/src/main/resources/db/migration/V88__notification_delivery_protection.sql @@ -0,0 +1,35 @@ +CREATE TABLE IF NOT EXISTS platform_notify_delivery_guard ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + channel_code VARCHAR(32) NOT NULL, + receiver_ref VARCHAR(128) NOT NULL, + stat_date DATE NOT NULL, + daily_count INT NOT NULL DEFAULT 0, + last_sent_at DATETIME DEFAULT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_platform_notify_delivery_guard (channel_code, receiver_ref, stat_date), + KEY idx_platform_notify_delivery_guard_date (stat_date, channel_code) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS platform_notify_circuit_breaker ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + channel_code VARCHAR(32) NOT NULL, + consecutive_failures INT NOT NULL DEFAULT 0, + breaker_until DATETIME DEFAULT NULL, + last_failure_message VARCHAR(500) DEFAULT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_platform_notify_circuit_breaker (channel_code) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +INSERT INTO platform_notify_circuit_breaker (channel_code, consecutive_failures, breaker_until, last_failure_message) +SELECT 'EMAIL', 0, NULL, NULL +WHERE NOT EXISTS ( + SELECT 1 FROM platform_notify_circuit_breaker WHERE channel_code = 'EMAIL' +); + +INSERT INTO platform_notify_circuit_breaker (channel_code, consecutive_failures, breaker_until, last_failure_message) +SELECT 'SMS', 0, NULL, NULL +WHERE NOT EXISTS ( + SELECT 1 FROM platform_notify_circuit_breaker WHERE channel_code = 'SMS' +); diff --git a/backend/src/main/resources/db/migration/V89__user_ui_preferences.sql b/backend/src/main/resources/db/migration/V89__user_ui_preferences.sql new file mode 100644 index 0000000..aaed699 --- /dev/null +++ b/backend/src/main/resources/db/migration/V89__user_ui_preferences.sql @@ -0,0 +1,73 @@ +SET @sys_user_theme_exists = ( + SELECT COUNT(1) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'sys_user' + AND COLUMN_NAME = 'ui_theme_mode' +); +SET @sys_user_theme_sql = IF( + @sys_user_theme_exists = 0, + 'ALTER TABLE sys_user ADD COLUMN ui_theme_mode VARCHAR(16) NULL AFTER valid_to', + 'SELECT 1' +); +PREPARE stmt_sys_user_theme FROM @sys_user_theme_sql; +EXECUTE stmt_sys_user_theme; +DEALLOCATE PREPARE stmt_sys_user_theme; + +SET @sys_user_density_exists = ( + SELECT COUNT(1) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'sys_user' + AND COLUMN_NAME = 'ui_density' +); +SET @sys_user_density_sql = IF( + @sys_user_density_exists = 0, + 'ALTER TABLE sys_user ADD COLUMN ui_density VARCHAR(16) NULL AFTER ui_theme_mode', + 'SELECT 1' +); +PREPARE stmt_sys_user_density FROM @sys_user_density_sql; +EXECUTE stmt_sys_user_density; +DEALLOCATE PREPARE stmt_sys_user_density; + +SET @platform_user_theme_exists = ( + SELECT COUNT(1) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'platform_user' + AND COLUMN_NAME = 'ui_theme_mode' +); +SET @platform_user_theme_sql = IF( + @platform_user_theme_exists = 0, + 'ALTER TABLE platform_user ADD COLUMN ui_theme_mode VARCHAR(16) NULL AFTER valid_to', + 'SELECT 1' +); +PREPARE stmt_platform_user_theme FROM @platform_user_theme_sql; +EXECUTE stmt_platform_user_theme; +DEALLOCATE PREPARE stmt_platform_user_theme; + +SET @platform_user_density_exists = ( + SELECT COUNT(1) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'platform_user' + AND COLUMN_NAME = 'ui_density' +); +SET @platform_user_density_sql = IF( + @platform_user_density_exists = 0, + 'ALTER TABLE platform_user ADD COLUMN ui_density VARCHAR(16) NULL AFTER ui_theme_mode', + 'SELECT 1' +); +PREPARE stmt_platform_user_density FROM @platform_user_density_sql; +EXECUTE stmt_platform_user_density; +DEALLOCATE PREPARE stmt_platform_user_density; + +UPDATE sys_user +SET ui_theme_mode = IFNULL(NULLIF(ui_theme_mode, ''), 'SYSTEM'), + ui_density = IFNULL(NULLIF(ui_density, ''), 'COMFORTABLE') +WHERE is_deleted = 0; + +UPDATE platform_user +SET ui_theme_mode = IFNULL(NULLIF(ui_theme_mode, ''), 'SYSTEM'), + ui_density = IFNULL(NULLIF(ui_density, ''), 'COMFORTABLE') +WHERE is_deleted = 0; diff --git a/backend/src/main/resources/db/migration/V8__permission_seed_iter_a.sql b/backend/src/main/resources/db/migration/V8__permission_seed_iter_a.sql new file mode 100644 index 0000000..268da73 --- /dev/null +++ b/backend/src/main/resources/db/migration/V8__permission_seed_iter_a.sql @@ -0,0 +1,27 @@ +INSERT INTO permission (id, permission_code, permission_name, module) +VALUES + (1013, 'audit.flow.read', '查看审核流配置', 'audit-flow'), + (1014, 'audit.flow.manage', '管理审核流配置', 'audit-flow'), + (1015, 'user.enable', '启用用户', 'system'), + (1016, 'user.disable', '禁用用户', 'system'), + (1017, 'user.password.reset', '重置用户密码', 'system'), + (1018, 'user.role.history.read', '查看用户角色变更历史', 'system'), + (1019, 'role.create', '创建角色', 'system'), + (1020, 'role.update', '编辑角色', 'system'), + (1021, 'role.enable', '启用角色', 'system'), + (1022, 'role.disable', '禁用角色', 'system') +ON DUPLICATE KEY UPDATE permission_name = VALUES(permission_name), module = VALUES(module); + +INSERT INTO role_permission (id, tenant_id, role_id, permission_id) +VALUES + (13, 1, 101, 1013), + (14, 1, 101, 1014), + (15, 1, 101, 1015), + (16, 1, 101, 1016), + (17, 1, 101, 1017), + (18, 1, 101, 1018), + (19, 1, 101, 1019), + (20, 1, 101, 1020), + (21, 1, 101, 1021), + (22, 1, 101, 1022) +ON DUPLICATE KEY UPDATE permission_id = VALUES(permission_id); diff --git a/backend/src/main/resources/db/migration/V90__tenant_switch_account_key.sql b/backend/src/main/resources/db/migration/V90__tenant_switch_account_key.sql new file mode 100644 index 0000000..a3b3c00 --- /dev/null +++ b/backend/src/main/resources/db/migration/V90__tenant_switch_account_key.sql @@ -0,0 +1,37 @@ +SET @sys_user_switch_account_key_exists = ( + SELECT COUNT(1) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'sys_user' + AND COLUMN_NAME = 'tenant_switch_account_key' +); + +SET @sys_user_switch_account_key_sql = IF( + @sys_user_switch_account_key_exists = 0, + 'ALTER TABLE sys_user ADD COLUMN tenant_switch_account_key VARCHAR(64) NULL AFTER password_hash', + 'SELECT 1' +); +PREPARE stmt_sys_user_switch_account_key FROM @sys_user_switch_account_key_sql; +EXECUTE stmt_sys_user_switch_account_key; +DEALLOCATE PREPARE stmt_sys_user_switch_account_key; + +SET @sys_user_switch_account_key_idx_exists = ( + SELECT COUNT(1) + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'sys_user' + AND INDEX_NAME = 'idx_sys_user_switch_account_key' +); + +SET @sys_user_switch_account_key_idx_sql = IF( + @sys_user_switch_account_key_idx_exists = 0, + 'ALTER TABLE sys_user ADD KEY idx_sys_user_switch_account_key (tenant_switch_account_key, status, is_deleted)', + 'SELECT 1' +); +PREPARE stmt_sys_user_switch_account_key_idx FROM @sys_user_switch_account_key_idx_sql; +EXECUTE stmt_sys_user_switch_account_key_idx; +DEALLOCATE PREPARE stmt_sys_user_switch_account_key_idx; + +UPDATE sys_user +SET tenant_switch_account_key = CONCAT('acct_', UPPER(MD5(CONCAT(IFNULL(phone, ''), '|', IFNULL(password_hash, ''))))) +WHERE tenant_switch_account_key IS NULL OR tenant_switch_account_key = ''; diff --git a/backend/src/main/resources/db/migration/V91__auth_password_setup_token.sql b/backend/src/main/resources/db/migration/V91__auth_password_setup_token.sql new file mode 100644 index 0000000..f0a7b1e --- /dev/null +++ b/backend/src/main/resources/db/migration/V91__auth_password_setup_token.sql @@ -0,0 +1,17 @@ +CREATE TABLE IF NOT EXISTS auth_password_setup_token ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + user_id BIGINT UNSIGNED NOT NULL, + scenario VARCHAR(64) NOT NULL, + token_hash CHAR(64) NOT NULL, + expires_at DATETIME NOT NULL, + used_at DATETIME NULL, + is_deleted TINYINT(1) NOT NULL DEFAULT 0, + created_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_password_setup_token_hash (token_hash), + KEY idx_password_setup_user (tenant_id, user_id, scenario, is_deleted), + KEY idx_password_setup_expire (expires_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/backend/src/main/resources/db/migration/V92__template_download_log_enhance.sql b/backend/src/main/resources/db/migration/V92__template_download_log_enhance.sql new file mode 100644 index 0000000..a884345 --- /dev/null +++ b/backend/src/main/resources/db/migration/V92__template_download_log_enhance.sql @@ -0,0 +1,83 @@ +SET @c := ( + SELECT COUNT(1) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'template_download_log' AND COLUMN_NAME = 'download_type' +); +SET @sql := IF( + @c = 0, + 'ALTER TABLE template_download_log ADD COLUMN download_type VARCHAR(32) NOT NULL DEFAULT ''NORMAL'' AFTER object_key', + 'SELECT 1' +); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := ( + SELECT COUNT(1) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'template_download_log' AND COLUMN_NAME = 'watermark_text' +); +SET @sql := IF( + @c = 0, + 'ALTER TABLE template_download_log ADD COLUMN watermark_text VARCHAR(255) NULL AFTER download_type', + 'SELECT 1' +); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := ( + SELECT COUNT(1) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'template_download_log' AND COLUMN_NAME = 'project_id' +); +SET @sql := IF( + @c = 0, + 'ALTER TABLE template_download_log ADD COLUMN project_id BIGINT UNSIGNED NULL AFTER watermark_text', + 'SELECT 1' +); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := ( + SELECT COUNT(1) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'template_download_log' AND COLUMN_NAME = 'meeting_id' +); +SET @sql := IF( + @c = 0, + 'ALTER TABLE template_download_log ADD COLUMN meeting_id BIGINT UNSIGNED NULL AFTER project_id', + 'SELECT 1' +); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @idx := ( + SELECT COUNT(1) + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'template_download_log' AND INDEX_NAME = 'idx_tenant_template_time' +); +SET @sql := IF( + @idx = 0, + 'ALTER TABLE template_download_log ADD KEY idx_tenant_template_time (tenant_id, template_id, downloaded_at)', + 'SELECT 1' +); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @idx := ( + SELECT COUNT(1) + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'template_download_log' AND INDEX_NAME = 'idx_tenant_user_time' +); +SET @sql := IF( + @idx = 0, + 'ALTER TABLE template_download_log ADD KEY idx_tenant_user_time (tenant_id, user_id, downloaded_at)', + 'SELECT 1' +); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @idx := ( + SELECT COUNT(1) + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'template_download_log' AND INDEX_NAME = 'idx_tenant_download_type_time' +); +SET @sql := IF( + @idx = 0, + 'ALTER TABLE template_download_log ADD KEY idx_tenant_download_type_time (tenant_id, download_type, downloaded_at)', + 'SELECT 1' +); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; diff --git a/backend/src/main/resources/db/migration/V93__notification_user_created_policy.sql b/backend/src/main/resources/db/migration/V93__notification_user_created_policy.sql new file mode 100644 index 0000000..6931b2c --- /dev/null +++ b/backend/src/main/resources/db/migration/V93__notification_user_created_policy.sql @@ -0,0 +1,101 @@ +INSERT INTO notification_text_template ( + tenant_id, + template_name, + subject_template, + title_template, + content_template, + status, + is_deleted, + created_by, + updated_by +) +SELECT + 1, + '用户创建邮件通知', + '用户账号开通通知', + '用户账号开通通知', + CONCAT( + '您好,$', '{userName}:\n', + '您的系统账号已由管理员开通。\n', + '租户名称:$', '{tenantName}\n', + '租户编码:$', '{tenantCode}\n', + '登录地址:$', '{loginPath}\n', + '登录账号:$', '{phone}\n', + '通知邮箱:$', '{email}\n', + '账号有效期:$', '{validFrom} ~ $', '{validTo}\n', + '请首次登录后及时修改密码。' + ), + 'ENABLED', + 0, + 0, + 0 +WHERE NOT EXISTS ( + SELECT 1 + FROM notification_text_template + WHERE tenant_id = 1 + AND template_name = '用户创建邮件通知' + AND is_deleted = 0 +); + +INSERT INTO notification_policy ( + tenant_id, + policy_name, + event_code, + channel, + receiver_type, + template_id, + variables_json, + status, + is_deleted, + created_by, + updated_by +) +SELECT + 1, + '用户创建邮件通知策略', + 'USER_CREATED', + 'EMAIL', + 'TARGET_USER', + t.id, + NULL, + 'ENABLED', + 0, + 0, + 0 +FROM notification_text_template t +WHERE t.tenant_id = 1 + AND t.template_name = '用户创建邮件通知' + AND t.is_deleted = 0 + AND NOT EXISTS ( + SELECT 1 + FROM notification_policy p + WHERE p.tenant_id = 1 + AND p.policy_name = '用户创建邮件通知策略' + AND p.is_deleted = 0 + ); + +INSERT INTO notification_policy_event ( + tenant_id, + policy_id, + event_code, + status, + created_by, + updated_by +) +SELECT + 1, + p.id, + 'USER_CREATED', + p.status, + 0, + 0 +FROM notification_policy p +WHERE p.tenant_id = 1 + AND p.policy_name = '用户创建邮件通知策略' + AND p.is_deleted = 0 + AND NOT EXISTS ( + SELECT 1 + FROM notification_policy_event e + WHERE e.tenant_id = 1 + AND e.policy_id = p.id + ); diff --git a/backend/src/main/resources/db/migration/V94__template_menu_permission_split.sql b/backend/src/main/resources/db/migration/V94__template_menu_permission_split.sql new file mode 100644 index 0000000..cfa63bc --- /dev/null +++ b/backend/src/main/resources/db/migration/V94__template_menu_permission_split.sql @@ -0,0 +1,72 @@ +INSERT INTO permission (id, permission_code, permission_name, module) +SELECT t.next_id, 'template.manage', '管理模板', 'template' +FROM (SELECT IFNULL(MAX(id), 0) + 1 AS next_id FROM permission) t +WHERE NOT EXISTS ( + SELECT 1 FROM permission WHERE permission_code = 'template.manage' +); + +INSERT INTO permission (id, permission_code, permission_name, module) +SELECT t.next_id, 'template.download.log.read.all', '查看全部模板下载日志', 'template' +FROM (SELECT IFNULL(MAX(id), 0) + 1 AS next_id FROM permission) t +WHERE NOT EXISTS ( + SELECT 1 FROM permission WHERE permission_code = 'template.download.log.read.all' +); + +UPDATE permission SET permission_name='管理模板', module='template' WHERE permission_code='template.manage'; +UPDATE permission SET permission_name='查看全部模板下载日志', module='template' WHERE permission_code='template.download.log.read.all'; + +UPDATE menu +SET permission_code='template.manage', + updated_at=CURRENT_TIMESTAMP +WHERE tenant_id=1 AND route_path='/templates'; + +UPDATE menu +SET menu_name='模板查看下载', + permission_code='template.read', + updated_at=CURRENT_TIMESTAMP +WHERE tenant_id=1 AND route_path='/template-download-logs'; + +INSERT INTO role_permission (id, tenant_id, role_id, permission_id) +SELECT t.next_id, 1, 101, p.id +FROM (SELECT IFNULL(MAX(id), 0) + 1 AS next_id FROM role_permission) t +JOIN permission p ON p.permission_code = 'template.manage' +WHERE NOT EXISTS ( + SELECT 1 FROM role_permission rp WHERE rp.tenant_id=1 AND rp.role_id=101 AND rp.permission_id=p.id +); + +INSERT INTO role_permission (id, tenant_id, role_id, permission_id) +SELECT t.next_id, 1, 101, p.id +FROM (SELECT IFNULL(MAX(id), 0) + 1 AS next_id FROM role_permission) t +JOIN permission p ON p.permission_code = 'template.download.log.read.all' +WHERE NOT EXISTS ( + SELECT 1 FROM role_permission rp WHERE rp.tenant_id=1 AND rp.role_id=101 AND rp.permission_id=p.id +); + +SET @next_role_menu_id = (SELECT IFNULL(MAX(id), 0) FROM role_menu); +INSERT INTO role_menu (id, tenant_id, role_id, menu_id) +SELECT + (@next_role_menu_id := @next_role_menu_id + 1) AS id, + r.tenant_id, + r.id, + m.id +FROM role r +JOIN menu m ON m.tenant_id=r.tenant_id AND m.route_path='/templates' AND m.is_deleted=0 +WHERE r.role_code='TENANT_ADMIN' + AND r.is_deleted=0 + AND NOT EXISTS ( + SELECT 1 FROM role_menu rm WHERE rm.tenant_id=r.tenant_id AND rm.role_id=r.id AND rm.menu_id=m.id + ); + +INSERT INTO role_menu (id, tenant_id, role_id, menu_id) +SELECT + (@next_role_menu_id := @next_role_menu_id + 1) AS id, + r.tenant_id, + r.id, + m.id +FROM role r +JOIN menu m ON m.tenant_id=r.tenant_id AND m.route_path='/template-download-logs' AND m.is_deleted=0 +WHERE r.role_code='TENANT_ADMIN' + AND r.is_deleted=0 + AND NOT EXISTS ( + SELECT 1 FROM role_menu rm WHERE rm.tenant_id=r.tenant_id AND rm.role_id=r.id AND rm.menu_id=m.id + ); diff --git a/backend/src/main/resources/db/migration/V95__meeting_material_export_permission.sql b/backend/src/main/resources/db/migration/V95__meeting_material_export_permission.sql new file mode 100644 index 0000000..62b7071 --- /dev/null +++ b/backend/src/main/resources/db/migration/V95__meeting_material_export_permission.sql @@ -0,0 +1,28 @@ +SET @next_permission_id := (SELECT IFNULL(MAX(id), 0) + 1 FROM permission); +INSERT INTO permission (id, permission_code, permission_name, module) +SELECT @next_permission_id, 'meeting.material.export', '导出会议资料包', 'meeting' +FROM dual +WHERE NOT EXISTS ( + SELECT 1 + FROM permission + WHERE permission_code = 'meeting.material.export' +); + +SET @next_role_permission_id := (SELECT IFNULL(MAX(id), 0) FROM role_permission); +INSERT INTO role_permission (id, tenant_id, role_id, permission_id) +SELECT + (@next_role_permission_id := @next_role_permission_id + 1) AS id, + r.tenant_id, + r.id AS role_id, + p.id AS permission_id +FROM role r +JOIN permission p ON p.permission_code = 'meeting.material.export' +WHERE r.role_code = 'PROJECT_OWNER' + AND r.is_deleted = 0 + AND NOT EXISTS ( + SELECT 1 + FROM role_permission rp + WHERE rp.tenant_id = r.tenant_id + AND rp.role_id = r.id + AND rp.permission_id = p.id + ); diff --git a/backend/src/main/resources/db/migration/V96__meeting_location_comment_free_text.sql b/backend/src/main/resources/db/migration/V96__meeting_location_comment_free_text.sql new file mode 100644 index 0000000..653fcb4 --- /dev/null +++ b/backend/src/main/resources/db/migration/V96__meeting_location_comment_free_text.sql @@ -0,0 +1,2 @@ +ALTER TABLE meeting + MODIFY COLUMN location VARCHAR(255) NULL COMMENT '会议地点'; diff --git a/backend/src/main/resources/db/migration/V97__user_theme_scheme_preferences.sql b/backend/src/main/resources/db/migration/V97__user_theme_scheme_preferences.sql new file mode 100644 index 0000000..40f9d25 --- /dev/null +++ b/backend/src/main/resources/db/migration/V97__user_theme_scheme_preferences.sql @@ -0,0 +1,39 @@ +SET @sys_user_theme_scheme_exists = ( + SELECT COUNT(1) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'sys_user' + AND COLUMN_NAME = 'ui_theme_scheme' +); +SET @sys_user_theme_scheme_sql = IF( + @sys_user_theme_scheme_exists = 0, + 'ALTER TABLE sys_user ADD COLUMN ui_theme_scheme VARCHAR(16) NULL AFTER ui_density', + 'SELECT 1' +); +PREPARE stmt_sys_user_theme_scheme FROM @sys_user_theme_scheme_sql; +EXECUTE stmt_sys_user_theme_scheme; +DEALLOCATE PREPARE stmt_sys_user_theme_scheme; + +SET @platform_user_theme_scheme_exists = ( + SELECT COUNT(1) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'platform_user' + AND COLUMN_NAME = 'ui_theme_scheme' +); +SET @platform_user_theme_scheme_sql = IF( + @platform_user_theme_scheme_exists = 0, + 'ALTER TABLE platform_user ADD COLUMN ui_theme_scheme VARCHAR(16) NULL AFTER ui_density', + 'SELECT 1' +); +PREPARE stmt_platform_user_theme_scheme FROM @platform_user_theme_scheme_sql; +EXECUTE stmt_platform_user_theme_scheme; +DEALLOCATE PREPARE stmt_platform_user_theme_scheme; + +UPDATE sys_user +SET ui_theme_scheme = IFNULL(NULLIF(UPPER(ui_theme_scheme), ''), 'SLATE') +WHERE is_deleted = 0; + +UPDATE platform_user +SET ui_theme_scheme = IFNULL(NULLIF(UPPER(ui_theme_scheme), ''), 'SLATE') +WHERE is_deleted = 0; diff --git a/backend/src/main/resources/db/migration/V98__enterprise_permission_cleanup.sql b/backend/src/main/resources/db/migration/V98__enterprise_permission_cleanup.sql new file mode 100644 index 0000000..8824c63 --- /dev/null +++ b/backend/src/main/resources/db/migration/V98__enterprise_permission_cleanup.sql @@ -0,0 +1,131 @@ +INSERT INTO permission (id, permission_code, permission_name, module) +SELECT t.next_id, 'enterprise.read', '查看企业', 'enterprise' +FROM (SELECT IFNULL(MAX(id), 0) + 1 AS next_id FROM permission) t +WHERE NOT EXISTS ( + SELECT 1 FROM permission WHERE permission_code = 'enterprise.read' +); + +INSERT INTO permission (id, permission_code, permission_name, module) +SELECT t.next_id, 'enterprise.manage', '管理企业', 'enterprise' +FROM (SELECT IFNULL(MAX(id), 0) + 1 AS next_id FROM permission) t +WHERE NOT EXISTS ( + SELECT 1 FROM permission WHERE permission_code = 'enterprise.manage' +); + +INSERT INTO permission (id, permission_code, permission_name, module) +SELECT t.next_id, 'enterprise.delete', '删除企业', 'enterprise' +FROM (SELECT IFNULL(MAX(id), 0) + 1 AS next_id FROM permission) t +WHERE NOT EXISTS ( + SELECT 1 FROM permission WHERE permission_code = 'enterprise.delete' +); + +UPDATE permission +SET permission_name = '查看企业', + module = 'enterprise' +WHERE permission_code = 'enterprise.read'; + +UPDATE permission +SET permission_name = '管理企业', + module = 'enterprise' +WHERE permission_code = 'enterprise.manage'; + +UPDATE permission +SET permission_name = '删除企业', + module = 'enterprise' +WHERE permission_code = 'enterprise.delete'; + +UPDATE menu +SET permission_code = 'enterprise.read', + updated_at = CURRENT_TIMESTAMP +WHERE route_path = '/enterprises'; + +SET @next_role_permission_id := (SELECT IFNULL(MAX(id), 0) FROM role_permission); + +INSERT INTO role_permission (id, tenant_id, role_id, permission_id) +SELECT + (@next_role_permission_id := @next_role_permission_id + 1) AS id, + src.tenant_id, + src.role_id, + p.id AS permission_id +FROM ( + SELECT DISTINCT rp.tenant_id, rp.role_id + FROM role_permission rp + JOIN permission legacy ON legacy.id = rp.permission_id + WHERE legacy.permission_code IN ('enterprises.read', 'enterprises.create', 'tenant.manage', 'project.create', 'enterprise.delete', 'enterprise.manage') +) src +JOIN permission p ON p.permission_code = 'enterprise.read' +WHERE NOT EXISTS ( + SELECT 1 + FROM role_permission rp + WHERE rp.tenant_id = src.tenant_id + AND rp.role_id = src.role_id + AND rp.permission_id = p.id + ); + +INSERT INTO role_permission (id, tenant_id, role_id, permission_id) +SELECT + (@next_role_permission_id := @next_role_permission_id + 1) AS id, + src.tenant_id, + src.role_id, + p.id AS permission_id +FROM ( + SELECT DISTINCT rp.tenant_id, rp.role_id + FROM role_permission rp + JOIN permission legacy ON legacy.id = rp.permission_id + WHERE legacy.permission_code IN ('enterprises.create', 'tenant.manage', 'enterprise.manage') +) src +JOIN permission p ON p.permission_code = 'enterprise.manage' +WHERE NOT EXISTS ( + SELECT 1 + FROM role_permission rp + WHERE rp.tenant_id = src.tenant_id + AND rp.role_id = src.role_id + AND rp.permission_id = p.id + ); + +INSERT INTO role_permission (id, tenant_id, role_id, permission_id) +SELECT + (@next_role_permission_id := @next_role_permission_id + 1) AS id, + src.tenant_id, + src.role_id, + p.id AS permission_id +FROM ( + SELECT DISTINCT rp.tenant_id, rp.role_id + FROM role_permission rp + JOIN permission legacy ON legacy.id = rp.permission_id + WHERE legacy.permission_code = 'enterprise.delete' +) src +JOIN permission p ON p.permission_code = 'enterprise.delete' +WHERE NOT EXISTS ( + SELECT 1 + FROM role_permission rp + WHERE rp.tenant_id = src.tenant_id + AND rp.role_id = src.role_id + AND rp.permission_id = p.id + ); + +DELETE rp +FROM role_permission rp +JOIN permission legacy ON legacy.id = rp.permission_id +WHERE legacy.permission_code IN ('enterprises.read', 'enterprises.create'); + +DELETE FROM permission +WHERE permission_code IN ('enterprises.read', 'enterprises.create'); + +INSERT INTO role_permission (id, tenant_id, role_id, permission_id) +SELECT + (@next_role_permission_id := @next_role_permission_id + 1) AS id, + r.tenant_id, + r.id AS role_id, + p.id AS permission_id +FROM role r +JOIN permission p ON p.permission_code = 'enterprise.read' +WHERE r.role_code IN ('TENANT_ADMIN', 'PROJECT_OWNER', 'EXECUTOR', 'AUDITOR', 'FINANCE') + AND r.is_deleted = 0 + AND NOT EXISTS ( + SELECT 1 + FROM role_permission rp + WHERE rp.tenant_id = r.tenant_id + AND rp.role_id = r.id + AND rp.permission_id = p.id + ); diff --git a/backend/src/main/resources/db/migration/V99__biz_change_log.sql b/backend/src/main/resources/db/migration/V99__biz_change_log.sql new file mode 100644 index 0000000..ea6ca26 --- /dev/null +++ b/backend/src/main/resources/db/migration/V99__biz_change_log.sql @@ -0,0 +1,22 @@ +CREATE TABLE IF NOT EXISTS biz_change_log ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + biz_type VARCHAR(64) NOT NULL, + biz_id BIGINT UNSIGNED NOT NULL, + change_type VARCHAR(64) NOT NULL, + field_code VARCHAR(64) DEFAULT NULL, + field_name VARCHAR(128) DEFAULT NULL, + before_value VARCHAR(2000) DEFAULT NULL, + after_value VARCHAR(2000) DEFAULT NULL, + related_user_id BIGINT UNSIGNED DEFAULT NULL, + related_user_name VARCHAR(128) DEFAULT NULL, + operator_user_id BIGINT UNSIGNED NOT NULL DEFAULT 0, + operator_user_name VARCHAR(128) DEFAULT NULL, + batch_id VARCHAR(64) DEFAULT NULL, + remark VARCHAR(500) DEFAULT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + is_deleted TINYINT(1) NOT NULL DEFAULT 0, + KEY idx_tenant_biz_time (tenant_id, biz_type, biz_id, created_at), + KEY idx_tenant_batch_time (tenant_id, batch_id, created_at), + KEY idx_tenant_type_time (tenant_id, change_type, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='统一业务变更日志'; diff --git a/backend/src/main/resources/db/migration/V9__audit_task_assignee_and_role_permission_bind.sql b/backend/src/main/resources/db/migration/V9__audit_task_assignee_and_role_permission_bind.sql new file mode 100644 index 0000000..04fea17 --- /dev/null +++ b/backend/src/main/resources/db/migration/V9__audit_task_assignee_and_role_permission_bind.sql @@ -0,0 +1,14 @@ +ALTER TABLE audit_task + ADD COLUMN assignee_user_id BIGINT UNSIGNED DEFAULT NULL AFTER audit_node; + +INSERT INTO permission (id, permission_code, permission_name, module) +VALUES + (1023, 'permission.read', '查看权限点', 'system'), + (1024, 'role.permission.bind', '角色绑定权限', 'system') +ON DUPLICATE KEY UPDATE permission_name = VALUES(permission_name), module = VALUES(module); + +INSERT INTO role_permission (id, tenant_id, role_id, permission_id) +VALUES + (23, 1, 101, 1023), + (24, 1, 101, 1024) +ON DUPLICATE KEY UPDATE permission_id = VALUES(permission_id); diff --git a/backend/src/main/resources/db/schema.sql b/backend/src/main/resources/db/schema.sql new file mode 100644 index 0000000..18fe265 --- /dev/null +++ b/backend/src/main/resources/db/schema.sql @@ -0,0 +1,211 @@ +-- MySQL 5.7 +-- 字符集建议:utf8mb4 + +CREATE TABLE IF NOT EXISTS tenant ( + id BIGINT UNSIGNED NOT NULL PRIMARY KEY, + tenant_name VARCHAR(128) NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE', + is_deleted TINYINT(1) NOT NULL DEFAULT 0, + created_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_tenant_name (tenant_name) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS sys_user ( + id BIGINT UNSIGNED NOT NULL PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + user_name VARCHAR(64) NOT NULL, + phone VARCHAR(32) NOT NULL, + email VARCHAR(128) DEFAULT NULL, + password_hash VARCHAR(255) NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'ENABLED', + is_deleted TINYINT(1) NOT NULL DEFAULT 0, + created_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_tenant_phone (tenant_id, phone), + KEY idx_tenant_status (tenant_id, status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS role ( + id BIGINT UNSIGNED NOT NULL PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + role_code VARCHAR(64) NOT NULL, + role_name VARCHAR(64) NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'ENABLED', + is_deleted TINYINT(1) NOT NULL DEFAULT 0, + created_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_tenant_role_code (tenant_id, role_code) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS permission ( + id BIGINT UNSIGNED NOT NULL PRIMARY KEY, + permission_code VARCHAR(128) NOT NULL, + permission_name VARCHAR(128) NOT NULL, + module VARCHAR(64) NOT NULL, + UNIQUE KEY uk_permission_code (permission_code) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS role_permission ( + id BIGINT UNSIGNED NOT NULL PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + role_id BIGINT UNSIGNED NOT NULL, + permission_id BIGINT UNSIGNED NOT NULL, + UNIQUE KEY uk_tenant_role_perm (tenant_id, role_id, permission_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS user_role ( + id BIGINT UNSIGNED NOT NULL PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + user_id BIGINT UNSIGNED NOT NULL, + role_id BIGINT UNSIGNED NOT NULL, + UNIQUE KEY uk_tenant_user_role (tenant_id, user_id, role_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS project ( + id BIGINT UNSIGNED NOT NULL PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + project_name VARCHAR(128) NOT NULL, + budget_cent BIGINT NOT NULL, + meeting_total INT NOT NULL, + status VARCHAR(32) NOT NULL, + is_deleted TINYINT(1) NOT NULL DEFAULT 0, + created_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + KEY idx_tenant_status (tenant_id, status), + KEY idx_tenant_updated (tenant_id, updated_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS meeting ( + id BIGINT UNSIGNED NOT NULL PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + project_id BIGINT UNSIGNED NOT NULL, + topic VARCHAR(256) NOT NULL, + budget_cent BIGINT NOT NULL, + meeting_status VARCHAR(32) NOT NULL, + audit_status VARCHAR(32) NOT NULL, + is_deleted TINYINT(1) NOT NULL DEFAULT 0, + created_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + invoice_config_json VARCHAR(1000) DEFAULT NULL COMMENT '会议发票动态模块配置JSON', + KEY idx_tenant_project (tenant_id, project_id), + KEY idx_tenant_audit (tenant_id, audit_status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS audit_task ( + id BIGINT UNSIGNED NOT NULL PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + meeting_id BIGINT UNSIGNED NOT NULL, + audit_node VARCHAR(32) NOT NULL, + status VARCHAR(32) NOT NULL, + opinion VARCHAR(500) DEFAULT NULL, + is_deleted TINYINT(1) NOT NULL DEFAULT 0, + created_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + KEY idx_tenant_node_status (tenant_id, audit_node, status), + KEY idx_tenant_meeting (tenant_id, meeting_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS finance_payment ( + id BIGINT UNSIGNED NOT NULL PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + project_id BIGINT UNSIGNED NOT NULL, + meeting_id BIGINT UNSIGNED NOT NULL, + amount_cent BIGINT NOT NULL, + payment_status VARCHAR(32) NOT NULL, + voucher_oss_key VARCHAR(512) DEFAULT NULL, + is_deleted TINYINT(1) NOT NULL DEFAULT 0, + created_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + KEY idx_tenant_project_status (tenant_id, project_id, payment_status), + KEY idx_tenant_meeting (tenant_id, meeting_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS operation_audit_log ( + id BIGINT UNSIGNED NOT NULL PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + operator_id BIGINT UNSIGNED NOT NULL, + action_code VARCHAR(128) NOT NULL, + target_type VARCHAR(64) NOT NULL, + target_id BIGINT UNSIGNED NOT NULL, + before_data TEXT, + after_data TEXT, + request_id VARCHAR(64), + ip VARCHAR(64), + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY idx_tenant_operator_time (tenant_id, operator_id, created_at), + KEY idx_tenant_action_time (tenant_id, action_code, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS async_job ( + id BIGINT UNSIGNED NOT NULL PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL DEFAULT 0, + job_type VARCHAR(64) NOT NULL, + payload TEXT, + status VARCHAR(32) NOT NULL DEFAULT 'READY', + next_run_at DATETIME NOT NULL, + retry_count INT NOT NULL DEFAULT 0, + max_retry INT NOT NULL DEFAULT 3, + idempotency_key VARCHAR(128) DEFAULT NULL, + locked_by VARCHAR(128) DEFAULT NULL, + locked_at DATETIME DEFAULT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + KEY idx_status_next_run (status, next_run_at), + KEY idx_locked_at (locked_at), + UNIQUE KEY uk_idempotency_key (idempotency_key) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS async_job_log ( + id BIGINT UNSIGNED NOT NULL PRIMARY KEY, + job_id BIGINT UNSIGNED NOT NULL, + execute_status VARCHAR(32) NOT NULL, + message VARCHAR(500) DEFAULT NULL, + executed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY idx_job_time (job_id, executed_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS error_code_dict ( + id BIGINT UNSIGNED NOT NULL PRIMARY KEY, + code INT NOT NULL, + message VARCHAR(255) NOT NULL, + category VARCHAR(64) NOT NULL, + UNIQUE KEY uk_code (code) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS biz_change_log ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + biz_type VARCHAR(64) NOT NULL, + biz_id BIGINT UNSIGNED NOT NULL, + change_type VARCHAR(64) NOT NULL, + field_code VARCHAR(64) DEFAULT NULL, + field_name VARCHAR(128) DEFAULT NULL, + before_value VARCHAR(2000) DEFAULT NULL, + after_value VARCHAR(2000) DEFAULT NULL, + related_user_id BIGINT UNSIGNED DEFAULT NULL, + related_user_name VARCHAR(128) DEFAULT NULL, + operator_user_id BIGINT UNSIGNED NOT NULL DEFAULT 0, + operator_user_name VARCHAR(128) DEFAULT NULL, + batch_id VARCHAR(64) DEFAULT NULL, + remark VARCHAR(500) DEFAULT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + is_deleted TINYINT(1) NOT NULL DEFAULT 0, + KEY idx_tenant_biz_time (tenant_id, biz_type, biz_id, created_at), + KEY idx_tenant_batch_time (tenant_id, batch_id, created_at), + KEY idx_tenant_type_time (tenant_id, change_type, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/backend/src/main/resources/logback-spring.xml b/backend/src/main/resources/logback-spring.xml new file mode 100644 index 0000000..2a291ce --- /dev/null +++ b/backend/src/main/resources/logback-spring.xml @@ -0,0 +1,28 @@ + + + + + + + %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} [reqId=%X{requestId}] - %msg%n + UTF-8 + + + + + logs/writeoff.log + + logs/writeoff.%d{yyyy-MM-dd}.log + 30 + + + %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} [reqId=%X{requestId}] - %msg%n + UTF-8 + + + + + + + + diff --git a/backend/src/main/resources/templates/meeting-summary-template.docx b/backend/src/main/resources/templates/meeting-summary-template.docx new file mode 100644 index 0000000..0abf95d Binary files /dev/null and b/backend/src/main/resources/templates/meeting-summary-template.docx differ diff --git a/backend/src/test/java/com/writeoff/AsyncJobServiceTest.java b/backend/src/test/java/com/writeoff/AsyncJobServiceTest.java new file mode 100644 index 0000000..14c8527 --- /dev/null +++ b/backend/src/test/java/com/writeoff/AsyncJobServiceTest.java @@ -0,0 +1,18 @@ +package com.writeoff; + +import com.writeoff.common.exception.BusinessException; +import com.writeoff.module.scheduler.repository.InMemoryAsyncJobRepository; +import com.writeoff.module.scheduler.service.AsyncJobService; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +class AsyncJobServiceTest { + + @Test + void shouldEnforceIdempotency() { + AsyncJobService asyncJobService = new AsyncJobService(new InMemoryAsyncJobRepository()); + asyncJobService.enqueue("AUDIT_REMIND", "meetingId=1", "idem-job-1"); + Assertions.assertThrows(BusinessException.class, () -> + asyncJobService.enqueue("AUDIT_REMIND", "meetingId=1", "idem-job-1")); + } +} diff --git a/backend/src/test/java/com/writeoff/MvpFlowIntegrationTest.java b/backend/src/test/java/com/writeoff/MvpFlowIntegrationTest.java new file mode 100644 index 0000000..3422a6a --- /dev/null +++ b/backend/src/test/java/com/writeoff/MvpFlowIntegrationTest.java @@ -0,0 +1,180 @@ +package com.writeoff; + +import com.writeoff.module.audit.dto.AuditActionRequest; +import com.writeoff.module.audit.model.AuditTask; +import com.writeoff.module.audit.repository.InMemoryAuditTaskRepository; +import com.writeoff.module.audit.service.AuditService; +import com.writeoff.module.finance.dto.ConfirmPaymentRequest; +import com.writeoff.module.finance.repository.InMemoryPaymentRepository; +import com.writeoff.module.finance.service.FinanceService; +import com.writeoff.module.meeting.dto.CreateMeetingRequest; +import com.writeoff.module.meeting.dto.SubmitMeetingRequest; +import com.writeoff.module.meeting.model.Meeting; +import com.writeoff.module.meeting.repository.InMemoryMeetingRepository; +import com.writeoff.module.meeting.service.MeetingService; +import com.writeoff.module.project.dto.CreateProjectRequest; +import com.writeoff.module.project.model.Project; +import com.writeoff.module.project.repository.InMemoryProjectRepository; +import com.writeoff.module.project.service.ProjectService; +import com.writeoff.module.scheduler.repository.InMemoryAsyncJobRepository; +import com.writeoff.module.scheduler.service.AsyncJobService; +import com.writeoff.security.AuthContext; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Field; +import java.time.LocalDate; +import java.util.List; + +class MvpFlowIntegrationTest { + + @Test + void shouldFinishCoreFlow() throws Exception { + AuthContext.set(1001L, 1L); + try { + InMemoryProjectRepository projectRepository = new InMemoryProjectRepository(); + InMemoryMeetingRepository meetingRepository = new InMemoryMeetingRepository(); + InMemoryAuditTaskRepository auditTaskRepository = new InMemoryAuditTaskRepository(); + InMemoryPaymentRepository paymentRepository = new InMemoryPaymentRepository(); + AsyncJobService asyncJobService = new AsyncJobService(new InMemoryAsyncJobRepository()); + + ProjectService projectService = new ProjectService(projectRepository); + MeetingService meetingService = new MeetingService(meetingRepository, projectService, auditTaskRepository, asyncJobService); + AuditService auditService = new AuditService(auditTaskRepository, meetingService, asyncJobService); + FinanceService financeService = new FinanceService(paymentRepository, meetingService); + + Project project = projectService.create(new CreateProjectRequestWrapper("P1", 1000000L, 3).toRequest()); + Meeting meeting = meetingService.create(new CreateMeetingRequestWrapper(project.getId(), "M1", 300000L).toRequest()); + meetingService.submit(meeting.getId(), new SubmitMeetingRequestWrapper("submit-key-1", "提交审核").toRequest()); + + List tasks = auditService.listTasks().getList(); + Assertions.assertFalse(tasks.isEmpty()); + + AuditTask first = tasks.get(0); + auditService.approve(first.getId(), new AuditActionRequestWrapper("audit-key-1", "初审通过").toRequest()); + AuditTask second = auditService.listTasks().getList().stream().filter(t -> t.getId() > first.getId()).findFirst().orElseThrow(IllegalStateException::new); + auditService.approve(second.getId(), new AuditActionRequestWrapper("audit-key-2", "复审通过").toRequest()); + AuditTask third = auditService.listTasks().getList().stream().filter(t -> t.getId() > second.getId()).findFirst().orElseThrow(IllegalStateException::new); + auditService.approve(third.getId(), new AuditActionRequestWrapper("audit-key-3", "终审通过").toRequest()); + + Object paymentResult = financeService.confirmPayment(new ConfirmPaymentRequestWrapper( + "pay-key-1", project.getId(), meeting.getId(), 200000L, "oss/voucher.pdf").toRequest()); + Assertions.assertNotNull(paymentResult); + } finally { + AuthContext.clear(); + } + } + + private static class CreateProjectRequestWrapper { + private final String name; + private final long budgetCent; + private final int meetingTotal; + + private CreateProjectRequestWrapper(String name, long budgetCent, int meetingTotal) { + this.name = name; + this.budgetCent = budgetCent; + this.meetingTotal = meetingTotal; + } + + private CreateProjectRequest toRequest() throws Exception { + CreateProjectRequest request = new CreateProjectRequest(); + setField(request, "name", name); + setField(request, "startDate", LocalDate.of(2026, 1, 1)); + setField(request, "endDate", LocalDate.of(2026, 12, 31)); + setField(request, "budgetCent", budgetCent); + setField(request, "meetingTotal", meetingTotal); + return request; + } + } + + private static class CreateMeetingRequestWrapper { + private final long projectId; + private final String topic; + private final long budgetCent; + + private CreateMeetingRequestWrapper(long projectId, String topic, long budgetCent) { + this.projectId = projectId; + this.topic = topic; + this.budgetCent = budgetCent; + } + + private CreateMeetingRequest toRequest() throws Exception { + CreateMeetingRequest request = new CreateMeetingRequest(); + setField(request, "projectId", projectId); + setField(request, "topic", topic); + setField(request, "budgetCent", budgetCent); + setField(request, "meetingCategory", "学术会"); + setField(request, "meetingForm", "线下"); + setField(request, "location", "线下"); + setField(request, "startTime", "2026-01-10 09:00:00"); + setField(request, "endTime", "2026-01-10 18:00:00"); + return request; + } + } + + private static class SubmitMeetingRequestWrapper { + private final String key; + private final String remark; + + private SubmitMeetingRequestWrapper(String key, String remark) { + this.key = key; + this.remark = remark; + } + + private SubmitMeetingRequest toRequest() throws Exception { + SubmitMeetingRequest request = new SubmitMeetingRequest(); + setField(request, "idempotencyKey", key); + setField(request, "remark", remark); + return request; + } + } + + private static class AuditActionRequestWrapper { + private final String key; + private final String opinion; + + private AuditActionRequestWrapper(String key, String opinion) { + this.key = key; + this.opinion = opinion; + } + + private AuditActionRequest toRequest() throws Exception { + AuditActionRequest request = new AuditActionRequest(); + setField(request, "idempotencyKey", key); + setField(request, "opinion", opinion); + return request; + } + } + + private static class ConfirmPaymentRequestWrapper { + private final String key; + private final long projectId; + private final long meetingId; + private final long amount; + private final String voucher; + + private ConfirmPaymentRequestWrapper(String key, long projectId, long meetingId, long amount, String voucher) { + this.key = key; + this.projectId = projectId; + this.meetingId = meetingId; + this.amount = amount; + this.voucher = voucher; + } + + private ConfirmPaymentRequest toRequest() throws Exception { + ConfirmPaymentRequest request = new ConfirmPaymentRequest(); + setField(request, "idempotencyKey", key); + setField(request, "projectId", projectId); + setField(request, "meetingId", meetingId); + setField(request, "amountCent", amount); + setField(request, "paymentVoucherOssKey", voucher); + return request; + } + } + + private static void setField(Object target, String fieldName, Object value) throws Exception { + Field field = target.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + field.set(target, value); + } +} diff --git a/backend/src/test/java/com/writeoff/module/system/service/SystemUserServicePasswordTest.java b/backend/src/test/java/com/writeoff/module/system/service/SystemUserServicePasswordTest.java new file mode 100644 index 0000000..f86f544 --- /dev/null +++ b/backend/src/test/java/com/writeoff/module/system/service/SystemUserServicePasswordTest.java @@ -0,0 +1,63 @@ +package com.writeoff.module.system.service; + +import com.writeoff.security.AuthContext; +import com.writeoff.security.PasswordCodecService; +import com.writeoff.security.PasswordPolicyService; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentMatcher; +import org.springframework.jdbc.core.JdbcTemplate; + +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class SystemUserServicePasswordTest { + + @AfterEach + void tearDown() { + AuthContext.clear(); + } + + @Test + void shouldAcceptLegacyPlaintextOldPasswordAndUpgradeToHashWhenChangingPassword() { + JdbcTemplate jdbcTemplate = mock(JdbcTemplate.class); + PasswordCodecService passwordCodecService = new PasswordCodecService(); + SystemUserService systemUserService = new SystemUserService( + jdbcTemplate, + null, + new PasswordPolicyService(), + passwordCodecService + ); + AuthContext.set(1001L, 2001L); + when(jdbcTemplate.queryForObject( + eq("SELECT COUNT(1) FROM sys_user WHERE tenant_id=? AND id=? AND is_deleted=0"), + eq(Integer.class), + eq(2001L), + eq(1001L) + )).thenReturn(1); + when(jdbcTemplate.queryForObject( + eq("SELECT password_hash FROM sys_user WHERE tenant_id=? AND id=? AND is_deleted=0 LIMIT 1"), + eq(String.class), + eq(2001L), + eq(1001L) + )).thenReturn("legacy-plain-password"); + + systemUserService.changeMyPassword(1001L, "legacy-plain-password", "Abcd1234!"); + + verify(jdbcTemplate).update( + eq("UPDATE sys_user SET password_hash=?, updated_at=CURRENT_TIMESTAMP WHERE tenant_id=? AND id=?"), + argThat(matchesEncodedPassword("Abcd1234!", passwordCodecService)), + eq(2001L), + eq(1001L) + ); + } + + private ArgumentMatcher matchesEncodedPassword(String rawPassword, PasswordCodecService passwordCodecService) { + return value -> value instanceof String + && passwordCodecService.isEncoded((String) value) + && passwordCodecService.matches(rawPassword, (String) value); + } +} diff --git a/backend/src/test/java/com/writeoff/module/system/service/UserDelegationServiceValidationTest.java b/backend/src/test/java/com/writeoff/module/system/service/UserDelegationServiceValidationTest.java new file mode 100644 index 0000000..c8d43e2 --- /dev/null +++ b/backend/src/test/java/com/writeoff/module/system/service/UserDelegationServiceValidationTest.java @@ -0,0 +1,85 @@ +package com.writeoff.module.system.service; + +import com.writeoff.common.exception.BusinessException; +import com.writeoff.module.system.dto.CreateUserDelegationRequest; +import com.writeoff.security.AuthContext; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.jdbc.core.JdbcTemplate; + +import java.lang.reflect.Field; +class UserDelegationServiceValidationTest { + + @AfterEach + void tearDown() { + AuthContext.clear(); + } + + @Test + void shouldRejectSelfDelegation() throws Exception { + JdbcTemplate jdbcTemplate = Mockito.mock(JdbcTemplate.class); + UserDelegationService service = new UserDelegationService(jdbcTemplate); + AuthContext.set(1L, 1L); + + Mockito.when(jdbcTemplate.queryForObject( + Mockito.eq("SELECT COUNT(1) FROM sys_user WHERE tenant_id=? AND id=? AND is_deleted=0"), + Mockito.eq(Integer.class), + Mockito.eq(1L), + Mockito.eq(10L) + )).thenReturn(1); + + CreateUserDelegationRequest request = new CreateUserDelegationRequest(); + setField(request, "delegateUserId", 10L); + setField(request, "effectiveFrom", "2026-03-10 10:00:00"); + setField(request, "effectiveTo", "2026-03-10 18:00:00"); + + Assertions.assertThrows(BusinessException.class, () -> service.create(10L, request)); + } + + @Test + void shouldRejectInvalidTimeWindow() throws Exception { + JdbcTemplate jdbcTemplate = Mockito.mock(JdbcTemplate.class); + UserDelegationService service = new UserDelegationService(jdbcTemplate); + AuthContext.set(1L, 1L); + + Mockito.when(jdbcTemplate.queryForObject( + Mockito.eq("SELECT COUNT(1) FROM sys_user WHERE tenant_id=? AND id=? AND is_deleted=0"), + Mockito.eq(Integer.class), + Mockito.eq(1L), + Mockito.eq(10L) + )).thenReturn(1); + Mockito.when(jdbcTemplate.queryForObject( + Mockito.eq("SELECT COUNT(1) FROM sys_user WHERE tenant_id=? AND id=? AND is_deleted=0"), + Mockito.eq(Integer.class), + Mockito.eq(1L), + Mockito.eq(20L) + )).thenReturn(1); + + CreateUserDelegationRequest request = new CreateUserDelegationRequest(); + setField(request, "delegateUserId", 20L); + setField(request, "effectiveFrom", "2026-03-10 18:00:00"); + setField(request, "effectiveTo", "2026-03-10 10:00:00"); + + Assertions.assertThrows(BusinessException.class, () -> service.create(10L, request)); + Mockito.verify(jdbcTemplate, Mockito.never()).update( + Mockito.contains("INSERT INTO user_delegation"), + Mockito.any(), + Mockito.any(), + Mockito.any(), + Mockito.any(), + Mockito.any(), + Mockito.any(), + Mockito.any(), + Mockito.any(), + Mockito.any() + ); + } + + private static void setField(Object target, String fieldName, Object value) throws Exception { + Field field = target.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + field.set(target, value); + } +} diff --git a/backend/src/test/java/com/writeoff/security/LoginPasswordCryptoServiceTest.java b/backend/src/test/java/com/writeoff/security/LoginPasswordCryptoServiceTest.java new file mode 100644 index 0000000..f9d1b57 --- /dev/null +++ b/backend/src/test/java/com/writeoff/security/LoginPasswordCryptoServiceTest.java @@ -0,0 +1,49 @@ +package com.writeoff.security; + +import org.junit.jupiter.api.Test; + +import javax.crypto.Cipher; +import javax.crypto.spec.OAEPParameterSpec; +import javax.crypto.spec.PSource; +import java.nio.charset.StandardCharsets; +import java.security.KeyFactory; +import java.security.PublicKey; +import java.security.spec.MGF1ParameterSpec; +import java.security.spec.X509EncodedKeySpec; +import java.util.Base64; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class LoginPasswordCryptoServiceTest { + + private static final OAEPParameterSpec OAEP_SHA256_MGF1_SHA256 = new OAEPParameterSpec( + "SHA-256", + "MGF1", + MGF1ParameterSpec.SHA256, + PSource.PSpecified.DEFAULT + ); + + @Test + void shouldUnwrapBrowserCompatibleEncryptedPassword() throws Exception { + LoginPasswordCryptoService service = new LoginPasswordCryptoService(); + service.init(); + + String rawPassword = "Abcd1234!"; + PublicKey publicKey = KeyFactory.getInstance("RSA").generatePublic( + new X509EncodedKeySpec(Base64.getDecoder().decode(service.getEncodedPublicKey())) + ); + Cipher cipher = Cipher.getInstance("RSA/ECB/OAEPWithSHA-256AndMGF1Padding"); + cipher.init(Cipher.ENCRYPT_MODE, publicKey, OAEP_SHA256_MGF1_SHA256); + String encryptedPassword = LoginPasswordCryptoService.PASSWORD_PREFIX + + Base64.getEncoder().encodeToString(cipher.doFinal(rawPassword.getBytes(StandardCharsets.UTF_8))); + + assertEquals(rawPassword, service.unwrapPassword(encryptedPassword)); + } + + @Test + void shouldKeepPlainPasswordUntouched() { + LoginPasswordCryptoService service = new LoginPasswordCryptoService(); + + assertEquals("123456", service.unwrapPassword("123456")); + } +} diff --git a/backend/src/test/java/com/writeoff/security/PasswordCodecServiceTest.java b/backend/src/test/java/com/writeoff/security/PasswordCodecServiceTest.java new file mode 100644 index 0000000..1e7d818 --- /dev/null +++ b/backend/src/test/java/com/writeoff/security/PasswordCodecServiceTest.java @@ -0,0 +1,32 @@ +package com.writeoff.security; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class PasswordCodecServiceTest { + + private final PasswordCodecService passwordCodecService = new PasswordCodecService(); + + @Test + void shouldEncodePasswordWithSaltAndMatch() { + String rawPassword = "Abcd1234!"; + + String first = passwordCodecService.encode(rawPassword); + String second = passwordCodecService.encode(rawPassword); + + assertTrue(passwordCodecService.isEncoded(first)); + assertTrue(passwordCodecService.matches(rawPassword, first)); + assertTrue(passwordCodecService.matches(rawPassword, second)); + assertNotEquals(first, second); + assertFalse(passwordCodecService.matches("wrong-password", first)); + } + + @Test + void shouldRemainCompatibleWithLegacyPlaintextPassword() { + assertTrue(passwordCodecService.matches("123456", "123456")); + assertFalse(passwordCodecService.isEncoded("123456")); + } +} diff --git a/backend/src/test/java/com/writeoff/security/PermissionServiceDelegationTest.java b/backend/src/test/java/com/writeoff/security/PermissionServiceDelegationTest.java new file mode 100644 index 0000000..1de09ad --- /dev/null +++ b/backend/src/test/java/com/writeoff/security/PermissionServiceDelegationTest.java @@ -0,0 +1,102 @@ +package com.writeoff.security; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.jdbc.core.JdbcTemplate; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +class PermissionServiceDelegationTest { + + @AfterEach + void tearDown() { + AuthContext.clear(); + } + + @Test + void shouldInheritPermissionFromPrincipalWhenDelegationEnabled() { + JdbcTemplate jdbcTemplate = Mockito.mock(JdbcTemplate.class); + PermissionService permissionService = new PermissionService(jdbcTemplate); + AuthContext.set(2001L, 1L); + + Mockito.when(jdbcTemplate.queryForObject( + Mockito.eq("SELECT COUNT(1) FROM user_role ur " + + "JOIN role_permission rp ON ur.role_id=rp.role_id AND ur.tenant_id=rp.tenant_id " + + "JOIN permission p ON rp.permission_id=p.id " + + "WHERE ur.user_id=? AND ur.tenant_id=? AND p.permission_code=?"), + Mockito.eq(Integer.class), + Mockito.eq(2001L), + Mockito.eq(1L), + Mockito.eq("meeting.approve") + )).thenReturn(0); + + Mockito.when(jdbcTemplate.queryForList( + Mockito.eq("SELECT user_id FROM user_delegation " + + "WHERE tenant_id=? AND delegate_user_id=? AND is_deleted=0 AND status='ENABLED' " + + "AND effective_from<=CURRENT_TIMESTAMP AND effective_to>=CURRENT_TIMESTAMP"), + Mockito.eq(Long.class), + Mockito.eq(1L), + Mockito.eq(2001L) + )).thenReturn(Collections.singletonList(1001L)); + + Mockito.when(jdbcTemplate.queryForObject( + Mockito.eq("SELECT COUNT(1) FROM user_role ur " + + "JOIN role_permission rp ON ur.role_id=rp.role_id AND ur.tenant_id=rp.tenant_id " + + "JOIN permission p ON rp.permission_id=p.id " + + "WHERE ur.user_id=? AND ur.tenant_id=? AND p.permission_code=?"), + Mockito.eq(Integer.class), + Mockito.eq(1001L), + Mockito.eq(1L), + Mockito.eq("meeting.approve") + )).thenReturn(1); + + boolean ok = permissionService.hasPermission(2001L, "meeting.approve"); + Assertions.assertTrue(ok); + } + + @Test + void shouldMergePermissionsFromSelfAndDelegationWithoutDuplicates() { + JdbcTemplate jdbcTemplate = Mockito.mock(JdbcTemplate.class); + PermissionService permissionService = new PermissionService(jdbcTemplate); + AuthContext.set(2001L, 1L); + + Mockito.when(jdbcTemplate.queryForList( + Mockito.eq("SELECT DISTINCT p.permission_code FROM user_role ur " + + "JOIN role_permission rp ON ur.role_id=rp.role_id AND ur.tenant_id=rp.tenant_id " + + "JOIN permission p ON rp.permission_id=p.id " + + "WHERE ur.user_id=? AND ur.tenant_id=?"), + Mockito.eq(String.class), + Mockito.eq(2001L), + Mockito.eq(1L) + )).thenReturn(Arrays.asList("user.read", "user.delegation.manage")); + + Mockito.when(jdbcTemplate.queryForList( + Mockito.eq("SELECT user_id FROM user_delegation " + + "WHERE tenant_id=? AND delegate_user_id=? AND is_deleted=0 AND status='ENABLED' " + + "AND effective_from<=CURRENT_TIMESTAMP AND effective_to>=CURRENT_TIMESTAMP"), + Mockito.eq(Long.class), + Mockito.eq(1L), + Mockito.eq(2001L) + )).thenReturn(Collections.singletonList(1001L)); + + Mockito.when(jdbcTemplate.queryForList( + Mockito.eq("SELECT DISTINCT p.permission_code FROM user_role ur " + + "JOIN role_permission rp ON ur.role_id=rp.role_id AND ur.tenant_id=rp.tenant_id " + + "JOIN permission p ON rp.permission_id=p.id " + + "WHERE ur.user_id=? AND ur.tenant_id=?"), + Mockito.eq(String.class), + Mockito.eq(1001L), + Mockito.eq(1L) + )).thenReturn(Arrays.asList("meeting.approve", "user.read")); + + List perms = permissionService.getPermissions(2001L); + Assertions.assertTrue(perms.contains("user.read")); + Assertions.assertTrue(perms.contains("user.delegation.manage")); + Assertions.assertTrue(perms.contains("meeting.approve")); + Assertions.assertEquals(3, perms.size()); + } +} diff --git a/docs/MVP_试运行与发布回滚预案.md b/docs/MVP_试运行与发布回滚预案.md new file mode 100644 index 0000000..1a91ef8 --- /dev/null +++ b/docs/MVP_试运行与发布回滚预案.md @@ -0,0 +1,35 @@ +# MVP试运行与发布回滚预案 + +## 1. 试运行前检查 +- 数据库:`schema.sql`、`data.sql` 已执行完成。 +- 配置:`DB/OSS/JWT/SCHEDULER` 环境变量已配置。 +- 服务检查:后端健康检查 `/api/system/health` 返回 `UP`。 +- 前端构建:`npm run build` 成功。 + +## 2. 核心验证用例(上线阻断项) +- 创建项目 -> 创建会议 -> 会议级提交 -> 初审/复审/终审 -> 支付确认 全链路可用。 +- 越权访问接口返回 `20001/20002`。 +- 幂等冲突返回 `10002`。 +- 未终审通过支付确认返回 `40003`。 +- 调度任务可执行并支持失败重试。 + +## 3. 灰度发布策略 +- 第1阶段:仅内部租户(10%流量)观察 30 分钟。 +- 第2阶段:扩大到 50% 租户观察 1 小时。 +- 第3阶段:全量发布。 +- 监控阈值 + - 5xx 错误率连续 5 分钟 > 1% 触发回滚。 + - 核心接口 P95 延迟连续 10 分钟劣化 > 30% 触发回滚评估。 + - 审核/支付关键失败率 > 2% 触发 P1 告警。 + +## 4. 回滚策略 +- 应用回滚:回滚到最近稳定版本(保留最近2版)。 +- 数据回滚:通过备份+binlog 恢复,禁止手工改生产数据。 +- 紧急开关 + - 关闭调度:`SCHEDULER_ENABLED=false` + - 暂停支付确认入口(前端隐藏+后端网关拦截) + +## 5. 试运行周期建议 +- 试运行 3-5 天。 +- 每日输出问题清单(功能、性能、权限、财务口径)。 +- 试运行结束召开上线评审,确认是否转正式运行。 diff --git a/docs/平台超级管理员双域鉴权开发文档.md b/docs/平台超级管理员双域鉴权开发文档.md new file mode 100644 index 0000000..7177678 --- /dev/null +++ b/docs/平台超级管理员双域鉴权开发文档.md @@ -0,0 +1,103 @@ +# 平台超级管理员与双域鉴权开发文档 + +## 1. 背景与目标 + +当前系统采用租户内 RBAC(`用户 -> 角色 -> 权限`),但“系统超级管理员”职责属于平台级,不应绑定任何租户。 +本迭代目标是在不破坏现有租户业务能力的前提下,建立“平台域 + 租户域”的双域鉴权模型。 + +## 2. 设计原则 + +- 平台域与租户域严格隔离,避免越权访问。 +- 平台账号不落入任何租户,不依赖 `tenant_id`。 +- 租户业务接口只允许租户令牌访问。 +- 平台管理接口只允许平台令牌访问。 +- 兼容现有租户登录接口与权限模型,按迭代逐步替换。 + +## 3. 总体方案 + +### 3.1 身份域模型 + +- `TENANT`:租户业务身份(必须携带 `tenantId`)。 +- `PLATFORM`:平台管理身份(不携带 `tenantId`)。 + +### 3.2 Token 约定 + +- 租户令牌 claims:`uid`、`tid`、`scope=TENANT`。 +- 平台令牌 claims:`uid`、`scope=PLATFORM`。 + +### 3.3 权限注解扩展 + +`@RequirePermission` 新增 `domain` 字段: + +- `domain=TENANT`(默认) +- `domain=PLATFORM` + +### 3.4 平台 RBAC 数据模型 + +新增表: + +- `platform_user` +- `platform_role` +- `platform_permission` +- `platform_user_role` +- `platform_role_permission` + +## 4. 迭代拆分 + +### Iteration 1(已启动) + +目标:落地双域鉴权底座,打通平台登录和租户管理平台化访问。 + +- [x] 增加鉴权域枚举:`AuthScope`、`PermissionDomain` +- [x] `AuthContext` 增加 `scope` 上下文 +- [x] JWT 支持租户/平台两类 token +- [x] 新增平台登录接口:`POST /api/auth/platform-login` +- [x] 拦截器按 `scope + domain` 双维度鉴权 +- [x] 租户管理接口切换到平台域权限:`platform.tenant.manage` +- [x] 增加平台 RBAC 初始化迁移:`V40__platform_admin_rbac.sql` + +### Iteration 2(进行中) + +目标:收敛租户默认兜底逻辑,修正平台日志归属。 + +- [x] 清理所有 `tenantId == null ? 1L : tenantId` 兜底逻辑 +- [x] 平台操作审计增加 `scope` 字段(`TENANT/PLATFORM`)并完成查询分流 +- [x] 平台接口统一路由前缀(新增 `/api/platform/tenants`、`/api/platform/audit-logs`) +- [x] 为平台接口补齐权限码与元数据校验(`domain=PLATFORM`) + +### Iteration 3(待开发) + +目标:前后端联动与灰度上线。 + +- [x] 前端增加登录入口切换(平台 / 租户) +- [x] 平台工作台页面(已接入租户管理、平台审计日志基础页面) +- [x] 平台菜单动态化(`platform_menu`、`platform_role_menu` + `/api/platform/menus/current`) +- [x] 平台菜单管理页(新增/编辑/排序/菜单绑定角色) +- [x] 平台 IAM 页面(平台用户管理、平台角色管理、平台权限查看) +- [ ] 双域登录回归测试与权限压测 +- [ ] 发布灰度与回滚预案 + +## 5. 初始账号与权限 + +初始化数据(`V40`): + +- 平台角色:`PLATFORM_SUPER_ADMIN` +- 平台权限:`platform.tenant.manage`、`platform.user.manage`、`platform.audit.read`、`platform.menu.manage`、`platform.role.read`、`platform.role.manage`、`platform.permission.read` +- 平台管理员账号: + - 手机号:`13900000000` + - 密码:`123456` + +> 上线前必须改密,并接入密码加密存储。 + +## 6. 风险与注意事项 + +- 已完成租户默认 `1L` 兜底清理;后续仍需补齐平台独立审计模型,避免平台操作与租户审计混用。 +- `tenant.manage` 与 `platform.tenant.manage` 属于不同域权限码,前后端配置需同步。 +- 建议后续引入统一 `domain` 中间件拦截,禁止跨域访问。 + +## 7. 验收标准(Iteration 1) + +- 平台管理员可通过 `/api/auth/platform-login` 获取 `scope=PLATFORM` token。 +- 平台管理员可访问租户管理接口(`/api/tenants`)。 +- 租户 token 访问平台域接口被拒绝。 +- 平台 token 访问租户域接口被拒绝。 diff --git a/docs/新租户初始化清单SQL.sql b/docs/新租户初始化清单SQL.sql new file mode 100644 index 0000000..44ec8d1 --- /dev/null +++ b/docs/新租户初始化清单SQL.sql @@ -0,0 +1,165 @@ +-- 新租户初始化清单 SQL(按模板租户复制) +-- 用法: +-- 1) 先创建租户拿到 @target_tenant_id +-- 2) 以 tenant_id=1 作为模板租户执行 +-- 3) 该脚本尽量幂等(重复执行不会重复插入) + +SET @template_tenant_id := 1; +SET @target_tenant_id := 2; -- TODO: 改成目标租户ID + +-- ===================================================== +-- A. 必须初始化(权限与菜单链路) +-- 表:role/menu/role_permission/role_menu +-- ===================================================== + +-- A1) 复制模板租户角色(role_code 维度) +SET @next_role_id := (SELECT IFNULL(MAX(id), 0) FROM role); + +INSERT INTO role (id, tenant_id, role_code, role_name, status, is_deleted, created_by, updated_by) +SELECT + (@next_role_id := @next_role_id + 1) AS id, + @target_tenant_id AS tenant_id, + tr.role_code, + tr.role_name, + tr.status, + 0 AS is_deleted, + 0 AS created_by, + 0 AS updated_by +FROM role tr +LEFT JOIN role er + ON er.tenant_id=@target_tenant_id + AND er.role_code=tr.role_code + AND er.is_deleted=0 +WHERE tr.tenant_id=@template_tenant_id + AND tr.is_deleted=0 + AND er.id IS NULL; + +-- A2) 复制模板租户菜单 +INSERT INTO menu (tenant_id, menu_code, menu_name, route_path, permission_code, sort_no, status, is_deleted, created_by, updated_by) +SELECT + @target_tenant_id AS tenant_id, + tm.menu_code, + tm.menu_name, + tm.route_path, + tm.permission_code, + tm.sort_no, + tm.status, + 0 AS is_deleted, + 0 AS created_by, + 0 AS updated_by +FROM menu tm +LEFT JOIN menu em + ON em.tenant_id=@target_tenant_id + AND em.menu_code=tm.menu_code + AND em.is_deleted=0 +WHERE tm.tenant_id=@template_tenant_id + AND tm.is_deleted=0 + AND em.id IS NULL; + +-- A3) 复制 role_permission(按 role_code 映射) +SET @next_role_perm_id := (SELECT IFNULL(MAX(id), 0) FROM role_permission); + +INSERT INTO role_permission (id, tenant_id, role_id, permission_id) +SELECT + (@next_role_perm_id := @next_role_perm_id + 1) AS id, + @target_tenant_id AS tenant_id, + target_role.id AS role_id, + rp.permission_id +FROM role_permission rp +JOIN role template_role + ON template_role.tenant_id=rp.tenant_id + AND template_role.id=rp.role_id + AND template_role.is_deleted=0 +JOIN role target_role + ON target_role.tenant_id=@target_tenant_id + AND target_role.role_code=template_role.role_code + AND target_role.is_deleted=0 +LEFT JOIN role_permission existing_rp + ON existing_rp.tenant_id=@target_tenant_id + AND existing_rp.role_id=target_role.id + AND existing_rp.permission_id=rp.permission_id +WHERE rp.tenant_id=@template_tenant_id + AND existing_rp.id IS NULL; + +-- A4) 复制 role_menu(按 role_code + menu_code 映射) +SET @next_role_menu_id := (SELECT IFNULL(MAX(id), 0) FROM role_menu); + +INSERT INTO role_menu (id, tenant_id, role_id, menu_id) +SELECT + (@next_role_menu_id := @next_role_menu_id + 1) AS id, + @target_tenant_id AS tenant_id, + target_role.id AS role_id, + target_menu.id AS menu_id +FROM role_menu rm +JOIN role template_role + ON template_role.tenant_id=rm.tenant_id + AND template_role.id=rm.role_id + AND template_role.is_deleted=0 +JOIN menu template_menu + ON template_menu.tenant_id=rm.tenant_id + AND template_menu.id=rm.menu_id + AND template_menu.is_deleted=0 +JOIN role target_role + ON target_role.tenant_id=@target_tenant_id + AND target_role.role_code=template_role.role_code + AND target_role.is_deleted=0 +JOIN menu target_menu + ON target_menu.tenant_id=@target_tenant_id + AND target_menu.menu_code=template_menu.menu_code + AND target_menu.is_deleted=0 +LEFT JOIN role_menu existing_rm + ON existing_rm.tenant_id=@target_tenant_id + AND existing_rm.role_id=target_role.id + AND existing_rm.menu_id=target_menu.id +WHERE rm.tenant_id=@template_tenant_id + AND existing_rm.id IS NULL; + +-- ===================================================== +-- B. 可选初始化(建议) +-- ===================================================== + +-- B1) 模板类型开关(template_type_option) +-- 注意:type_code 是主键。若库里是“全局唯一”,目标租户可能已存在同 code。 +-- 这里按 (type_code, tenant_id) 语义做幂等,重复时仅更新展示字段。 +INSERT INTO template_type_option (type_code, tenant_id, type_name, status, sort_no) +SELECT + tto.type_code, + @target_tenant_id AS tenant_id, + tto.type_name, + tto.status, + tto.sort_no +FROM template_type_option tto +WHERE tto.tenant_id=@template_tenant_id +ON DUPLICATE KEY UPDATE + type_name=VALUES(type_name), + status=VALUES(status), + sort_no=VALUES(sort_no), + updated_at=CURRENT_TIMESTAMP; + +-- B2) 会议字段字典(meeting_field) +INSERT INTO meeting_field (tenant_id, field_code, field_name, field_values, scope_type, project_id, sort_no, status, is_deleted, created_by, updated_by) +SELECT + @target_tenant_id AS tenant_id, + mf.field_code, + mf.field_name, + mf.field_values, + mf.scope_type, + NULL AS project_id, + mf.sort_no, + mf.status, + 0 AS is_deleted, + 0 AS created_by, + 0 AS updated_by +FROM meeting_field mf +LEFT JOIN meeting_field emf + ON emf.tenant_id=@target_tenant_id + AND emf.field_code=mf.field_code + AND emf.is_deleted=0 +WHERE mf.tenant_id=@template_tenant_id + AND mf.is_deleted=0 + AND emf.id IS NULL; + +-- ===================================================== +-- C. 不建议初始化(运行数据表,仅业务发生时产生) +-- project/meeting/audit*/finance*/notification_task/export_task/operation_audit_log 等 +-- ===================================================== diff --git a/docs/未开发项开发迭代清单.md b/docs/未开发项开发迭代清单.md new file mode 100644 index 0000000..6718f6d --- /dev/null +++ b/docs/未开发项开发迭代清单.md @@ -0,0 +1,558 @@ +# 会议核销SaaS 未开发项迭代清单(2026-03 最新) + +## 一、现状结论 + +- 已完成:项目/会议/审核/财务主链路、审核流配置化、用户角色增强、数据权限、模板管理(上传/发布/停用/回滚/下载留痕/类型配置)、会议资料六大模块(`BASIC_INFO`、`WRITE_OFF_DOCS`、`ONSITE_PHOTO`、`LABOR_PROTOCOL`、`INVOICE_DETAIL`、`EXPERT_PROFILE`)、通知策略中心、可观测性告警中心(含抑制与恢复)。 +- 主要缺口:通知外部通道真实SDK接入(厂商鉴权/模板审核/限流策略)与经营分析报表(多维钻取)。 +- 本清单同步维护“状态看板 + 迭代任务”,用于区分已完成、进行中与未开始项。 + +### 1.1 状态看板(2026-03-10) + +| 模块/任务 | 当前状态 | 说明 | +|---|---|---| +| T1 租户管理(C1) | 已完成(待QA执行) | 后端接口、前端页面、启停与权限控制已接通;仍需按测试单执行并回填结果。 | +| T2 企业管理(C2) | 已完成(待QA执行) | 企业主数据与项目引用联动已落地;仍需回填回归结果。 | +| T3 菜单管理(C3) | 已完成(待QA执行) | 菜单维护、角色绑定、排序与动态加载已落地;仍需补最终回归记录。 | +| T4 代理授权(C4) | 已完成(待手工QA执行) | 生命周期、鉴权继承、自动化单测与回归模板已完成;手工用例执行结果待回填。 | +| T5 账号有效期(C5) | 功能已完成(QA未闭环) | 字段迁移、登录/会话拦截、前端展示已实现;缺少任务卡级进展与QA执行记录。 | +| P2-5 通知真实SDK接入 | 未开始 | 真实供应商鉴权、模板审核、失败原因标准化仍为主要缺口。 | +| 经营分析报表(多维钻取) | 未开始 | 当前仅有运营看板汇总,未形成报表钻取能力。 | + +## 二、P0(必须补齐) + +### P0-1 多租户真实化改造(替换硬编码 tenant_id=1) + +- 范围 + - 租户上下文从登录态注入,业务查询/写入全部走动态 `tenant_id`。 + - 新增租户管理(平台管理员):创建、启停、查询。 +- 后端 API + - `GET /api/tenants` + - `POST /api/tenants` + - `POST /api/tenants/{id}/enable` + - `POST /api/tenants/{id}/disable` +- DDL + - `tenant`(若未创建) + - 业务高频表补充复合索引(`tenant_id + 业务键`) +- 验收 + - 同一账号跨租户不可见数据。 + - 导出/下载/搜索均走租户隔离。 +- 人天 + - 后端 8d + 前端 2d + 测试 3d = **13d** + +### P0-2 审计日志中心(关键动作可检索/导出) + +- 范围 + - 登录、权限变更、审核动作、支付确认、导出下载等关键操作留痕。 + - 支持条件检索与导出。 +- 后端 API + - `GET /api/audit-logs` + - `GET /api/audit-logs/export` +- 前端 + - 新增“审计日志”页面(筛选、列表、导出)。 +- DDL + - `operation_audit_log`(落表 + 索引优化) +- 验收 + - 高风险动作 100% 可追溯到人/时间/对象。 +- 人天 + - 后端 4d + 前端 2d + 测试 2d = **8d** + +## 三、P1(高优先级业务补齐) + +### P1-1 会议资料四大模块补齐 + +- 范围 + - 新增模块:`ONSITE_PHOTO`、`LABOR_PROTOCOL`、`INVOICE_DETAIL`、`EXPERT_PROFILE`。 + - 当前进度:四个新增模块已全部完成(`ONSITE_PHOTO`、`LABOR_PROTOCOL`、`INVOICE_DETAIL`、`EXPERT_PROFILE`)。 + - 模块级保存/提交/历史/审核查看对齐现有两模块能力。 +- 后端 API + - 复用现有 `/materials/{moduleCode}/save|submit|history|current` + - 扩展模块校验规则与字段结构 +- 前端 + - 会议资料页补齐四个模块录入与历史对比 + - 审核端“查看资料”补齐结构化展示 +- DDL + - 复用 `meeting_material` / `meeting_material_history` +- 验收 + - 六大资料模块都支持提交前必填校验与历史可追溯。 +- 人天 + - 后端 5d + 前端 6d + 测试 3d = **14d** + +### P1-2 审核管理增强(转审 + SLA + 批量) + +- 范围 + - 转审、批量催办、SLA 超时升级。 + - 当前进度:已完成后端 API、DDL 与前端审核页能力(转审弹窗、批量催办、SLA 统计与超时标识)。 +- 后端 API + - `POST /api/audits/tasks/{id}/transfer` + - `POST /api/audits/tasks/batch-remind` + - `GET /api/audits/tasks/sla-stat` +- 前端 + - 审核页增加转审弹窗、批量催办、SLA 标识 +- DDL + - `audit_task` 补 `sla_deadline_at`、`timeout_level` + - `audit_transfer_log` +- 验收 + - 超时 4h/12h/24h 升级可见,转审链路有日志。 +- 人天 + - 后端 4d + 前端 3d + 测试 2d = **9d** + +### P1-3 财务对账与锁账 + +- 范围 + - 对账工单、锁账/解锁、差异追踪。 + - 当前进度:已完成后端 API、DDL、支付确认拦截(锁账)与前端财务页对账/锁账记录展示。 +- 后端 API + - `POST /api/finance/reconciliation` + - `POST /api/finance/lock` + - `POST /api/finance/unlock` + - `GET /api/finance/reconciliation/list` +- 前端 + - 财务页补“对账结果”“锁账状态”“解锁记录” +- DDL + - `finance_reconciliation` + - `finance_lock_log` +- 验收 + - 锁账期间禁止关键财务字段写入。 +- 人天 + - 后端 5d + 前端 3d + 测试 2d = **10d** + +### P1-4 模板管理剩余闭环 + +- 范围 + - 归档状态、版本差异追踪、水印下载、流程模板联动(通知模板/结算模板)。 + - 当前进度:已完成归档、版本差异追踪、水印下载、流程模板联动(会议推荐/审核通知/结算模板绑定)。 +- 后端 API + - `POST /api/templates/{id}/archive` + - `GET /api/templates/{id}/versions/diff` + - `GET /api/templates/{id}/download-watermark` +- 前端 + - 模板页补“归档”“版本差异查看” +- 验收 + - 已归档模板不可再发布,差异可视化可用。 +- 人天 + - 后端 3d + 前端 2d + 测试 1d = **6d** + +## 四、P2(治理与效率) + +### P2-1 专家(参会人)管理全量能力 + +- 范围 + - 主档案、多卡、去重合并、导入导出、会议快照。 + - 当前进度:已完成主档案、多卡、去重合并、导入导出与专家管理页面;会议提交已接入专家快照写入链路。 +- 后端 API + - `GET/POST /api/experts` + - `POST /api/experts/{id}/merge` + - `POST /api/experts/import` + - `GET /api/experts/export` +- 前端 + - 专家管理页面 + 合并记录 + 银行卡管理 +- DDL + - `expert`、`expert_bank_card`、`expert_merge_log`、`meeting_expert_snapshot` +- 验收 + - 身份证唯一校验,历史会议读快照。 +- 人天 + - 后端 8d + 前端 6d + 测试 4d = **18d** + +### P2-2 通知策略中心 + +- 范围 + - 事件->渠道->对象可配置,模板变量统一管理。 + - 当前进度:已完成策略中心 DDL、后端 `GET/POST/PUT /api/notification-policies` 与前端通知策略页面。 +- 后端 API + - `GET/POST/PUT /api/notification-policies` +- 前端 + - 通知策略页面 +- DDL + - `notification_policy`、`notification_policy_event` +- 验收 + - 策略即时生效,可追溯变更。 +- 人天 + - 后端 4d + 前端 2d + 测试 2d = **8d** + +### P2-3 可观测性与告警 + +- 范围 + - 指标、日志、告警阈值、任务失败告警闭环。 + - 当前进度:已完成指标埋点(API、异步任务、导出)与告警规则中心(规则配置、手动评估、事件查询、抑制窗口、自动恢复)。 +- 交付 + - 指标埋点(API、异步任务、导出) + - 告警规则(5xx、任务积压、超时率) +- 验收 + - 告警可触发、可恢复、可追踪。 +- 人天 + - 后端 3d + 运维 2d + 测试 1d = **6d** + +### P2-4 补缺闭环(文档对照新增) + +- 范围 + - 已完成:会议撤回提交(状态机与审计联动) + - 已完成:会议字段管理(字典配置) + - 已完成:发票管理(抬头主数据) + - 已完成:通知执行引擎(任务化发送、重试、回执) + - 已完成:导出任务中心(任务状态、下载安全) + - 已完成:可观测性自动评估与连续恢复判定 +- 后端 API(建议) + - `POST /api/meetings/{id}/withdraw` + - `GET/POST/PUT /api/meeting-fields` + - `GET/POST/PUT /api/invoice-profiles` + - `POST /api/notifications/dispatch` + - `POST /api/notifications/receipts` + - `GET /api/notifications/tasks` + - `GET/POST /api/export-tasks` + - `POST /api/export-tasks/{id}/refresh-token` + - `GET /api/export-tasks/{id}/download` + - `POST /api/observability/alert-rules/evaluate/auto` + - `GET /api/operations/dashboard` +- 验收 + - 文档与实现接口 1:1 对齐,新增模块可独立上线并通过回归。 +- 人天 + - 后端 8d + 前端 6d + 测试 4d = **18d** + +### P2-5 深化治理(进行中) + +- 范围 + - 已完成:通知回执标准化(消息ID、回执码、回执日志) + - 已完成:导出下载鉴权与过期策略(令牌刷新、过期控制、下载计数) + - 已完成:运营看板趋势与TOP(通知/导出/告警) + - 待完成:真实供应商SDK接入与失败原因规范化字典 +- 后端 API(新增) + - `POST /api/notifications/receipts` + - `POST /api/notifications/receipts/webhook` + - `POST /api/export-tasks/{id}/refresh-token` + - `GET /api/export-tasks/{id}/download` + - `GET /api/operations/dashboard` + +## 五、建议迭代顺序(接下来 4 个 Sprint) + +- Sprint E(2周):P0-1 多租户真实化 + P0-2 审计日志中心 +- Sprint F(2周):P1-1 资料四模块补齐 + P1-2 审核增强 +- Sprint G(2周):P1-3 财务对账锁账 + P1-4 模板闭环 +- Sprint H(2-3周):P2-1 专家管理 + P2-2 通知策略 + P2-3 可观测性 + +## 六、DoD(本清单统一完成标准) + +- 接口文档:请求/响应/错误码补齐。 +- 数据迁移:Flyway 脚本可重复执行,预发验证通过。 +- 权限校验:菜单权限 + 动作权限 + 数据权限全链路生效。 +- 测试:单测 + 关键集成测试 + 最少 1 条回归脚本。 +- 留痕:关键动作有审计日志(操作者、对象、前后值、时间)。 + +## 七、“有概念、无模块化定义”缺口补全(本轮新增) + +### C1 租户管理模块化补全(平台级) + +- 缺口来源 + - 业务文档已有“平台超级管理员可创建/启停单位主体”概念,但缺页面与流程化定义。 + - 技术文档“接口实施状态”提到已实现租户能力,但接口清单未显式列出租户分组。 +- 页面定义(前端) + - 新增“租户管理”菜单与页面:列表、创建、启用、停用。 + - 字段:`tenantCode`、`tenantName`、`status`、`createdAt`。 +- 后端 API + - `GET /api/tenants` + - `POST /api/tenants` + - `POST /api/tenants/{id}/enable` + - `POST /api/tenants/{id}/disable` +- 数据与约束 + - `tenant_code` 唯一,状态仅允许 `ENABLED/DISABLED`。 + - 启停租户需写入审计日志(动作码建议:`tenant.manage`)。 +- 验收 + - 平台管理员可在页面完成全流程;无 `tenant.manage` 权限用户不可操作。 + - 两份主文档均包含模块、接口、权限点与页面入口说明。 +- 人天 + - 后端 1d + 前端 1d + 测试 1d = **3d** + +### C2 企业管理模块化补全(系统设置) + +- 缺口来源 + - 业务文档有“企业管理”字段要求,但技术文档无对应模块定义/接口清单。 +- 页面定义(前端) + - 列表、详情、创建、编辑、启停(如业务确认需要)。 + - 字段:企业名称、网址、Logo、状态、更新时间。 +- 后端 API(建议) + - `GET /api/enterprises` + - `POST /api/enterprises` + - `PUT /api/enterprises/{id}` + - `POST /api/enterprises/{id}/enable` + - `POST /api/enterprises/{id}/disable` +- 数据与约束 + - 新增 `enterprise` 主表;名称唯一,Logo 必填校验。 + - 项目模块“合作企业”字段改为引用企业主数据。 +- 验收 + - 项目创建/编辑可引用企业主数据,禁止手填脏数据。 + - 企业启停后在项目选择器实时生效。 +- 人天 + - 后端 3d + 前端 2d + 测试 2d = **7d** + +### C3 菜单管理模块化补全(权限可见性) + +- 缺口来源 + - 业务文档有“菜单管理”概念,技术文档未定义菜单数据模型和权限映射策略。 +- 页面定义(前端) + - 菜单列表(树形)、角色菜单绑定、菜单启停与排序。 +- 后端 API(建议) + - `GET /api/menus` + - `POST /api/menus` + - `PUT /api/menus/{id}` + - `POST /api/roles/{id}/menus` + - `GET /api/roles/{id}/menus` +- 数据与约束 + - `menu`、`role_menu` 表;菜单与 `permissionCode` 建立映射。 + - 前端“可见性”与后端“动作权限”双校验,禁止只做前端隐藏。 +- 验收 + - 不同角色登录看到不同菜单树,且后端越权访问仍被拦截。 +- 人天 + - 后端 4d + 前端 3d + 测试 2d = **9d** + +### C4 代理授权模块化补全(用户生命周期) + +- 缺口来源 + - 业务文档明确要求代理授权生效/失效与留痕,技术文档仅概念提及。 +- 页面定义(前端) + - 用户详情新增“代理授权”配置:代理人、生效时间、失效时间、原因。 + - 代理记录列表:状态(待生效/生效中/已失效)。 +- 后端 API(建议) + - `POST /api/users/{id}/delegations` + - `GET /api/users/{id}/delegations` + - `POST /api/delegations/{id}/disable` +- 数据与约束 + - 新增 `user_delegation` 表,记录授权窗口、状态、创建人与失效原因。 + - 审计记录动作码建议:`user.delegation.manage`。 +- 验收 + - 到达失效时间自动失效;代理操作可完整追溯授权链。 +- 人天 + - 后端 3d + 前端 2d + 测试 2d = **7d** + +### C5 账号有效期模块化补全(鉴权拦截) + +- 缺口来源 + - 业务文档要求账号有效期必填,技术文档缺字段规范与认证拦截定义。 +- 页面定义(前端) + - 用户新增/编辑增加“有效期开始/结束”。 + - 到期账号列表筛选与状态提示。 +- 后端 API(建议) + - 复用 `POST/PUT /api/users` 增加 `validFrom`、`validTo` 字段。 + - 登录接口增加有效期校验失败错误码返回(建议 `11004`)。 +- 数据与约束 + - `sys_user` 增加 `valid_from`、`valid_to`。 + - 登录、刷新令牌、关键写操作统一做有效期检查。 +- 验收 + - 过期账号无法登录且返回统一错误码;在有效期内恢复正常。 +- 人天 + - 后端 2d + 前端 1d + 测试 1d = **4d** + +## 八、缺口补全里程碑(建议) + +| 里程碑 | Sprint | 交付模块 | 目标结果 | +|---|---|---|---| +| M1 | Sprint I(1周) | C1 租户管理、C5 账号有效期 | 平台可创建/启停租户;账号到期拦截口径统一 | +| M2 | Sprint J(2周) | C2 企业管理、C4 代理授权 | 企业主数据可维护并被项目引用;代理授权可配置并自动失效 | +| M3 | Sprint K(2周) | C3 菜单管理 | 角色菜单树可配置;菜单可见性与动作权限一体化生效 | +| M4 | Sprint L(1周) | 文档与验收收口 | 两份主文档与实现清单 1:1 对齐,补齐回归与审计验证 | + +### 里程碑验收门槛(统一) + +- 接口与页面:每个模块至少 1 条端到端回归脚本通过。 +- 权限链路:登录鉴权、动作权限、数据权限、有效期校验全部生效。 +- 数据治理:Flyway 脚本可重复执行,升级/回滚路径可验证。 +- 审计追溯:创建、启停、授权、失效、权限变更全量留痕可检索。 + +## 九、开发任务卡(可直接排期) + +### 9.0 任务状态总览(2026-03-10) + +| 任务 | 状态 | 备注 | +|---|---|---| +| T1 | 已完成(待QA执行) | 功能已落地,测试结果待回填。 | +| T2 | 已完成(待QA执行) | 功能已落地,测试结果待回填。 | +| T3 | 已完成(待QA执行) | 功能已落地,测试结果待回填。 | +| T4 | 已完成(待手工QA执行) | 自动化基线已补,手工回归待执行。 | +| T5 | 功能已完成(QA未闭环) | 缺任务卡进展说明与回归结果。 | + +### T1 租户管理(平台级) + +- 目标 + - 提供租户列表、创建、启用、停用能力,并纳入统一权限与审计链路。 +- 后端任务 + - 完成 `TenantController/TenantService` 接口稳定化与参数校验。 + - 增加错误码映射:重复租户编码、状态非法、无权限。 + - 审计日志接入:`tenant.manage`(创建/启停分动作码)。 +- 前端任务 + - 新增 `TenantPage`(列表、创建弹窗、启停按钮)。 + - 在菜单中增加平台入口(仅具备权限用户可见)。 + - 按钮权限:`tenant.manage`。 +- SQL/Flyway + - 确认 `tenant` 表字段完整:`tenant_code` 唯一、`status`、审计字段。 + - 历史数据修复脚本:空 `tenant_code` 回填与唯一约束检查。 +- 测试用例 + - 正常创建租户、重复编码拦截、启停状态切换、无权限拦截。 + - 停用租户下账号登录失败验证。 +- DoD + - 页面可操作、接口稳定、日志可检索、回归通过。 + +- 当前实现进展(2026-03-10) + - 已完成:租户列表/创建/启停接口与前端页面,菜单入口与 `tenant.manage` 按钮权限控制。 + - 已完成:Flyway 兼容修复与 `tenant_code` 约束补齐。 + - 待完成:按 `TASK-T1-QA-01` 执行手工回归并回填结果。 + +### T2 企业管理(系统设置) + +- 目标 + - 企业主数据独立维护,并可在项目模块引用,杜绝手填脏数据。 +- 后端任务 + - 新增企业 CRUD 与启停接口。 + - 项目创建/编辑改为企业 ID 引用校验。 + - 约束:企业停用后不可被新增项目引用。 +- 前端任务 + - 新增企业管理页(列表、详情、创建、编辑、启停)。 + - 项目页面“合作企业”改为下拉选择企业主数据。 +- SQL/Flyway + - 新增 `enterprise` 表及唯一索引(企业名称/编码按最终口径确定)。 + - 项目表增加 `enterprise_id`(若当前为名称存储需迁移脚本)。 +- 测试用例 + - 企业启停与项目引用联动、重复名称校验、历史项目兼容查询。 +- DoD + - 企业数据可维护,项目联动稳定,历史数据可回溯。 + +- 当前实现进展(2026-03-10) + - 已完成:企业表迁移、企业 CRUD/启停接口、项目绑定企业引用校验。 + - 已完成:企业管理页与项目“合作企业”下拉联动。 + - 待完成:按 `TASK-T2-QA-01` 执行联调回归并回填结果。 + +### T3 菜单管理(角色菜单树) + +- 目标 + - 菜单可见性可配置,且与动作权限统一治理。 +- 后端任务 + - 提供菜单树查询、菜单维护、角色菜单绑定接口。 + - 校验角色绑定数据合法性(菜单存在、状态可用、层级正确)。 +- 前端任务 + - 新增菜单管理页(树形、排序、启停、角色绑定)。 + - 登录后按角色加载菜单树,未授权菜单不展示。 +- SQL/Flyway + - 新增 `menu`、`role_menu` 表,建立索引(`role_id`、`menu_id`)。 + - 菜单与 `permission_code` 字段对齐。 +- 测试用例 + - 角色切换后菜单变化、越权接口后端拦截、菜单停用即时生效。 +- DoD + - 菜单可配置、权限一致、越权不可达。 + +- 当前实现进展(2026-03-10) + - 已完成:菜单管理接口、角色菜单绑定、`permission_code` 对齐与批量排序保存。 + - 已完成:前端菜单管理页、角色绑菜单、登录后按权限动态加载菜单。 + - 待完成:按 `TASK-T3-QA-01` 执行越权与可见性回归并回填结果。 + +### T4 代理授权(用户生命周期) + +- 目标 + - 支持代理授权时间窗配置、自动失效、全链路审计。 +- 后端任务 + - 提供授权创建/查询/停用接口。 + - 增加定时任务:过期授权自动置为失效。 + - 鉴权侧接入代理上下文判定(仅在有效窗口内生效)。 +- 前端任务 + - 用户详情新增代理授权配置与记录列表。 + - 显示授权状态(待生效/生效中/已失效/手动停用)。 +- SQL/Flyway + - 新增 `user_delegation` 表及状态索引(`status`,`effective_to`)。 +- 测试用例 + - 授权生效、过期自动失效、手动停用、审计记录完整。 +- DoD + - 授权链路可追溯、时间窗行为正确、异常场景可控。 + +- 当前实现进展(2026-03-10) + - 已完成:`V37__user_delegation.sql`(表结构+索引+权限种子)、授权创建/查询/停用接口、过期自动失效定时任务、用户页代理授权弹窗(新增/列表/停用)。 + - 已完成:鉴权链路代理上下文生效(代理人可在有效窗口内继承被代理人权限),并补充了后端自动化测试覆盖关键路径。 + - 已完成:`TASK-T4-QA-01` 回归用例模板与自动化单测基线(代理权限继承、时间窗参数校验)。 + +- T4-QA 回归执行清单(可直接拷贝到测试单) + +| 用例ID | 场景 | 前置条件 | 操作步骤 | 预期结果 | 执行结果 | +|---|---|---|---|---|---| +| T4-QA-001 | 授权立即生效 | 用户A、用户B均启用;A具有业务权限;B不具备该权限 | 在用户页为A配置代理人B,生效时间=当前前,失效时间=未来 | 授权记录状态为`ENABLED`;B可访问A对应权限接口 | 待执行 | +| T4-QA-002 | 授权待生效 | 用户A、用户B均启用 | 配置生效时间=未来,失效时间=更未来 | 记录状态为`PENDING`;B暂不可继承A权限 | 待执行 | +| T4-QA-003 | 授权自动过期 | 已存在`ENABLED`授权记录,失效时间即将到达 | 等待定时任务窗口(或手工触发任务) | 状态自动切为`EXPIRED`;B失去继承权限 | 待执行 | +| T4-QA-004 | 手动停用授权 | 已存在`ENABLED`或`PENDING`授权记录 | 在代理授权列表点击“停用”并填写原因 | 状态切为`DISABLED`;停用原因可查询 | 待执行 | +| T4-QA-005 | 禁止自代理 | 用户A存在 | 为A创建代理,代理人也选择A | 创建失败,返回业务错误“代理人不能与被代理人相同” | 待执行 | +| T4-QA-006 | 非法时间窗拦截 | 用户A、用户B存在 | 创建授权:`effectiveTo <= effectiveFrom` | 创建失败,返回业务错误“失效时间必须晚于生效时间” | 待执行 | +| T4-QA-007 | 权限闭环审计 | 已开启操作审计 | 完成一次“创建授权->停用授权”链路 | 审计日志存在对应 API 调用记录与状态变更痕迹 | 待执行 | + +- T4-QA 自动化测试映射(当前已落地) + - `PermissionServiceDelegationTest`:覆盖代理权限继承与权限集合去重。 + - `UserDelegationServiceValidationTest`:覆盖自代理拦截、时间窗非法拦截。 + +### T5 账号有效期(统一鉴权拦截) + +- 目标 + - 在登录、令牌刷新、关键写操作统一校验账号有效期。 +- 后端任务 + - `sys_user` 引入 `valid_from/valid_to` 并在认证链路校验。 + - 新增错误码(建议 `11004`)与统一错误文案。 +- 前端任务 + - 用户创建/编辑增加有效期字段及校验。 + - 到期状态在用户列表显式标记。 +- SQL/Flyway + - `sys_user` 加字段与默认值迁移;历史账号回填策略。 +- 测试用例 + - 有效期前不可登录、有效期内可登录、过期后自动拦截。 + - 关键写接口(非登录)也进行有效期拒绝校验。 +- DoD + - 各链路校验一致,错误码统一,回归通过。 + +- 当前实现进展(2026-03-10) + - 已完成:`V33__user_account_validity.sql` 字段迁移与历史回填(`valid_from/valid_to`)。 + - 已完成:登录与鉴权拦截统一有效期校验,错误码 `11004` 接入前端自动登出处理。 + - 已完成:用户页面有效期录入与“已过期”状态展示。 + - 待完成:补齐 `TASK-T5-QA-01` 回归执行清单与结果回填。 + +## 十、Jira/禅道建单模板(可导入) + +### 10.1 使用说明 + +- 建议先创建 5 个 Epic:`EPIC-T1`~`EPIC-T5`(对应 T1~T5)。 +- 下面“任务导入表”可直接复制为 CSV(逗号分隔)导入。 +- `依赖` 列用于排期时设置“前置任务”,避免并行冲突。 + +### 10.2 Epic 列表 + +| Epic ID | Epic 名称 | 目标 | +|---|---|---| +| EPIC-T1 | 租户管理平台化 | 完成租户创建/启停/审计全链路 | +| EPIC-T2 | 企业管理主数据化 | 企业主数据维护并联动项目引用 | +| EPIC-T3 | 菜单权限一体化 | 菜单树与动作权限统一治理 | +| EPIC-T4 | 代理授权生命周期 | 授权配置、自动失效、可审计 | +| EPIC-T5 | 账号有效期统一校验 | 登录与关键写操作统一拦截 | + +### 10.3 任务导入表(CSV列头) + +`IssueKey,Summary,IssueType,EpicLink,OwnerRole,EstimateDays,Priority,DependsOn,AcceptanceCriteria` + +`TASK-T1-BE-01,租户管理后端接口与校验,Task,EPIC-T1,后端,1,P0,,实现GET/POST tenants与启停接口并通过单测` +`TASK-T1-FE-01,租户管理前端页面与菜单入口,Task,EPIC-T1,前端,1,P0,TASK-T1-BE-01,完成列表创建启停页面并接通权限控制` +`TASK-T1-QA-01,租户管理联调与回归,Task,EPIC-T1,测试,1,P0,TASK-T1-BE-01;TASK-T1-FE-01,覆盖创建重复编码启停无权限用例` + +`TASK-T2-DB-01,企业主数据表设计与迁移脚本,Task,EPIC-T2,后端,1,P1,,新增enterprise表并完成迁移验证` +`TASK-T2-BE-01,企业管理接口与项目引用校验,Task,EPIC-T2,后端,2,P1,TASK-T2-DB-01,实现企业CRUD启停并联动项目引用约束` +`TASK-T2-FE-01,企业管理页面与项目企业下拉,Task,EPIC-T2,前端,2,P1,TASK-T2-BE-01,企业页可维护且项目页改为企业下拉` +`TASK-T2-QA-01,企业管理联调回归,Task,EPIC-T2,测试,2,P1,TASK-T2-BE-01;TASK-T2-FE-01,覆盖启停联动重复名称历史数据兼容` + +`TASK-T3-DB-01,菜单与角色菜单关系表迁移,Task,EPIC-T3,后端,1,P1,,新增menu与role_menu并建索引` +`TASK-T3-BE-01,菜单树与角色绑定后端接口,Task,EPIC-T3,后端,2,P1,TASK-T3-DB-01,实现菜单树查询维护与角色绑定接口` +`TASK-T3-FE-01,菜单管理页面与动态菜单加载,Task,EPIC-T3,前端,3,P1,TASK-T3-BE-01,角色切换后菜单可见性正确生效` +`TASK-T3-QA-01,菜单权限回归与越权验证,Task,EPIC-T3,测试,2,P1,TASK-T3-BE-01;TASK-T3-FE-01,覆盖前端可见性与后端越权拦截` + +`TASK-T4-DB-01,代理授权表与状态索引迁移,Task,EPIC-T4,后端,1,P1,,新增user_delegation表并通过迁移验证` +`TASK-T4-BE-01,代理授权接口与自动失效任务,Task,EPIC-T4,后端,2,P1,TASK-T4-DB-01,实现授权创建查询停用与过期自动失效` +`TASK-T4-FE-01,用户详情代理授权UI,Task,EPIC-T4,前端,2,P1,TASK-T4-BE-01,支持授权配置与状态展示` +`TASK-T4-QA-01,代理授权链路回归,Task,EPIC-T4,测试,2,P1,TASK-T4-BE-01;TASK-T4-FE-01,覆盖生效过期停用与审计追溯` + +`TASK-T5-DB-01,用户有效期字段迁移,Task,EPIC-T5,后端,1,P0,,sys_user新增valid_from与valid_to并回填策略` +`TASK-T5-BE-01,认证链路有效期统一校验,Task,EPIC-T5,后端,1,P0,TASK-T5-DB-01,登录刷新关键写操作统一拦截并返回11004` +`TASK-T5-FE-01,用户有效期表单与状态展示,Task,EPIC-T5,前端,1,P0,TASK-T5-BE-01,新增有效期字段与到期标识展示` +`TASK-T5-QA-01,有效期全链路回归,Task,EPIC-T5,测试,1,P0,TASK-T5-BE-01;TASK-T5-FE-01,覆盖生效前有效期内过期后三段场景` + +### 10.4 建议前置依赖图 + +- `T1` 与 `T5` 可并行,建议优先完成(P0)。 +- `T2` 依赖企业表迁移后再做项目联动。 +- `T3` 建议在 `T1/T5` 稳定后推进,避免权限链路改动冲突。 +- `T4` 可与 `T2/T3` 并行,但需与鉴权改造保持分支隔离。 diff --git a/docs/未开发项开发迭代清单V2.md b/docs/未开发项开发迭代清单V2.md new file mode 100644 index 0000000..94c4a70 --- /dev/null +++ b/docs/未开发项开发迭代清单V2.md @@ -0,0 +1,131 @@ +# 会议核销SaaS 未开发项开发迭代清单 V2(2026-03) + +## 一、V2目标与范围 + +- 目标:基于 `会议核销SaaS系统_技术开发文档.md` 新增 `6.10/7.7/7.8`,完成“字段级口径 + 接口闭环 + 权限矩阵”落地。 +- 范围:仅覆盖当前确认缺口,不回滚已完成能力。 +- 原则:先补齐 P0 闭环,再推进 P1 增强;所有新增能力必须带审计、权限、测试。 + +## 二、当前迭代状态(V2启动) + +| 迭代项 | 优先级 | 状态 | 说明 | +|---|---|---|---| +| V2-P0-1 字段级数据字典落库(6.10) | P0 | 已完成(待预发验证) | 已新增 `V38__v2a_field_dictionary.sql`,包含加列/新表/索引。 | +| V2-P0-2 接口缺口补齐(7.7) | P0 | 已完成(后端待QA,前端待联调) | 角色、数据权限、审计日志、资料包、会议总结、通知策略补齐。 | +| V2-P0-3 权限矩阵落地(7.8) | P0 | 进行中 | 接口三元绑定(`permissionCode/dataScopeType/auditActionCode`)统一治理。 | +| V2-P1-1 发票结构化明细全链路 | P1 | 未开始 | 明细项、附件、汇总、校验规则与财务联动。 | +| V2-P1-2 审核SLA与批量能力增强 | P1 | 未开始 | 催办、批量催办、超时升级、审计回溯增强。 | + +## 三、迭代批次规划(建议 3 个 Sprint) + +- Sprint V2-A(本期,1.5~2周) + - V2-P0-1、V2-P0-2、V2-P0-3 全部完成可提测版本。 +- Sprint V2-B(下期,2周) + - V2-P1-1 发票结构化明细 + 财务分类费用打通。 +- Sprint V2-C(下下期,1~1.5周) + - V2-P1-2 审核SLA批量能力增强 + 回归收口。 + +## 四、第一轮迭代(已启动) + +### 4.1 V2-P0-1 字段级数据字典落库(6.10) + +- 范围 + - `project`、`meeting`、`meeting_material`、`audit_task`、`audit_action_log`、`expert`、`expert_bank_card`、`template/template_version`、`finance_meeting_bill` 字段补齐。 + - 新增建议表:`meeting_material_invoice_item`、`meeting_material_invoice_file`、`meeting_invoice_summary`。 +- 后端任务 + - 完成实体/DO/DTO 字段扩展与向后兼容映射。 + - 对关键写接口补充字段校验(金额单位“分”、比例范围、状态枚举合法性)。 + - 当前进展:已完成 `meeting_material` 发票结构化链路、`audit_task` 新字段读写链路(SLA/超时/转审扩展)、`meeting/project` 新字段基础映射、`finance_meeting_bill` 读写接口、`expert/template` 新字段映射。 +- DB/Flyway任务 + - 已完成:`backend/src/main/resources/db/migration/V38__v2a_field_dictionary.sql`(幂等加列 + 新表 + 索引)。 + - 待完成:预发执行验证、回填策略脚本(如需)与回滚演练记录。 +- 验收标准 + - 新增字段不破坏现有接口。 + - 发票明细可结构化保存并查询。 + - 审核、财务、专家关键字段可检索可追溯。 + +### 4.2 V2-P0-2 接口缺口补齐(7.7) + +- 范围 + - 角色管理、数据权限管理、审计日志查询导出、会议资料包导出、会议总结生成下载、通知策略配置侧。 +- 目标接口组(本期必须交付) + - 角色:`GET/POST/PUT /api/roles`、`POST /api/roles/{id}/enable|disable`、`POST /api/roles/{id}/permissions` + - 数据权限:`GET/POST/PUT /api/data-scope-policies`、`POST /api/data-scope-policies/{id}/copy|assign-roles|enable|disable` + - 审计日志:`GET /api/audit-logs`、`GET/POST /api/audit-logs/export-tasks` + - 会议资料:`POST /api/meetings/{id}/materials/{module}/submit|resubmit`、`POST /api/meetings/{id}/materials/export` + - 会议总结:`POST /api/meetings/{id}/summary/generate`、`GET /api/meetings/{id}/summary/download` + - 通知策略配置:`GET/POST/PUT /api/notification-policies`、`POST /api/notification-policies/{id}/events|enable|disable` +- 验收标准 + - 接口文档、权限码、错误码、审计动作码齐全。 + - 前端按钮权限与后端接口权限一致。 + - 导出/下载能力遵循数据权限范围。 +- 当前进展 + - 已补齐通知策略配置侧接口:`POST /api/notification-policies/{id}/events|enable|disable`。 + - 已补齐会议字段配置启停接口:`POST /api/meeting-fields/{id}/enable|disable`。 + - 已补齐发票抬头配置启停接口与别名路由:`POST /api/invoice-profiles/{id}/enable|disable`、`/api/invoice-heads`。 + - 前端系统设置页已接入上述能力:数据权限(复制/启停)、通知策略(绑定事件/启停)、会议字段(启停)、发票抬头(启停)。 + - 审计日志页已接入导出任务能力:`GET/POST /api/audit-logs/export-tasks`。 + - 会议页已接入资料包导出与会议总结入口:资料包导出、总结生成、总结令牌刷新、总结下载。 + +### 4.3 V2-P0-3 权限矩阵落地(7.8) + +- 范围 + - 全量接口补三元绑定:`permissionCode`、`dataScopeType`、`auditActionCode`。 + - 接口发布前增加权限元数据检查门禁。 +- 后端任务 + - 新增权限元数据校验组件(启动校验 + 单测校验)。 + - 统一数据范围枚举:`TENANT/PROJECT/MEETING/MEETING_MODULE/GLOBAL_READONLY`。 +- 当前进展 + - 已完成注解扩展:`@RequirePermission` 支持 `dataScopeType/auditActionCode` 元数据。 + - 已新增启动校验门禁组件:`PermissionMetadataGuard`(支持 `writeoff.permission-metadata.strict` 严格模式)。 + - 已在核心接口组补齐三元标注:通知策略、会议字段、发票抬头、数据权限、审计日志、会议资料包/会议总结接口。 + - 已批量补齐存量控制器三元标注:角色/菜单/用户/租户/企业、模板、专家、审核流/审核任务、财务、导出任务、通知分发、可观测、文件下载等。 +- 前端任务 + - 按钮权限码与接口权限码同源配置。 + - 无权限状态统一“置灰+原因提示”。 +- 当前进展 + - 已新增前端权限常量单源:`frontend/src/constants/permissions.ts`。 + - 已完成页面侧 `hasPermission("...")` 到 `hasPermission(PERMS.xxx)` 的统一替换(系统设置、审核、财务、模板、专家、导出、通知、可观测等模块)。 + - 已补齐主要操作按钮的“无权限置灰 + 原因提示(title)”展示规范。 +- 验收标准 + - 缺权限元数据的接口不可发布。 + - 越权请求稳定返回统一错误码(`20001/20002`)。 + +## 五、任务卡(可直接建单) + +| 任务ID | 模块 | 任务 | 负责人 | 预计人天 | 状态 | 依赖 | +|---|---|---|---|---:|---|---| +| V2A-DB-01 | 字段落库 | 6.10 加列与新表迁移脚本 | 后端 | 2 | 已完成(待预发验证) | - | +| V2A-BE-01 | 字段落库 | 实体/接口字段扩展与校验 | 后端 | 3 | 已完成(待QA) | V2A-DB-01 | +| V2A-QA-01 | 字段落库 | 兼容性回归(存量接口) | 测试 | 1.5 | 未开始 | V2A-BE-01 | +| V2A-BE-02 | 接口补齐 | 角色+数据权限+审计日志接口组 | 后端 | 3 | 已完成(待QA) | - | +| V2A-FE-01 | 接口补齐 | 系统设置页面能力补齐 | 前端 | 3 | 已完成(待QA) | V2A-BE-02 | +| V2A-BE-03 | 接口补齐 | 会议资料包/会议总结接口组 | 后端 | 2 | 已完成(待QA) | - | +| V2A-FE-02 | 接口补齐 | 资料包导出与会议总结入口 | 前端 | 2 | 已完成(待QA) | V2A-BE-03 | +| V2A-BE-04 | 权限矩阵 | 三元绑定校验门禁 | 后端 | 1.5 | 已完成(待QA) | - | +| V2A-FE-03 | 权限矩阵 | 按钮权限同源配置改造 | 前端 | 1.5 | 已完成(待QA) | V2A-BE-04 | +| V2A-QA-02 | 全链路 | 权限+审计+导出下载回归 | 测试 | 2 | 未开始 | V2A-FE-01;V2A-FE-02;V2A-FE-03 | + +## 六、风险与阻塞位 + +- 风险1:历史数据缺失(如旧会议无结构化发票明细)导致回填不完整。 + - 处理:回填脚本允许空值,前端按“旧数据兼容展示”策略处理。 +- 风险2:权限码重构影响存量按钮显隐。 + - 处理:灰度发布,先日志观测再全量切换。 +- 风险3:导出/下载接口并发高峰导致任务堆积。 + - 处理:复用导出任务中心,增加限流与重试监控。 + +## 七、统一DoD(V2) + +- 文档:OpenAPI 与技术文档同步更新,接口示例可直接联调。 +- 数据:Flyway 迁移可重复执行,预发演练通过。 +- 安全:权限、数据范围、审计动作三链路全部生效。 +- 质量:单测覆盖新增校验与状态流转;至少 1 条端到端回归通过。 +- 发布:灰度策略、回滚策略、监控告警规则完成配置。 + +## 八、下一步(默认执行顺序) + +- 第1步:先完成 `V2A-DB-01` + `V2A-BE-01`,锁定字段口径。 +- 第2步:并行推进 `V2A-BE-02`、`V2A-BE-03`、`V2A-BE-04`。 +- 第3步:前端接入 `V2A-FE-01/02/03`,随后执行 `V2A-QA-01/02`。 +- 第4步:输出 Sprint V2-A 版本说明与上线检查单。 diff --git a/docs/租户域模板管理模块优化方案清单.md b/docs/租户域模板管理模块优化方案清单.md new file mode 100644 index 0000000..4a37343 --- /dev/null +++ b/docs/租户域模板管理模块优化方案清单.md @@ -0,0 +1,420 @@ +# 租户域模板管理模块优化方案清单 + +## 1. 适用范围 + +- 本清单仅针对**租户域模板管理模块**。 +- 不包含平台域统一模板治理、跨租户模板审计、平台级模板库等能力。 +- 目标是把租户域模板管理从“能上传、能发布”升级为“可治理、可检索、可追踪、可联动”。 + +## 2. 优化目标 + +- 提升模板管理页面的信息架构与使用效率。 +- 提升模板生命周期治理能力,包括草稿、发布、停用、归档、回滚。 +- 提升模板与会议、审核、结算等业务场景的联动准确性。 +- 提升模板下载留痕、权限控制、时效控制和风险控制能力。 +- 提升后端接口一致性、可扩展性和并发安全。 +- 提升数据库模型的多租户适配性与查询性能。 + +## 3. 现状问题归纳 + +### 3.1 前端页面布局 + +- 一个页面同时承载模板类型开关、模板列表、流程绑定、版本回滚、版本差异、上传建版,信息密度过高。 +- 缺少统一筛选区,用户只能翻表找模板。 +- 创建模板时需要手填 `projectId`、`meetingId`、`objectKey`,使用门槛高。 +- 模板列表缺少关键治理字段展示,例如生效时间、水印开关、下载限制、最近更新时间、维护人。 + +### 3.2 显示内容 + +- 当前列表更偏“技术字段展示”,不够业务化。 +- 版本信息展示不完整,无法快速判断“当前发布版”和“历史草稿版”差异。 +- 下载日志页展示维度少,问题排查效率低。 + +### 3.3 用户操作 + +- 缺少按业务对象选择模板适用范围的交互。 +- 流程绑定操作没有足够的场景提示和影响提示。 +- 水印下载、普通下载、发布、回滚等高风险操作缺少确认与说明。 +- 缺少预览、复制模板、批量筛选、快速查看版本历史等高频能力。 + +### 3.4 后端逻辑 + +- 模板流程绑定与业务消费链路没有完全闭环。 +- 模板创建和新增版本存在并发安全风险。 +- 有效期、水印、下载限流等字段虽然存在,但未完整落地到业务逻辑。 +- 下载日志缺少标准分页、标准筛选和关联名称字段返回。 + +### 3.5 数据库设计 + +- 模板类型配置表的主键设计与多租户语义不完全一致。 +- 日志和版本表索引偏弱,后续数据量上来后容易退化。 +- 模板主表、版本表、流程绑定表的约束还不够完整。 + +## 4. 租户域模板管理优化方案 + +## 4.1 前端页面布局优化 + +### 4.1.1 页面结构重构 + +- 将当前单页改为“三级结构”: + - 一级:模板管理首页 + - 二级:模板详情页 + - 二级:模板下载日志页 +- 模板管理首页仅保留: + - 查询筛选区 + - 模板列表区 + - 快捷操作区 +- 版本历史、版本差异、流程绑定改为详情抽屉或详情页的子区块,不与主列表混排。 + +### 4.1.2 首页布局建议 + +- 顶部操作栏: + - 新建模板 + - 刷新 + - 导出模板清单 +- 查询筛选区: + - 模板名称 + - 模板类型 + - 模板状态 + - 适用范围 + - 业务场景 + - 是否启用水印 + - 生效状态 + - 最近更新时间范围 +- 列表区: + - 默认按“最近更新时间倒序” + - 支持分页 + - 支持空态提示 + +### 4.1.3 详情页布局建议 + +- 基本信息卡片: + - 模板名称、类型、状态、业务场景、适用范围、生效时间、水印、下载限制 +- 当前版本卡片: + - 当前版本号、对象文件、变更说明、创建时间、创建人 +- 版本历史区: + - 版本号、状态、是否生效、变更说明、回滚原因、创建时间 +- 流程联动区: + - 当前已绑定场景 + - 是否允许绑定 + - 绑定影响说明 +- 下载记录区: + - 最近下载记录摘要 + +## 4.2 显示内容优化 + +### 4.2.1 模板列表字段建议 + +- 模板名称 +- 模板类型 +- 业务场景 +- 适用范围 +- 关联项目/会议名称 +- 当前版本号 +- 当前状态 +- 生效开始时间 +- 生效结束时间 +- 水印开关 +- 下载限流 +- 最近更新时间 +- 更新人 + +### 4.2.2 版本列表字段建议 + +- 版本号 +- 版本状态 +- 是否当前生效 +- 文件名 +- 对象存储路径 +- 变更说明 +- 回滚原因 +- 创建人 +- 创建时间 + +### 4.2.3 下载日志字段建议 + +- 模板名称 +- 模板版本号 +- 下载人姓名 +- 下载人账号/手机号 +- 下载时间 +- IP +- User-Agent +- 下载方式 + - 普通下载 + - 水印下载 +- 水印文案 +- 关联项目/会议 + +## 4.3 用户操作优化 + +### 4.3.1 新建模板 + +- 取消手填 `objectKey` 为主的方式,改为: + - 选择模板类型 + - 选择业务场景 + - 选择适用范围 + - 若为项目级,选择项目 + - 若为会议级,选择会议 + - 上传文件 + - 填写变更说明 + - 配置生效时间、水印、下载限流 +- 上传成功后由系统自动回填对象存储路径。 + +### 4.3.2 模板列表操作 + +- 为每条模板提供分层操作: + - 查看详情 + - 新增版本 + - 发布 + - 停用 + - 归档 + - 下载 + - 水印下载 + - 查看版本差异 + - 查看下载记录 + - 复制模板 +- 对危险操作增加二次确认: + - 发布 + - 回滚 + - 停用 + - 归档 + +### 4.3.3 版本管理 + +- 新增版本时默认继承模板基础配置,不重复填写范围与场景。 +- 增加“与当前发布版对比”快捷入口。 +- 回滚时必须填写回滚原因。 +- 已归档模板不可新增版本。 + +### 4.3.4 流程联动 + +- 流程绑定独立为一个区块或独立弹窗。 +- 绑定时只允许选择: + - 已发布 + - 未停用 + - 场景一致 + - 当前生效 + 的模板。 +- 显示绑定影响提示,例如: + - 绑定后将用于会议推荐 + - 绑定后将用于审核通知 + - 绑定后将用于结算材料 + +### 4.3.5 下载日志 + +- 支持按以下条件筛选: + - 模板名称 + - 模板 ID + - 下载人 + - 版本号 + - 下载时间范围 + - IP + - 下载方式 +- 支持跳转查看“该模板全部下载记录”。 + +## 4.4 后端逻辑优化 + +### 4.4.1 查询接口 + +- 模板列表接口增加标准分页与筛选参数: + - `templateName` + - `templateType` + - `status` + - `scopeType` + - `bizScene` + - `watermarkEnabled` + - `effectiveStatus` + - `pageNo` + - `pageSize` +- 下载日志接口增加标准分页与筛选参数,并返回关联名称字段,不再依赖前端二次查用户和模板列表补名。 + +### 4.4.2 模板生命周期校验 + +- 发布校验: + - 当前版本文件存在 + - 模板类型已启用 + - 若配置有效期,开始时间不能晚于结束时间 +- 停用校验: + - 若被流程联动使用,需提示影响 +- 归档校验: + - 已归档模板不可再发布、不可新增版本 +- 回滚校验: + - 只能回滚到本模板已有版本 + - 回滚原因必填 + +### 4.4.3 流程联动闭环 + +- 流程绑定时增加强校验: + - `sceneCode` 必须与模板 `bizScene` 一致 + - 模板必须为 `PUBLISHED` + - 模板必须在生效期内 +- 业务消费链路按联动关系读模板,不允许页面配置和实际业务读取脱节。 +- 会议推荐、审核通知、结算模板获取逻辑统一经过模板服务,不各自散落实现。 + +### 4.4.4 下载能力治理 + +- 普通下载与水印下载分开记录下载类型。 +- 若模板启用下载限流,则下载前校验单位时间内下载次数。 +- 若模板配置有效期,则过期后禁止下载。 +- 若模板启用水印下载,则需要真正生成带水印文件或带水印预签名资源,而不是只回传文案。 + +### 4.4.5 并发与事务安全 + +- 创建模板后获取主键不要再用 `MAX(id)`,改为标准主键回填。 +- 新增版本号不要再用 `MAX(version_no)+1` 裸算,改为: + - 悲观锁 + - 或唯一约束重试 + - 或独立版本号分配策略 +- 发布、回滚、建版操作保持事务一致性,避免主表状态和版本表状态不一致。 + +### 4.4.6 可测试性 + +- 增加自动化测试覆盖: + - 创建模板 + - 并发新增版本 + - 发布/停用/归档/回滚 + - 流程绑定场景校验 + - 有效期校验 + - 下载限流 + - 日志分页与筛选 + +## 4.5 数据库设计优化 + +### 4.5.1 `template` + +- 建议补充或强化约束: + - `tenant_id + template_name + scope_type + scope_id + biz_scene` 可考虑唯一性策略 + - 状态字段增加明确枚举约束 +- 建议补充索引: + - `(tenant_id, status, updated_at)` + - `(tenant_id, template_type, status)` + - `(tenant_id, biz_scene, status)` + - `(tenant_id, scope_type, scope_id)` + +### 4.5.2 `template_version` + +- 建议保留唯一约束: + - `(tenant_id, template_id, version_no)` +- 建议新增索引: + - `(tenant_id, template_id, is_effective)` + - `(tenant_id, template_id, created_at)` +- 建议增加字段: + - `file_name` + - `file_size` + - `content_type` + - `checksum` + +### 4.5.3 `template_download_log` + +- 建议新增字段: + - `download_type` + - `watermark_text` + - `project_id` + - `meeting_id` +- 建议新增索引: + - `(tenant_id, template_id, downloaded_at)` + - `(tenant_id, user_id, downloaded_at)` + - `(tenant_id, downloaded_at)` + +### 4.5.4 `template_type_option` + +- 现有主键建议调整为适合多租户的联合唯一约束: + - 主键独立 `id` + - 唯一键 `(tenant_id, type_code)` +- 避免 `type_code` 全局唯一导致多租户扩展受限。 + +### 4.5.5 `template_flow_link` + +- 建议保留: + - `(tenant_id, scene_code)` 唯一 +- 建议增加: + - 绑定来源 + - 绑定说明 + - 最近绑定人 + - 最近绑定时间 + +## 4.6 权限与风控优化 + +- 继续保留租户域权限拆分: + - 查询 + - 创建 + - 发布 + - 停用 + - 归档 + - 回滚 + - 下载 + - 流程绑定 +- 补充细粒度操作审计: + - 模板创建 + - 版本新增 + - 发布 + - 回滚 + - 流程绑定 + - 普通下载 + - 水印下载 +- 对高风险操作输出审计原因字段: + - 回滚原因 + - 归档原因 + - 水印下载说明 + +## 4.7 交互与体验优化 + +- 统一中文文案,避免技术字段直接暴露给业务用户。 +- 统一状态文案与颜色: + - 草稿 + - 已发布 + - 已停用 + - 已归档 +- 对空数据场景增加引导: + - 暂无模板,请先创建 + - 暂无下载记录 +- 对失败场景返回可理解提示: + - 模板已归档,不能新增版本 + - 模板未在生效期内,不能绑定流程 + - 当前小时下载次数已达上限 + +## 5. 分期实施建议 + +## 5.1 P0:必须先做 + +- 模板列表筛选与标准分页 +- 下载日志标准分页与筛选 +- 创建模板交互优化,取消手填 `objectKey` +- 流程绑定强校验 +- 模板创建/新增版本并发安全修复 +- 发布/停用/归档/回滚状态机校验补齐 + +## 5.2 P1:强烈建议做 + +- 模板详情页与版本历史重构 +- 有效期控制落地 +- 水印下载真实落地 +- 下载限流落地 +- 下载日志返回关联名称,去掉前端补查 +- 复制模板能力 + +## 5.3 P2:体验增强 + +- 模板预览 +- 模板变更摘要对比优化 +- 关联业务影响提示 +- 模板清单导出 +- 模板治理仪表盘 + +## 6. 验收标准建议 + +- 用户可以在 3 步内完成模板创建,不需要手填对象存储路径。 +- 用户可以在列表中快速筛出“某场景、某状态、某范围”的模板。 +- 流程绑定后,业务读取到的模板与绑定配置一致。 +- 并发建版不会出现版本号冲突或串模板。 +- 下载日志可分页、可筛选、可定位到具体用户与版本。 +- 有效期、水印、下载限流配置能被真实执行,而不是只存库不生效。 + +## 7. 推荐实施顺序 + +1. 先改后端查询、状态机、并发安全、日志接口。 +2. 再改前端页面结构与创建流程。 +3. 再补流程联动闭环、水印、限流、有效期。 +4. 最后补预览、复制、导出、治理报表等增强能力。 + diff --git a/fix_btns.js b/fix_btns.js new file mode 100644 index 0000000..ac423b1 --- /dev/null +++ b/fix_btns.js @@ -0,0 +1,6 @@ +const fs = require('fs'); +const filepath = 'd:\\haomi\\cursor_projects\\writeOff\\frontend\\src\\views\\modules\\meeting-page\\MeetingMaterialDrawer.vue'; +let text = fs.readFileSync(filepath, 'utf-8'); +text = text.replace(//g, ''); +fs.writeFileSync(filepath, text); +console.log('Fixed buttons'); diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..4954cdb --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,158 @@ + + + + + + + + 会议核销系统 + + + +
+
+
+
+ + +
+
+ 会议核销系统 + 正在初始化工作台... +
+ +
+
+
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..bbdc2d1 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1873 @@ +{ + "name": "writeoff-frontend", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "writeoff-frontend", + "version": "0.0.1", + "dependencies": { + "axios": "^1.7.7", + "compressorjs": "^1.3.0", + "element-plus": "^2.8.4", + "pinia": "^3.0.4", + "vue": "^3.5.10", + "vue-router": "^4.4.5" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.1.4", + "typescript": "^5.6.2", + "vite": "^5.4.8" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@ctrl/tinycolor": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/@ctrl/tinycolor/-/tinycolor-4.2.0.tgz", + "integrity": "sha512-kzyuwOAQnXJNLS9PSyrk0CWk35nWJW/zl/6KvnTBMFK65gm7U1/Z5BqjxeapjZCIhQcM/DsrEmcbRwDyXyXK4A==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/@element-plus/icons-vue": { + "version": "2.3.2", + "resolved": "https://registry.npmmirror.com/@element-plus/icons-vue/-/icons-vue-2.3.2.tgz", + "integrity": "sha512-OzIuTaIfC8QXEPmJvB4Y4kw34rSXdCJzxcD1kFStBvr8bK6X1zQAYDo0CNMjojnfTqRQCJ0I7prlErcoRiET2A==", + "license": "MIT", + "peerDependencies": { + "vue": "^3.2.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.5", + "resolved": "https://registry.npmmirror.com/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmmirror.com/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.11", + "resolved": "https://registry.npmmirror.com/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "license": "MIT" + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@popperjs/core": { + "name": "@sxzz/popperjs-es", + "version": "2.11.8", + "resolved": "https://registry.npmmirror.com/@sxzz/popperjs-es/-/popperjs-es-2.11.8.tgz", + "integrity": "sha512-wOwESXvvED3S8xBmcPWHs2dUuzrE4XiZeFu7e1hROIJkm02a49N120pmOXxY33sBb6hArItm5W5tcg1cBtV+HQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/lodash": { + "version": "4.17.24", + "resolved": "https://registry.npmmirror.com/@types/lodash/-/lodash-4.17.24.tgz", + "integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==", + "license": "MIT" + }, + "node_modules/@types/lodash-es": { + "version": "4.17.12", + "resolved": "https://registry.npmmirror.com/@types/lodash-es/-/lodash-es-4.17.12.tgz", + "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", + "license": "MIT", + "dependencies": { + "@types/lodash": "*" + } + }, + "node_modules/@types/web-bluetooth": { + "version": "0.0.20", + "resolved": "https://registry.npmmirror.com/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz", + "integrity": "sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==", + "license": "MIT" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmmirror.com/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.29", + "resolved": "https://registry.npmmirror.com/@vue/compiler-core/-/compiler-core-3.5.29.tgz", + "integrity": "sha512-cuzPhD8fwRHk8IGfmYaR4eEe4cAyJEL66Ove/WZL7yWNL134nqLddSLwNRIsFlnnW1kK+p8Ck3viFnC0chXCXw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@vue/shared": "3.5.29", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.29", + "resolved": "https://registry.npmmirror.com/@vue/compiler-dom/-/compiler-dom-3.5.29.tgz", + "integrity": "sha512-n0G5o7R3uBVmVxjTIYcz7ovr8sy7QObFG8OQJ3xGCDNhbG60biP/P5KnyY8NLd81OuT1WJflG7N4KWYHaeeaIg==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.29", + "@vue/shared": "3.5.29" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.29", + "resolved": "https://registry.npmmirror.com/@vue/compiler-sfc/-/compiler-sfc-3.5.29.tgz", + "integrity": "sha512-oJZhN5XJs35Gzr50E82jg2cYdZQ78wEwvRO6Y63TvLVTc+6xICzJHP1UIecdSPPYIbkautNBanDiWYa64QSFIA==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@vue/compiler-core": "3.5.29", + "@vue/compiler-dom": "3.5.29", + "@vue/compiler-ssr": "3.5.29", + "@vue/shared": "3.5.29", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.6", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.29", + "resolved": "https://registry.npmmirror.com/@vue/compiler-ssr/-/compiler-ssr-3.5.29.tgz", + "integrity": "sha512-Y/ARJZE6fpjzL5GH/phJmsFwx3g6t2KmHKHx5q+MLl2kencADKIrhH5MLF6HHpRMmlRAYBRSvv347Mepf1zVNw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.29", + "@vue/shared": "3.5.29" + } + }, + "node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmmirror.com/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + }, + "node_modules/@vue/devtools-kit": { + "version": "7.7.9", + "resolved": "https://registry.npmmirror.com/@vue/devtools-kit/-/devtools-kit-7.7.9.tgz", + "integrity": "sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA==", + "license": "MIT", + "dependencies": { + "@vue/devtools-shared": "^7.7.9", + "birpc": "^2.3.0", + "hookable": "^5.5.3", + "mitt": "^3.0.1", + "perfect-debounce": "^1.0.0", + "speakingurl": "^14.0.1", + "superjson": "^2.2.2" + } + }, + "node_modules/@vue/devtools-shared": { + "version": "7.7.9", + "resolved": "https://registry.npmmirror.com/@vue/devtools-shared/-/devtools-shared-7.7.9.tgz", + "integrity": "sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA==", + "license": "MIT", + "dependencies": { + "rfdc": "^1.4.1" + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.29", + "resolved": "https://registry.npmmirror.com/@vue/reactivity/-/reactivity-3.5.29.tgz", + "integrity": "sha512-zcrANcrRdcLtmGZETBxWqIkoQei8HaFpZWx/GHKxx79JZsiZ8j1du0VUJtu4eJjgFvU/iKL5lRXFXksVmI+5DA==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.29" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.29", + "resolved": "https://registry.npmmirror.com/@vue/runtime-core/-/runtime-core-3.5.29.tgz", + "integrity": "sha512-8DpW2QfdwIWOLqtsNcds4s+QgwSaHSJY/SUe04LptianUQ/0xi6KVsu/pYVh+HO3NTVvVJjIPL2t6GdeKbS4Lg==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.29", + "@vue/shared": "3.5.29" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.29", + "resolved": "https://registry.npmmirror.com/@vue/runtime-dom/-/runtime-dom-3.5.29.tgz", + "integrity": "sha512-AHvvJEtcY9tw/uk+s/YRLSlxxQnqnAkjqvK25ZiM4CllCZWzElRAoQnCM42m9AHRLNJ6oe2kC5DCgD4AUdlvXg==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.29", + "@vue/runtime-core": "3.5.29", + "@vue/shared": "3.5.29", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.29", + "resolved": "https://registry.npmmirror.com/@vue/server-renderer/-/server-renderer-3.5.29.tgz", + "integrity": "sha512-G/1k6WK5MusLlbxSE2YTcqAAezS+VuwHhOvLx2KnQU7G2zCH6KIb+5Wyt6UjMq7a3qPzNEjJXs1hvAxDclQH+g==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.29", + "@vue/shared": "3.5.29" + }, + "peerDependencies": { + "vue": "3.5.29" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.29", + "resolved": "https://registry.npmmirror.com/@vue/shared/-/shared-3.5.29.tgz", + "integrity": "sha512-w7SR0A5zyRByL9XUkCfdLs7t9XOHUyJ67qPGQjOou3p6GvBeBW+AVjUUmlxtZ4PIYaRvE+1LmK44O4uajlZwcg==", + "license": "MIT" + }, + "node_modules/@vueuse/core": { + "version": "12.0.0", + "resolved": "https://registry.npmmirror.com/@vueuse/core/-/core-12.0.0.tgz", + "integrity": "sha512-C12RukhXiJCbx4MGhjmd/gH52TjJsc3G0E0kQj/kb19H3Nt6n1CA4DRWuTdWWcaFRdlTe0npWDS942mvacvNBw==", + "license": "MIT", + "dependencies": { + "@types/web-bluetooth": "^0.0.20", + "@vueuse/metadata": "12.0.0", + "@vueuse/shared": "12.0.0", + "vue": "^3.5.13" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/metadata": { + "version": "12.0.0", + "resolved": "https://registry.npmmirror.com/@vueuse/metadata/-/metadata-12.0.0.tgz", + "integrity": "sha512-Yzimd1D3sjxTDOlF05HekU5aSGdKjxhuhRFHA7gDWLn57PRbBIh+SF5NmjhJ0WRgF3my7T8LBucyxdFJjIfRJQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared": { + "version": "12.0.0", + "resolved": "https://registry.npmmirror.com/@vueuse/shared/-/shared-12.0.0.tgz", + "integrity": "sha512-3i6qtcq2PIio5i/vVYidkkcgvmTjCqrf26u+Fd4LhnbBmIT6FN8y6q/GJERp8lfcB9zVEfjdV0Br0443qZuJpw==", + "license": "MIT", + "dependencies": { + "vue": "^3.5.13" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/async-validator": { + "version": "4.2.5", + "resolved": "https://registry.npmmirror.com/async-validator/-/async-validator-4.2.5.tgz", + "integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==", + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmmirror.com/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.13.6", + "resolved": "https://registry.npmmirror.com/axios/-/axios-1.13.6.tgz", + "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/birpc": { + "version": "2.9.0", + "resolved": "https://registry.npmmirror.com/birpc/-/birpc-2.9.0.tgz", + "integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/blueimp-canvas-to-blob": { + "version": "3.29.0", + "resolved": "https://registry.npmmirror.com/blueimp-canvas-to-blob/-/blueimp-canvas-to-blob-3.29.0.tgz", + "integrity": "sha512-0pcSSGxC0QxT+yVkivxIqW0Y4VlO2XSDPofBAqoJ1qJxgH9eiUDLv50Rixij2cDuEfx4M6DpD9UGZpRhT5Q8qg==", + "license": "MIT" + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/compressorjs": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/compressorjs/-/compressorjs-1.3.0.tgz", + "integrity": "sha512-TsvzkRgDm/6mIRUdxJbrTH7kfSW3oJzOw8b1xU60fziQSosTML5TczpO6Z4H1LGF0yRmTotk6r5UNhuRxEwA1A==", + "license": "MIT", + "dependencies": { + "blueimp-canvas-to-blob": "^3.29.0", + "is-blob": "^2.1.0" + } + }, + "node_modules/copy-anything": { + "version": "4.0.5", + "resolved": "https://registry.npmmirror.com/copy-anything/-/copy-anything-4.0.5.tgz", + "integrity": "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==", + "license": "MIT", + "dependencies": { + "is-what": "^5.2.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/dayjs": { + "version": "1.11.19", + "resolved": "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.19.tgz", + "integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==", + "license": "MIT" + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/element-plus": { + "version": "2.13.5", + "resolved": "https://registry.npmmirror.com/element-plus/-/element-plus-2.13.5.tgz", + "integrity": "sha512-dmY24fhSREfZN/PuUt0YZigMso7wWzl+B5o+YKNN15kQIn/0hzamsPU+ebj9SES0IbUqsLX1wkrzYmzU8VrVOQ==", + "license": "MIT", + "dependencies": { + "@ctrl/tinycolor": "^4.2.0", + "@element-plus/icons-vue": "^2.3.2", + "@floating-ui/dom": "^1.0.1", + "@popperjs/core": "npm:@sxzz/popperjs-es@^2.11.7", + "@types/lodash": "^4.17.20", + "@types/lodash-es": "^4.17.12", + "@vueuse/core": "12.0.0", + "async-validator": "^4.2.5", + "dayjs": "^1.11.19", + "lodash": "^4.17.23", + "lodash-es": "^4.17.23", + "lodash-unified": "^1.0.3", + "memoize-one": "^6.0.0", + "normalize-wheel-es": "^1.2.0" + }, + "peerDependencies": { + "vue": "^3.3.0" + } + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmmirror.com/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmmirror.com/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hookable": { + "version": "5.5.3", + "resolved": "https://registry.npmmirror.com/hookable/-/hookable-5.5.3.tgz", + "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==", + "license": "MIT" + }, + "node_modules/is-blob": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/is-blob/-/is-blob-2.1.0.tgz", + "integrity": "sha512-SZ/fTft5eUhQM6oF/ZaASFDEdbFVe89Imltn9uZr03wdKMcWNVYSMjQPFtg05QuNkt5l5c135ElvXEQG0rk4tw==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-what": { + "version": "5.5.0", + "resolved": "https://registry.npmmirror.com/is-what/-/is-what-5.5.0.tgz", + "integrity": "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "node_modules/lodash": { + "version": "4.17.23", + "resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "license": "MIT" + }, + "node_modules/lodash-es": { + "version": "4.17.23", + "resolved": "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.17.23.tgz", + "integrity": "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==", + "license": "MIT" + }, + "node_modules/lodash-unified": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/lodash-unified/-/lodash-unified-1.0.3.tgz", + "integrity": "sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==", + "license": "MIT", + "peerDependencies": { + "@types/lodash-es": "*", + "lodash": "*", + "lodash-es": "*" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/memoize-one": { + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/memoize-one/-/memoize-one-6.0.0.tgz", + "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==", + "license": "MIT" + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/normalize-wheel-es": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/normalize-wheel-es/-/normalize-wheel-es-1.2.0.tgz", + "integrity": "sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==", + "license": "BSD-3-Clause" + }, + "node_modules/perfect-debounce": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/perfect-debounce/-/perfect-debounce-1.0.0.tgz", + "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/pinia": { + "version": "3.0.4", + "resolved": "https://registry.npmmirror.com/pinia/-/pinia-3.0.4.tgz", + "integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^7.7.7" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "typescript": ">=4.5.0", + "vue": "^3.5.11" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/pinia/node_modules/@vue/devtools-api": { + "version": "7.7.9", + "resolved": "https://registry.npmmirror.com/@vue/devtools-api/-/devtools-api-7.7.9.tgz", + "integrity": "sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==", + "license": "MIT", + "dependencies": { + "@vue/devtools-kit": "^7.7.9" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmmirror.com/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "license": "MIT" + }, + "node_modules/rollup": { + "version": "4.59.0", + "resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/speakingurl": { + "version": "14.0.1", + "resolved": "https://registry.npmmirror.com/speakingurl/-/speakingurl-14.0.1.tgz", + "integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/superjson": { + "version": "2.2.6", + "resolved": "https://registry.npmmirror.com/superjson/-/superjson-2.2.6.tgz", + "integrity": "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==", + "license": "MIT", + "dependencies": { + "copy-anything": "^4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmmirror.com/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vue": { + "version": "3.5.29", + "resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.29.tgz", + "integrity": "sha512-BZqN4Ze6mDQVNAni0IHeMJ5mwr8VAJ3MQC9FmprRhcBYENw+wOAAjRj8jfmN6FLl0j96OXbR+CjWhmAmM+QGnA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.29", + "@vue/compiler-sfc": "3.5.29", + "@vue/runtime-dom": "3.5.29", + "@vue/server-renderer": "3.5.29", + "@vue/shared": "3.5.29" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-router": { + "version": "4.6.4", + "resolved": "https://registry.npmmirror.com/vue-router/-/vue-router-4.6.4.tgz", + "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..5c19abb --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,24 @@ +{ + "name": "writeoff-frontend", + "version": "0.0.1", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "axios": "^1.7.7", + "compressorjs": "^1.3.0", + "element-plus": "^2.8.4", + "pinia": "^3.0.4", + "vue": "^3.5.10", + "vue-router": "^4.4.5" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.1.4", + "typescript": "^5.6.2", + "vite": "^5.4.8" + } +} diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..f020f50 --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,13 @@ + + + diff --git a/frontend/src/api/http.ts b/frontend/src/api/http.ts new file mode 100644 index 0000000..421751f --- /dev/null +++ b/frontend/src/api/http.ts @@ -0,0 +1,148 @@ +import axios from "axios"; +import { ElMessage } from "element-plus"; +import { pinia } from "../stores"; +import { useAuthStore } from "../stores/auth"; +import { resolveLoginPath } from "../utils/authNavigation"; + +const http = axios.create({ + baseURL: "/api", + timeout: 30000, +}); + +const FORCE_LOGOUT_CODES = new Set([11001, 11003, 11004, 11005, 11006, 11007]); +let refreshPromise: Promise | null = null; +let pendingForceLogout = false; +const getAuthStore = () => useAuthStore(pinia); + +const isAuthSessionEndpoint = (url: string): boolean => { + return /\/auth\/(login|platform-login|refresh|logout|logout-all)/.test(url); +}; + +const resolveLoginPathByScope = (): string => { + const authStore = getAuthStore(); + return resolveLoginPath(authStore.scope, authStore.tenantCode); +}; + +const getForceLogoutMessage = (code: number): string => { + if (code === 11003) { + return "会话失效,请重新登录"; + } + if (code === 11004) { + return "账号有效期异常,请重新登录"; + } + if (code === 11005 || code === 11006 || code === 11007) { + return "登录状态异常,请重新登录"; + } + return "登录状态已失效,请重新登录"; +}; + +const forceLogoutAndRedirect = (message?: string) => { + if (pendingForceLogout) { + return; + } + pendingForceLogout = true; + const nextLoginPath = resolveLoginPathByScope(); + if (message) { + ElMessage.error(message); + } + // Delay cleanup/redirect so business catch can receive response details first. + window.setTimeout(() => { + getAuthStore().clearAuthStorage(); + window.location.href = nextLoginPath; + }, 600); +}; + +const ensureRefreshedToken = async (): Promise => { + if (refreshPromise) { + return refreshPromise; + } + refreshPromise = axios.post("/api/auth/refresh", null, { timeout: 10000 }) + .then((resp) => { + const payload = resp?.data?.data || {}; + const token = String(payload?.token || "").trim(); + if (!token) { + throw new Error("refresh token missing"); + } + getAuthStore().saveAuthPayload(payload); + return token; + }) + .finally(() => { + refreshPromise = null; + }); + return refreshPromise; +}; + +http.interceptors.request.use((config) => { + const token = getAuthStore().token; + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; +}); + +http.interceptors.response.use((resp) => resp.data, (error) => { + const requestUrl = String(error?.config?.url || ""); + const businessCode = Number(error?.response?.data?.code || 0); + const isAuthRequest = isAuthSessionEndpoint(requestUrl); + const originalRequest = error?.config as any; + if (!isAuthRequest && businessCode === 11002 && !originalRequest?._retry) { + originalRequest._retry = true; + return ensureRefreshedToken() + .then((token) => { + originalRequest.headers = originalRequest.headers || {}; + originalRequest.headers.Authorization = `Bearer ${token}`; + return http.request(originalRequest); + }) + .catch((refreshError) => { + forceLogoutAndRedirect("会话已过期,请重新登录"); + return Promise.reject(refreshError); + }); + } + if ( + !isAuthRequest && + FORCE_LOGOUT_CODES.has(businessCode) + ) { + const backendMessage = error?.response?.data?.message || error?.response?.data?.msg || error?.response?.data?.error; + const fallbackMessage = backendMessage || getForceLogoutMessage(businessCode); + if (!error?.response) { + error.response = { + status: 422, + data: { + code: businessCode || 11003, + message: fallbackMessage, + }, + }; + } else if (!error.response.data) { + error.response.data = { + code: businessCode || 11003, + message: fallbackMessage, + }; + } + forceLogoutAndRedirect(fallbackMessage); + return Promise.reject(error); + } + const backendMessage = error?.response?.data?.message || error?.response?.data?.msg || error?.response?.data?.error; + let errorMessage = backendMessage; + if (!errorMessage) { + if (error?.code === "ECONNABORTED") { + errorMessage = "请求超时,请稍后重试"; + } else if (error?.message === "Network Error") { + errorMessage = "网络异常,请检查网络连接"; + } else if (error?.response?.status) { + errorMessage = `请求失败(${error.response.status})`; + } else { + errorMessage = "请求失败,请稍后重试"; + } + } + // 从响应头或响应体中解析 requestId,附加到错误提示 + const requestId = error?.response?.headers?.["x-request-id"] + || error?.response?.data?.requestId + || ""; + if (requestId) { + errorMessage = `${errorMessage}(RequestId: ${requestId})`; + } + ElMessage.error(errorMessage); + return Promise.reject(error); +}); + +export default http; diff --git a/frontend/src/api/modules.ts b/frontend/src/api/modules.ts new file mode 100644 index 0000000..f19caff --- /dev/null +++ b/frontend/src/api/modules.ts @@ -0,0 +1,1079 @@ +import http from "./http"; +import { encryptLoginPassword } from "../utils/authCrypto"; + +export const fetchCaptcha = () => http.get("/captcha"); +export const login = async (payload: { tenantCode: string; phone: string; password: string; captchaId?: string; captchaCode?: string }) => + http.post("/auth/login", { + ...payload, + password: await encryptLoginPassword(payload.password), + }); +export const platformLogin = async (payload: { phone: string; password: string; captchaId?: string; captchaCode?: string }) => + http.post("/auth/platform-login", { + ...payload, + password: await encryptLoginPassword(payload.password), + }); +export const verifyPasswordSetupToken = (params: { tenantCode: string; token: string }) => + http.get("/auth/password-setup/verify", { params }); +export const completePasswordSetup = (payload: { tenantCode: string; token: string; newPassword: string }) => + http.post("/auth/password-setup/complete", payload); +export const refreshAuth = () => http.post("/auth/refresh"); +export const fetchSwitchableTenants = () => http.get("/auth/switchable-tenants"); +export const switchTenant = (payload: { tenantId: number }) => http.post("/auth/switch-tenant", payload); +export const logoutAuth = () => http.post("/auth/logout"); +export const logoutAllAuth = () => http.post("/auth/logout-all"); +export const fetchGlobalSearch = (params: { q: string; limitPerType?: number }) => http.get("/search/global", { params }); + +export const fetchProjects = (params?: { parentOnly?: boolean; includeDeleted?: boolean }) => http.get("/projects", { params }); +export const fetchProjectChildren = (id: number, params?: { includeDeleted?: boolean }) => http.get(`/projects/${id}/children`, { params }); +export const createProject = (payload: { + name: string; + parentProjectId?: number; + startDate?: string; + endDate?: string; + hostEnterpriseName?: string; + partnerEnterpriseId?: number; + budgetCent: number; + meetingTotal: number; + allowMeetingOverBudget?: boolean; + overBudgetThresholdRatio?: number; + overBudgetApprovalChainJson?: string; + paymentStatus?: string; + writeOffStatus?: string; + laborFeeRatio?: number; + allowProjectOverBudget?: boolean; + invoiceInfo?: string; + expenseRatioJson?: string; + projectFeeJson?: string; +}) => http.post("/projects", payload); +export const updateProject = ( + id: number, + payload: { + name: string; + parentProjectId?: number; + startDate?: string; + endDate?: string; + hostEnterpriseName?: string; + partnerEnterpriseId?: number; + budgetCent: number; + meetingTotal: number; + allowMeetingOverBudget?: boolean; + overBudgetThresholdRatio?: number; + overBudgetApprovalChainJson?: string; + paymentStatus?: string; + writeOffStatus?: string; + laborFeeRatio?: number; + allowProjectOverBudget?: boolean; + invoiceInfo?: string; + expenseRatioJson?: string; + projectFeeJson?: string; + }, +) => http.put(`/projects/${id}`, payload); +export const freezeProject = (id: number, reason: string) => + http.post(`/projects/${id}/freeze`, null, { params: { reason } }); +export const unfreezeProject = (id: number, payload: { reason: string }) => + http.post(`/projects/${id}/unfreeze`, payload); +export const archiveProject = (id: number) => + http.post(`/projects/${id}/archive`); +export const fetchProjectBindingCandidates = () => http.get("/projects/binding-candidates"); +export const fetchProjectBindings = (id: number) => http.get(`/projects/${id}/bindings`); +export const fetchProjectKeyChangeLogs = (id: number) => http.get(`/projects/${id}/key-change-logs`); +export const fetchProjectChangeLogs = (id: number) => http.get(`/projects/${id}/key-change-logs`); +export const saveProjectBindings = ( + id: number, + payload: { ownerUserIds: number[]; executorUserIds: number[]; legacyExecutorUserIds?: number[] }, +) => http.post(`/projects/${id}/bindings`, payload); + +export const fetchMeetings = (params?: { + projectId?: number; + projectName?: string; + topic?: string; + meetingStatus?: string; + auditStatus?: string; + currentAuditNode?: string; + currentAuditorUserId?: number; + meetingStartFrom?: string; + meetingStartTo?: string; + lastSubmitFrom?: string; + lastSubmitTo?: string; + includeDeleted?: boolean; +}) => http.get("/meetings", { params }); +export const fetchMeetingPlatformExperts = (params?: { keyword?: string }) => http.get("/meetings/tenant-experts", { params }); +export const createMeetingPlatformExpert = (payload: { + expertName: string; + idNo: string; + phone: string; + titleCode?: string; + title?: string; + hospitalCode?: string; + organization?: string; + idCardFrontOssKey?: string; + idCardBackOssKey?: string; +}) => http.post("/meetings/tenant-experts", payload); +export const addMeetingTenantExpertBankCard = ( + expertId: number, + payload: { + bankName: string; + bankProvince?: string; + bankCity?: string; + bankBranchName?: string; + bankCardNo: string; + bankCardFrontOssKey?: string; + bankCardBackOssKey?: string; + accountName: string; + isDefault?: boolean; + }, +) => http.post(`/meetings/tenant-experts/${expertId}/bank-cards`, payload); +export const submitMeetingLaborAgreementExtractTask = ( + meetingId: number, + payload: { objectKey: string; fileName: string }, +) => http.post(`/meetings/${meetingId}/labor-agreement-extract/task`, payload); +export const queryMeetingLaborAgreementExtract = ( + meetingId: number, + payload: { taskId: string }, +) => http.post(`/meetings/${meetingId}/labor-agreement-extract/query`, payload); +export const applyMeetingLaborAgreementExtract = ( + meetingId: number, + payload: { + taskId: string; + existingExpertId?: number; + updateExistingExpert?: boolean; + objectKey?: string; + fileName?: string; + }, +) => http.post(`/meetings/${meetingId}/labor-agreement-extract/apply`, payload); +export const fetchMeetingExpertBindings = (meetingId: number) => http.get(`/meetings/${meetingId}/experts`); +export const bindMeetingExperts = (meetingId: number, payload: { expertIds: number[] }) => + http.post(`/meetings/${meetingId}/experts/bind`, payload); +export const unbindMeetingExpert = (meetingId: number, expertId: number) => + http.delete(`/meetings/${meetingId}/experts/${expertId}`); +export const createMeeting = (payload: { + projectId: number; + topic: string; + budgetCent: number; + meetingCategory?: string; + meetingForm?: string; + location?: string; + startTime?: string; + endTime?: string; + laborRatio?: number; + cateringRatio?: number; +}) => http.post("/meetings", payload); +export const fetchMeetingDetail = (id: number) => http.get(`/meetings/${id}`); +export const fetchMeetingChangeLogs = (id: number) => http.get(`/meetings/${id}/change-logs`); +export const updateMeeting = ( + id: number, + payload: { + projectId: number; + topic: string; + budgetCent: number; + meetingCategory?: string; + meetingForm?: string; + location?: string; + startTime?: string; + endTime?: string; + laborRatio?: number; + cateringRatio?: number; + }, +) => http.put(`/meetings/${id}`, payload); +export const submitMeeting = (id: number, payload: { idempotencyKey: string; remark: string }) => + http.post(`/meetings/${id}/submit`, payload); +export const withdrawMeeting = (id: number, payload: { idempotencyKey: string; reason: string }) => + http.post(`/meetings/${id}/withdraw`, payload); +export const deleteMeeting = (id: number) => + http.post(`/meetings/${id}/delete`); +export const cancelMeeting = (id: number, payload: { reason: string }) => + http.post(`/meetings/${id}/cancel`, payload); +export const updateMeetingInvoiceConfig = (id: number, payload: { invoiceModules: string[] }) => + http.put(`/meetings/${id}/invoice-config`, payload); +export const fetchMeetingMaterials = (meetingId: number) => + http.get(`/meetings/${meetingId}/materials`); +export const fetchMeetingMaterialCurrent = ( + meetingId: number, + moduleCode: "BASIC_INFO" | "WRITE_OFF_DOCS" | "EXPERT_LIST" | "MEETING_INVOICE", +) => http.get(`/meetings/${meetingId}/materials/${moduleCode}/current`); +export const saveMeetingMaterial = ( + meetingId: number, + moduleCode: "BASIC_INFO" | "WRITE_OFF_DOCS" | "EXPERT_LIST" | "MEETING_INVOICE", + payload: { contentJson: string; remark?: string }, +) => http.post(`/meetings/${meetingId}/materials/${moduleCode}/save`, payload); +export const submitMeetingMaterial = ( + meetingId: number, + moduleCode: "BASIC_INFO" | "WRITE_OFF_DOCS" | "EXPERT_LIST" | "MEETING_INVOICE", + payload: { contentJson: string; remark?: string }, +) => http.post(`/meetings/${meetingId}/materials/${moduleCode}/submit`, payload); +export const fetchMeetingMaterialUploadSign = ( + meetingId: number, + moduleCode: "BASIC_INFO" | "WRITE_OFF_DOCS" | "EXPERT_LIST" | "MEETING_INVOICE", + payload: { fileName: string; contentType?: string }, +) => http.post(`/meetings/${meetingId}/materials/${moduleCode}/upload-sign`, payload); +export const fetchMeetingMaterialHistory = ( + meetingId: number, + moduleCode: "BASIC_INFO" | "WRITE_OFF_DOCS" | "EXPERT_LIST" | "MEETING_INVOICE", +) => http.get(`/meetings/${meetingId}/materials/${moduleCode}/history`); +export const fetchFilePresignDownload = (params: { objectKey: string }) => + http.get("/files/presign-download", { params }); + +export const recognizeMultipleInvoice = (payload: { objectKey: string }) => + http.post("/ocr/multiple-invoice", payload); +export const recognizeIdCard = (payload: { objectKey: string; idCardSide: "front" | "back" }) => + http.post("/ocr/id-card", payload); +export const recognizePlatformIdCard = (payload: { objectKey: string; idCardSide: "front" | "back" }) => + http.post("/platform/ocr/id-card", payload); +export const recognizeBankCard = (payload: { objectKey: string }) => + http.post("/ocr/bank-card", payload); +export const recognizePlatformBankCard = (payload: { objectKey: string }) => + http.post("/platform/ocr/bank-card", payload); +export const submitDocumentExtractTask = (payload: { + objectKey?: string; + fileName?: string; + fileUrls?: string[]; + manifestVersionId?: string; + manifest?: Array<{ + key: string; + parentKey?: string; + description?: string; + }>; + removeDuplicates?: boolean; + pageRange?: string; + extractSeal?: boolean; + eraseWatermark?: boolean; +}) => http.post("/ocr/document-extract/task", payload); +export const queryDocumentExtractTask = (payload: { taskId: string }) => + http.post("/ocr/document-extract/query-task", payload); +export const fetchMeetingMatchedTemplates = (meetingId: number) => + http.get(`/meetings/${meetingId}/matched-templates`); +export const createMeetingMaterialsExportTask = ( + meetingId: number, + payload: { idempotencyKey: string; fileName?: string }, +) => http.post(`/meetings/${meetingId}/materials/export`, payload); +export const generateMeetingSummaryTask = ( + meetingId: number, + payload: { idempotencyKey: string; fileName?: string }, +) => http.post(`/meetings/${meetingId}/summary/generate`, payload); +export const fetchMeetingSummaryTaskStatus = ( + meetingId: number, + params?: { taskId?: number }, +) => http.get(`/meetings/${meetingId}/summary/task-status`, { params }); +export const refreshMeetingSummaryToken = ( + meetingId: number, + params: { taskId: number }, +) => http.post(`/meetings/${meetingId}/summary/refresh-token`, null, { params }); +export const downloadMeetingSummary = ( + meetingId: number, + params: { taskId: number; token: string }, +) => http.get(`/meetings/${meetingId}/summary/download`, { params }); + +export const fetchAuditTasks = (params?: boolean | { + mine?: boolean; + scope?: string; + meetingId?: number; + pageNo?: number; + pageSize?: number; + sortBy?: string; + order?: "asc" | "desc"; +}) => { + if (typeof params === "boolean") { + return http.get("/audits/tasks", { params: { mine: params } }); + } + return http.get("/audits/tasks", { + params: { + mine: !!params?.mine, + scope: params?.scope, + meetingId: params?.meetingId, + pageNo: params?.pageNo, + pageSize: params?.pageSize, + sortBy: params?.sortBy, + order: params?.order, + }, + }); +}; +export const exportAuditOpinions = () => http.get("/audits/export-opinions"); +export const readAuditTaskMaterial = ( + taskId: number, + moduleCode: "BASIC_INFO" | "WRITE_OFF_DOCS" | "EXPERT_LIST" | "MEETING_INVOICE" | "EXPERT_PROFILE", +) => + http.get(`/audits/tasks/${taskId}/material`, { params: { moduleCode } }); +export const approveAuditMaterialModule = ( + taskId: number, + payload: { + idempotencyKey: string; + moduleCode: "BASIC_INFO" | "WRITE_OFF_DOCS" | "EXPERT_PROFILE" | "EXPERT_LIST" | "MEETING_INVOICE"; + }, +) => http.post(`/audits/tasks/${taskId}/material/approve-module`, payload); +export const rejectAuditMaterialItem = ( + taskId: number, + payload: { + idempotencyKey: string; + moduleCode: "BASIC_INFO" | "WRITE_OFF_DOCS" | "EXPERT_PROFILE" | "EXPERT_LIST" | "MEETING_INVOICE"; + itemKey: string; + itemLabel: string; + reason: string; + }, +) => http.post(`/audits/tasks/${taskId}/material/reject-item`, payload); +export const approveAuditTask = (id: number, payload: { idempotencyKey: string; opinion: string }) => + http.post(`/audits/tasks/${id}/approve`, payload); +export const rejectAuditTask = (id: number, payload: { idempotencyKey: string; opinion: string }) => + http.post(`/audits/tasks/${id}/reject`, payload); +export const returnAuditTask = (id: number, payload: { idempotencyKey: string; opinion: string }) => + http.post(`/audits/tasks/${id}/return`, payload); +export const transferAuditTask = ( + id: number, + payload: { idempotencyKey: string; toUserId: number; reason: string }, +) => http.post(`/audits/tasks/${id}/transfer`, payload); +export const batchRemindAuditTasks = (payload: { idempotencyKey: string; taskIds?: number[] }) => + http.post("/audits/tasks/batch-remind", payload); +export const batchApproveAuditTasks = (payload: { idempotencyKey: string; taskIds: number[]; opinion: string }) => + http.post("/audits/tasks/batch-approve", payload); +export const batchRejectAuditTasks = (payload: { idempotencyKey: string; taskIds: number[]; opinion: string }) => + http.post("/audits/tasks/batch-reject", payload); +export const fetchAuditSlaStat = () => http.get("/audits/tasks/sla-stat"); + +export const fetchAuditFlows = (params?: { pageNo?: number; pageSize?: number }) => + http.get("/audit-flows", { params }); +export const createAuditFlow = (payload: { + flowCode: string; + flowName: string; + effectiveStartAt?: string; + effectiveEndAt?: string; + nodes: Array<{ nodeCode: string; nodeName: string; sortNo: number; assigneeType?: string; assigneeRefId?: number }>; +}) => http.post("/audit-flows", payload); +export const updateAuditFlow = ( + id: number, + payload: { + flowCode: string; + flowName: string; + effectiveStartAt?: string; + effectiveEndAt?: string; + nodes: Array<{ nodeCode: string; nodeName: string; sortNo: number; assigneeType?: string; assigneeRefId?: number }>; + }, +) => http.put(`/audit-flows/${id}`, payload); +export const copyAuditFlow = (id: number) => http.post(`/audit-flows/${id}/copy`); +export const setDefaultAuditFlow = (id: number) => http.post(`/audit-flows/${id}/default`); +export const enableAuditFlow = (id: number) => http.post(`/audit-flows/${id}/enable`); +export const disableAuditFlow = (id: number) => http.post(`/audit-flows/${id}/disable`); + +export const fetchFinanceProjects = () => http.get("/finance/projects"); +export const exportFinanceLedger = () => http.get("/finance/ledger/export"); +export const reconcileFinance = (payload: { idempotencyKey: string; projectId: number; expectedAmountCent: number }) => + http.post("/finance/reconciliation", payload); +export const lockFinance = (payload: { idempotencyKey: string; projectId: number; reason: string }) => + http.post("/finance/lock", payload); +export const unlockFinance = (payload: { idempotencyKey: string; projectId: number; reason: string }) => + http.post("/finance/unlock", payload); +export const fetchFinanceReconciliationList = (params?: { projectId?: number }) => + http.get("/finance/reconciliation/list", { params }); +export const confirmPayment = (payload: { + idempotencyKey: string; + projectId: number; + meetingId: number; + amountCent: number; + paymentVoucherOssKey: string; +}) => http.post("/finance/payments", payload); + +export const fetchUsers = (params?: { pageNo?: number; pageSize?: number; includeDeleted?: boolean; keyword?: string }) => http.get("/users", { params }); +export const fetchTenants = () => http.get("/tenants"); +export const fetchPlatformTenants = () => http.get("/platform/tenants"); +export const createTenant = (payload: { tenantCode: string; tenantName: string; logoUrl?: string }) => + http.post("/tenants", payload); +export const updateTenant = ( + id: number, + payload: { tenantName: string; logoUrl?: string }, +) => http.put(`/tenants/${id}`, payload); +export const createPlatformTenant = (payload: { tenantCode: string; tenantName: string; logoUrl?: string }) => + http.post("/platform/tenants", payload); +export const updatePlatformTenant = ( + id: number, + payload: { tenantName: string; logoUrl?: string }, +) => http.put(`/platform/tenants/${id}`, payload); +export const fetchTenantLogoUploadSign = (payload: { fileName: string; contentType?: string }) => + http.post("/tenants/logo-upload-sign", payload); +export const fetchPlatformTenantLogoUploadSign = (payload: { fileName: string; contentType?: string }) => + http.post("/platform/tenants/logo-upload-sign", payload); +export const enableTenant = (id: number) => http.post(`/tenants/${id}/enable`); +export const disableTenant = (id: number) => http.post(`/tenants/${id}/disable`); +export const enablePlatformTenant = (id: number) => http.post(`/platform/tenants/${id}/enable`); +export const disablePlatformTenant = (id: number) => http.post(`/platform/tenants/${id}/disable`); +export const deletePlatformTenant = (id: number) => http.post(`/platform/tenants/${id}/delete`); +export const fetchPlatformTenantAdmin = (id: number) => http.get(`/platform/tenants/${id}/admin`); +export const initPlatformTenantBaseline = (id: number) => http.post(`/platform/tenants/${id}/init-baseline`); +export const setPlatformTenantAdmin = ( + id: number, + payload: { userName: string; phone: string; email?: string; roleCode?: string }, +) => http.post(`/platform/tenants/${id}/admin`, payload); +export const fetchEnterprises = (params?: { pageNo?: number; pageSize?: number }) => + http.get("/enterprises", { params }); +export const createEnterprise = (payload: { enterpriseName: string; enterpriseUrl?: string; logoUrl?: string }) => + http.post("/enterprises", payload); +export const fetchEnterpriseLogoUploadSign = (payload: { fileName: string; contentType?: string }) => + http.post("/enterprises/logo-upload-sign", payload); +export const updateEnterprise = ( + id: number, + payload: { enterpriseName: string; enterpriseUrl?: string; logoUrl?: string }, +) => http.put(`/enterprises/${id}`, payload); +export const enableEnterprise = (id: number) => http.post(`/enterprises/${id}/enable`); +export const disableEnterprise = (id: number) => http.post(`/enterprises/${id}/disable`); +export const deleteEnterprise = (id: number) => http.post(`/enterprises/${id}/delete`); +export const createUser = (payload: { + userName: string; + phone: string; + password: string; + email?: string; + validFrom?: string; + validTo?: string; +}) => http.post("/users", payload); +export const updateUser = ( + id: number, + payload: { + userName: string; + phone: string; + email?: string; + validFrom?: string; + validTo?: string; + password?: string; + }, +) => http.put(`/users/${id}`, payload); +export const assignUserRole = (payload: { userId: number; roleId: number }) => + http.post("/users/assign-role", payload); +export const enableUser = (id: number) => http.post(`/users/${id}/enable`); +export const disableUser = (id: number) => http.post(`/users/${id}/disable`); +export const deleteUser = (id: number) => http.post(`/users/${id}/delete`); +export const resetUserPassword = (id: number, payload: { newPassword: string }) => + http.post(`/users/${id}/reset-password`, payload); +export const batchImportUsers = (payload: { + users: Array<{ + userName: string; + phone: string; + password: string; + email?: string; + validFrom?: string; + validTo?: string; + roleCode?: string; + }>; +}) => http.post("/users/import", payload); +export const fetchUserRoleHistory = (id: number) => http.get(`/users/${id}/role-history`); +export const fetchUserDelegations = (id: number) => http.get(`/users/${id}/delegations`); +export const createUserDelegation = ( + id: number, + payload: { delegateUserId: number; effectiveFrom: string; effectiveTo: string; reason?: string }, +) => http.post(`/users/${id}/delegations`, payload); +export const disableUserDelegation = (id: number, payload: { reason: string }) => + http.post(`/delegations/${id}/disable`, payload); +export const changeMyPassword = (payload: { oldPassword: string; newPassword: string }) => + http.post("/profile/change-password", payload); +export const fetchProfilePreferences = () => http.get("/profile/preferences"); +export const saveProfilePreferences = (payload: { themeMode: string; density: string; themeScheme: string }) => + http.put("/profile/preferences", payload); + +export const fetchRoles = (params?: { pageNo?: number; pageSize?: number }) => + http.get("/roles", { params }); +export const createRole = (payload: { roleCode: string; roleName: string }) => + http.post("/roles", payload); +export const updateRole = (id: number, payload: { roleName: string }) => + http.put(`/roles/${id}`, payload); +export const enableRole = (id: number) => http.post(`/roles/${id}/enable`); +export const disableRole = (id: number) => http.post(`/roles/${id}/disable`); +export const deleteRole = (id: number) => http.post(`/roles/${id}/delete`); +export const fetchPermissions = () => http.get("/permissions"); +export const fetchRolePermissions = (id: number) => http.get(`/roles/${id}/permissions`); +export const bindRolePermissions = (id: number, payload: { permissionIds: number[] }) => + http.post(`/roles/${id}/permissions`, payload); +export const fetchRoleMenus = (id: number) => http.get(`/roles/${id}/menus`); +export const bindRoleMenus = (id: number, payload: { menuIds: number[] }) => + http.post(`/roles/${id}/menus`, payload); +export const fetchMenus = () => http.get("/menus"); +export const fetchCurrentMenus = () => http.get("/menus/current"); +export const fetchPlatformCurrentMenus = () => http.get("/platform/menus/current"); +export const fetchMenuRoles = (id: number) => http.get(`/menus/${id}/roles`); +export const reorderMenus = (payload: { menus: Array<{ id: number; sortNo: number }> }) => + http.post("/menus/reorder", payload); +export const createMenu = (payload: { + menuCode: string; + menuName: string; + routePath: string; + permissionCode: string; + sortNo: number; +}) => + http.post("/menus", payload); +export const updateMenu = ( + id: number, + payload: { + menuName: string; + routePath: string; + permissionCode: string; + sortNo: number; + status: "ENABLED" | "DISABLED"; + }, +) => http.put(`/menus/${id}`, payload); + +export const fetchDataPermissions = () => http.get("/data-permissions"); +export const createDataPermission = (payload: { + policyName: string; + projectScope: string; + projectIdsCsv?: string; + meetingScope: string; + meetingIdsCsv?: string; + userScope: string; + userIdsCsv?: string; + expertScope: string; + expertIdsCsv?: string; + moduleScope?: string; + exportAllowed?: boolean; +}) => http.post("/data-permissions", payload); +export const updateDataPermission = ( + id: number, + payload: { + policyName: string; + projectScope: string; + projectIdsCsv?: string; + meetingScope: string; + meetingIdsCsv?: string; + userScope: string; + userIdsCsv?: string; + expertScope: string; + expertIdsCsv?: string; + moduleScope?: string; + exportAllowed?: boolean; + }, +) => http.put(`/data-permissions/${id}`, payload); +export const assignDataPermissionRoles = (id: number, payload: { roleIds: number[]; assignMode?: "APPEND" | "REPLACE" }) => + http.post(`/data-permissions/${id}/assign-roles`, payload); +export const copyDataPermission = (id: number) => http.post(`/data-permissions/${id}/copy`); +export const enableDataPermission = (id: number) => http.post(`/data-permissions/${id}/enable`); +export const disableDataPermission = (id: number) => http.post(`/data-permissions/${id}/disable`); +export const fetchDataPermissionRoles = (id: number) => http.get(`/data-permissions/${id}/roles`); +export const fetchCurrentDataScope = () => http.get("/data-permissions/current-scope"); +export const fetchAuditLogs = (params?: { userId?: number; actionCode?: string; pageNo?: number; pageSize?: number }) => + http.get("/audit-logs", { params }); +export const fetchPlatformAuditLogs = (params?: { + userId?: number; + actionCode?: string; + tenantId?: number; + scope?: "TENANT" | "PLATFORM"; + pageNo?: number; + pageSize?: number; +}) => + http.get("/platform/audit-logs", { params }); +export const fetchPlatformRoles = () => http.get("/platform/roles"); +export const createPlatformRole = (payload: { roleCode: string; roleName: string }) => http.post("/platform/roles", payload); +export const updatePlatformRole = (id: number, payload: { roleName: string }) => http.put(`/platform/roles/${id}`, payload); +export const enablePlatformRole = (id: number) => http.post(`/platform/roles/${id}/enable`); +export const disablePlatformRole = (id: number) => http.post(`/platform/roles/${id}/disable`); +export const fetchPlatformRolePermissions = (id: number) => http.get(`/platform/roles/${id}/permissions`); +export const bindPlatformRolePermissions = (id: number, payload: { permissionIds: number[] }) => + http.post(`/platform/roles/${id}/permissions`, payload); +export const fetchPlatformRoleMenus = (id: number) => http.get(`/platform/roles/${id}/menus`); +export const bindPlatformRoleMenus = (id: number, payload: { menuIds: number[] }) => + http.post(`/platform/roles/${id}/menus`, payload); +export const fetchPlatformPermissions = () => http.get("/platform/permissions"); +export const fetchPlatformUsers = (params?: { keyword?: string }) => http.get("/platform/users", { params }); +export const createPlatformUser = (payload: { + userName: string; + phone: string; + password: string; + email?: string; + validFrom?: string; + validTo?: string; +}) => http.post("/platform/users", payload); +export const batchImportPlatformUsers = (payload: { + users: Array<{ + userName: string; + phone: string; + password: string; + email?: string; + validFrom?: string; + validTo?: string; + roleCode?: string; + }>; +}) => http.post("/platform/users/import", payload); +export const assignPlatformUserRole = (payload: { userId: number; roleId: number }) => + http.post("/platform/users/assign-role", payload); +export const enablePlatformUser = (id: number) => http.post(`/platform/users/${id}/enable`); +export const disablePlatformUser = (id: number) => http.post(`/platform/users/${id}/disable`); +export const resetPlatformUserPassword = (id: number, payload: { newPassword: string }) => + http.post(`/platform/users/${id}/reset-password`, payload); +export const fetchPlatformMenus = () => http.get("/platform/menus"); +export const createPlatformMenu = (payload: { + menuCode: string; + menuName: string; + routePath: string; + permissionCode: string; + sortNo: number; +}) => http.post("/platform/menus", payload); +export const updatePlatformMenu = ( + id: number, + payload: { + menuName: string; + routePath: string; + permissionCode: string; + sortNo: number; + status: "ENABLED" | "DISABLED"; + }, +) => http.put(`/platform/menus/${id}`, payload); +export const reorderPlatformMenus = (payload: { menus: Array<{ id: number; sortNo: number }> }) => + http.post("/platform/menus/reorder", payload); +export const fetchPlatformMenuRoles = (id: number) => http.get(`/platform/menus/${id}/roles`); +export const bindPlatformMenuRoles = (id: number, payload: { roleIds: number[] }) => + http.post(`/platform/menus/${id}/roles`, payload); +export const exportAuditLogs = (params?: { userId?: number; actionCode?: string }) => + http.get("/audit-logs/export", { params }); +export const fetchAuditLogExportTasks = () => http.get("/audit-logs/export-tasks"); +export const createAuditLogExportTask = (payload: { + idempotencyKey: string; + userId?: number; + actionCode?: string; + fileName?: string; +}) => http.post("/audit-logs/export-tasks", payload); +export const fetchInvoiceProfiles = () => http.get("/invoice-profiles"); +export const createInvoiceProfile = (payload: { + companyName: string; + taxNo: string; + bankName: string; + accountNo: string; + address?: string; + phone?: string; + defaultProjectId?: number; + status?: "ENABLED" | "DISABLED"; +}) => http.post("/invoice-profiles", payload); +export const updateInvoiceProfile = ( + id: number, + payload: { + companyName: string; + taxNo: string; + bankName: string; + accountNo: string; + address?: string; + phone?: string; + defaultProjectId?: number; + status?: "ENABLED" | "DISABLED"; + }, +) => http.put(`/invoice-profiles/${id}`, payload); +export const enableInvoiceProfile = (id: number) => http.post(`/invoice-profiles/${id}/enable`); +export const disableInvoiceProfile = (id: number) => http.post(`/invoice-profiles/${id}/disable`); + +export const fetchTemplates = (params?: { + templateName?: string; + templateType?: string; + status?: string; + scopeType?: string; + bizScene?: string; + watermarkEnabled?: boolean; + effectiveStatus?: string; + pageNo?: number; + pageSize?: number; +}) => + http.get("/templates", { params }); +export const fetchPublishedTemplateOptions = (params?: { bizScene?: string }) => + http.get("/templates/published-options", { params }); +export const fetchTemplateTypeOptions = () => http.get("/templates/type-options"); +export const fetchTemplateFlowSceneOptions = () => http.get("/templates/flow-scene-options"); +export const fetchTemplateFlowLinks = () => http.get("/templates/flow-links"); +export const bindTemplateFlowLink = (sceneCode: string, payload: { templateId: number }) => + http.post(`/templates/flow-links/${sceneCode}/bind`, payload); +export const enableTemplateTypeOption = (typeCode: string) => http.post(`/templates/type-options/${typeCode}/enable`); +export const disableTemplateTypeOption = (typeCode: string) => http.post(`/templates/type-options/${typeCode}/disable`); +export const createTemplate = (payload: { + templateName: string; + templateType: string; + scopeType: "ALL" | "PROJECT" | "MEETING"; + projectId?: number; + meetingId?: number; + bizScene?: "MEETING_RECOMMEND" | "AUDIT_NOTIFY" | "SETTLEMENT"; + objectKey: string; + changeLog?: string; + effectiveFrom?: string; + effectiveTo?: string; + watermarkEnabled?: boolean; + downloadRateLimitPerHour?: number; +}) => http.post("/templates", payload); +export const fetchTemplateUploadSign = (payload: { + fileName: string; + contentType?: string; + templateType?: string; +}) => http.post("/templates/upload-sign", payload); +export const fetchTemplateVersions = (id: number) => http.get(`/templates/${id}/versions`); +export const addTemplateVersion = ( + id: number, + payload: { + objectKey: string; + changeLog?: string; + }, +) => http.post(`/templates/${id}/versions`, payload); +export const publishTemplate = (id: number) => http.post(`/templates/${id}/publish`); +export const disableTemplate = (id: number) => http.post(`/templates/${id}/disable`); +export const archiveTemplate = (id: number) => http.post(`/templates/${id}/archive`); +export const rollbackTemplate = (id: number, payload: { versionNo: number; rollbackReason: string }) => + http.post(`/templates/${id}/rollback`, payload); +export const downloadTemplate = (id: number) => http.get(`/templates/${id}/download`); +export const downloadTemplateWatermark = (id: number, params?: { watermarkText?: string }) => + http.get(`/templates/${id}/download-watermark`, { params }); +export const fetchTemplateVersionDiff = (id: number, params?: { leftVersionNo?: number; rightVersionNo?: number }) => + http.get(`/templates/${id}/versions/diff`, { params }); +export const fetchTemplateDownloadLogs = (params?: { + templateId?: number; + templateName?: string; + userId?: number; + userKeyword?: string; + versionNo?: number; + downloadType?: "NORMAL" | "WATERMARK"; + ip?: string; + downloadedFrom?: string; + downloadedTo?: string; + pageNo?: number; + pageSize?: number; +}) => + http.get("/templates/download-logs", { params }); + +export const fetchExperts = (params?: { keyword?: string; pageNo?: number; pageSize?: number }) => http.get("/experts", { params }); +export const fetchPlatformExperts = (params?: { keyword?: string }) => http.get("/platform/experts", { params }); +export const fetchExpertDetail = (id: number) => http.get(`/experts/${id}`); +export const fetchPlatformExpertDetail = (id: number) => http.get(`/platform/experts/${id}`); +export const fetchExpertUploadSign = (payload: { fileName: string; contentType?: string }) => + http.post("/experts/upload-sign", payload); +export const fetchPlatformExpertUploadSign = (payload: { fileName: string; contentType?: string }) => + http.post("/platform/experts/upload-sign", payload); +export const createExpert = (payload: { + expertName: string; + idNo: string; + phone: string; + titleCode?: string; + title?: string; + hospitalCode?: string; + organization?: string; +}) => http.post("/experts", payload); +export const createPlatformExpert = (payload: { + expertName: string; + idNo: string; + phone: string; + titleCode?: string; + title?: string; + hospitalCode?: string; + organization?: string; +}) => http.post("/platform/experts", payload); +export const updateExpert = ( + id: number, + payload: { + expertName: string; + idNo: string; + phone: string; + titleCode?: string; + title?: string; + hospitalCode?: string; + organization?: string; + gender?: string; + birthday?: string; + idCardValidUntil?: string; + statusReason?: string; + }, +) => http.put(`/experts/${id}`, payload); +export const updatePlatformExpert = ( + id: number, + payload: { + expertName: string; + idNo: string; + phone: string; + titleCode?: string; + title?: string; + hospitalCode?: string; + organization?: string; + gender?: string; + birthday?: string; + idCardValidUntil?: string; + statusReason?: string; + }, +) => http.put(`/platform/experts/${id}`, payload); +export const importExperts = (payload: { + experts: Array<{ expertName: string; idNo: string; phone: string; titleCode?: string; title?: string; hospitalCode?: string; organization?: string }>; +}) => http.post("/experts/import", payload); +export const importPlatformExperts = (payload: { + experts: Array<{ expertName: string; idNo: string; phone: string; titleCode?: string; title?: string; hospitalCode?: string; organization?: string }>; +}) => http.post("/platform/experts/import", payload); +export const exportExperts = () => http.get("/experts/export"); +export const exportPlatformExperts = () => http.get("/platform/experts/export"); +export const mergeExpert = (id: number, payload: { sourceExpertId: number; reason: string }) => + http.post(`/experts/${id}/merge`, payload); +export const mergePlatformExpert = (id: number, payload: { sourceExpertId: number; reason: string }) => + http.post(`/platform/experts/${id}/merge`, payload); +export const fetchExpertBankCards = (id: number) => http.get(`/experts/${id}/bank-cards`); +export const fetchPlatformExpertBankCards = (id: number) => http.get(`/platform/experts/${id}/bank-cards`); +export const fetchExpertBankCardDetail = (id: number, cardId: number) => http.get(`/experts/${id}/bank-cards/${cardId}`); +export const fetchPlatformExpertBankCardDetail = (id: number, cardId: number) => + http.get(`/platform/experts/${id}/bank-cards/${cardId}`); +export const addExpertBankCard = ( + id: number, + payload: { + bankName: string; + bankProvince?: string; + bankCity?: string; + bankBranchName?: string; + bankCardNo: string; + bankCardFrontOssKey?: string; + bankCardBackOssKey?: string; + accountName: string; + isDefault?: boolean; + }, +) => http.post(`/experts/${id}/bank-cards`, payload); +export const addPlatformExpertBankCard = ( + id: number, + payload: { + bankName: string; + bankProvince?: string; + bankCity?: string; + bankBranchName?: string; + bankCardNo: string; + bankCardFrontOssKey?: string; + bankCardBackOssKey?: string; + accountName: string; + isDefault?: boolean; + }, +) => http.post(`/platform/experts/${id}/bank-cards`, payload); +export const updateExpertBankCard = ( + id: number, + cardId: number, + payload: { + bankName: string; + bankProvince?: string; + bankCity?: string; + bankBranchName?: string; + bankCardNo: string; + bankCardFrontOssKey?: string; + bankCardBackOssKey?: string; + accountName: string; + isDefault?: boolean; + }, +) => http.put(`/experts/${id}/bank-cards/${cardId}`, payload); +export const updatePlatformExpertBankCard = ( + id: number, + cardId: number, + payload: { + bankName: string; + bankProvince?: string; + bankCity?: string; + bankBranchName?: string; + bankCardNo: string; + bankCardFrontOssKey?: string; + bankCardBackOssKey?: string; + accountName: string; + isDefault?: boolean; + }, +) => http.put(`/platform/experts/${id}/bank-cards/${cardId}`, payload); +export const fetchExpertDictionaryOptions = () => http.get("/dictionaries/expert-options"); +export const fetchDictionaries = (params?: { dictType?: string; enabledOnly?: boolean }) => + http.get("/dictionaries", { params }); +export const fetchPlatformDictionaries = (params?: { dictType?: string; enabledOnly?: boolean }) => + http.get("/platform/dictionaries", { params }); +export const fetchPlatformDictionaryTypes = (params?: { enabledOnly?: boolean }) => + http.get("/platform/dictionaries/types", { params }); +export const createPlatformDictionaryType = (payload: { + dictType: string; + dictName: string; + sortNo: number; + remark?: string; +}) => http.post("/platform/dictionaries/types", payload); +export const createPlatformDictionary = (payload: { + dictType: string; + dictCode: string; + dictName: string; + sortNo: number; + remark?: string; +}) => http.post("/platform/dictionaries", payload); +export const updatePlatformDictionary = ( + id: number, + payload: { dictName: string; sortNo: number; status: "ENABLED" | "DISABLED"; remark?: string }, +) => http.put(`/platform/dictionaries/${id}`, payload); +export const enablePlatformDictionary = (id: number) => http.post(`/platform/dictionaries/${id}/enable`); +export const disablePlatformDictionary = (id: number) => http.post(`/platform/dictionaries/${id}/disable`); +export const fetchPlatformAuthSessions = (params?: { + scope?: "TENANT" | "PLATFORM"; + status?: "ACTIVE" | "ROTATED" | "REVOKED" | "EXPIRED"; + userId?: number; + tenantId?: number; +}) => http.get("/platform/auth-sessions", { params }); +export const revokePlatformAuthSession = (id: number) => http.post(`/platform/auth-sessions/${id}/revoke`); +export const revokePlatformPrincipalSessions = (payload: { + userId: number; + scope: "TENANT" | "PLATFORM"; + tenantId?: number; +}) => http.post("/platform/auth-sessions/revoke-principal", payload); +export const fetchPlatformNotifyGateways = () => http.get("/platform/notify-gateways"); +export const savePlatformNotifyGateway = ( + channelCode: string, + payload: { + gatewayName: string; + providerCode: string; + status: "ENABLED" | "DISABLED"; + remark?: string; + config: Record; + }, +) => http.put(`/platform/notify-gateways/${channelCode}`, payload); +export const testPlatformNotifyGateway = ( + channelCode: string, + payload: { + receiverRef: string; + subject?: string; + content?: string; + }, +) => http.post(`/platform/notify-gateways/${channelCode}/test`, payload); + +export const fetchNotificationPolicies = (params?: { pageNo?: number; pageSize?: number }) => + http.get("/notification-policies", { params }); +export const fetchNotificationTextTemplates = (params?: { pageNo?: number; pageSize?: number }) => + http.get("/notification-text-templates", { params }); +export const createNotificationTextTemplate = (payload: { + templateName: string; + subjectTemplate?: string; + titleTemplate?: string; + contentTemplate: string; + status?: "ENABLED" | "DISABLED"; +}) => http.post("/notification-text-templates", payload); +export const updateNotificationTextTemplate = ( + id: number, + payload: { + templateName: string; + subjectTemplate?: string; + titleTemplate?: string; + contentTemplate: string; + status?: "ENABLED" | "DISABLED"; + }, +) => http.put(`/notification-text-templates/${id}`, payload); +export const enableNotificationTextTemplate = (id: number) => http.post(`/notification-text-templates/${id}/enable`); +export const disableNotificationTextTemplate = (id: number) => http.post(`/notification-text-templates/${id}/disable`); +export const createNotificationPolicy = (payload: { + policyName: string; + eventCode: string; + channel: string; + receiverType: string; + templateId: number; + variablesJson?: string; + status?: "ENABLED" | "DISABLED"; +}) => http.post("/notification-policies", payload); +export const updateNotificationPolicy = ( + id: number, + payload: { + policyName: string; + eventCode: string; + channel: string; + receiverType: string; + templateId: number; + variablesJson?: string; + status?: "ENABLED" | "DISABLED"; + }, +) => http.put(`/notification-policies/${id}`, payload); +export const bindNotificationPolicyEvents = (id: number, payload: { eventCode: string }) => + http.post(`/notification-policies/${id}/events`, payload); +export const enableNotificationPolicy = (id: number) => http.post(`/notification-policies/${id}/enable`); +export const disableNotificationPolicy = (id: number) => http.post(`/notification-policies/${id}/disable`); +export const dispatchNotification = (payload: { + idempotencyKey: string; + eventCode: string; + bizType?: string; + bizId?: string; + variablesJson?: string; + policyId?: number; +}) => http.post("/notifications/dispatch", payload); +export const fetchNotificationTasks = (params?: { pageNo?: number; pageSize?: number }) => + http.get("/notifications/tasks", { params }); +export const ingestNotificationReceipt = (payload: { + taskId: number; + providerMessageId: string; + receiptCode: string; + receiptMessage?: string; + delivered?: boolean; +}) => http.post("/notifications/receipts", payload); +export const fetchInAppNotifications = (params?: { ts?: number }) => http.get("/in-app-notifications", { params }); +export const markInAppNotificationRead = (id: number) => http.post(`/in-app-notifications/${id}/read`); +export const markAllInAppNotificationsRead = () => http.post("/in-app-notifications/read-all"); +export const fetchExportTasks = () => http.get("/export-tasks"); +export const createExportTask = (payload: { + idempotencyKey: string; + taskCode: string; + bizType: string; + bizId?: string; + filtersJson?: string; + fileName?: string; +}) => http.post("/export-tasks", payload); +export const refreshExportTaskToken = (id: number) => http.post(`/export-tasks/${id}/refresh-token`); +export const downloadExportTask = (id: number, params: { token: string }) => + http.get(`/export-tasks/${id}/download`, { params }); +export const fetchOperationsDashboard = () => http.get("/operations/dashboard"); + +export const fetchObservabilityMetrics = (params: { metricCode: string; minutes?: number }) => + http.get("/observability/metrics", { params }); +export const fetchObservabilityExportMetrics = (params?: { minutes?: number }) => + http.get("/observability/metrics/export", { params }); +export const fetchAlertRules = () => http.get("/observability/alert-rules"); +export const createAlertRule = (payload: { + ruleCode: string; + ruleName: string; + compareOp: string; + thresholdValue: number; + windowMinute: number; + suppressWindowMinute?: number; + status?: "ENABLED" | "DISABLED"; +}) => http.post("/observability/alert-rules", payload); +export const updateAlertRule = ( + id: number, + payload: { + ruleCode: string; + ruleName: string; + compareOp: string; + thresholdValue: number; + windowMinute: number; + suppressWindowMinute?: number; + status?: "ENABLED" | "DISABLED"; + }, +) => http.put(`/observability/alert-rules/${id}`, payload); +export const evaluateAlertRules = () => http.post("/observability/alert-rules/evaluate"); +export const evaluateAlertRulesAuto = (params?: { recoveryWindowMinute?: number }) => + http.post("/observability/alert-rules/evaluate/auto", null, { params }); +export const fetchAlertEvents = () => http.get("/observability/alert-events"); + +// ========== Phase Zero: Dashboard Stats ========== +export const fetchDashboardStats = () => http.get("/dashboard/stats"); + +// ========== Phase Zero: Soft Delete & Lifecycle ========== +export const deleteAuditFlow = (id: number) => http.post(`/audit-flows/${id}/delete`); +export const deleteNotificationPolicy = (id: number) => http.post(`/notification-policies/${id}/delete`); +export const deleteNotificationTextTemplate = (id: number) => http.post(`/notification-text-templates/${id}/delete`); + +// ========== Phase Zero: Platform User Update ========== +export const updatePlatformUser = ( + id: number, + payload: { + userName: string; + phone: string; + email?: string; + validFrom?: string; + validTo?: string; + password?: string; + }, +) => http.put(`/platform/users/${id}`, payload); + +// ========== Phase Zero: Data Export ========== +export const exportMeetings = (payload: { + idempotencyKey: string; + filtersJson?: string; + fileName?: string; +}) => http.post("/meetings/export", payload); +export const exportUsers = (payload: { + idempotencyKey: string; + fileName?: string; +}) => http.post("/users/export", payload); +export const exportProjects = (payload: { + idempotencyKey: string; + fileName?: string; +}) => http.post("/projects/export", payload); + +// ========== Phase Zero: Tenant Dictionary ========== +export const createDictionary = (payload: { + dictType: string; + dictCode: string; + dictName: string; + sortNo: number; + remark?: string; +}) => http.post("/dictionaries", payload); +export const updateDictionary = ( + id: number, + payload: { dictName: string; sortNo: number; status: "ENABLED" | "DISABLED"; remark?: string }, +) => http.put(`/dictionaries/${id}`, payload); +export const enableDictionary = (id: number) => http.post(`/dictionaries/${id}/enable`); +export const disableDictionary = (id: number) => http.post(`/dictionaries/${id}/disable`); diff --git a/frontend/src/components/BreadcrumbNav.vue b/frontend/src/components/BreadcrumbNav.vue new file mode 100644 index 0000000..68d8809 --- /dev/null +++ b/frontend/src/components/BreadcrumbNav.vue @@ -0,0 +1,62 @@ + + + + + diff --git a/frontend/src/components/GlobalSearchLauncher.vue b/frontend/src/components/GlobalSearchLauncher.vue new file mode 100644 index 0000000..f5e3fc6 --- /dev/null +++ b/frontend/src/components/GlobalSearchLauncher.vue @@ -0,0 +1,364 @@ + + + + + diff --git a/frontend/src/components/PageContainer.vue b/frontend/src/components/PageContainer.vue new file mode 100644 index 0000000..c995588 --- /dev/null +++ b/frontend/src/components/PageContainer.vue @@ -0,0 +1,64 @@ + + + + + diff --git a/frontend/src/components/PasswordStrengthBar.vue b/frontend/src/components/PasswordStrengthBar.vue new file mode 100644 index 0000000..cf1a489 --- /dev/null +++ b/frontend/src/components/PasswordStrengthBar.vue @@ -0,0 +1,110 @@ + + + + + diff --git a/frontend/src/components/QueryToolbar.vue b/frontend/src/components/QueryToolbar.vue new file mode 100644 index 0000000..234fddf --- /dev/null +++ b/frontend/src/components/QueryToolbar.vue @@ -0,0 +1,15 @@ + + + + + diff --git a/frontend/src/components/SectionTitle.vue b/frontend/src/components/SectionTitle.vue new file mode 100644 index 0000000..54aa75f --- /dev/null +++ b/frontend/src/components/SectionTitle.vue @@ -0,0 +1,23 @@ + + + + + diff --git a/frontend/src/constants/permissions.ts b/frontend/src/constants/permissions.ts new file mode 100644 index 0000000..db07789 --- /dev/null +++ b/frontend/src/constants/permissions.ts @@ -0,0 +1,155 @@ +export const PERMS = { + project: { + create: "project.create", + keyChangeLogRead: "project.key-change-log.read", + freeze: "project.freeze", + unfreeze: "project.unfreeze", + archive: "project.archive", + bindUser: "project.bind.user", + bindExecutorUser: "project.bind.executor_user", + }, + meeting: { + read: "meeting.read", + manage: "meeting.manage", + create: "meeting.create", + delete: "meeting.delete", + cancel: "meeting.cancel", + submit: "meeting.submit", + withdraw: "meeting.withdraw", + materialSave: "meeting.material.save", + materialSubmit: "meeting.material.submit", + materialHistoryRead: "meeting.material.history.read", + materialExport: "meeting.material.export", + invoiceConfig: "meeting.invoice.config", + changeLogRead: "meeting.change-log.read", + }, + audit: { + read: "audit.read", + approve: "audit.approve", + reject: "audit.reject", + back: "audit.return", + transfer: "audit.transfer", + remind: "audit.remind", + slaRead: "audit.sla.read", + exportOpinions: "audit.export.opinions", + materialRead: "audit.material.read", + flowRead: "audit.flow.read", + flowManage: "audit.flow.manage", + logRead: "audit.log.read", + }, + finance: { + paymentConfirm: "finance.payment.confirm", + ledgerExport: "finance.ledger.export", + reconciliation: "finance.reconciliation", + lock: "finance.lock", + unlock: "finance.unlock", + }, + user: { + read: "user.read", + create: "user.create", + import: "user.import", + update: "user.update", + delete: "user.delete", + roleAssign: "user.role.assign", + enable: "user.enable", + disable: "user.disable", + resetPassword: "user.password.reset", + roleHistoryRead: "user.role.history.read", + delegationManage: "user.delegation.manage", + }, + role: { + read: "role.read", + create: "role.create", + update: "role.update", + delete: "role.delete", + enable: "role.enable", + disable: "role.disable", + permissionBind: "role.permission.bind", + }, + permission: { + read: "permission.read", + }, + tenant: { + manage: "tenant.manage", + switch: "tenant.switch", + }, + dataPermission: { + read: "data.permission.read", + manage: "data.permission.manage", + }, + template: { + read: "template.read", + manage: "template.manage", + create: "template.create", + publish: "template.publish", + disable: "template.disable", + archive: "template.archive", + rollback: "template.rollback", + download: "template.download", + downloadLogReadAll: "template.download.log.read.all", + flowLink: "template.flow.link", + }, + expert: { + read: "expert.read", + create: "expert.create", + merge: "expert.merge", + import: "expert.import", + export: "expert.export", + cardManage: "expert.card.manage", + idCardOcr: "ocr.idcard", + bankCardOcr: "ocr.bankcard", + }, + notification: { + policyRead: "notification.policy.read", + policyManage: "notification.policy.manage", + textTemplateRead: "notification.text-template.read", + textTemplateManage: "notification.text-template.manage", + dispatch: "notification.dispatch", + taskRead: "notification.task.read", + inAppRead: "notification.inapp.read", + inAppMarkRead: "notification.inapp.mark-read", + }, + observability: { + read: "observability.read", + manage: "observability.manage", + }, + invoiceProfile: { + read: "invoice.profile.read", + manage: "invoice.profile.manage", + }, + exportTask: { + read: "export.task.read", + manage: "export.task.manage", + }, + dashboard: { + read: "dashboard.read", + }, + enterprise: { + read: "enterprise.read", + manage: "enterprise.manage", + delete: "enterprise.delete", + }, + dictionary: { + read: "dictionary.read", + manage: "dictionary.manage", + }, + platform: { + tenantManage: "platform.tenant.manage", + userManage: "platform.user.manage", + auditRead: "platform.audit.read", + menuManage: "platform.menu.manage", + roleRead: "platform.role.read", + roleManage: "platform.role.manage", + permissionRead: "platform.permission.read", + expertRead: "platform.expert.read", + expertManage: "platform.expert.manage", + idCardOcr: "platform.ocr.idcard", + bankCardOcr: "platform.ocr.bankcard", + dictionaryRead: "platform.dictionary.read", + dictionaryManage: "platform.dictionary.manage", + sessionRead: "platform.session.read", + sessionManage: "platform.session.manage", + notifyGatewayRead: "platform.notify-gateway.read", + notifyGatewayManage: "platform.notify-gateway.manage", + }, +} as const; diff --git a/frontend/src/constants/ui.ts b/frontend/src/constants/ui.ts new file mode 100644 index 0000000..e4512d1 --- /dev/null +++ b/frontend/src/constants/ui.ts @@ -0,0 +1,50 @@ +/** + * UI 常量 — 弹窗/抽屉尺寸 & 表单控件标准宽度 + * P0-03 & P0-04 + */ + +/** Drawer 标准宽度档位 */ +export const DRAWER_SIZE = { + /** 480px — 简单表单(新增角色、基本信息等) */ + sm: "480px", + /** 640px — 中等表单(用户编辑、策略编辑等) */ + md: "640px", + /** 55% — 稍大表单(项目编辑、详情预览等) */ + xl: "55%", + /** 80% — 大型内容(银行卡列表、资料详情等) */ + lg: "80%", +} as const; + +/** Dialog 标准宽度档位 */ +export const DIALOG_WIDTH = { + /** 520px — 简单确认/输入 */ + sm: "520px", + /** 680px — 中等表单/列表 */ + md: "680px", + /** 860px — 大型复杂表单 */ + lg: "860px", +} as const; + +/** 表单控件标准宽度档位 */ +export const INPUT_WIDTH = { + /** 100px — 极窄(比较符等) */ + xs: "100px", + /** 160px — 搜索栏下拉/状态选择 */ + sm: "160px", + /** 220px — 标准表单下拉 */ + md: "220px", + /** 280px — 宽表单下拉 */ + lg: "280px", + /** 100% — 充满父容器 */ + full: "100%", +} as const; + +/** 标准 label-width(el-form) */ +export const LABEL_WIDTH = { + /** 简洁表单 */ + sm: "70px", + /** 标准表单 */ + md: "90px", + /** 宽标签表单 */ + lg: "110px", +} as const; diff --git a/frontend/src/main.ts b/frontend/src/main.ts new file mode 100644 index 0000000..cbdcda1 --- /dev/null +++ b/frontend/src/main.ts @@ -0,0 +1,124 @@ +import { createApp } from "vue"; +import ElementPlus from "element-plus"; +import { ElMessage } from "element-plus"; +import "element-plus/dist/index.css"; +import "element-plus/theme-chalk/dark/css-vars.css"; +import "./styles/variables.css"; +import "./styles/theme.css"; +import "./styles/utilities.css"; +import App from "./App.vue"; +import router from "./router"; +import { pinia } from "./stores"; +import { useAppearanceStore } from "./stores/appearance"; +import { useAuthStore } from "./stores/auth"; +import { logoutAuth } from "./api/modules"; +import { resolveLoginPath } from "./utils/authNavigation"; + +const IDLE_TIMEOUT_MINUTES = Number(import.meta.env.VITE_IDLE_TIMEOUT_MINUTES || 60); +const IDLE_TIMEOUT_MS = (Number.isFinite(IDLE_TIMEOUT_MINUTES) && IDLE_TIMEOUT_MINUTES > 0 ? IDLE_TIMEOUT_MINUTES : 60) * 60 * 1000; +const IDLE_ACTIVITY_EVENTS = ["mousedown", "keydown", "scroll", "touchstart"] as const; +const PUBLIC_ENTRY_PATH_PATTERNS = [/^\/login$/, /^\/[^/]+\/login$/, /^\/[^/]+\/setup-password$/]; + +let idleTimer: number | null = null; +let lastActivityAt = Date.now(); +let idleLogoutPending = false; +let previousAuthLoggedIn = false; + +const stopIdleTimer = () => { + if (idleTimer !== null) { + window.clearTimeout(idleTimer); + idleTimer = null; + } +}; + +const scheduleIdleTimeout = () => { + stopIdleTimer(); + const authStore = useAuthStore(pinia); + if (!authStore.token) { + previousAuthLoggedIn = false; + return; + } + const elapsed = Date.now() - lastActivityAt; + const delay = Math.max(0, IDLE_TIMEOUT_MS - elapsed); + idleTimer = window.setTimeout(() => { + void handleIdleTimeout(); + }, delay); +}; + +const markUserActivity = () => { + const authStore = useAuthStore(pinia); + if (!authStore.token) { + stopIdleTimer(); + previousAuthLoggedIn = false; + return; + } + lastActivityAt = Date.now(); + scheduleIdleTimeout(); +}; + +async function handleIdleTimeout() { + const authStore = useAuthStore(pinia); + if (!authStore.token || idleLogoutPending) { + return; + } + idleLogoutPending = true; + const nextLoginPath = resolveLoginPath(authStore.scope, authStore.tenantCode); + try { + await logoutAuth(); + } catch (_error) { + // ignore logout failure during idle cleanup + } + authStore.clearAuthStorage(); + window.location.href = nextLoginPath; +} + +const handleAuthStateChanged = () => { + const authStore = useAuthStore(pinia); + const loggedIn = Boolean(authStore.token); + if (!loggedIn) { + stopIdleTimer(); + previousAuthLoggedIn = false; + idleLogoutPending = false; + return; + } + if (!previousAuthLoggedIn) { + lastActivityAt = Date.now(); + } + previousAuthLoggedIn = true; + scheduleIdleTimeout(); +}; + +const app = createApp(App); +app.use(pinia); +const appearanceStore = useAppearanceStore(pinia); +appearanceStore.initAppearance(); + +const isPublicEntryPath = (path: string) => PUBLIC_ENTRY_PATH_PATTERNS.some((pattern) => pattern.test(path)); + +// 全局错误边界:捕获未处理的组件异常,防止白屏 +app.config.errorHandler = (err, _instance, info) => { + console.error("[全局错误边界]", info, err); + ElMessage.error("页面发生异常,请尝试刷新页面"); +}; + +app.use(router).use(ElementPlus).mount("#app"); + +router.isReady().then(() => { + if (isPublicEntryPath(router.currentRoute.value.path)) { + appearanceStore.markBootstrapped(); + } + appearanceStore.syncFromServer(); + for (const eventName of IDLE_ACTIVITY_EVENTS) { + window.addEventListener(eventName, markUserActivity, { passive: true }); + } + window.addEventListener("auth:token-updated", handleAuthStateChanged as EventListener); + document.addEventListener("visibilitychange", () => { + if (document.visibilityState === "visible") { + markUserActivity(); + } + }); + router.afterEach(() => { + markUserActivity(); + }); + handleAuthStateChanged(); +}); diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts new file mode 100644 index 0000000..3c5d41c --- /dev/null +++ b/frontend/src/router/index.ts @@ -0,0 +1,151 @@ +import { createRouter, createWebHistory } from "vue-router"; +import AppLayout from "../views/layout/AppLayout.vue"; +import TenantDashboardPage from "../views/modules/TenantDashboardPage.vue"; +import ProfilePage from "../views/modules/ProfilePage.vue"; +import ProjectPage from "../views/modules/ProjectPage.vue"; +import MeetingPage from "../views/modules/MeetingPage.vue"; +import AuditPage from "../views/modules/AuditPage.vue"; +import FinancePage from "../views/modules/FinancePage.vue"; +import UserPage from "../views/modules/UserPage.vue"; +import RolePage from "../views/modules/RolePage.vue"; +import TenantPage from "../views/modules/TenantPage.vue"; +import EnterprisePage from "../views/modules/EnterprisePage.vue"; +import MenuPage from "../views/modules/MenuPage.vue"; +import PlatformLoginPage from "../views/modules/PlatformLoginPage.vue"; +import TenantLoginPage from "../views/modules/TenantLoginPage.vue"; +import TenantPasswordSetupPage from "../views/modules/TenantPasswordSetupPage.vue"; +import AuditFlowPage from "../views/modules/AuditFlowPage.vue"; +import DataPermissionPage from "../views/modules/DataPermissionPage.vue"; +import TemplatePage from "../views/modules/TemplatePage.vue"; +import TemplateDownloadLogPage from "../views/modules/TemplateDownloadLogPage.vue"; +import AuditLogPage from "../views/modules/AuditLogPage.vue"; +import ExpertPage from "../views/modules/ExpertPage.vue"; +import NotificationPolicyPage from "../views/modules/NotificationPolicyPage.vue"; +import NotificationTextTemplatePage from "../views/modules/NotificationTextTemplatePage.vue"; +import InAppNotificationPage from "../views/modules/InAppNotificationPage.vue"; +import ObservabilityPage from "../views/modules/ObservabilityPage.vue"; +import InvoiceProfilePage from "../views/modules/InvoiceProfilePage.vue"; +import ExportTaskPage from "../views/modules/ExportTaskPage.vue"; +import OperationsDashboardPage from "../views/modules/OperationsDashboardPage.vue"; +import PlatformMenuPage from "../views/modules/PlatformMenuPage.vue"; +import PlatformUserPage from "../views/modules/PlatformUserPage.vue"; +import PlatformRolePage from "../views/modules/PlatformRolePage.vue"; +import PlatformDictionaryPage from "../views/modules/PlatformDictionaryPage.vue"; +import PlatformSessionPage from "../views/modules/PlatformSessionPage.vue"; +import PlatformNotifyGatewayPage from "../views/modules/PlatformNotifyGatewayPage.vue"; +import NotFoundPage from "../views/modules/NotFoundPage.vue"; +import { refreshAuth } from "../api/modules"; +import { pinia } from "../stores"; +import { useAuthStore } from "../stores/auth"; +import { resolveLoginPath } from "../utils/authNavigation"; + +const router = createRouter({ + history: createWebHistory(), + routes: [ + { + path: "/login", + component: PlatformLoginPage, + meta: { title: "平台管理端登录" }, + }, + { + path: "/:tenantCode/login", + component: TenantLoginPage, + meta: { title: "租户登录" }, + }, + { + path: "/:tenantCode/setup-password", + component: TenantPasswordSetupPage, + meta: { title: "租户管理员密码设置" }, + }, + { + path: "/", + component: AppLayout, + children: [ + { path: "", redirect: "/dashboard" }, + { path: "/dashboard", component: TenantDashboardPage, meta: { title: "工作台" } }, + { path: "/profile", component: ProfilePage, meta: { title: "个人设置" } }, + { path: "/projects", component: ProjectPage, meta: { title: "项目管理" } }, + { path: "/meetings", component: MeetingPage, meta: { title: "会议管理" } }, + { path: "/audits", component: AuditPage, meta: { title: "审核管理" } }, + { path: "/finance", component: FinancePage, meta: { title: "财务管理" } }, + { path: "/users", component: UserPage, meta: { title: "用户管理" } }, + { path: "/tenants", component: TenantPage, meta: { title: "租户管理" } }, + { path: "/enterprises", component: EnterprisePage, meta: { title: "企业管理" } }, + { path: "/menus", component: MenuPage, meta: { title: "菜单与权限" } }, + { path: "/roles", component: RolePage, meta: { title: "角色管理" } }, + { path: "/permissions", redirect: { path: "/menus", query: { tab: "permissions" } } }, + { path: "/audit-flows", component: AuditFlowPage, meta: { title: "审核流配置" } }, + { path: "/data-permissions", component: DataPermissionPage, meta: { title: "数据权限管理" } }, + { path: "/templates", component: TemplatePage, meta: { title: "模板管理" } }, + { path: "/template-download-logs", component: TemplateDownloadLogPage, meta: { title: "模板查看下载" } }, + { path: "/audit-logs", component: AuditLogPage, meta: { title: "审计日志" } }, + { path: "/experts", component: ExpertPage, meta: { title: "专家管理" } }, + { path: "/invoice-profiles", component: InvoiceProfilePage, meta: { title: "发票管理" } }, + { path: "/export-tasks", component: ExportTaskPage, meta: { title: "导出任务中心" } }, + { path: "/notification-policies", component: NotificationPolicyPage, meta: { title: "通知策略中心" } }, + { path: "/notification-text-templates", component: NotificationTextTemplatePage, meta: { title: "通知文案模板" } }, + { path: "/in-app-notifications", component: InAppNotificationPage, meta: { title: "站内通知中心" } }, + { path: "/observability", component: ObservabilityPage, meta: { title: "可观测性与告警" } }, + { path: "/operations-dashboard", component: OperationsDashboardPage, meta: { title: "运营看板" } }, + { path: "/platform/tenants", component: TenantPage, meta: { title: "平台租户管理" } }, + { path: "/platform/audit-logs", component: AuditLogPage, meta: { title: "平台审计日志" } }, + { path: "/platform/menus", component: PlatformMenuPage, meta: { title: "平台菜单与权限" } }, + { path: "/platform/users", component: PlatformUserPage, meta: { title: "平台用户管理" } }, + { path: "/platform/roles", component: PlatformRolePage, meta: { title: "平台角色管理" } }, + { path: "/platform/dictionaries", component: PlatformDictionaryPage, meta: { title: "平台字典管理" } }, + { path: "/platform/auth-sessions", component: PlatformSessionPage, meta: { title: "平台会话管理" } }, + { path: "/platform/notify-gateways", component: PlatformNotifyGatewayPage, meta: { title: "通知网关配置" } }, + { path: "/platform/permissions", redirect: { path: "/platform/menus", query: { tab: "permissions" } } }, + { path: "/platform/experts", component: ExpertPage, meta: { title: "平台专家管理" } }, + { path: "/:pathMatch(.*)*", component: NotFoundPage, meta: { title: "页面未找到" } }, + ], + }, + ], +}); + +router.beforeEach((to, _from, next) => { + const authStore = useAuthStore(pinia); + if (to.path === "/login" || /^\/[^/]+\/login$/.test(to.path) || /^\/[^/]+\/setup-password$/.test(to.path)) { + return next(); + } + const proceedWithTokenCheck = async () => { + const token = authStore.token; + if (!token) { + try { + const resp = await refreshAuth(); + const refreshedToken = String(resp?.data?.token || "").trim(); + if (!refreshedToken) { + return next(resolveLoginPath(authStore.scope, authStore.tenantCode)); + } + authStore.saveAuthPayload(resp?.data || null); + } catch (_e) { + return next(resolveLoginPath(authStore.scope, authStore.tenantCode)); + } + } + const scope = authStore.scope; + const isPlatformPath = to.path.startsWith("/platform/"); + const isSharedProfilePath = to.path === "/profile"; + if (scope === "PLATFORM") { + if (!isPlatformPath && !isSharedProfilePath) { + return next("/platform/tenants"); + } + return next(); + } + if (to.path === "/audit-logs") { + return next("/dashboard"); + } + if (isPlatformPath) { + return next("/dashboard"); + } + next(); + }; + proceedWithTokenCheck(); +}); + +router.afterEach((to) => { + const meta = to.meta as Record | undefined; + const pageTitle = String(meta?.title || "").trim(); + document.title = pageTitle ? `${pageTitle} - 会议核销系统` : "会议核销系统"; +}); + +export default router; diff --git a/frontend/src/static/专题授课.png b/frontend/src/static/专题授课.png new file mode 100644 index 0000000..cd3ebc9 Binary files /dev/null and b/frontend/src/static/专题授课.png differ diff --git a/frontend/src/static/会场发票.png b/frontend/src/static/会场发票.png new file mode 100644 index 0000000..b9d1ffd Binary files /dev/null and b/frontend/src/static/会场发票.png differ diff --git a/frontend/src/static/会场合同.png b/frontend/src/static/会场合同.png new file mode 100644 index 0000000..10f22c9 Binary files /dev/null and b/frontend/src/static/会场合同.png differ diff --git a/frontend/src/static/会场小票.png b/frontend/src/static/会场小票.png new file mode 100644 index 0000000..7ee5958 Binary files /dev/null and b/frontend/src/static/会场小票.png differ diff --git a/frontend/src/static/会场明细单.png b/frontend/src/static/会场明细单.png new file mode 100644 index 0000000..cd56de1 Binary files /dev/null and b/frontend/src/static/会场明细单.png differ diff --git a/frontend/src/static/会议串场.png b/frontend/src/static/会议串场.png new file mode 100644 index 0000000..f76bc30 Binary files /dev/null and b/frontend/src/static/会议串场.png differ diff --git a/frontend/src/static/会议主持.png b/frontend/src/static/会议主持.png new file mode 100644 index 0000000..29015b7 Binary files /dev/null and b/frontend/src/static/会议主持.png differ diff --git a/frontend/src/static/会议主题.png b/frontend/src/static/会议主题.png new file mode 100644 index 0000000..cd9f253 Binary files /dev/null and b/frontend/src/static/会议主题.png differ diff --git a/frontend/src/static/会议总结.png b/frontend/src/static/会议总结.png new file mode 100644 index 0000000..f154084 Binary files /dev/null and b/frontend/src/static/会议总结.png differ diff --git a/frontend/src/static/会议总结2.png b/frontend/src/static/会议总结2.png new file mode 100644 index 0000000..3622dd1 Binary files /dev/null and b/frontend/src/static/会议总结2.png differ diff --git a/frontend/src/static/会议日程 copy.png b/frontend/src/static/会议日程 copy.png new file mode 100644 index 0000000..a0730b9 Binary files /dev/null and b/frontend/src/static/会议日程 copy.png differ diff --git a/frontend/src/static/会议日程.png b/frontend/src/static/会议日程.png new file mode 100644 index 0000000..bc2a85d Binary files /dev/null and b/frontend/src/static/会议日程.png differ diff --git a/frontend/src/static/会议结算单.png b/frontend/src/static/会议结算单.png new file mode 100644 index 0000000..b10d81a Binary files /dev/null and b/frontend/src/static/会议结算单.png differ diff --git a/frontend/src/static/会议讨论.png b/frontend/src/static/会议讨论.png new file mode 100644 index 0000000..4029108 Binary files /dev/null and b/frontend/src/static/会议讨论.png differ diff --git a/frontend/src/static/劳务费协议.png b/frontend/src/static/劳务费协议.png new file mode 100644 index 0000000..fa06975 Binary files /dev/null and b/frontend/src/static/劳务费协议.png differ diff --git a/frontend/src/static/劳务费发票.png b/frontend/src/static/劳务费发票.png new file mode 100644 index 0000000..4b2b9c8 Binary files /dev/null and b/frontend/src/static/劳务费发票.png differ diff --git a/frontend/src/static/大交通发票.png b/frontend/src/static/大交通发票.png new file mode 100644 index 0000000..c017055 Binary files /dev/null and b/frontend/src/static/大交通发票.png differ diff --git a/frontend/src/static/大交通发票2.png b/frontend/src/static/大交通发票2.png new file mode 100644 index 0000000..d781142 Binary files /dev/null and b/frontend/src/static/大交通发票2.png differ diff --git a/frontend/src/static/大交通明细2.png b/frontend/src/static/大交通明细2.png new file mode 100644 index 0000000..83439b3 Binary files /dev/null and b/frontend/src/static/大交通明细2.png differ diff --git a/frontend/src/static/大交通明细单.png b/frontend/src/static/大交通明细单.png new file mode 100644 index 0000000..e8d63d7 Binary files /dev/null and b/frontend/src/static/大交通明细单.png differ diff --git a/frontend/src/static/大会主持.png b/frontend/src/static/大会主持.png new file mode 100644 index 0000000..fd8bd38 Binary files /dev/null and b/frontend/src/static/大会主持.png differ diff --git a/frontend/src/static/小交通发票.jpg b/frontend/src/static/小交通发票.jpg new file mode 100644 index 0000000..1e5f7fe Binary files /dev/null and b/frontend/src/static/小交通发票.jpg differ diff --git a/frontend/src/static/小交通发票.png b/frontend/src/static/小交通发票.png new file mode 100644 index 0000000..01944e4 Binary files /dev/null and b/frontend/src/static/小交通发票.png differ diff --git a/frontend/src/static/小交通明细.png b/frontend/src/static/小交通明细.png new file mode 100644 index 0000000..f0b9e18 Binary files /dev/null and b/frontend/src/static/小交通明细.png differ diff --git a/frontend/src/static/桌牌.png b/frontend/src/static/桌牌.png new file mode 100644 index 0000000..2467276 Binary files /dev/null and b/frontend/src/static/桌牌.png differ diff --git a/frontend/src/static/物料.png b/frontend/src/static/物料.png new file mode 100644 index 0000000..f72a66f Binary files /dev/null and b/frontend/src/static/物料.png differ diff --git a/frontend/src/static/物料2.png b/frontend/src/static/物料2.png new file mode 100644 index 0000000..5fb599b Binary files /dev/null and b/frontend/src/static/物料2.png differ diff --git a/frontend/src/static/物料3.png b/frontend/src/static/物料3.png new file mode 100644 index 0000000..02cd9b0 Binary files /dev/null and b/frontend/src/static/物料3.png differ diff --git a/frontend/src/static/物料4.png b/frontend/src/static/物料4.png new file mode 100644 index 0000000..bbdbefa Binary files /dev/null and b/frontend/src/static/物料4.png differ diff --git a/frontend/src/static/物料明细.png b/frontend/src/static/物料明细.png new file mode 100644 index 0000000..22c9a2e Binary files /dev/null and b/frontend/src/static/物料明细.png differ diff --git a/frontend/src/static/签到表.png b/frontend/src/static/签到表.png new file mode 100644 index 0000000..49ea3ec Binary files /dev/null and b/frontend/src/static/签到表.png differ diff --git a/frontend/src/static/设备.png b/frontend/src/static/设备.png new file mode 100644 index 0000000..e1048c2 Binary files /dev/null and b/frontend/src/static/设备.png differ diff --git a/frontend/src/static/设备2.png b/frontend/src/static/设备2.png new file mode 100644 index 0000000..6e9d246 Binary files /dev/null and b/frontend/src/static/设备2.png differ diff --git a/frontend/src/static/设计稿.png b/frontend/src/static/设计稿.png new file mode 100644 index 0000000..7118567 Binary files /dev/null and b/frontend/src/static/设计稿.png differ diff --git a/frontend/src/static/邀请函.png b/frontend/src/static/邀请函.png new file mode 100644 index 0000000..da4bebc Binary files /dev/null and b/frontend/src/static/邀请函.png differ diff --git a/frontend/src/static/餐饮发票.png b/frontend/src/static/餐饮发票.png new file mode 100644 index 0000000..e5febb5 Binary files /dev/null and b/frontend/src/static/餐饮发票.png differ diff --git a/frontend/src/stores/appearance.ts b/frontend/src/stores/appearance.ts new file mode 100644 index 0000000..e37fa07 --- /dev/null +++ b/frontend/src/stores/appearance.ts @@ -0,0 +1,241 @@ +import { defineStore } from "pinia"; +import { fetchProfilePreferences } from "../api/modules"; + +export type ThemeMode = "SYSTEM" | "LIGHT" | "DARK"; +export type DensityMode = "COMFORTABLE" | "COMPACT"; +export type ThemeScheme = "SLATE" | "OCEAN" | "FOREST" | "GRAPHITE" | "AMBER" | "RUBY" | "MIST" | "SAGE" | "DAWN"; + +type AppearancePayload = { + themeMode?: unknown; + density?: unknown; + themeScheme?: unknown; +}; + +type AppearanceState = { + themeMode: ThemeMode; + density: DensityMode; + themeScheme: ThemeScheme; + initialized: boolean; + bootstrapped: boolean; +}; + +const THEME_STORAGE_KEY = "themeMode"; +const DENSITY_STORAGE_KEY = "density"; +const THEME_SCHEME_STORAGE_KEY = "themeScheme"; +const APPEARANCE_STORAGE_KEY = "appearance"; +const APP_BOOT_OVERLAY_ID = "app-boot-overlay"; + +let mediaQuery: MediaQueryList | null = null; +let mediaListenerBound = false; +let authSyncListenerBound = false; +let currentStore: ReturnType | null = null; + +export const normalizeThemeMode = (raw: unknown): ThemeMode => { + const value = String(raw || "").trim().toUpperCase(); + if (value === "LIGHT" || value === "DARK" || value === "SYSTEM") { + return value; + } + return "SYSTEM"; +}; + +export const normalizeDensity = (raw: unknown): DensityMode => { + const value = String(raw || "").trim().toUpperCase(); + if (value === "COMPACT" || value === "COMFORTABLE") { + return value; + } + return "COMFORTABLE"; +}; + +export const normalizeThemeScheme = (raw: unknown): ThemeScheme => { + const value = String(raw || "").trim().toUpperCase(); + if ( + value === "SLATE" || + value === "OCEAN" || + value === "FOREST" || + value === "GRAPHITE" || + value === "AMBER" || + value === "RUBY" || + value === "MIST" || + value === "SAGE" || + value === "DAWN" + ) { + return value; + } + return "SLATE"; +}; + +const readStoredAppearance = (): { themeMode?: string; density?: string; themeScheme?: string } => { + const raw = localStorage.getItem(APPEARANCE_STORAGE_KEY); + if (!raw) { + return { + themeMode: String(localStorage.getItem(THEME_STORAGE_KEY) || "").trim(), + density: String(localStorage.getItem(DENSITY_STORAGE_KEY) || "").trim(), + themeScheme: String(localStorage.getItem(THEME_SCHEME_STORAGE_KEY) || "").trim(), + }; + } + try { + const parsed = JSON.parse(raw) || {}; + return { + themeMode: String(parsed?.themeMode || localStorage.getItem(THEME_STORAGE_KEY) || "").trim(), + density: String(parsed?.density || localStorage.getItem(DENSITY_STORAGE_KEY) || "").trim(), + themeScheme: String(parsed?.themeScheme || localStorage.getItem(THEME_SCHEME_STORAGE_KEY) || "").trim(), + }; + } catch (_e) { + return { + themeMode: String(localStorage.getItem(THEME_STORAGE_KEY) || "").trim(), + density: String(localStorage.getItem(DENSITY_STORAGE_KEY) || "").trim(), + themeScheme: String(localStorage.getItem(THEME_SCHEME_STORAGE_KEY) || "").trim(), + }; + } +}; + +const persistAppearance = (state: AppearanceState) => { + localStorage.setItem(THEME_STORAGE_KEY, state.themeMode); + localStorage.setItem(DENSITY_STORAGE_KEY, state.density); + localStorage.setItem(THEME_SCHEME_STORAGE_KEY, state.themeScheme); + localStorage.setItem( + APPEARANCE_STORAGE_KEY, + JSON.stringify({ themeMode: state.themeMode, density: state.density, themeScheme: state.themeScheme }), + ); +}; + +const resolveThemeMode = (themeMode: ThemeMode): "light" | "dark" => { + if (themeMode === "LIGHT") { + return "light"; + } + if (themeMode === "DARK") { + return "dark"; + } + return mediaQuery?.matches ? "dark" : "light"; +}; + +const applyAppearanceToDocument = (state: AppearanceState) => { + const root = document.documentElement; + const resolvedTheme = resolveThemeMode(state.themeMode); + root.dataset.themeMode = resolvedTheme; + root.dataset.themePreference = state.themeMode.toLowerCase(); + root.dataset.density = state.density.toLowerCase(); + root.dataset.themeScheme = state.themeScheme.toLowerCase(); + root.classList.toggle("dark", resolvedTheme === "dark"); +}; + +const removeBootOverlay = () => { + if (typeof document === "undefined") { + return; + } + const overlay = document.getElementById(APP_BOOT_OVERLAY_ID); + if (!overlay) { + return; + } + overlay.classList.add("is-leaving"); + window.setTimeout(() => { + overlay.remove(); + }, 320); +}; + +const handleSystemThemeChange = () => { + if (currentStore?.themeMode === "SYSTEM") { + currentStore.applyToDocument(); + } +}; + +const handleAuthTokenUpdated = () => { + currentStore?.syncFromStorage(); +}; + +const ensureSystemThemeListener = () => { + if (typeof window === "undefined") { + return; + } + if (!mediaQuery) { + mediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); + } + if (mediaQuery && !mediaListenerBound) { + if (typeof mediaQuery.addEventListener === "function") { + mediaQuery.addEventListener("change", handleSystemThemeChange); + } else if (typeof mediaQuery.addListener === "function") { + mediaQuery.addListener(handleSystemThemeChange); + } + mediaListenerBound = true; + } +}; + +const ensureAuthSyncListener = () => { + if (typeof window === "undefined" || authSyncListenerBound) { + return; + } + window.addEventListener("auth:token-updated", handleAuthTokenUpdated as EventListener); + authSyncListenerBound = true; +}; + +export const useAppearanceStore = defineStore("appearance", { + state: (): AppearanceState => ({ + themeMode: "SYSTEM", + density: "COMFORTABLE", + themeScheme: "SLATE", + initialized: false, + bootstrapped: false, + }), + getters: { + elementPlusSize: (state): "small" | "default" => (state.density === "COMPACT" ? "small" : "default"), + isBootstrapped: (state): boolean => state.bootstrapped, + }, + actions: { + initAppearance() { + currentStore = this; + ensureSystemThemeListener(); + ensureAuthSyncListener(); + this.syncFromStorage(); + }, + applyToDocument() { + applyAppearanceToDocument(this); + }, + setAppearance(payload: AppearancePayload, persist = true) { + this.themeMode = normalizeThemeMode(payload?.themeMode ?? this.themeMode); + this.density = normalizeDensity(payload?.density ?? this.density); + this.themeScheme = normalizeThemeScheme(payload?.themeScheme ?? this.themeScheme); + this.initialized = true; + if (persist) { + persistAppearance(this); + } + this.applyToDocument(); + }, + syncFromStorage() { + const stored = readStoredAppearance(); + this.setAppearance( + { + themeMode: stored.themeMode, + density: stored.density, + themeScheme: stored.themeScheme, + }, + true, + ); + }, + async syncFromServer() { + if (!String(localStorage.getItem("token") || "").trim()) { + return; + } + try { + const resp = await fetchProfilePreferences(); + const appearance = resp?.data || {}; + this.setAppearance( + { + themeMode: appearance?.themeMode, + density: appearance?.density, + themeScheme: appearance?.themeScheme, + }, + true, + ); + } catch (_e) { + // Keep local preferences on sync failure. + } + }, + markBootstrapped() { + if (this.bootstrapped) { + return; + } + this.bootstrapped = true; + removeBootOverlay(); + }, + }, +}); diff --git a/frontend/src/stores/auth.ts b/frontend/src/stores/auth.ts new file mode 100644 index 0000000..feb8a9c --- /dev/null +++ b/frontend/src/stores/auth.ts @@ -0,0 +1,273 @@ +import { defineStore } from "pinia"; + +export type AuthScope = "TENANT" | "PLATFORM"; + +export type StoredAppearance = { + themeMode?: string; + density?: string; + themeScheme?: string; +}; + +export type AuthPayload = { + token?: string; + permissions?: string[]; + roles?: string[]; + userId?: number | string | null; + phone?: string; + tenantId?: number | string | null; + userName?: string; + tenantName?: string; + tenantCode?: string; + tenant?: { + logoUrl?: string; + [key: string]: unknown; + } | null; + scope?: AuthScope; + appearance?: StoredAppearance | null; +}; + +type TenantProfilePatch = { + tenantName?: string | null; + logoUrl?: string | null; +}; + +type AuthState = { + token: string; + permissions: string[]; + roles: string[]; + userId: number | null; + phone: string; + tenantId: number | null; + userName: string; + tenantName: string; + tenantCode: string; + tenantInfo: Record | null; + tenantLogoUrl: string; + scope: AuthScope; + appearance: StoredAppearance; + authVersion: string; + tenantContextVersion: string; +}; + +const AUTH_EVENT_NAME = "auth:token-updated"; + +type TenantContextSnapshot = { + scope: AuthScope; + tenantId: number | null; + userId: number | null; +}; + +const parseNumber = (raw: string | null): number | null => { + if (!raw) { + return null; + } + const value = Number(raw); + return Number.isFinite(value) ? value : null; +}; + +const parseStringArray = (raw: string | null): string[] => { + if (!raw) { + return []; + } + try { + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed.map((item) => String(item)) : []; + } catch (_e) { + return []; + } +}; + +const parseObject = (raw: string | null): Record | null => { + if (!raw) { + return null; + } + try { + const parsed = JSON.parse(raw); + return parsed && typeof parsed === "object" ? parsed : null; + } catch (_e) { + return null; + } +}; + +const readStoredScope = (): AuthScope => (localStorage.getItem("scope") === "PLATFORM" ? "PLATFORM" : "TENANT"); + +const readStoredTenantContext = (): TenantContextSnapshot => ({ + scope: readStoredScope(), + tenantId: parseNumber(localStorage.getItem("tenantId")), + userId: parseNumber(localStorage.getItem("userId")), +}); + +const normalizeTenantContext = (payload: AuthPayload | null | undefined): TenantContextSnapshot => { + const data = payload || {}; + const nextTenantId = data.tenantId === undefined ? null : parseNumber(String(data.tenantId ?? "")); + const nextUserId = data.userId === undefined ? null : parseNumber(String(data.userId ?? "")); + return { + scope: data.scope === "PLATFORM" ? "PLATFORM" : "TENANT", + tenantId: nextTenantId, + userId: nextUserId, + }; +}; + +const isSameTenantContext = (left: TenantContextSnapshot, right: TenantContextSnapshot): boolean => + left.scope === right.scope && left.tenantId === right.tenantId && left.userId === right.userId; + +const nextVersion = (): string => String(Date.now()); + +const readStoredAppearance = (): StoredAppearance => { + const raw = localStorage.getItem("appearance"); + const fallbackThemeMode = String(localStorage.getItem("themeMode") || "").trim(); + const fallbackDensity = String(localStorage.getItem("density") || "").trim(); + const fallbackThemeScheme = String(localStorage.getItem("themeScheme") || "").trim(); + if (!raw) { + return { + themeMode: fallbackThemeMode, + density: fallbackDensity, + themeScheme: fallbackThemeScheme, + }; + } + try { + const parsed = JSON.parse(raw) || {}; + return { + themeMode: String(parsed?.themeMode || fallbackThemeMode).trim(), + density: String(parsed?.density || fallbackDensity).trim(), + themeScheme: String(parsed?.themeScheme || fallbackThemeScheme).trim(), + }; + } catch (_e) { + return { + themeMode: fallbackThemeMode, + density: fallbackDensity, + themeScheme: fallbackThemeScheme, + }; + } +}; + +const readAuthStateFromStorage = (): AuthState => { + const tenantInfo = parseObject(localStorage.getItem("tenantInfo")); + const tenantLogoUrl = String(localStorage.getItem("tenantLogoUrl") || "").trim(); + const authVersion = String(localStorage.getItem("authVersion") || "").trim(); + return { + token: String(localStorage.getItem("token") || "").trim(), + permissions: parseStringArray(localStorage.getItem("permissions")), + roles: parseStringArray(localStorage.getItem("roles")), + userId: parseNumber(localStorage.getItem("userId")), + phone: String(localStorage.getItem("phone") || "").trim(), + tenantId: parseNumber(localStorage.getItem("tenantId")), + userName: String(localStorage.getItem("userName") || "").trim(), + tenantName: String(localStorage.getItem("tenantName") || "").trim(), + tenantCode: String(localStorage.getItem("tenantCode") || "").trim(), + tenantInfo, + tenantLogoUrl, + scope: readStoredScope(), + appearance: readStoredAppearance(), + authVersion, + tenantContextVersion: String(localStorage.getItem("tenantContextVersion") || authVersion || "").trim(), + }; +}; + +const applyAuthState = (target: AuthState, source: AuthState) => { + target.token = source.token; + target.permissions = source.permissions; + target.roles = source.roles; + target.userId = source.userId; + target.phone = source.phone; + target.tenantId = source.tenantId; + target.userName = source.userName; + target.tenantName = source.tenantName; + target.tenantCode = source.tenantCode; + target.tenantInfo = source.tenantInfo; + target.tenantLogoUrl = source.tenantLogoUrl; + target.scope = source.scope; + target.appearance = source.appearance; + target.authVersion = source.authVersion; + target.tenantContextVersion = source.tenantContextVersion; +}; + +const dispatchAuthUpdated = () => { + if (typeof window !== "undefined") { + window.dispatchEvent(new CustomEvent(AUTH_EVENT_NAME)); + } +}; + +export const useAuthStore = defineStore("auth", { + state: (): AuthState => readAuthStateFromStorage(), + getters: { + hasPermission: (state) => (code: string): boolean => state.permissions.indexOf(code) >= 0, + currentTenantLogo: (state): string => { + const logoFromInfo = String(state.tenantInfo?.logoUrl || "").trim(); + return logoFromInfo || state.tenantLogoUrl; + }, + }, + actions: { + syncFromStorage() { + applyAuthState(this, readAuthStateFromStorage()); + }, + saveAuthPayload(payload: AuthPayload | null | undefined) { + const data = payload || {}; + const previousTenantContext = readStoredTenantContext(); + const nextTenantContext = normalizeTenantContext(data); + const authVersion = nextVersion(); + const tenantContextVersion = isSameTenantContext(previousTenantContext, nextTenantContext) + ? String(localStorage.getItem("tenantContextVersion") || authVersion) + : authVersion; + localStorage.setItem("token", String(data.token || "")); + localStorage.setItem("userId", String(data.userId || "")); + localStorage.setItem("phone", String(data.phone || "")); + localStorage.setItem("tenantId", String(data.tenantId || "")); + localStorage.setItem("userName", String(data.userName || "")); + localStorage.setItem("tenantName", String(data.tenantName || "")); + localStorage.setItem("tenantCode", String(data.tenantCode || "")); + localStorage.setItem("tenantLogoUrl", String(data.tenant?.logoUrl || "")); + localStorage.setItem("tenantInfo", JSON.stringify(data.tenant || {})); + localStorage.setItem("scope", data.scope === "PLATFORM" ? "PLATFORM" : "TENANT"); + localStorage.setItem("permissions", JSON.stringify(Array.isArray(data.permissions) ? data.permissions : [])); + localStorage.setItem("roles", JSON.stringify(Array.isArray(data.roles) ? data.roles : [])); + localStorage.setItem("appearance", JSON.stringify(data.appearance || {})); + localStorage.setItem("themeMode", String(data.appearance?.themeMode || "")); + localStorage.setItem("density", String(data.appearance?.density || "")); + localStorage.setItem("themeScheme", String(data.appearance?.themeScheme || "")); + localStorage.setItem("authVersion", authVersion); + localStorage.setItem("tenantContextVersion", tenantContextVersion); + this.syncFromStorage(); + dispatchAuthUpdated(); + }, + updateTenantProfile(patch: TenantProfilePatch | null | undefined) { + const data = patch || {}; + const nextTenantName = data.tenantName === undefined ? this.tenantName : String(data.tenantName || "").trim(); + const nextLogoUrl = data.logoUrl === undefined ? this.currentTenantLogo : String(data.logoUrl || "").trim(); + const nextTenantInfo = { + ...(this.tenantInfo || {}), + logoUrl: nextLogoUrl, + }; + localStorage.setItem("tenantName", nextTenantName); + localStorage.setItem("tenantLogoUrl", nextLogoUrl); + localStorage.setItem("tenantInfo", JSON.stringify(nextTenantInfo)); + this.tenantName = nextTenantName; + this.tenantLogoUrl = nextLogoUrl; + this.tenantInfo = nextTenantInfo; + dispatchAuthUpdated(); + }, + clearAuthStorage() { + const clearedVersion = nextVersion(); + localStorage.removeItem("token"); + localStorage.removeItem("permissions"); + localStorage.removeItem("roles"); + localStorage.removeItem("userId"); + localStorage.removeItem("phone"); + localStorage.removeItem("tenantId"); + localStorage.removeItem("userName"); + localStorage.removeItem("tenantName"); + localStorage.removeItem("tenantLogoUrl"); + localStorage.removeItem("tenantInfo"); + localStorage.removeItem("tenantCode"); + localStorage.removeItem("scope"); + localStorage.removeItem("appearance"); + localStorage.removeItem("themeMode"); + localStorage.removeItem("density"); + localStorage.removeItem("themeScheme"); + localStorage.setItem("authVersion", clearedVersion); + localStorage.setItem("tenantContextVersion", clearedVersion); + this.syncFromStorage(); + dispatchAuthUpdated(); + }, + }, +}); diff --git a/frontend/src/stores/index.ts b/frontend/src/stores/index.ts new file mode 100644 index 0000000..6a09442 --- /dev/null +++ b/frontend/src/stores/index.ts @@ -0,0 +1,4 @@ +import { createPinia } from "pinia"; + +export const pinia = createPinia(); + diff --git a/frontend/src/stores/menu.ts b/frontend/src/stores/menu.ts new file mode 100644 index 0000000..8b67ba6 --- /dev/null +++ b/frontend/src/stores/menu.ts @@ -0,0 +1,139 @@ +import { defineStore } from "pinia"; +import { + fetchCurrentMenus, + fetchFilePresignDownload, + fetchPlatformCurrentMenus, + fetchSwitchableTenants, +} from "../api/modules"; +import { PERMS } from "../constants/permissions"; +import { useAuthStore } from "./auth"; + +export type MenuItem = { + menuName: string; + routePath: string; +}; + +export type TenantOption = { + tenantId: number; + tenantCode: string; + tenantName: string; + logoUrl?: string; + current?: boolean; +}; + +const fallbackMenus: MenuItem[] = []; +const platformMenus: MenuItem[] = [ + { menuName: "租户管理", routePath: "/platform/tenants" }, + { menuName: "平台用户管理", routePath: "/platform/users" }, + { menuName: "平台角色管理", routePath: "/platform/roles" }, + { menuName: "通知网关配置", routePath: "/platform/notify-gateways" }, + { menuName: "专家管理", routePath: "/platform/experts" }, + { menuName: "平台字典管理", routePath: "/platform/dictionaries" }, + { menuName: "平台会话管理", routePath: "/platform/auth-sessions" }, + { menuName: "平台审计日志", routePath: "/platform/audit-logs" }, + { menuName: "平台菜单与权限", routePath: "/platform/menus" }, +]; +const platformRouteWhitelist = new Set(platformMenus.map((item) => item.routePath)); + +export const useMenuStore = defineStore("menu", { + state: () => ({ + menus: [] as MenuItem[], + tenantLogoDisplay: "", + switchableTenants: [] as TenantOption[], + tenantSwitching: false, + }), + getters: { + useTopMenuLayout: (state): boolean => state.menus.length > 0 && state.menus.length <= 5, + }, + actions: { + reset() { + this.menus = []; + this.tenantLogoDisplay = ""; + this.switchableTenants = []; + this.tenantSwitching = false; + }, + setTenantSwitching(next: boolean) { + this.tenantSwitching = next; + }, + async loadTenantLogo() { + const authStore = useAuthStore(); + this.tenantLogoDisplay = ""; + if (authStore.scope !== "TENANT") { + return; + } + const tenantLogo = authStore.currentTenantLogo; + if (!tenantLogo) { + return; + } + if (tenantLogo.startsWith("http://") || tenantLogo.startsWith("https://")) { + this.tenantLogoDisplay = tenantLogo; + return; + } + try { + const resp = await fetchFilePresignDownload({ objectKey: tenantLogo }); + this.tenantLogoDisplay = String(resp?.data?.signedUrl || ""); + } catch (_e) { + this.tenantLogoDisplay = ""; + } + }, + async loadMenus() { + const authStore = useAuthStore(); + if (authStore.scope === "PLATFORM") { + try { + const resp = await fetchPlatformCurrentMenus(); + const list = Array.isArray(resp?.data) ? resp.data : []; + const normalized = list + .map((item: any) => ({ + menuName: String(item?.routePath || "") === "/platform/permissions" ? "平台菜单与权限" : String(item?.menuName || ""), + routePath: String(item?.routePath || "") === "/platform/permissions" ? "/platform/menus" : String(item?.routePath || ""), + })) + .filter((item: MenuItem) => platformRouteWhitelist.has(item.routePath)); + const deduped = normalized.filter((item, index, arr) => arr.findIndex((row) => row.routePath === item.routePath) === index); + this.menus = deduped.length > 0 ? deduped : platformMenus; + } catch (_e) { + this.menus = platformMenus; + } + return; + } + + try { + const resp = await fetchCurrentMenus(); + const list = Array.isArray(resp?.data) ? resp.data : []; + const resolved = list.length > 0 ? list : fallbackMenus; + const normalized = resolved.map((item: any) => ({ + menuName: String(item?.routePath || "") === "/permissions" ? "菜单与权限" : String(item?.menuName || ""), + routePath: String(item?.routePath || "") === "/permissions" ? "/menus" : String(item?.routePath || ""), + })); + const hasMenuEntry = normalized.some((item: MenuItem) => item.routePath === "/menus"); + const canSeeMenuPage = authStore.hasPermission(PERMS.role.read) || authStore.hasPermission(PERMS.permission.read); + if (canSeeMenuPage && !hasMenuEntry) { + normalized.push({ menuName: "菜单与权限", routePath: "/menus" }); + } + this.menus = normalized.filter((item: MenuItem, index: number, arr: MenuItem[]) => + arr.findIndex((row) => row.routePath === item.routePath) === index, + ); + } catch (_e) { + this.menus = fallbackMenus; + } + }, + async loadSwitchableTenants() { + const authStore = useAuthStore(); + const canSwitchTenant = authStore.scope === "TENANT" && authStore.hasPermission(PERMS.tenant.switch); + if (!canSwitchTenant) { + this.switchableTenants = []; + return; + } + try { + const resp = await fetchSwitchableTenants(); + this.switchableTenants = Array.isArray(resp?.data) ? resp.data : []; + } catch (_e) { + this.switchableTenants = []; + } + }, + async refreshContext() { + await this.loadTenantLogo(); + await this.loadMenus(); + await this.loadSwitchableTenants(); + }, + }, +}); diff --git a/frontend/src/stores/notification.ts b/frontend/src/stores/notification.ts new file mode 100644 index 0000000..aa1dbe1 --- /dev/null +++ b/frontend/src/stores/notification.ts @@ -0,0 +1,212 @@ +import { defineStore } from "pinia"; +import { + fetchInAppNotifications, + markAllInAppNotificationsRead, + markInAppNotificationRead, +} from "../api/modules"; +import { PERMS } from "../constants/permissions"; +import { useAuthStore } from "./auth"; + +const canReadInApp = () => { + const authStore = useAuthStore(); + return authStore.scope === "TENANT" && authStore.hasPermission(PERMS.notification.inAppRead); +}; + +const IN_APP_POLL_INTERVAL_MS = 30000; +const IN_APP_WS_RECONNECT_MS = 5000; + +let inAppRefreshTimer: number | null = null; +let inAppWs: WebSocket | null = null; +let inAppWsReconnectTimer: number | null = null; + +const emitUnreadChanged = (unreadCount: number) => { + if (typeof window === "undefined") { + return; + } + try { + window.dispatchEvent(new CustomEvent("in-app-unread-changed", { detail: { unreadCount } })); + } catch (_error) { + // ignore + } +}; + +export const useNotificationStore = defineStore("notification", { + state: () => ({ + unreadCount: 0, + notifRows: [] as any[], + polling: false, + websocketConnected: false, + }), + actions: { + stopPolling() { + if (inAppRefreshTimer !== null && typeof window !== "undefined") { + window.clearInterval(inAppRefreshTimer); + } + inAppRefreshTimer = null; + this.polling = false; + }, + startPolling() { + if (typeof window === "undefined" || inAppRefreshTimer !== null || !canReadInApp()) { + return; + } + this.polling = true; + inAppRefreshTimer = window.setInterval(() => { + void this.loadUnreadCount(); + }, IN_APP_POLL_INTERVAL_MS); + }, + closeWebSocket() { + if (inAppWsReconnectTimer !== null && typeof window !== "undefined") { + window.clearTimeout(inAppWsReconnectTimer); + } + inAppWsReconnectTimer = null; + if (inAppWs) { + try { + inAppWs.close(); + } catch (_error) { + // ignore + } + inAppWs = null; + } + this.websocketConnected = false; + }, + scheduleWsReconnect() { + if (typeof window === "undefined" || !canReadInApp() || inAppWsReconnectTimer !== null) { + return; + } + inAppWsReconnectTimer = window.setTimeout(() => { + inAppWsReconnectTimer = null; + this.ensureWebSocket(); + }, IN_APP_WS_RECONNECT_MS); + }, + ensureWebSocket() { + if (typeof window === "undefined" || !canReadInApp()) { + this.closeWebSocket(); + return; + } + if (inAppWs && (inAppWs.readyState === WebSocket.OPEN || inAppWs.readyState === WebSocket.CONNECTING)) { + return; + } + const authStore = useAuthStore(); + if (!authStore.token) { + this.closeWebSocket(); + return; + } + const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; + const wsUrl = `${protocol}//${window.location.host}/ws/notifications?token=${encodeURIComponent(authStore.token)}`; + try { + const ws = new WebSocket(wsUrl); + inAppWs = ws; + ws.onopen = () => { + this.websocketConnected = true; + void this.loadUnreadCount(); + }; + ws.onmessage = (event) => { + let data: any = null; + try { + data = JSON.parse(String(event.data || "{}")); + } catch (_error) { + data = null; + } + const type = String(data?.type || ""); + if (type === "IN_APP_UNREAD_CHANGED" || type === "IN_APP_NOTIFICATION_NEW") { + void this.loadUnreadCount(); + } + }; + ws.onclose = () => { + inAppWs = null; + this.websocketConnected = false; + this.scheduleWsReconnect(); + }; + ws.onerror = () => { + try { + ws.close(); + } catch (_error) { + // ignore + } + }; + } catch (_error) { + this.websocketConnected = false; + this.scheduleWsReconnect(); + } + }, + async startRealtime() { + if (!canReadInApp()) { + this.stopRealtime(); + this.notifRows = []; + this.setUnreadCount(0); + return; + } + await this.loadUnreadCount(); + this.startPolling(); + this.ensureWebSocket(); + }, + stopRealtime() { + this.stopPolling(); + this.closeWebSocket(); + }, + async refreshContext() { + this.stopRealtime(); + if (!canReadInApp()) { + this.notifRows = []; + this.setUnreadCount(0); + return; + } + await this.startRealtime(); + }, + handleVisibilityVisible() { + if (!canReadInApp()) { + return; + } + void this.loadUnreadCount(); + this.ensureWebSocket(); + }, + reset() { + this.stopRealtime(); + this.unreadCount = 0; + this.notifRows = []; + emitUnreadChanged(0); + }, + setUnreadCount(nextCount: number) { + this.unreadCount = Number.isFinite(nextCount) && nextCount >= 0 ? nextCount : 0; + emitUnreadChanged(this.unreadCount); + }, + async loadUnreadCount() { + if (!canReadInApp()) { + this.setUnreadCount(0); + return; + } + try { + const resp = await fetchInAppNotifications({ ts: Date.now() }); + const list = resp?.data?.list || []; + this.setUnreadCount(list.filter((item: any) => String(item?.status || "") === "UNREAD").length); + } catch (_e) { + this.setUnreadCount(0); + } + }, + async loadRows() { + if (!canReadInApp()) { + this.notifRows = []; + this.setUnreadCount(0); + return; + } + try { + const resp = await fetchInAppNotifications({ ts: Date.now() }); + const list = Array.isArray(resp?.data?.list) ? resp.data.list : []; + this.notifRows = list; + this.setUnreadCount(list.filter((item: any) => String(item?.status || "") === "UNREAD").length); + } catch (_e) { + this.notifRows = []; + this.setUnreadCount(0); + } + }, + async markRead(id: number) { + await markInAppNotificationRead(id); + await this.loadRows(); + }, + async markAllRead() { + const resp = await markAllInAppNotificationsRead(); + await this.loadRows(); + return Number(resp?.data?.affected || 0); + }, + }, +}); diff --git a/frontend/src/styles/theme.css b/frontend/src/styles/theme.css new file mode 100644 index 0000000..cf2afa6 --- /dev/null +++ b/frontend/src/styles/theme.css @@ -0,0 +1,677 @@ +/* ============================================ + * 全局主题 Token / 主题方案 + * ============================================ + * 主题模式(浅色 / 深色)与主题方案(商务蓝 / 海湾青 / 山岚绿)分离, + * 避免全局固定为高饱和蓝紫,减少模板化观感。 + */ + +:root { + /* ---- 全局字体 ---- */ + --el-font-family: "Inter", "PingFang SC", "Microsoft YaHei", -apple-system, sans-serif; + + /* ---- 全局圆角增强 ---- */ + --el-border-radius-base: 8px; + --el-border-radius-small: 6px; + + /* ---- 默认主题方案:商务蓝 ---- */ + --wo-theme-rgb: 60, 110, 145; + --wo-brand-primary: #3c6e91; + --wo-brand-primary-light: #6f93af; + --wo-brand-primary-dark: #315977; + --wo-brand-primary-light-3: #6f93af; + --wo-brand-primary-light-5: #90aec3; + --wo-brand-primary-light-7: #b7cad7; + --wo-brand-primary-light-8: #d1dde6; + --wo-brand-primary-light-9: #e8eff4; + --wo-brand-primary-dark-2: #315977; + --wo-brand-accent: #5c88a7; + --wo-brand-accent-light: #87a9bf; + --wo-brand-gradient: linear-gradient(135deg, #3c6e91 0%, #5c88a7 100%); + --wo-sidebar-bg-light: linear-gradient(180deg, #22364b 0%, #2f4b64 100%); + --wo-sidebar-bg-dark: linear-gradient(180deg, #22364b 0%, #2f4b64 100%); + --wo-sidebar-text-light: rgba(255, 255, 255, 0.76); + --wo-sidebar-text-dark: rgba(255, 255, 255, 0.76); + --wo-sidebar-text-active-light: #ffffff; + --wo-sidebar-text-active-dark: #ffffff; + --wo-sidebar-brand-bg-light: rgba(0, 0, 0, 0.12); + --wo-sidebar-brand-bg-dark: rgba(0, 0, 0, 0.12); + --wo-sidebar-item-hover-light: rgba(255, 255, 255, 0.08); + --wo-sidebar-item-hover-dark: rgba(255, 255, 255, 0.08); + --wo-sidebar-item-active-bg-light: rgba(var(--wo-theme-rgb), 0.22); + --wo-sidebar-item-active-bg-dark: rgba(var(--wo-theme-rgb), 0.22); + --wo-sidebar-border-light: rgba(255, 255, 255, 0.08); + --wo-sidebar-border-dark: rgba(255, 255, 255, 0.08); + --wo-sidebar-bg: var(--wo-sidebar-bg-light); + --wo-sidebar-text: var(--wo-sidebar-text-light); + --wo-sidebar-text-active: var(--wo-sidebar-text-active-light); + --wo-sidebar-brand-bg: var(--wo-sidebar-brand-bg-light); + --wo-sidebar-item-hover: var(--wo-sidebar-item-hover-light); + --wo-sidebar-item-active-bg: var(--wo-sidebar-item-active-bg-light); + --wo-sidebar-border: var(--wo-sidebar-border-light); + --wo-login-bg-platform: linear-gradient(135deg, #172638 0%, #27445d 52%, #1f3347 100%); + --wo-login-bg-tenant: linear-gradient(135deg, #101d2b 0%, #1f3447 42%, #162838 100%); + --wo-login-orb-platform-1: #3c6e91; + --wo-login-orb-platform-2: #5c88a7; + --wo-login-orb-platform-3: #87a9bf; + --wo-login-orb-platform-4: #a7c0d0; + --wo-login-orb-tenant-1: #4a87ad; + --wo-login-orb-tenant-2: #3c6e91; + --wo-login-orb-tenant-3: #5c88a7; + --wo-login-orb-tenant-4: #87a9bf; + --wo-app-header-bg-light: linear-gradient(135deg, #22364b 0%, #2f4b64 100%); + --wo-app-header-bg-dark: linear-gradient(135deg, #111a23 0%, #203344 55%, #2f4b64 100%); + --wo-profile-avatar-bg-light: linear-gradient(135deg, #edf3f7, #cfdde7); + --wo-profile-avatar-bg-dark: linear-gradient(135deg, rgba(var(--wo-theme-rgb), 0.18), rgba(var(--wo-theme-rgb), 0.32)); + --wo-profile-avatar-text-light: #315977; + --wo-profile-avatar-text-dark: #d5e3ec; + + /* ---- Element Plus 主色映射 ---- */ + --el-color-primary: var(--wo-brand-primary); + --el-color-primary-light-3: var(--wo-brand-primary-light-3); + --el-color-primary-light-5: var(--wo-brand-primary-light-5); + --el-color-primary-light-7: var(--wo-brand-primary-light-7); + --el-color-primary-light-8: var(--wo-brand-primary-light-8); + --el-color-primary-light-9: var(--wo-brand-primary-light-9); + --el-color-primary-dark-2: var(--wo-brand-primary-dark-2); + + /* ---- 壳层与内容区域 ---- */ + --wo-header-bg: #ffffff; + --wo-header-shadow: 0 1px 4px rgba(15, 23, 42, 0.08); + --wo-content-bg: #eef2f5; + --wo-app-header-bg: var(--wo-app-header-bg-light); + --wo-app-header-text: #ffffff; + --wo-sub-header-bg: #ffffff; + --wo-sub-header-border: #dde4ea; + --wo-main-content-bg: #f6f8fa; + --wo-card-border: #e4ebf1; + --wo-top-nav-text: rgba(255, 255, 255, 0.74); + --wo-top-nav-active-bg: rgba(255, 255, 255, 0.12); + --wo-header-action-bg: rgba(255, 255, 255, 0.12); + --wo-header-action-text: #ffffff; + --wo-header-action-divider: rgba(255, 255, 255, 0.16); + --wo-light-action-bg: #f3f5f7; + --wo-light-action-text: #4b5563; + --wo-light-action-divider: #d4dbe2; + --wo-profile-avatar-bg: var(--wo-profile-avatar-bg-light); + --wo-profile-avatar-text: var(--wo-profile-avatar-text-light); + --wo-search-surface: rgba(255, 255, 255, 0.9); + --wo-search-surface-hover: rgba(var(--wo-theme-rgb), 0.08); + --wo-search-border: rgba(var(--wo-theme-rgb), 0.18); + --wo-search-muted: #64748b; + --wo-search-title: #1f2937; + --wo-main-content-padding: 20px; + --wo-shell-gap: 16px; + --wo-header-action-size: 36px; + --wo-header-avatar-size: 34px; + --wo-header-avatar-icon-size: 18px; +} + +:root[data-theme-scheme="ocean"] { + --wo-theme-rgb: 14, 116, 144; + --wo-brand-primary: #0e7490; + --wo-brand-primary-light: #4493a9; + --wo-brand-primary-dark: #0c5d74; + --wo-brand-primary-light-3: #4493a9; + --wo-brand-primary-light-5: #73afbd; + --wo-brand-primary-light-7: #a7ccd4; + --wo-brand-primary-light-8: #c9dfe4; + --wo-brand-primary-light-9: #e7f0f2; + --wo-brand-primary-dark-2: #0c5d74; + --wo-brand-accent: #0284c7; + --wo-brand-accent-light: #5ba9d8; + --wo-brand-gradient: linear-gradient(135deg, #0e7490 0%, #0284c7 100%); + --wo-sidebar-bg-light: linear-gradient(180deg, #0f3b49 0%, #13566a 100%); + --wo-sidebar-bg-dark: linear-gradient(180deg, #0f3b49 0%, #13566a 100%); + --wo-login-bg-platform: linear-gradient(135deg, #0b2430 0%, #0f4b5d 52%, #12394a 100%); + --wo-login-bg-tenant: linear-gradient(135deg, #081a22 0%, #0c3947 42%, #0f2f3a 100%); + --wo-login-orb-platform-1: #0e7490; + --wo-login-orb-platform-2: #0284c7; + --wo-login-orb-platform-3: #67b7cf; + --wo-login-orb-platform-4: #7dd3fc; + --wo-login-orb-tenant-1: #06b6d4; + --wo-login-orb-tenant-2: #0e7490; + --wo-login-orb-tenant-3: #0284c7; + --wo-login-orb-tenant-4: #67e8f9; + --wo-app-header-bg-light: linear-gradient(135deg, #0f3b49 0%, #13566a 100%); + --wo-app-header-bg-dark: linear-gradient(135deg, #0a1820 0%, #103744 55%, #13566a 100%); + --wo-profile-avatar-bg-light: linear-gradient(135deg, #ebf6f8, #c5e2e8); + --wo-profile-avatar-text-light: #0c5d74; + --wo-profile-avatar-text-dark: #ccebf3; +} + +:root[data-theme-scheme="forest"] { + --wo-theme-rgb: 74, 124, 89; + --wo-brand-primary: #4a7c59; + --wo-brand-primary-light: #749a7f; + --wo-brand-primary-dark: #3c6548; + --wo-brand-primary-light-3: #749a7f; + --wo-brand-primary-light-5: #93b099; + --wo-brand-primary-light-7: #bbd0bf; + --wo-brand-primary-light-8: #d4e1d6; + --wo-brand-primary-light-9: #ebf2ec; + --wo-brand-primary-dark-2: #3c6548; + --wo-brand-accent: #7c9a4d; + --wo-brand-accent-light: #9cb374; + --wo-brand-gradient: linear-gradient(135deg, #4a7c59 0%, #7c9a4d 100%); + --wo-sidebar-bg-light: linear-gradient(180deg, #24372a 0%, #35523d 100%); + --wo-sidebar-bg-dark: linear-gradient(180deg, #24372a 0%, #35523d 100%); + --wo-login-bg-platform: linear-gradient(135deg, #18241b 0%, #2c4934 52%, #24372a 100%); + --wo-login-bg-tenant: linear-gradient(135deg, #101712 0%, #22362a 42%, #1a291f 100%); + --wo-login-orb-platform-1: #4a7c59; + --wo-login-orb-platform-2: #7c9a4d; + --wo-login-orb-platform-3: #9cb374; + --wo-login-orb-platform-4: #bbd0bf; + --wo-login-orb-tenant-1: #5b9a6d; + --wo-login-orb-tenant-2: #4a7c59; + --wo-login-orb-tenant-3: #7c9a4d; + --wo-login-orb-tenant-4: #c6d9b6; + --wo-app-header-bg-light: linear-gradient(135deg, #24372a 0%, #35523d 100%); + --wo-app-header-bg-dark: linear-gradient(135deg, #111913 0%, #22362a 55%, #35523d 100%); + --wo-profile-avatar-bg-light: linear-gradient(135deg, #eef4ef, #d4e2d7); + --wo-profile-avatar-text-light: #3c6548; + --wo-profile-avatar-text-dark: #dce9df; +} + +:root[data-theme-scheme="graphite"] { + --wo-theme-rgb: 71, 85, 105; + --wo-brand-primary: #475569; + --wo-brand-primary-light: #6b778a; + --wo-brand-primary-dark: #364152; + --wo-brand-primary-light-3: #6b778a; + --wo-brand-primary-light-5: #8791a0; + --wo-brand-primary-light-7: #b0b7c2; + --wo-brand-primary-light-8: #ccd1d8; + --wo-brand-primary-light-9: #e8ebee; + --wo-brand-primary-dark-2: #364152; + --wo-brand-accent: #64748b; + --wo-brand-accent-light: #8794a6; + --wo-brand-gradient: linear-gradient(135deg, #334155 0%, #64748b 100%); + --wo-sidebar-bg-light: linear-gradient(180deg, #1f2937 0%, #334155 100%); + --wo-sidebar-bg-dark: linear-gradient(180deg, #1f2937 0%, #334155 100%); + --wo-login-bg-platform: linear-gradient(135deg, #141c27 0%, #253243 52%, #1c2734 100%); + --wo-login-bg-tenant: linear-gradient(135deg, #0f1620 0%, #1c2938 42%, #141d29 100%); + --wo-login-orb-platform-1: #475569; + --wo-login-orb-platform-2: #64748b; + --wo-login-orb-platform-3: #94a3b8; + --wo-login-orb-platform-4: #cbd5e1; + --wo-login-orb-tenant-1: #64748b; + --wo-login-orb-tenant-2: #475569; + --wo-login-orb-tenant-3: #94a3b8; + --wo-login-orb-tenant-4: #e2e8f0; + --wo-app-header-bg-light: linear-gradient(135deg, #1f2937 0%, #334155 100%); + --wo-app-header-bg-dark: linear-gradient(135deg, #111827 0%, #1f2937 55%, #334155 100%); + --wo-profile-avatar-bg-light: linear-gradient(135deg, #eef2f5, #d7dde5); + --wo-profile-avatar-text-light: #364152; + --wo-profile-avatar-text-dark: #e2e8f0; +} + +:root[data-theme-scheme="amber"] { + --wo-theme-rgb: 154, 107, 63; + --wo-brand-primary: #9a6b3f; + --wo-brand-primary-light: #b18761; + --wo-brand-primary-dark: #7f5733; + --wo-brand-primary-light-3: #b18761; + --wo-brand-primary-light-5: #c5a080; + --wo-brand-primary-light-7: #dcc0ab; + --wo-brand-primary-light-8: #ead4c7; + --wo-brand-primary-light-9: #f5ece6; + --wo-brand-primary-dark-2: #7f5733; + --wo-brand-accent: #c08a52; + --wo-brand-accent-light: #d2a373; + --wo-brand-gradient: linear-gradient(135deg, #7f5733 0%, #c08a52 100%); + --wo-sidebar-bg-light: linear-gradient(180deg, #4a382c 0%, #6a503e 100%); + --wo-sidebar-bg-dark: linear-gradient(180deg, #4a382c 0%, #6a503e 100%); + --wo-login-bg-platform: linear-gradient(135deg, #241912 0%, #4b3527 52%, #35251b 100%); + --wo-login-bg-tenant: linear-gradient(135deg, #1d140f 0%, #3b2a1f 42%, #2b1f18 100%); + --wo-login-orb-platform-1: #9a6b3f; + --wo-login-orb-platform-2: #c08a52; + --wo-login-orb-platform-3: #d8b07b; + --wo-login-orb-platform-4: #f0d3a8; + --wo-login-orb-tenant-1: #b87d43; + --wo-login-orb-tenant-2: #9a6b3f; + --wo-login-orb-tenant-3: #c08a52; + --wo-login-orb-tenant-4: #e8c18e; + --wo-app-header-bg-light: linear-gradient(135deg, #4a382c 0%, #6a503e 100%); + --wo-app-header-bg-dark: linear-gradient(135deg, #201610 0%, #443224 55%, #6a503e 100%); + --wo-profile-avatar-bg-light: linear-gradient(135deg, #f7efe8, #ead7c5); + --wo-profile-avatar-text-light: #7f5733; + --wo-profile-avatar-text-dark: #f4e3d3; +} + +:root[data-theme-scheme="ruby"] { + --wo-theme-rgb: 122, 62, 70; + --wo-brand-primary: #7a3e46; + --wo-brand-primary-light: #955962; + --wo-brand-primary-dark: #65333a; + --wo-brand-primary-light-3: #955962; + --wo-brand-primary-light-5: #ac7478; + --wo-brand-primary-light-7: #cdafb1; + --wo-brand-primary-light-8: #decbcc; + --wo-brand-primary-light-9: #f2e8e8; + --wo-brand-primary-dark-2: #65333a; + --wo-brand-accent: #a95c57; + --wo-brand-accent-light: #c07a74; + --wo-brand-gradient: linear-gradient(135deg, #65333a 0%, #a95c57 100%); + --wo-sidebar-bg-light: linear-gradient(180deg, #392126 0%, #5a3138 100%); + --wo-sidebar-bg-dark: linear-gradient(180deg, #392126 0%, #5a3138 100%); + --wo-login-bg-platform: linear-gradient(135deg, #201014 0%, #44272e 52%, #311a1f 100%); + --wo-login-bg-tenant: linear-gradient(135deg, #180c10 0%, #341c23 42%, #261319 100%); + --wo-login-orb-platform-1: #7a3e46; + --wo-login-orb-platform-2: #a95c57; + --wo-login-orb-platform-3: #c78982; + --wo-login-orb-platform-4: #e2b6af; + --wo-login-orb-tenant-1: #a95c57; + --wo-login-orb-tenant-2: #7a3e46; + --wo-login-orb-tenant-3: #c78982; + --wo-login-orb-tenant-4: #f0d0cb; + --wo-app-header-bg-light: linear-gradient(135deg, #392126 0%, #5a3138 100%); + --wo-app-header-bg-dark: linear-gradient(135deg, #180d10 0%, #311a20 55%, #5a3138 100%); + --wo-profile-avatar-bg-light: linear-gradient(135deg, #f7eded, #ead6d5); + --wo-profile-avatar-text-light: #65333a; + --wo-profile-avatar-text-dark: #f2dfdc; +} + +:root[data-theme-scheme="mist"] { + --wo-theme-rgb: 111, 143, 175; + --wo-brand-primary: #6f8faf; + --wo-brand-primary-light: #8ea8c0; + --wo-brand-primary-dark: #5b7998; + --wo-brand-primary-light-3: #8ea8c0; + --wo-brand-primary-light-5: #a8bbcd; + --wo-brand-primary-light-7: #c5d2dd; + --wo-brand-primary-light-8: #dce5ec; + --wo-brand-primary-light-9: #eff4f7; + --wo-brand-primary-dark-2: #5b7998; + --wo-brand-accent: #8fb4c8; + --wo-brand-accent-light: #aac8d8; + --wo-brand-gradient: linear-gradient(135deg, #6f8faf 0%, #8fb4c8 100%); + --wo-sidebar-bg-light: linear-gradient(180deg, #fbfdff 0%, #f2f7fb 100%); + --wo-sidebar-bg-dark: linear-gradient(180deg, #2a3b4d 0%, #3f5971 100%); + --wo-sidebar-text-light: #6d8196; + --wo-sidebar-text-dark: rgba(255, 255, 255, 0.76); + --wo-sidebar-text-active-light: #284158; + --wo-sidebar-text-active-dark: #ffffff; + --wo-sidebar-brand-bg-light: rgba(111, 143, 175, 0.1); + --wo-sidebar-brand-bg-dark: rgba(255, 255, 255, 0.08); + --wo-sidebar-item-hover-light: rgba(111, 143, 175, 0.1); + --wo-sidebar-item-hover-dark: rgba(255, 255, 255, 0.08); + --wo-sidebar-item-active-bg-light: rgba(111, 143, 175, 0.16); + --wo-sidebar-item-active-bg-dark: rgba(111, 143, 175, 0.24); + --wo-sidebar-border-light: rgba(111, 143, 175, 0.14); + --wo-sidebar-border-dark: rgba(255, 255, 255, 0.08); + --wo-login-bg-platform: linear-gradient(135deg, #31475b 0%, #5d7c95 52%, #43617a 100%); + --wo-login-bg-tenant: linear-gradient(135deg, #253749 0%, #506f87 42%, #395469 100%); + --wo-login-orb-platform-1: #6f8faf; + --wo-login-orb-platform-2: #8fb4c8; + --wo-login-orb-platform-3: #c5dbe8; + --wo-login-orb-platform-4: #dceaf4; + --wo-login-orb-tenant-1: #7ba9c2; + --wo-login-orb-tenant-2: #6f8faf; + --wo-login-orb-tenant-3: #8fb4c8; + --wo-login-orb-tenant-4: #cce0ec; + --wo-app-header-bg-light: linear-gradient(135deg, #fcfdff 0%, #edf4f9 100%); + --wo-app-header-bg-dark: linear-gradient(135deg, #1a2732 0%, #31495d 55%, #47617a 100%); + --wo-profile-avatar-bg-light: linear-gradient(135deg, #f8fbfd, #dce7ef); + --wo-profile-avatar-text-light: #47627a; + --wo-profile-avatar-text-dark: #dce8f1; + --wo-header-shadow: 0 8px 24px rgba(148, 163, 184, 0.18); + --wo-content-bg: #f3f7fa; + --wo-sub-header-border: #d8e2eb; + --wo-main-content-bg: #f7fafc; + --wo-card-border: #dde6ee; + --wo-app-header-text: #26415a; + --wo-top-nav-text: rgba(38, 65, 90, 0.72); + --wo-top-nav-active-bg: rgba(111, 143, 175, 0.14); + --wo-header-action-bg: rgba(111, 143, 175, 0.12); + --wo-header-action-text: #39556e; + --wo-header-action-divider: rgba(111, 143, 175, 0.18); + --wo-light-action-bg: #edf4f8; + --wo-light-action-text: #4c6277; + --wo-light-action-divider: #d6e0e8; + --wo-search-surface: rgba(255, 255, 255, 0.96); + --wo-search-surface-hover: rgba(111, 143, 175, 0.1); + --wo-search-border: rgba(111, 143, 175, 0.16); + --wo-search-muted: #70859b; + --wo-search-title: #22384d; +} + +:root[data-theme-scheme="sage"] { + --wo-theme-rgb: 110, 148, 125; + --wo-brand-primary: #6e947d; + --wo-brand-primary-light: #8cad97; + --wo-brand-primary-dark: #5a7a67; + --wo-brand-primary-light-3: #8cad97; + --wo-brand-primary-light-5: #a3beab; + --wo-brand-primary-light-7: #c3d5c8; + --wo-brand-primary-light-8: #dae6dc; + --wo-brand-primary-light-9: #eef4ef; + --wo-brand-primary-dark-2: #5a7a67; + --wo-brand-accent: #92b29b; + --wo-brand-accent-light: #b2cbb8; + --wo-brand-gradient: linear-gradient(135deg, #6e947d 0%, #92b29b 100%); + --wo-sidebar-bg-light: linear-gradient(180deg, #fbfdfb 0%, #eef5f0 100%); + --wo-sidebar-bg-dark: linear-gradient(180deg, #25342a 0%, #425c4a 100%); + --wo-sidebar-text-light: #6f8576; + --wo-sidebar-text-dark: rgba(255, 255, 255, 0.76); + --wo-sidebar-text-active-light: #294135; + --wo-sidebar-text-active-dark: #ffffff; + --wo-sidebar-brand-bg-light: rgba(110, 148, 125, 0.1); + --wo-sidebar-brand-bg-dark: rgba(255, 255, 255, 0.08); + --wo-sidebar-item-hover-light: rgba(110, 148, 125, 0.1); + --wo-sidebar-item-hover-dark: rgba(255, 255, 255, 0.08); + --wo-sidebar-item-active-bg-light: rgba(110, 148, 125, 0.15); + --wo-sidebar-item-active-bg-dark: rgba(110, 148, 125, 0.24); + --wo-sidebar-border-light: rgba(110, 148, 125, 0.14); + --wo-sidebar-border-dark: rgba(255, 255, 255, 0.08); + --wo-login-bg-platform: linear-gradient(135deg, #2d4135 0%, #56715f 52%, #3c5144 100%); + --wo-login-bg-tenant: linear-gradient(135deg, #233229 0%, #496454 42%, #31453a 100%); + --wo-login-orb-platform-1: #6e947d; + --wo-login-orb-platform-2: #92b29b; + --wo-login-orb-platform-3: #c3d5c8; + --wo-login-orb-platform-4: #dce9de; + --wo-login-orb-tenant-1: #86a992; + --wo-login-orb-tenant-2: #6e947d; + --wo-login-orb-tenant-3: #92b29b; + --wo-login-orb-tenant-4: #d1e2d5; + --wo-app-header-bg-light: linear-gradient(135deg, #fcfefc 0%, #edf5ef 100%); + --wo-app-header-bg-dark: linear-gradient(135deg, #18231c 0%, #304439 55%, #496454 100%); + --wo-profile-avatar-bg-light: linear-gradient(135deg, #f7fcf8, #dfeae2); + --wo-profile-avatar-text-light: #476554; + --wo-profile-avatar-text-dark: #dce9df; + --wo-header-shadow: 0 8px 24px rgba(148, 163, 148, 0.16); + --wo-content-bg: #f4f8f4; + --wo-sub-header-border: #d8e5dc; + --wo-main-content-bg: #f8fbf8; + --wo-card-border: #dce7df; + --wo-app-header-text: #2d4738; + --wo-top-nav-text: rgba(45, 71, 56, 0.72); + --wo-top-nav-active-bg: rgba(110, 148, 125, 0.14); + --wo-header-action-bg: rgba(110, 148, 125, 0.12); + --wo-header-action-text: #41604d; + --wo-header-action-divider: rgba(110, 148, 125, 0.18); + --wo-light-action-bg: #edf5ef; + --wo-light-action-text: #576e60; + --wo-light-action-divider: #d6e2d9; + --wo-search-surface: rgba(255, 255, 255, 0.96); + --wo-search-surface-hover: rgba(110, 148, 125, 0.1); + --wo-search-border: rgba(110, 148, 125, 0.16); + --wo-search-muted: #718576; + --wo-search-title: #263b30; +} + +:root[data-theme-scheme="dawn"] { + --wo-theme-rgb: 172, 117, 92; + --wo-brand-primary: #ac755c; + --wo-brand-primary-light: #c18f7b; + --wo-brand-primary-dark: #8d604b; + --wo-brand-primary-light-3: #c18f7b; + --wo-brand-primary-light-5: #cfa595; + --wo-brand-primary-light-7: #e1c3b7; + --wo-brand-primary-light-8: #ecd5cd; + --wo-brand-primary-light-9: #f7eeea; + --wo-brand-primary-dark-2: #8d604b; + --wo-brand-accent: #d19b83; + --wo-brand-accent-light: #dfb6a4; + --wo-brand-gradient: linear-gradient(135deg, #ac755c 0%, #d19b83 100%); + --wo-sidebar-bg-light: linear-gradient(180deg, #fffaf7 0%, #f7efe9 100%); + --wo-sidebar-bg-dark: linear-gradient(180deg, #3b2a22 0%, #5c4134 100%); + --wo-sidebar-text-light: #8b6d5d; + --wo-sidebar-text-dark: rgba(255, 255, 255, 0.76); + --wo-sidebar-text-active-light: #5b4034; + --wo-sidebar-text-active-dark: #ffffff; + --wo-sidebar-brand-bg-light: rgba(172, 117, 92, 0.1); + --wo-sidebar-brand-bg-dark: rgba(255, 255, 255, 0.08); + --wo-sidebar-item-hover-light: rgba(172, 117, 92, 0.1); + --wo-sidebar-item-hover-dark: rgba(255, 255, 255, 0.08); + --wo-sidebar-item-active-bg-light: rgba(172, 117, 92, 0.15); + --wo-sidebar-item-active-bg-dark: rgba(172, 117, 92, 0.24); + --wo-sidebar-border-light: rgba(172, 117, 92, 0.14); + --wo-sidebar-border-dark: rgba(255, 255, 255, 0.08); + --wo-login-bg-platform: linear-gradient(135deg, #3c2b23 0%, #76523e 52%, #563c30 100%); + --wo-login-bg-tenant: linear-gradient(135deg, #2f211b 0%, #644736 42%, #483329 100%); + --wo-login-orb-platform-1: #ac755c; + --wo-login-orb-platform-2: #d19b83; + --wo-login-orb-platform-3: #e6c3b3; + --wo-login-orb-platform-4: #f3dfd6; + --wo-login-orb-tenant-1: #c88b6d; + --wo-login-orb-tenant-2: #ac755c; + --wo-login-orb-tenant-3: #d19b83; + --wo-login-orb-tenant-4: #edd2c6; + --wo-app-header-bg-light: linear-gradient(135deg, #fffdfb 0%, #f7efe9 100%); + --wo-app-header-bg-dark: linear-gradient(135deg, #201611 0%, #3f2d24 55%, #604436 100%); + --wo-profile-avatar-bg-light: linear-gradient(135deg, #fff8f4, #eedcd2); + --wo-profile-avatar-text-light: #704d3d; + --wo-profile-avatar-text-dark: #f2dfd7; + --wo-header-shadow: 0 8px 24px rgba(180, 148, 127, 0.18); + --wo-content-bg: #faf5f1; + --wo-sub-header-border: #eadbd2; + --wo-main-content-bg: #fcf8f5; + --wo-card-border: #ecddd4; + --wo-app-header-text: #67483a; + --wo-top-nav-text: rgba(103, 72, 58, 0.72); + --wo-top-nav-active-bg: rgba(172, 117, 92, 0.14); + --wo-header-action-bg: rgba(172, 117, 92, 0.12); + --wo-header-action-text: #855e4b; + --wo-header-action-divider: rgba(172, 117, 92, 0.18); + --wo-light-action-bg: #f6ede7; + --wo-light-action-text: #7a5f52; + --wo-light-action-divider: #e5d7cf; + --wo-search-surface: rgba(255, 255, 255, 0.96); + --wo-search-surface-hover: rgba(172, 117, 92, 0.1); + --wo-search-border: rgba(172, 117, 92, 0.16); + --wo-search-muted: #8c6e5e; + --wo-search-title: #573d31; +} + +:root[data-theme-mode="dark"] { + --wo-header-bg: #111827; + --wo-header-shadow: 0 1px 4px rgba(0, 0, 0, 0.28); + --wo-content-bg: #111827; + --wo-app-header-bg: var(--wo-app-header-bg-dark); + --wo-app-header-text: #f9fafb; + --wo-sub-header-bg: #111827; + --wo-sub-header-border: rgba(148, 163, 184, 0.22); + --wo-main-content-bg: #0b1120; + --wo-card-border: rgba(148, 163, 184, 0.2); + --wo-top-nav-text: rgba(255, 255, 255, 0.72); + --wo-top-nav-active-bg: rgba(var(--wo-theme-rgb), 0.2); + --wo-header-action-bg: rgba(255, 255, 255, 0.08); + --wo-header-action-text: #f9fafb; + --wo-header-action-divider: rgba(148, 163, 184, 0.24); + --wo-light-action-bg: rgba(255, 255, 255, 0.06); + --wo-light-action-text: #d1d5db; + --wo-light-action-divider: rgba(148, 163, 184, 0.2); + --wo-profile-avatar-bg: var(--wo-profile-avatar-bg-dark); + --wo-profile-avatar-text: var(--wo-profile-avatar-text-dark); + --wo-search-surface: rgba(15, 23, 42, 0.82); + --wo-search-surface-hover: rgba(var(--wo-theme-rgb), 0.18); + --wo-search-border: rgba(var(--wo-theme-rgb), 0.34); + --wo-search-muted: #94a3b8; + --wo-search-title: #e5e7eb; + --wo-sidebar-bg: var(--wo-sidebar-bg-dark); + --wo-sidebar-text: var(--wo-sidebar-text-dark); + --wo-sidebar-text-active: var(--wo-sidebar-text-active-dark); + --wo-sidebar-brand-bg: var(--wo-sidebar-brand-bg-dark); + --wo-sidebar-item-hover: var(--wo-sidebar-item-hover-dark); + --wo-sidebar-item-active-bg: var(--wo-sidebar-item-active-bg-dark); + --wo-sidebar-border: var(--wo-sidebar-border-dark); +} + +:root[data-density="compact"] { + --wo-main-content-padding: 16px; + --wo-shell-gap: 12px; + --wo-header-action-size: 32px; + --wo-header-avatar-size: 30px; + --wo-header-avatar-icon-size: 16px; +} + +/* ---- 全局平滑过渡 ---- */ +*, +*::before, +*::after { + transition-property: background-color, border-color, box-shadow, color, opacity, transform; + transition-duration: 0s; /* default off; components opt in */ +} + +/* ---- 按钮 hover 微弱上浮 ---- */ +.el-button { + transition: all 0.2s ease !important; +} + +.el-button:hover { + transform: translateY(-1px); + box-shadow: 0 2px 8px rgba(var(--wo-theme-rgb), 0.12); +} + +.el-button:active { + transform: translateY(0); +} + +/* ---- Primary 按钮渐变 ---- */ +.el-button--primary { + background: var(--wo-brand-gradient) !important; + border: none !important; + box-shadow: 0 2px 8px rgba(var(--wo-theme-rgb), 0.22); +} + +.el-button--primary:hover { + box-shadow: 0 4px 14px rgba(var(--wo-theme-rgb), 0.28) !important; +} + +.el-button--primary.is-plain { + background: transparent !important; + border: 1px solid var(--wo-brand-primary) !important; + color: var(--wo-brand-primary) !important; +} + +.el-button--primary.is-plain:hover { + background: rgba(var(--wo-theme-rgb), 0.06) !important; +} + +/* ---- 表格样式增强 ---- */ +.el-table { + --el-table-border-color: var(--wo-card-border); + --el-table-header-bg-color: var(--el-fill-color-light); + border-radius: 8px; + overflow: hidden; +} + +.el-table th.el-table__cell { + font-weight: 600; + color: var(--el-text-color-primary); + font-size: 13px; +} + +.el-table .el-table__row:hover > td.el-table__cell { + background: rgba(var(--wo-theme-rgb), 0.04) !important; +} + +/* ---- 卡片增强 ---- */ +.el-card { + border: 1px solid var(--wo-card-border); + border-radius: 12px; + box-shadow: 0 1px 3px rgba(15, 23, 42, 0.05); + transition: box-shadow 0.2s ease; +} + +.el-card:hover { + box-shadow: 0 4px 12px rgba(15, 23, 42, 0.08); +} + +.el-card__header { + border-bottom: 1px solid var(--wo-card-border); + padding: 16px 20px; +} + +/* ---- 抽屉 / 弹窗头 ---- */ +.el-drawer__header, +.el-dialog__header { + border-bottom: 1px solid var(--wo-card-border); + padding: 16px 20px !important; + margin-right: 0 !important; +} + +.el-drawer__title, +.el-dialog__title { + font-weight: 700; + font-size: 16px; + color: var(--el-text-color-primary); +} + +html.dark { + color-scheme: dark; +} + +html.dark body { + color: var(--el-text-color-primary); + background: var(--wo-main-content-bg); +} + +html.dark .el-card, +html.dark .el-dialog, +html.dark .el-drawer { + background-color: var(--el-bg-color-overlay); +} + +html.dark .el-table { + --el-table-header-bg-color: rgba(15, 23, 42, 0.92); + --el-table-tr-bg-color: rgba(15, 23, 42, 0.88); + --el-table-row-hover-bg-color: rgba(var(--wo-theme-rgb), 0.12); + color: var(--el-text-color-primary); +} + +html.dark .el-table .el-table__cell, +html.dark .el-descriptions__label, +html.dark .el-descriptions__content, +html.dark .el-form-item__label, +html.dark .el-empty__description p { + color: var(--el-text-color-primary); +} + +html.dark .el-descriptions { + --el-descriptions-table-border: var(--wo-card-border); +} + +html.dark .el-textarea__inner, +html.dark .el-input__wrapper, +html.dark .el-select__wrapper { + color: var(--el-text-color-primary); +} + +/* ---- Input 聚焦环 ---- */ +.el-input__wrapper.is-focus { + box-shadow: 0 0 0 2px rgba(var(--wo-theme-rgb), 0.18) !important; +} + +.el-textarea__inner:focus { + box-shadow: 0 0 0 2px rgba(var(--wo-theme-rgb), 0.18) !important; +} + +/* ---- Badge 渐变 ---- */ +.el-badge__content--danger { + background: linear-gradient(135deg, #ef4444, #f97316); +} + +/* ---- Tag 圆角增大 ---- */ +.el-tag { + border-radius: 6px; +} + +/* ---- 滚动条美化 ---- */ +::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +::-webkit-scrollbar-thumb { + background: rgba(var(--wo-theme-rgb), 0.18); + border-radius: 10px; +} + +::-webkit-scrollbar-thumb:hover { + background: rgba(var(--wo-theme-rgb), 0.3); +} + +::-webkit-scrollbar-track { + background: transparent; +} diff --git a/frontend/src/styles/utilities.css b/frontend/src/styles/utilities.css new file mode 100644 index 0000000..1df484b --- /dev/null +++ b/frontend/src/styles/utilities.css @@ -0,0 +1,135 @@ +/* ============================================ + * 全局工具类 (Utility Classes) + * ============================================ + * 命名规则:{属性缩写}-{档位} + * mt = margin-top, mb = margin-bottom + * ml = margin-left, mr = margin-right + * pt/pb/pl/pr = padding + * gap = flex/grid gap + * w = width + */ + +/* ---- Margin Top ---- */ +.mt-xs { margin-top: var(--wo-spacing-xs); } +.mt-sm { margin-top: var(--wo-spacing-sm); } +.mt-md { margin-top: var(--wo-spacing-md); } +.mt-lg { margin-top: var(--wo-spacing-lg); } +.mt-xl { margin-top: var(--wo-spacing-xl); } + +/* ---- Margin Bottom ---- */ +.mb-xs { margin-bottom: var(--wo-spacing-xs); } +.mb-sm { margin-bottom: var(--wo-spacing-sm); } +.mb-md { margin-bottom: var(--wo-spacing-md); } +.mb-lg { margin-bottom: var(--wo-spacing-lg); } +.mb-xl { margin-bottom: var(--wo-spacing-xl); } + +/* ---- Margin Left ---- */ +.ml-xs { margin-left: var(--wo-spacing-xs); } +.ml-sm { margin-left: var(--wo-spacing-sm); } +.ml-md { margin-left: var(--wo-spacing-md); } +.ml-lg { margin-left: var(--wo-spacing-lg); } + +/* ---- Margin Right ---- */ +.mr-xs { margin-right: var(--wo-spacing-xs); } +.mr-sm { margin-right: var(--wo-spacing-sm); } +.mr-md { margin-right: var(--wo-spacing-md); } +.mr-lg { margin-right: var(--wo-spacing-lg); } + +/* ---- Gap ---- */ +.gap-xs { gap: var(--wo-spacing-xs); } +.gap-sm { gap: var(--wo-spacing-sm); } +.gap-md { gap: var(--wo-spacing-md); } +.gap-lg { gap: var(--wo-spacing-lg); } + +/* ---- 文字辅助 ---- */ +.text-regular { color: var(--wo-text-regular); } +.text-secondary { color: var(--wo-text-secondary); } +.text-placeholder { color: var(--wo-text-placeholder); } +.text-title { font-weight: var(--wo-font-weight-title); } +.text-hint { font-size: 12px; color: var(--wo-text-secondary); } + +/* ---- 行高 ---- */ +.lh-loose { line-height: var(--wo-line-height-loose); } + +/* ---- 宽度 ---- */ +.w-full { width: 100%; } +.w-input-xs { width: var(--wo-input-xs); } +.w-input-sm { width: var(--wo-input-sm); } +.w-input-md { width: var(--wo-input-md); } +.w-input-lg { width: var(--wo-input-lg); } + +/* ---- Flex 快捷 ---- */ +.flex-center { + display: flex; + align-items: center; +} +.flex-between { + display: flex; + align-items: center; + justify-content: space-between; +} +.flex-end { + display: flex; + justify-content: flex-end; +} +.flex-wrap { + display: flex; + flex-wrap: wrap; +} +.flex-col { + display: flex; + flex-direction: column; +} + +/* ---- 预览盒(通知策略等处使用) ---- */ +.preview-box { + width: 100%; + background: var(--wo-bg-light); + border: 1px solid var(--wo-border-light); + border-radius: var(--wo-radius-md); + padding: 10px 12px; + line-height: var(--wo-line-height-loose); +} + +/* ---- 图片缩略图 ---- */ +.thumbnail-sm { + width: 56px; + height: 56px; + border-radius: var(--wo-radius-sm); + object-fit: cover; +} + +/* ---- 审核状态文字 ---- */ +.text-danger { color: var(--el-color-danger, #f56c6c); } +.text-warning { color: var(--el-color-warning, #e6a23c); } + +/* ---- Display helpers ---- */ +.flex { display: flex; } +.inline-flex { display: inline-flex; } +.flex-1 { flex: 1; } + +/* ---- Padding ---- */ +.p-sm { padding: var(--wo-spacing-sm); } +.p-md { padding: var(--wo-spacing-md); } + +/* ---- Compound helpers ---- */ +.ml-sm-text-danger { margin-left: var(--wo-spacing-sm); color: var(--el-color-danger, #f56c6c); } +.ml-sm-text-warning { margin-left: var(--wo-spacing-sm); color: var(--el-color-warning, #e6a23c); } +.ml-sm-text-regular { margin-left: var(--wo-spacing-sm); color: var(--wo-text-regular); } +.ml-sm-text-secondary { margin-left: var(--wo-spacing-sm); color: var(--wo-text-secondary); } +.mt-xs-text-danger { margin-top: var(--wo-spacing-xs); color: var(--el-color-danger, #f56c6c); } + +/* ---- Section Card (bordered content area) ---- */ +.section-card { + border: 1px solid var(--wo-border-light); + border-radius: var(--wo-radius-md); + padding: var(--wo-spacing-md); +} +.section-card-sm { + border: 1px solid var(--wo-border-light); + border-radius: var(--wo-radius-sm); + padding: var(--wo-spacing-sm); +} + +/* ---- Font Weight ---- */ +.fw-600 { font-weight: 600; } diff --git a/frontend/src/styles/variables.css b/frontend/src/styles/variables.css new file mode 100644 index 0000000..b1af503 --- /dev/null +++ b/frontend/src/styles/variables.css @@ -0,0 +1,58 @@ +/* ============================================ + * 全局 Design Token / CSS 变量 + * ============================================ + * 约定:所有自定义变量以 --wo- 前缀(writeOff), + * 避免与 Element Plus 的 --el- 命名空间冲突。 + */ + +:root { + /* ---- 间距 Token ---- */ + --wo-spacing-xxs: 2px; + --wo-spacing-xs: 4px; + --wo-spacing-sm: 8px; + --wo-spacing-md: 12px; + --wo-spacing-lg: 16px; + --wo-spacing-xl: 24px; + --wo-spacing-xxl: 32px; + + /* ---- 圆角 ---- */ + --wo-radius-sm: 4px; + --wo-radius-md: 6px; + --wo-radius-lg: 8px; + + /* ---- 弹窗 / 抽屉标准档位 ---- */ + --wo-drawer-sm: 480px; + --wo-drawer-md: 640px; + --wo-drawer-lg: 80%; + + --wo-dialog-sm: 520px; + --wo-dialog-md: 680px; + --wo-dialog-lg: 860px; + + /* ---- 表单控件宽度标准档位 ---- */ + --wo-input-xs: 100px; + --wo-input-sm: 160px; + --wo-input-md: 220px; + --wo-input-lg: 280px; + + /* ---- 文字颜色(引用 Element Plus 变量,保持主题一致) ---- */ + --wo-text-primary: var(--el-text-color-primary); + --wo-text-regular: var(--el-text-color-regular); + --wo-text-secondary: var(--el-text-color-secondary); + --wo-text-placeholder: var(--el-text-color-placeholder); + + /* ---- 背景 & 边框(引用 Element Plus 变量) ---- */ + --wo-bg-light: #f0f2f8; + --wo-border-light: #edf0f7; + --wo-border-base: var(--el-border-color, #dcdfe6); + + /* ---- 品牌色 Token ---- */ + --wo-brand-primary: #3c6e91; + --wo-brand-accent: #5c88a7; + + /* ---- 标题字重 ---- */ + --wo-font-weight-title: 700; + + /* ---- 行高 ---- */ + --wo-line-height-loose: 1.8; +} diff --git a/frontend/src/utils/authCrypto.ts b/frontend/src/utils/authCrypto.ts new file mode 100644 index 0000000..6bd13a1 --- /dev/null +++ b/frontend/src/utils/authCrypto.ts @@ -0,0 +1,58 @@ +import http from "../api/http"; + +const PASSWORD_PREFIX = "rsa:"; + +const decodeBase64 = (value: string): Uint8Array => { + const normalized = String(value || "").trim(); + const binary = window.atob(normalized); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i += 1) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; +}; + +const encodeBase64 = (bytes: Uint8Array): string => { + let binary = ""; + bytes.forEach((byte) => { + binary += String.fromCharCode(byte); + }); + return window.btoa(binary); +}; + +const loadPasswordPublicKey = async (): Promise => { + const resp = await http.get("/auth/password-public-key"); + const publicKey = String(resp?.data?.publicKey || "").trim(); + if (!publicKey) { + throw new Error("Password public key is missing"); + } + return window.crypto.subtle.importKey( + "spki", + decodeBase64(publicKey), + { + name: "RSA-OAEP", + hash: "SHA-256", + }, + false, + ["encrypt"], + ); +}; + +const getPasswordPublicKey = async (): Promise => { + if (!window.isSecureContext || !window.crypto?.subtle) { + throw new Error("Current page is not in a secure context"); + } + return loadPasswordPublicKey(); +}; + +export const encryptLoginPassword = async (password: string): Promise => { + try { + const cryptoKey = await getPasswordPublicKey(); + const encoded = new TextEncoder().encode(password); + const encrypted = await window.crypto.subtle.encrypt({ name: "RSA-OAEP" }, cryptoKey, encoded); + return `${PASSWORD_PREFIX}${encodeBase64(new Uint8Array(encrypted))}`; + } catch (error) { + console.warn("Login password encryption is unavailable, falling back to plain password submission.", error); + return password; + } +}; diff --git a/frontend/src/utils/authNavigation.ts b/frontend/src/utils/authNavigation.ts new file mode 100644 index 0000000..d42ace5 --- /dev/null +++ b/frontend/src/utils/authNavigation.ts @@ -0,0 +1,11 @@ +import type { AuthScope } from "../stores/auth"; + +export const resolveLoginPath = (scope?: AuthScope | null, tenantCode?: string | null): string => { + if (scope === "TENANT") { + const normalizedTenantCode = String(tenantCode || "").trim(); + if (normalizedTenantCode) { + return `/${normalizedTenantCode}/login`; + } + } + return "/login"; +}; diff --git a/frontend/src/utils/batchImport.ts b/frontend/src/utils/batchImport.ts new file mode 100644 index 0000000..0df6c78 --- /dev/null +++ b/frontend/src/utils/batchImport.ts @@ -0,0 +1,94 @@ +export const readTextFile = (file: File): Promise => + new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(String(reader.result || "")); + reader.onerror = () => reject(reader.error || new Error("文件读取失败")); + reader.readAsText(file, "utf-8"); + }); + +export const downloadCsvTemplate = (fileName: string, headers: string[], sampleRows: string[][] = []) => { + downloadCsvRows(fileName, [headers, ...sampleRows]); +}; + +export const downloadCsvRows = (fileName: string, rows: string[][]) => { + const csv = toCsv(rows); + const blob = new Blob([`\uFEFF${csv}`], { type: "text/csv;charset=utf-8" }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = fileName; + anchor.click(); + URL.revokeObjectURL(url); +}; + +export const parseDelimitedText = (text: string): string[][] => { + const normalized = String(text || "").replace(/^\uFEFF/, "").replace(/\r\n/g, "\n").replace(/\r/g, "\n"); + if (!normalized.trim()) { + return []; + } + const firstLine = normalized.split("\n").find((line) => line.trim().length > 0) || ""; + const delimiter = firstLine.includes("\t") ? "\t" : ","; + return parseWithDelimiter(normalized, delimiter) + .map((row) => row.map((cell) => cell.trim())) + .filter((row) => row.some((cell) => cell.length > 0)); +}; + +export const findMissingHeaders = (headerRow: string[], requiredHeaders: string[]): string[] => { + const existing = new Set(headerRow.map((item) => String(item || "").trim())); + return requiredHeaders.filter((header) => !existing.has(header)); +}; + +const toCsv = (rows: string[][]): string => + rows + .map((row) => row.map(escapeCell).join(",")) + .join("\r\n"); + +const escapeCell = (value: string): string => { + const text = String(value ?? ""); + if (/[",\r\n]/.test(text)) { + return `"${text.replace(/"/g, "\"\"")}"`; + } + return text; +}; + +const parseWithDelimiter = (text: string, delimiter: string): string[][] => { + const rows: string[][] = []; + let currentRow: string[] = []; + let currentCell = ""; + let insideQuotes = false; + + for (let i = 0; i < text.length; i += 1) { + const char = text[i]; + const next = text[i + 1]; + + if (char === "\"") { + if (insideQuotes && next === "\"") { + currentCell += "\""; + i += 1; + } else { + insideQuotes = !insideQuotes; + } + continue; + } + + if (!insideQuotes && char === delimiter) { + currentRow.push(currentCell); + currentCell = ""; + continue; + } + + if (!insideQuotes && char === "\n") { + currentRow.push(currentCell); + rows.push(currentRow); + currentRow = []; + currentCell = ""; + continue; + } + + currentCell += char; + } + + currentRow.push(currentCell); + rows.push(currentRow); + return rows; +}; diff --git a/frontend/src/utils/compress.ts b/frontend/src/utils/compress.ts new file mode 100644 index 0000000..fc83475 --- /dev/null +++ b/frontend/src/utils/compress.ts @@ -0,0 +1,42 @@ +import Compressor from "compressorjs"; + +/** + * 异步压缩图片文件 + * @param file 原始图片文件 + * @param quality 压缩质量 (0 ~ 1),默认 0.8 + * @param maxWidth 最大宽度,默认 1920 + * @returns 压缩后的文件对象 + */ +export const compressImageFile = ( + file: File, + quality = 0.8, + maxWidth = 1920 +): Promise => { + return new Promise((resolve, reject) => { + // 只有图片类型才进行压缩,直接返回其他文件类型(如 PDF) + if (!file.type.startsWith("image/")) { + resolve(file); + return; + } + + new Compressor(file, { + quality, + maxWidth, + // 小于 200KB 的图片不不处理 + convertSize: 200000, + success(result: File | Blob) { + // compressorjs 可能会返回 Blob,需确保转回 File + const compressedFile = new File([result], file.name, { + type: result.type, + lastModified: Date.now(), + }); + resolve(compressedFile); + }, + error(err) { + // 如果压缩失败,最好打印个警告,然后兜底返回原文件 + console.warn("Image compression failed:", err.message); + resolve(file); + }, + }); + }); +}; diff --git a/frontend/src/utils/status.ts b/frontend/src/utils/status.ts new file mode 100644 index 0000000..4dab4a9 --- /dev/null +++ b/frontend/src/utils/status.ts @@ -0,0 +1,111 @@ +const STATUS_TEXT_MAP: Record = { + ENABLED: "启用", + DISABLED: "停用", + DELETED: "已删除", + PENDING: "待处理", + CLAIMED: "已认领", + IN_REVIEW: "审核中", + APPROVED: "已通过", + REJECTED: "已拒绝", + RETURNED: "已退回", + TRANSFERRED: "已转交", + NOT_STARTED: "未开始", + IN_PROGRESS: "进行中", + COMPLETED: "已完成", + CANCELED: "已取消", + CANCELLED: "已取消", + DELAYED: "已延期", + FROZEN: "已冻结", + WAITING: "待开始", + WAIT_SUBMIT: "待提交", + WAIT_FINANCE_CONFIRM: "待财务确认", + CONFIRMED: "已确认", + PARTIAL_PAID: "部分支付", + SETTLED: "已结算", + PROCESSING: "处理中", + SUCCESS: "成功", + FAILED: "失败", + LOCKED: "已锁定", + UNLOCKED: "已解锁", + SAVE: "保存", + SUBMIT: "提交", + CREATE: "创建", + UPDATE: "更新", + DELETE: "删除", + EXPIRED: "已过期", + DRAFT: "草稿", + PUBLISHED: "已发布", + ARCHIVED: "已归档", + ROLE: "角色", + USER: "用户", + ALL: "全部", + IDS: "指定ID", + OWNER: "负责人", + PROJECT: "项目级", + MEETING: "会议级", + APPEND: "追加", + REPLACE: "替换", + EXECUTOR: "执行人", + FINANCE: "财务", + IN_APP: "站内", + SMS: "短信", + EMAIL: "邮件", + AGENDA: "议程模板", + SIGN_IN: "签到模板", + INVITATION: "邀请函模板", + OTHER: "其他", + SUBMITTER: "提交人", + AUDITOR: "审核人", + FINANCE_ROLE: "财务角色", + TARGET_USER: "目标用户", + AUDIT_APPROVED: "审核通过", + AUDIT_REJECTED: "审核拒绝", + FINANCE_CONFIRMED: "财务已确认", + USER_CREATED: "用户创建", + DELIVERED: "已送达", + UNREAD: "未读", + READ: "已读", +}; + +const AUDIT_NODE_MAP: Record = { + INIT_REVIEW: "初审", + RE_REVIEW: "复审", + FINAL_REVIEW: "终审", +}; + +const ASSIGNEE_TYPE_MAP: Record = { + ROLE: "按角色", + USER: "按用户", +}; + +const BIZ_SCENE_MAP: Record = { + MEETING_RECOMMEND: "会议推荐", + AUDIT_NOTIFY: "审核通知", + SETTLEMENT: "结算", +}; + +const MODULE_CODE_MAP: Record = { + BASIC_INFO: "基础资料", + WRITE_OFF_DOCS: "核销证明", + EXPERT_LIST: "参会专家", + MEETING_INVOICE: "发票与流水", + EXPERT_PROFILE: "专家主档案校验", +}; + +const formatText = (mapping: Record, value?: unknown): string => { + const key = String(value ?? "").trim(); + if (!key) { + return "-"; + } + return mapping[key] || key; +}; + +export const toZhStatus = (value?: unknown): string => formatText(STATUS_TEXT_MAP, value); + +export const toZhAuditNode = (value?: unknown): string => formatText(AUDIT_NODE_MAP, value); + +export const toZhAssigneeType = (value?: unknown): string => formatText(ASSIGNEE_TYPE_MAP, value); + +export const toZhBizScene = (value?: unknown): string => formatText(BIZ_SCENE_MAP, value); + +export const toZhModuleCode = (value?: unknown): string => formatText(MODULE_CODE_MAP, value); diff --git a/frontend/src/views/layout/AppLayout.vue b/frontend/src/views/layout/AppLayout.vue new file mode 100644 index 0000000..07ba7e3 --- /dev/null +++ b/frontend/src/views/layout/AppLayout.vue @@ -0,0 +1,826 @@ + + + + + diff --git a/frontend/src/views/modules/AuditFlowPage.vue b/frontend/src/views/modules/AuditFlowPage.vue new file mode 100644 index 0000000..6ea8c39 --- /dev/null +++ b/frontend/src/views/modules/AuditFlowPage.vue @@ -0,0 +1,329 @@ + + + diff --git a/frontend/src/views/modules/AuditLogPage.vue b/frontend/src/views/modules/AuditLogPage.vue new file mode 100644 index 0000000..4134557 --- /dev/null +++ b/frontend/src/views/modules/AuditLogPage.vue @@ -0,0 +1,197 @@ + + + diff --git a/frontend/src/views/modules/AuditPage.vue b/frontend/src/views/modules/AuditPage.vue new file mode 100644 index 0000000..500453e --- /dev/null +++ b/frontend/src/views/modules/AuditPage.vue @@ -0,0 +1,1632 @@ + + + diff --git a/frontend/src/views/modules/DataPermissionPage.vue b/frontend/src/views/modules/DataPermissionPage.vue new file mode 100644 index 0000000..cac2951 --- /dev/null +++ b/frontend/src/views/modules/DataPermissionPage.vue @@ -0,0 +1,346 @@ + + + diff --git a/frontend/src/views/modules/EnterprisePage.vue b/frontend/src/views/modules/EnterprisePage.vue new file mode 100644 index 0000000..42629d3 --- /dev/null +++ b/frontend/src/views/modules/EnterprisePage.vue @@ -0,0 +1,350 @@ + + + diff --git a/frontend/src/views/modules/ExpertPage.vue b/frontend/src/views/modules/ExpertPage.vue new file mode 100644 index 0000000..aa23fe0 --- /dev/null +++ b/frontend/src/views/modules/ExpertPage.vue @@ -0,0 +1,1009 @@ + + + diff --git a/frontend/src/views/modules/ExportTaskPage.vue b/frontend/src/views/modules/ExportTaskPage.vue new file mode 100644 index 0000000..8d1ed39 --- /dev/null +++ b/frontend/src/views/modules/ExportTaskPage.vue @@ -0,0 +1,170 @@ + + + diff --git a/frontend/src/views/modules/FinancePage.vue b/frontend/src/views/modules/FinancePage.vue new file mode 100644 index 0000000..4fb986c --- /dev/null +++ b/frontend/src/views/modules/FinancePage.vue @@ -0,0 +1,144 @@ + + + diff --git a/frontend/src/views/modules/InAppNotificationPage.vue b/frontend/src/views/modules/InAppNotificationPage.vue new file mode 100644 index 0000000..c2fc78e --- /dev/null +++ b/frontend/src/views/modules/InAppNotificationPage.vue @@ -0,0 +1,116 @@ + + + + + diff --git a/frontend/src/views/modules/InvoiceProfilePage.vue b/frontend/src/views/modules/InvoiceProfilePage.vue new file mode 100644 index 0000000..55e651d --- /dev/null +++ b/frontend/src/views/modules/InvoiceProfilePage.vue @@ -0,0 +1,187 @@ + + + diff --git a/frontend/src/views/modules/MeetingPage.vue b/frontend/src/views/modules/MeetingPage.vue new file mode 100644 index 0000000..631e3d5 --- /dev/null +++ b/frontend/src/views/modules/MeetingPage.vue @@ -0,0 +1,5417 @@ + + + + + + diff --git a/frontend/src/views/modules/MenuPage.vue b/frontend/src/views/modules/MenuPage.vue new file mode 100644 index 0000000..b262d8c --- /dev/null +++ b/frontend/src/views/modules/MenuPage.vue @@ -0,0 +1,291 @@ + + + diff --git a/frontend/src/views/modules/NotFoundPage.vue b/frontend/src/views/modules/NotFoundPage.vue new file mode 100644 index 0000000..76ad627 --- /dev/null +++ b/frontend/src/views/modules/NotFoundPage.vue @@ -0,0 +1,89 @@ + + + + + diff --git a/frontend/src/views/modules/NotificationPolicyPage.vue b/frontend/src/views/modules/NotificationPolicyPage.vue new file mode 100644 index 0000000..48bbfc8 --- /dev/null +++ b/frontend/src/views/modules/NotificationPolicyPage.vue @@ -0,0 +1,774 @@ + + + diff --git a/frontend/src/views/modules/NotificationTextTemplatePage.vue b/frontend/src/views/modules/NotificationTextTemplatePage.vue new file mode 100644 index 0000000..8c034a0 --- /dev/null +++ b/frontend/src/views/modules/NotificationTextTemplatePage.vue @@ -0,0 +1,209 @@ + + + diff --git a/frontend/src/views/modules/ObservabilityPage.vue b/frontend/src/views/modules/ObservabilityPage.vue new file mode 100644 index 0000000..85c7aa3 --- /dev/null +++ b/frontend/src/views/modules/ObservabilityPage.vue @@ -0,0 +1,232 @@ + + + diff --git a/frontend/src/views/modules/OperationsDashboardPage.vue b/frontend/src/views/modules/OperationsDashboardPage.vue new file mode 100644 index 0000000..7e7ceba --- /dev/null +++ b/frontend/src/views/modules/OperationsDashboardPage.vue @@ -0,0 +1,129 @@ + + + diff --git a/frontend/src/views/modules/PermissionPage.vue b/frontend/src/views/modules/PermissionPage.vue new file mode 100644 index 0000000..69afb9e --- /dev/null +++ b/frontend/src/views/modules/PermissionPage.vue @@ -0,0 +1,38 @@ + + + diff --git a/frontend/src/views/modules/PlatformDictionaryPage.vue b/frontend/src/views/modules/PlatformDictionaryPage.vue new file mode 100644 index 0000000..53023cf --- /dev/null +++ b/frontend/src/views/modules/PlatformDictionaryPage.vue @@ -0,0 +1,300 @@ + + + diff --git a/frontend/src/views/modules/PlatformLoginPage.vue b/frontend/src/views/modules/PlatformLoginPage.vue new file mode 100644 index 0000000..364e6d0 --- /dev/null +++ b/frontend/src/views/modules/PlatformLoginPage.vue @@ -0,0 +1,589 @@ + + + + + diff --git a/frontend/src/views/modules/PlatformMenuPage.vue b/frontend/src/views/modules/PlatformMenuPage.vue new file mode 100644 index 0000000..50f11ec --- /dev/null +++ b/frontend/src/views/modules/PlatformMenuPage.vue @@ -0,0 +1,283 @@ + + + diff --git a/frontend/src/views/modules/PlatformNotifyGatewayPage.vue b/frontend/src/views/modules/PlatformNotifyGatewayPage.vue new file mode 100644 index 0000000..179da04 --- /dev/null +++ b/frontend/src/views/modules/PlatformNotifyGatewayPage.vue @@ -0,0 +1,536 @@ + + + + + diff --git a/frontend/src/views/modules/PlatformPermissionPage.vue b/frontend/src/views/modules/PlatformPermissionPage.vue new file mode 100644 index 0000000..6dde72d --- /dev/null +++ b/frontend/src/views/modules/PlatformPermissionPage.vue @@ -0,0 +1,38 @@ + + + diff --git a/frontend/src/views/modules/PlatformRolePage.vue b/frontend/src/views/modules/PlatformRolePage.vue new file mode 100644 index 0000000..01cd4c3 --- /dev/null +++ b/frontend/src/views/modules/PlatformRolePage.vue @@ -0,0 +1,247 @@ + + + diff --git a/frontend/src/views/modules/PlatformSessionPage.vue b/frontend/src/views/modules/PlatformSessionPage.vue new file mode 100644 index 0000000..7e4393d --- /dev/null +++ b/frontend/src/views/modules/PlatformSessionPage.vue @@ -0,0 +1,137 @@ + + + diff --git a/frontend/src/views/modules/PlatformUserPage.vue b/frontend/src/views/modules/PlatformUserPage.vue new file mode 100644 index 0000000..3ddc75d --- /dev/null +++ b/frontend/src/views/modules/PlatformUserPage.vue @@ -0,0 +1,530 @@ + + + diff --git a/frontend/src/views/modules/ProfilePage.vue b/frontend/src/views/modules/ProfilePage.vue new file mode 100644 index 0000000..295beca --- /dev/null +++ b/frontend/src/views/modules/ProfilePage.vue @@ -0,0 +1,678 @@ + + + + + diff --git a/frontend/src/views/modules/ProjectPage.vue b/frontend/src/views/modules/ProjectPage.vue new file mode 100644 index 0000000..543a254 --- /dev/null +++ b/frontend/src/views/modules/ProjectPage.vue @@ -0,0 +1,516 @@ + + + + + diff --git a/frontend/src/views/modules/RolePage.vue b/frontend/src/views/modules/RolePage.vue new file mode 100644 index 0000000..3f5a820 --- /dev/null +++ b/frontend/src/views/modules/RolePage.vue @@ -0,0 +1,300 @@ + + + diff --git a/frontend/src/views/modules/TemplateDownloadLogPage.vue b/frontend/src/views/modules/TemplateDownloadLogPage.vue new file mode 100644 index 0000000..d8b00fc --- /dev/null +++ b/frontend/src/views/modules/TemplateDownloadLogPage.vue @@ -0,0 +1,625 @@ + + + diff --git a/frontend/src/views/modules/TemplatePage.vue b/frontend/src/views/modules/TemplatePage.vue new file mode 100644 index 0000000..adc3bc4 --- /dev/null +++ b/frontend/src/views/modules/TemplatePage.vue @@ -0,0 +1,1131 @@ + + + diff --git a/frontend/src/views/modules/TenantDashboardPage.vue b/frontend/src/views/modules/TenantDashboardPage.vue new file mode 100644 index 0000000..8923863 --- /dev/null +++ b/frontend/src/views/modules/TenantDashboardPage.vue @@ -0,0 +1,283 @@ + + + + + diff --git a/frontend/src/views/modules/TenantLoginPage.vue b/frontend/src/views/modules/TenantLoginPage.vue new file mode 100644 index 0000000..1b3ef54 --- /dev/null +++ b/frontend/src/views/modules/TenantLoginPage.vue @@ -0,0 +1,628 @@ + + + + + diff --git a/frontend/src/views/modules/TenantPage.vue b/frontend/src/views/modules/TenantPage.vue new file mode 100644 index 0000000..9a8a80d --- /dev/null +++ b/frontend/src/views/modules/TenantPage.vue @@ -0,0 +1,505 @@ + + + diff --git a/frontend/src/views/modules/TenantPasswordSetupPage.vue b/frontend/src/views/modules/TenantPasswordSetupPage.vue new file mode 100644 index 0000000..c1aa9e4 --- /dev/null +++ b/frontend/src/views/modules/TenantPasswordSetupPage.vue @@ -0,0 +1,384 @@ + + + + + diff --git a/frontend/src/views/modules/UserPage.vue b/frontend/src/views/modules/UserPage.vue new file mode 100644 index 0000000..e95db7b --- /dev/null +++ b/frontend/src/views/modules/UserPage.vue @@ -0,0 +1,837 @@ + + + diff --git a/frontend/src/views/modules/audit-page/AuditBasicInfoReviewPanel.vue b/frontend/src/views/modules/audit-page/AuditBasicInfoReviewPanel.vue new file mode 100644 index 0000000..4ccb66e --- /dev/null +++ b/frontend/src/views/modules/audit-page/AuditBasicInfoReviewPanel.vue @@ -0,0 +1,311 @@ + + + + + diff --git a/frontend/src/views/modules/audit-page/AuditExpertProfileReviewPanel.vue b/frontend/src/views/modules/audit-page/AuditExpertProfileReviewPanel.vue new file mode 100644 index 0000000..a14e93c --- /dev/null +++ b/frontend/src/views/modules/audit-page/AuditExpertProfileReviewPanel.vue @@ -0,0 +1,262 @@ + + + + + diff --git a/frontend/src/views/modules/audit-page/AuditExpertReviewPanel.vue b/frontend/src/views/modules/audit-page/AuditExpertReviewPanel.vue new file mode 100644 index 0000000..c5b1c84 --- /dev/null +++ b/frontend/src/views/modules/audit-page/AuditExpertReviewPanel.vue @@ -0,0 +1,1202 @@ + + + + + diff --git a/frontend/src/views/modules/audit-page/AuditListTable.vue b/frontend/src/views/modules/audit-page/AuditListTable.vue new file mode 100644 index 0000000..d462624 --- /dev/null +++ b/frontend/src/views/modules/audit-page/AuditListTable.vue @@ -0,0 +1,91 @@ + + + diff --git a/frontend/src/views/modules/audit-page/AuditMaterialDrawer.vue b/frontend/src/views/modules/audit-page/AuditMaterialDrawer.vue new file mode 100644 index 0000000..c04cf94 --- /dev/null +++ b/frontend/src/views/modules/audit-page/AuditMaterialDrawer.vue @@ -0,0 +1,598 @@ + + + + + diff --git a/frontend/src/views/modules/audit-page/AuditQueryToolbar.vue b/frontend/src/views/modules/audit-page/AuditQueryToolbar.vue new file mode 100644 index 0000000..fd1dd1f --- /dev/null +++ b/frontend/src/views/modules/audit-page/AuditQueryToolbar.vue @@ -0,0 +1,52 @@ + + + diff --git a/frontend/src/views/modules/audit-page/AuditWriteOffDocsReviewPanel.vue b/frontend/src/views/modules/audit-page/AuditWriteOffDocsReviewPanel.vue new file mode 100644 index 0000000..32586f5 --- /dev/null +++ b/frontend/src/views/modules/audit-page/AuditWriteOffDocsReviewPanel.vue @@ -0,0 +1,367 @@ + + + + + diff --git a/frontend/src/views/modules/meeting-page/MaterialPictureCardFileItem.vue b/frontend/src/views/modules/meeting-page/MaterialPictureCardFileItem.vue new file mode 100644 index 0000000..66f990a --- /dev/null +++ b/frontend/src/views/modules/meeting-page/MaterialPictureCardFileItem.vue @@ -0,0 +1,120 @@ + + + + + diff --git a/frontend/src/views/modules/meeting-page/MeetingAuditProgressDialog.vue b/frontend/src/views/modules/meeting-page/MeetingAuditProgressDialog.vue new file mode 100644 index 0000000..dfd4adc --- /dev/null +++ b/frontend/src/views/modules/meeting-page/MeetingAuditProgressDialog.vue @@ -0,0 +1,62 @@ + + + diff --git a/frontend/src/views/modules/meeting-page/MeetingBindExpertDialog.vue b/frontend/src/views/modules/meeting-page/MeetingBindExpertDialog.vue new file mode 100644 index 0000000..d1bce61 --- /dev/null +++ b/frontend/src/views/modules/meeting-page/MeetingBindExpertDialog.vue @@ -0,0 +1,239 @@ + + + + + diff --git a/frontend/src/views/modules/meeting-page/MeetingCreateExpertBankCardOcrDialog.vue b/frontend/src/views/modules/meeting-page/MeetingCreateExpertBankCardOcrDialog.vue new file mode 100644 index 0000000..1469f1b --- /dev/null +++ b/frontend/src/views/modules/meeting-page/MeetingCreateExpertBankCardOcrDialog.vue @@ -0,0 +1,33 @@ + + + diff --git a/frontend/src/views/modules/meeting-page/MeetingCreateExpertIdOcrDialog.vue b/frontend/src/views/modules/meeting-page/MeetingCreateExpertIdOcrDialog.vue new file mode 100644 index 0000000..8170f86 --- /dev/null +++ b/frontend/src/views/modules/meeting-page/MeetingCreateExpertIdOcrDialog.vue @@ -0,0 +1,43 @@ + + + diff --git a/frontend/src/views/modules/meeting-page/MeetingCreatePlatformExpertDialog.vue b/frontend/src/views/modules/meeting-page/MeetingCreatePlatformExpertDialog.vue new file mode 100644 index 0000000..a022d41 --- /dev/null +++ b/frontend/src/views/modules/meeting-page/MeetingCreatePlatformExpertDialog.vue @@ -0,0 +1,577 @@ + + + + + diff --git a/frontend/src/views/modules/meeting-page/MeetingDetailDrawer.vue b/frontend/src/views/modules/meeting-page/MeetingDetailDrawer.vue new file mode 100644 index 0000000..db71d16 --- /dev/null +++ b/frontend/src/views/modules/meeting-page/MeetingDetailDrawer.vue @@ -0,0 +1,57 @@ + + + diff --git a/frontend/src/views/modules/meeting-page/MeetingDocPreviewDialog.vue b/frontend/src/views/modules/meeting-page/MeetingDocPreviewDialog.vue new file mode 100644 index 0000000..2160301 --- /dev/null +++ b/frontend/src/views/modules/meeting-page/MeetingDocPreviewDialog.vue @@ -0,0 +1,61 @@ + + +