This commit is contained in:
haomingming 2026-05-20 18:21:39 +08:00
commit 815aa04fe8
564 changed files with 82601 additions and 0 deletions

40
.gitignore vendored Normal file
View File

@ -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/

94
README.md Normal file
View File

@ -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 <token>`
- 新增 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 上传、模板治理、专家模块。

107
backend/pom.xml Normal file
View File

@ -0,0 +1,107 @@
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.7.18</version>
<relativePath/>
</parent>
<groupId>com.writeoff</groupId>
<artifactId>writeoff-backend</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>writeoff-backend</name>
<description>Meeting Write-off SaaS Backend</description>
<properties>
<java.version>1.8</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<flyway.version>7.15.0</flyway.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-mail</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
</dependency>
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-ui</artifactId>
<version>1.7.0</version>
</dependency>
<dependency>
<groupId>com.aliyun.oss</groupId>
<artifactId>aliyun-sdk-oss</artifactId>
<version>3.17.4</version>
</dependency>
<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi-ooxml</artifactId>
<version>5.2.5</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.11.5</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>

View File

@ -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);
}
}

View File

@ -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<String, String> errors;
private String requestId;
private String timestamp;
public ApiErrorResponse(int code, String message, Map<String, String> 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<String, String> errors) {
return new ApiErrorResponse(code, message, errors, RequestIdContext.get(), Instant.now().toString());
}
public int getCode() {
return code;
}
public String getMessage() {
return message;
}
public Map<String, String> getErrors() {
return errors;
}
public String getRequestId() {
return requestId;
}
public String getTimestamp() {
return timestamp;
}
}

View File

@ -0,0 +1,44 @@
package com.writeoff.common.api;
import com.writeoff.common.web.RequestIdContext;
import java.time.Instant;
public class ApiResponse<T> {
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 <T> ApiResponse<T> 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;
}
}

View File

@ -0,0 +1,33 @@
package com.writeoff.common.api;
import java.util.List;
public class PageResult<T> {
private List<T> list;
private long total;
private int pageNo;
private int pageSize;
public PageResult(List<T> list, long total, int pageNo, int pageSize) {
this.list = list;
this.total = total;
this.pageNo = pageNo;
this.pageSize = pageSize;
}
public List<T> getList() {
return list;
}
public long getTotal() {
return total;
}
public int getPageNo() {
return pageNo;
}
public int getPageSize() {
return pageSize;
}
}

View File

@ -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;
}
}

View File

@ -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;
}

View File

@ -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<ApiErrorResponse> 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.<String, String>emptyMap()));
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiErrorResponse> handleValidation(MethodArgumentNotValidException ex) {
Map<String, String> 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<ApiErrorResponse> handleConstraint(ConstraintViolationException ex) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(ApiErrorResponse.of(10001, "参数校验失败", Collections.singletonMap("message", ex.getMessage())));
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiErrorResponse> handleUnknown(Exception ex) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiErrorResponse.of(90001, "系统内部异常", Collections.singletonMap("message", ex.getMessage())));
}
}

View File

@ -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<ImportRowError> errors = new ArrayList<ImportRowError>();
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<ImportRowError> getErrors() {
return errors;
}
public void setErrors(List<ImportRowError> errors) {
this.errors = errors == null ? new ArrayList<ImportRowError>() : 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;
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -0,0 +1,21 @@
package com.writeoff.common.web;
public final class RequestIdContext {
private static final ThreadLocal<String> HOLDER = new ThreadLocal<String>();
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();
}
}

View File

@ -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/**");
}
}

View File

@ -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<PageResult<AuditTask>> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> slaStat() {
return ApiResponse.success(auditService.slaStat());
}
@PostMapping("/tasks/batch-approve")
@RequirePermission(value = "audit.approve", dataScope = DataScopeType.TENANT, auditAction = "AUDIT_BATCH_APPROVE")
public ApiResponse<Map<String, Object>> 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<Map<String, Object>> batchReject(@RequestBody @Valid BatchAuditActionRequest request) {
return ApiResponse.success(auditService.batchReject(request));
}
}

View File

@ -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<PageResult<AuditFlowInfo>> 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<AuditFlowInfo> 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<AuditFlowInfo> 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<AuditFlowInfo> 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<String> 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<String> 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<String> 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<String> delete(@PathVariable("id") Long id) {
auditFlowManageService.softDelete(id);
return ApiResponse.success("OK");
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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<Long> taskIds;
@NotBlank(message = "审核意见不能为空")
private String opinion;
public String getIdempotencyKey() {
return idempotencyKey;
}
public void setIdempotencyKey(String idempotencyKey) {
this.idempotencyKey = idempotencyKey;
}
public List<Long> getTaskIds() {
return taskIds;
}
public void setTaskIds(List<Long> taskIds) {
this.taskIds = taskIds;
}
public String getOpinion() {
return opinion;
}
public void setOpinion(String opinion) {
this.opinion = opinion;
}
}

View File

@ -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<Long> taskIds;
public String getIdempotencyKey() {
return idempotencyKey;
}
public void setIdempotencyKey(String idempotencyKey) {
this.idempotencyKey = idempotencyKey;
}
public List<Long> getTaskIds() {
return taskIds;
}
public void setTaskIds(List<Long> taskIds) {
this.taskIds = taskIds;
}
}

View File

@ -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<AuditFlowNodeRequest> 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<AuditFlowNodeRequest> getNodes() {
return nodes;
}
public void setNodes(List<AuditFlowNodeRequest> nodes) {
this.nodes = nodes;
}
}

View File

@ -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;
}
}

View File

@ -0,0 +1,4 @@
package com.writeoff.module.audit.dto;
public class UpdateAuditFlowRequest extends CreateAuditFlowRequest {
}

View File

@ -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<AuditFlowNodeInfo> nodes;
public AuditFlowInfo(Long id, String flowCode, String flowName, String status, Boolean isDefault, String effectiveStartAt, String effectiveEndAt, List<AuditFlowNodeInfo> 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<AuditFlowNodeInfo> getNodes() {
return nodes;
}
}

View File

@ -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;
}
}

View File

@ -0,0 +1,7 @@
package com.writeoff.module.audit.model;
public enum AuditNode {
INIT_REVIEW,
RE_REVIEW,
FINAL_REVIEW
}

View File

@ -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<AuditFlowNodeInfo> 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<AuditFlowNodeInfo> 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<AuditFlowNodeInfo> flowNodes) {
this.flowNodes = flowNodes;
}
}

View File

@ -0,0 +1,7 @@
package com.writeoff.module.audit.model;
public enum AuditTaskStatus {
PENDING,
APPROVED,
REJECTED
}

View File

@ -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<AuditTask> findById(Long id);
List<AuditTask> findAll();
Optional<AuditTask> findLatestByMeetingId(Long meetingId);
int withdrawPendingByMeetingId(Long meetingId, String reason, Long operatorUserId);
void transfer(Long taskId, Long toUserId, String reason, Long operatorUserId);
int batchRemind(List<Long> taskIds, Long operatorUserId);
Map<String, Object> slaStat();
}

View File

@ -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<Long, AuditTask> 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<AuditTask> findById(Long id) {
return Optional.ofNullable(store.get(id));
}
@Override
public List<AuditTask> findAll() {
return new ArrayList<>(store.values());
}
@Override
public Optional<AuditTask> 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<Long> toRemove = new ArrayList<Long>();
for (Map.Entry<Long, AuditTask> 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<Long> taskIds, Long operatorUserId) {
int count = 0;
for (Long taskId : taskIds) {
if (store.containsKey(taskId)) {
count++;
}
}
return count;
}
@Override
public Map<String, Object> slaStat() {
Map<String, Object> data = new LinkedHashMap<>();
data.put("pendingTotal", store.size());
data.put("timeout4h", 0);
data.put("timeout12h", 0);
data.put("timeout24h", 0);
return data;
}
}

View File

@ -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<AuditTask> 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<AuditTask> findById(Long id) {
List<AuditTask> 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<AuditTask> 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<AuditTask> findLatestByMeetingId(Long meetingId) {
List<AuditTask> 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<Long> 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<String, Object> 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<String, Object> 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();
}
}

View File

@ -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<AuditNode> getEnabledNodes(Long tenantId) {
List<String> 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<AuditNode> 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<AuditNode> 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<Map<String, Object>> 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<Long> 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;
}
}

View File

@ -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<AuditFlowNodeInfo> 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<AuditFlowInfo> 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<AuditFlowInfo> 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<AuditFlowNodeRequest> 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<AuditFlowInfo> 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<AuditFlowNodeInfo> 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<AuditFlowNodeRequest> 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();
}
}

View File

@ -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<String, Long> 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<AuditTask> listTasks(boolean mine) {
List<AuditTask> filtered = filterTasks(mine, null, null);
return new PageResult<>(filtered, filtered.size(), 1, filtered.size() == 0 ? 20 : filtered.size());
}
public PageResult<AuditTask> listTasks(boolean mine, String scope) {
List<AuditTask> filtered = filterTasks(mine, scope, null);
return new PageResult<>(filtered, filtered.size(), 1, filtered.size() == 0 ? 20 : filtered.size());
}
public PageResult<AuditTask> listTasks(
boolean mine,
String scope,
Long meetingId,
Integer pageNo,
Integer pageSize,
String sortBy,
String order
) {
List<AuditTask> 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<AuditTask> filterTasks(boolean mine, String scope, Long meetingId) {
List<AuditTask> list = auditTaskRepository.findAll();
if (dataPermissionService != null) {
DataPermissionService.DataScope dataScope = dataPermissionService.resolveCurrentUserScope();
Set<Long> meetingIds = list.stream().map(AuditTask::getMeetingId).collect(Collectors.toCollection(HashSet::new));
Map<Long, Long> meetingCreatorMap = dataPermissionService.listMeetingCreators(meetingIds);
Map<Long, Long> meetingProjectMap = dataPermissionService.listMeetingProjectIds(meetingIds);
Set<Long> projectIds = new HashSet<>(meetingProjectMap.values());
Map<Long, Long> 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<AuditTask> list) {
if (list == null || list.isEmpty() || jdbcTemplate == null) {
return;
}
Set<Long> 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<Object> 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<Map<String, Object>> rows = jdbcTemplate.queryForList(sql, args.toArray());
Map<Long, String> userNameMap = new HashMap<>();
for (Map<String, Object> 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<AuditTask> list) {
if (list == null || list.isEmpty()) {
return;
}
List<AuditFlowNodeInfo> nodes = buildFlowNodes();
if (nodes.isEmpty()) {
return;
}
for (AuditTask task : list) {
task.setFlowNodes(nodes);
}
}
private List<AuditFlowNodeInfo> buildFlowNodes() {
List<AuditNode> 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<AuditFlowNodeInfo> 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<AuditTask> 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<AuditTask> 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<AuditTask> listTasks() {
return listTasks(false);
}
public Map<String, Object> 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<String, Object> result = new LinkedHashMap<>();
result.put("taskId", task.getId());
result.put("nodeStatus", task.getStatus().name());
return result;
}
public Map<String, Object> 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<String, Object> result = new LinkedHashMap<>();
result.put("taskId", task.getId());
result.put("nodeStatus", task.getStatus().name());
return result;
}
public Map<String, Object> 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<String, Object> result = new LinkedHashMap<>();
result.put("taskId", task.getId());
result.put("nodeStatus", "RETURNED");
return result;
}
public Map<String, Object> exportOpinions() {
if (dataPermissionService != null && !dataPermissionService.canExportCurrentUser()) {
throw new BusinessException(20001, "当前账号无导出权限");
}
List<AuditTask> list = listTasks(false).getList();
Map<String, Object> data = new LinkedHashMap<>();
data.put("total", list.size());
data.put("records", list);
return data;
}
public Map<String, Object> 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<String, Object> 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<String, Object> emptyBasicInfoExpertsPayload() {
Map<String, Object> 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<Long> parseExpertIdListFromJson(Object raw) {
List<Long> 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<Map<String, Object>> toExpertDisplayRows(List<Long> orderedIds, Map<Long, Map<String, Object>> displayMap) {
List<Map<String, Object>> rows = new ArrayList<>();
for (Long id : orderedIds) {
if (id == null || id <= 0) {
continue;
}
Map<String, Object> row = new LinkedHashMap<>();
row.put("expertId", id);
Map<String, Object> 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<String, Object> 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<String, Object> parsed = objectMapper.readValue(material.getContentJson(), LinkedHashMap.class);
List<Long> chairman = parseExpertIdListFromJson(parsed.get("chairmanExpertIds"));
List<Long> speaker = parseExpertIdListFromJson(parsed.get("speakerExpertIds"));
List<Long> host = parseExpertIdListFromJson(parsed.get("hostExpertIds"));
List<Long> discussionGuest = parseExpertIdListFromJson(parsed.get("discussionGuestExpertIds"));
Set<Long> all = new HashSet<>();
all.addAll(chairman);
all.addAll(speaker);
all.addAll(host);
all.addAll(discussionGuest);
Map<Long, Map<String, Object>> displayMap = expertService == null
? Collections.emptyMap()
: expertService.mapExpertDisplayByIds(all);
Map<String, Object> 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<String, Object> approveMaterialModule(Long taskId, AuditMaterialModuleApproveRequest request) {
checkIdempotencyOnly(request.getIdempotencyKey(), "material-module-approve");
AuditTask task = requirePendingTask(taskId);
if (meetingMaterialService == null) {
throw new BusinessException(10001, "资料服务不可用");
}
List<Map<String, Object>> items = meetingMaterialService.listMaterialReviewItems(task.getMeetingId(), request.getModuleCode());
// 查出当前已有 REJECTED 状态的 itemKey批量通过时跳过它们避免覆盖单项不通过结果
List<Map<String, Object>> existingReviews = meetingMaterialService.listMaterialItemReviews(
taskId, task.getNode().name(), request.getModuleCode());
Set<String> rejectedKeys = new HashSet<>();
for (Map<String, Object> 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<Map<String, Object>> approveItems = new ArrayList<>();
for (Map<String, Object> 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<String, Object> 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<String, Object> rejectMaterialItem(Long taskId, AuditMaterialItemRejectRequest request) {
checkIdempotencyOnly(request.getIdempotencyKey(), "material-item-reject");
AuditTask task = requirePendingTask(taskId);
if (meetingMaterialService == null) {
throw new BusinessException(10001, "资料服务不可用");
}
Map<String, Object> 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<String, Object> 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<String, Object> transfer(Long taskId, TransferAuditTaskRequest request) {
AuditTask task = getPendingTask(taskId, request.getIdempotencyKey());
Long operatorUserId = AuthContext.userId();
auditTaskRepository.transfer(taskId, request.getToUserId(), request.getReason(), operatorUserId);
Map<String, Object> 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<String, Object> batchRemind(BatchRemindRequest request) {
checkIdempotencyOnly(request.getIdempotencyKey(), "batch-remind");
List<Long> 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<String, Object> data = new LinkedHashMap<>();
data.put("taskCount", taskIds.size());
data.put("acceptedCount", count);
return data;
}
public Map<String, Object> slaStat() {
return auditTaskRepository.slaStat();
}
public Map<String, Object> batchApprove(BatchAuditActionRequest request) {
checkIdempotencyOnly(request.getIdempotencyKey(), "batch-approve");
List<Long> taskIds = request.getTaskIds();
int successCount = 0;
List<Long> failedTaskIds = new ArrayList<>();
List<String> 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<String, Object> 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<String, Object> batchReject(BatchAuditActionRequest request) {
checkIdempotencyOnly(request.getIdempotencyKey(), "batch-reject");
List<Long> taskIds = request.getTaskIds();
int successCount = 0;
List<Long> failedTaskIds = new ArrayList<>();
List<String> 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<String, Object> 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<String, Object> vars = new LinkedHashMap<String, Object>();
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);
}
}
}

View File

@ -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<Map<String, String>> 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<String, String> 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<String, String> errors = new LinkedHashMap<>();
errors.put("lockRemainingSeconds", String.valueOf(remaining));
return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY)
.body(ApiErrorResponse.of(11005, "账号已被锁定,请" + (remaining / 60 + 1) + "分钟后重试", errors));
}
List<Map<String, Object>> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, String> 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<String, String> errors = new LinkedHashMap<>();
errors.put("lockRemainingSeconds", String.valueOf(remaining));
return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY)
.body(ApiErrorResponse.of(11005, "账号已被锁定,请" + (remaining / 60 + 1) + "分钟后重试", errors));
}
List<Map<String, Object>> 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<String, Object> 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<String, Object> 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<String, Object> data = buildPlatformAuthData(userId, String.valueOf(row.get("user_name")), request.getPhone(), token);
return ResponseEntity.ok(ApiResponse.success(data));
}
@GetMapping("/password-setup/verify")
public ApiResponse<Map<String, Object>> verifyPasswordSetup(@RequestParam("tenantCode") String tenantCode,
@RequestParam("token") String token) {
return ApiResponse.success(passwordSetupService.verifyTenantPasswordSetupToken(tenantCode, token));
}
@PostMapping("/password-setup/complete")
public ApiResponse<Map<String, Object>> completePasswordSetup(@RequestBody @Valid PasswordSetupCompleteRequest request) {
return ApiResponse.success(passwordSetupService.completeTenantPasswordSetup(
request.getTenantCode(),
request.getToken(),
request.getNewPassword()
));
}
/**
* 构建登录失败响应含剩余尝试次数结构化数据
*/
private ResponseEntity<ApiErrorResponse> buildLoginFailResponse(String lockKey) {
LoginAttemptService.LoginAttemptStatus status = loginAttemptService.recordFailure(lockKey);
int remaining = status.getRemainingAttempts();
Map<String, String> 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<Map<String, Object>> refresh(HttpServletRequest request, HttpServletResponse response) {
String currentRefreshToken = getCookieValue(request, refreshCookieName);
Map<String, Object> 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<String, Object> 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<List<TenantSwitchOption>> 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<Map<String, Object>> switchTenant(@RequestBody @Valid SwitchTenantRequest request,
HttpServletRequest httpRequest,
HttpServletResponse httpResponse) {
Long currentUserId = AuthContext.userId();
Long currentTenantId = AuthContext.requireTenantId();
validateTenantSession(currentUserId, currentTenantId);
Map<String, Object> currentIdentity = loadCurrentTenantIdentity(currentUserId, currentTenantId);
Long targetTenantId = request.getTenantId();
if (targetTenantId == null) {
throw new BusinessException(10001, "\u76ee\u6807\u79df\u6237\u4e0d\u80fd\u4e3a\u7a7a");
}
Map<String, Object> 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<String, Object> 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<Map<String, Object>> logout(HttpServletRequest request, HttpServletResponse response) {
String token = getCookieValue(request, refreshCookieName);
refreshTokenService.revokeCurrent(token, "LOGOUT");
setRefreshCookie(response, "", true);
Map<String, Object> data = new LinkedHashMap<String, Object>();
data.put("ok", Boolean.TRUE);
return ApiResponse.success(data);
}
@PostMapping("/logout-all")
public ApiResponse<Map<String, Object>> logoutAll(HttpServletRequest request, HttpServletResponse response) {
String currentRefreshToken = getCookieValue(request, refreshCookieName);
Map<String, Object> 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<String, Object> data = new LinkedHashMap<String, Object>();
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<String, Object> loadTenantInfo(Long tenantId) {
List<Map<String, Object>> 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<String, Object>();
}
Map<String, Object> r = tenantRows.get(0);
Map<String, Object> tenant = new LinkedHashMap<String, Object>();
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<String, Object> buildTenantAuthData(Long userId, Long tenantId, String userName, String phone, String token) {
Map<String, Object> tenant = loadTenantInfo(tenantId);
Map<String, Object> data = new LinkedHashMap<String, Object>();
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<String, Object> buildPlatformAuthData(Long userId, String userName, String phone, String token) {
Map<String, Object> data = new LinkedHashMap<String, Object>();
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<Map<String, Object>> 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<String, Object> 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<Map<String, Object>> 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<String, Object> 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<Map<String, Object>> 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<String, Object> 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<Map<String, Object>> 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<String, Object> 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<TenantSwitchOption> loadSwitchableTenants(Long currentUserId, Long currentTenantId) {
Map<String, Object> 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<String, Object> loadCurrentTenantIdentity(Long userId, Long tenantId) {
List<Map<String, Object>> 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<String, Object> loadSwitchTarget(Long tenantId, String switchAccountKey, String phone, String passwordHash) {
String normalizedSwitchAccountKey = normalizeTenantSwitchAccountKey(switchAccountKey);
if (!normalizedSwitchAccountKey.isEmpty()) {
List<Map<String, Object>> 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<Map<String, Object>> 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());
}
}

View File

@ -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<Map<String, String>> getCaptcha() {
return ApiResponse.success(captchaService.generate());
}
}

View File

@ -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<PlatformAuthSessionInfo>> 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<Map<String, Object>> 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<Map<String, Object>> revokePrincipal(@RequestBody @Valid PlatformSessionRevokePrincipalRequest request) {
return ApiResponse.success(
platformAuthSessionService.revokeByPrincipal(
request.getUserId(),
request.getTenantId(),
request.getScope()
)
);
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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<PlatformAuthSessionInfo> list(String scope, String status, Long userId, Long tenantId) {
StringBuilder sql = new StringBuilder();
List<Object> args = new ArrayList<Object>();
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<Map<String, Object>> rows = jdbcTemplate.queryForList(sql.toString(), args.toArray());
List<PlatformAuthSessionInfo> list = new ArrayList<PlatformAuthSessionInfo>();
for (Map<String, Object> 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<String, Object> revokeBySessionId(Long sessionId) {
List<Map<String, Object>> 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<String, Object> data = new LinkedHashMap<String, Object>();
data.put("sessionId", sessionId);
data.put("ok", Boolean.TRUE);
return data;
}
@Transactional(rollbackFor = Exception.class)
public Map<String, Object> 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<String, Object> data = new LinkedHashMap<String, Object>();
data.put("affected", affected);
data.put("ok", Boolean.TRUE);
return data;
}
private String str(Object value) {
return value == null ? "" : String.valueOf(value);
}
}

View File

@ -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<String, Object> 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<String, Object> data = new LinkedHashMap<String, Object>();
data.put("sessionId", sessionId);
data.put("refreshToken", rawToken);
return data;
}
@Transactional(rollbackFor = Exception.class)
public Map<String, Object> 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<Map<String, Object>> 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<String, Object> 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<String, Object> data = new LinkedHashMap<String, Object>();
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);
}
}

View File

@ -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<Map<String, Object>> summary() {
return ApiResponse.success(operationsDashboardService.summary());
}
}

View File

@ -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<Map<String, Object>> stats() {
Long tenantId = AuthContext.requireTenantId();
Map<String, Object> 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);
}
}

View File

@ -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<String, Object> summary() {
Map<String, Object> data = new LinkedHashMap<String, Object>();
data.put("notification", notificationSummary());
data.put("export", exportSummary());
data.put("alert", alertSummary());
data.put("trends", trends());
data.put("tops", tops());
return data;
}
private Map<String, Object> notificationSummary() {
Map<String, Object> m = new LinkedHashMap<String, Object>();
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<String, Object> exportSummary() {
Map<String, Object> m = new LinkedHashMap<String, Object>();
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<String, Object> alertSummary() {
Map<String, Object> m = new LinkedHashMap<String, Object>();
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<String, Object> trends() {
Map<String, Object> data = new LinkedHashMap<String, Object>();
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<String, Object> tops() {
Map<String, Object> data = new LinkedHashMap<String, Object>();
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<Map<String, Object>> trendRows(String sql) {
List<Map<String, Object>> rows = jdbcTemplate.queryForList(sql, tenantId());
List<Map<String, Object>> out = new ArrayList<Map<String, Object>>();
for (Map<String, Object> row : rows) {
Map<String, Object> item = new LinkedHashMap<String, Object>();
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();
}
}

View File

@ -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<PageResult<ExpertInfo>> 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<ExpertInfo> get(@PathVariable("id") Long id) {
return ApiResponse.success(expertService.get(id));
}
@PostMapping
@RequirePermission(value = "expert.create", dataScope = DataScopeType.TENANT, auditAction = "EXPERT_CREATE")
public ApiResponse<ExpertInfo> 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<Map<String, Object>> 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<ExpertInfo> 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<ImportResult> 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<Map<String, Object>> exportExperts() {
return ApiResponse.success(expertService.export());
}
@PostMapping("/{id}/merge")
@RequirePermission(value = "expert.merge", dataScope = DataScopeType.TENANT, auditAction = "EXPERT_MERGE")
public ApiResponse<Map<String, Object>> 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<List<ExpertBankCardInfo>> 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<ExpertBankCardInfo> 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<ExpertBankCardInfo> 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<ExpertBankCardInfo> updateCard(@PathVariable("id") Long id, @PathVariable("cardId") Long cardId, @RequestBody @Valid AddBankCardRequest request) {
return ApiResponse.success(expertService.updateCard(id, cardId, request));
}
}

View File

@ -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<PageResult<ExpertInfo>> 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<ExpertInfo> 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<ExpertInfo> 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<Map<String, Object>> 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<ExpertInfo> 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<ImportResult> 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<Map<String, Object>> 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<Map<String, Object>> 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<List<ExpertBankCardInfo>> 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<ExpertBankCardInfo> 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<ExpertBankCardInfo> 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<ExpertBankCardInfo> updateCard(@PathVariable("id") Long id, @PathVariable("cardId") Long cardId, @RequestBody @Valid AddBankCardRequest request) {
return ApiResponse.success(platformExpertService.updateCard(id, cardId, request));
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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<CreateExpertRequest> experts;
public List<CreateExpertRequest> getExperts() {
return experts;
}
public void setExperts(List<CreateExpertRequest> experts) {
this.experts = experts;
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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<ExpertInfo> 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<ExpertBankCardInfo> 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<String, Object> 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<String, Object> data = new LinkedHashMap<String, Object>();
data.put("objectKey", objectKey);
data.put("uploadUrl", uploadUrl);
data.put("contentType", normalizedType);
data.put("method", "PUT");
return data;
}
public PageResult<ExpertInfo> 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<Object> 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<ExpertInfo> list = jdbcTemplate.query(sql.toString(), EXPERT_ROW_MAPPER);
list = filterByExpertScope(list);
List<ExpertInfo> maskedList = new java.util.ArrayList<ExpertInfo>(list.size());
for (ExpertInfo item : list) {
maskedList.add(maskSensitiveFields(item));
}
return new PageResult<ExpertInfo>(maskedList, totalCount, safePage, safeSize);
}
public ExpertInfo get(Long id) {
ExpertInfo expert = findById(id);
assertExpertAccessible(expert.getId());
return expert;
}
/**
* 按专家主键批量查询姓名与医院展示名字典名优先否则 organization用于会议资料审核等只读展示
*/
public Map<Long, Map<String, Object>> mapExpertDisplayByIds(Collection<Long> expertIds) {
if (expertIds == null || expertIds.isEmpty()) {
return new LinkedHashMap<>();
}
List<Long> 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<Object> args = new ArrayList<>();
args.add(PLATFORM_TENANT_ID);
args.addAll(idList);
return jdbcTemplate.query(sql, rs -> {
Map<Long, Map<String, Object>> m = new LinkedHashMap<>();
while (rs.next()) {
long id = rs.getLong("id");
Map<String, Object> 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<CreateExpertRequest> experts) {
ImportResult result = new ImportResult();
result.setTotal(experts == null ? 0 : experts.size());
if (experts == null) {
return result;
}
Set<String> batchIdNos = new HashSet<String>();
Set<String> batchPhones = new HashSet<String>();
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<ExpertBankCardInfo> 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<ExpertBankCardInfo> 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<String, Object> 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<ExpertBankCardInfo> 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<String, Object> data = new LinkedHashMap<String, Object>();
data.put("targetExpertId", targetExpertId);
data.put("sourceExpertId", request.getSourceExpertId());
data.put("status", "MERGED");
return data;
}
public Map<String, Object> export() {
List<ExpertInfo> list = list(null, 1, 10000).getList();
Map<String, Object> data = new LinkedHashMap<String, Object>();
data.put("total", list.size());
data.put("records", list);
return data;
}
private List<ExpertInfo> filterByExpertScope(List<ExpertInfo> source) {
DataPermissionService.DataScope scope = dataPermissionService.resolveCurrentUserScope();
if (scope.isExpertAll()) {
return source;
}
Set<Long> expertIds = new HashSet<Long>();
for (ExpertInfo item : source) {
if (item != null && item.getId() != null) {
expertIds.add(item.getId());
}
}
Map<Long, Long> creatorMap = dataPermissionService.listExpertCreators(expertIds);
List<ExpertInfo> filtered = new java.util.ArrayList<ExpertInfo>();
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<Long, Long> 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<ExpertInfo> 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<ExpertBankCardInfo> 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<DictionaryItem> 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<DictionaryItem> 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<String> batchIdNos, Set<String> 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;
}
}
}

View File

@ -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<String> 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<Map<String, Object>> profiles = new java.util.ArrayList<Map<String, Object>>();
try {
Map<String, Object> root = objectMapper.readValue(list.get(0), new TypeReference<Map<String, Object>>() {});
Object profileObj = root.get("profiles");
if (!(profileObj instanceof List)) {
return 0;
}
List<?> rawProfiles = (List<?>) profileObj;
for (Object item : rawProfiles) {
if (item instanceof Map) {
Map<String, Object> profile = new LinkedHashMap<String, Object>();
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<String, Object> profile : profiles) {
Long expertId = resolveExpertId(profile);
if (expertId == null) {
continue;
}
String snapshotJson;
try {
Map<String, Object> snapshot = new LinkedHashMap<String, Object>(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<String, Object> 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<Long> 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();
}
}

View File

@ -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<ExpertInfo> 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<ExpertBankCardInfo> 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<String, Object> 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<String, Object> data = new LinkedHashMap<String, Object>();
data.put("objectKey", objectKey);
data.put("uploadUrl", uploadUrl);
data.put("contentType", normalizedType);
data.put("method", "PUT");
return data;
}
public PageResult<ExpertInfo> 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<Object> params = new ArrayList<Object>();
if (idNoKeyword != null) {
sql.append(" AND e.id_no = ?");
params.add(idNoKeyword);
}
sql.append(" ORDER BY e.id DESC LIMIT 200");
List<ExpertInfo> list = params.isEmpty()
? jdbcTemplate.query(sql.toString(), EXPERT_ROW_MAPPER)
: jdbcTemplate.query(sql.toString(), EXPERT_ROW_MAPPER, params.toArray());
List<ExpertInfo> maskedList = new ArrayList<ExpertInfo>(list.size());
for (ExpertInfo item : list) {
maskedList.add(maskSensitiveFields(item));
}
return new PageResult<ExpertInfo>(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<ExpertInfo> 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<CreateExpertRequest> experts) {
ImportResult result = new ImportResult();
result.setTotal(experts == null ? 0 : experts.size());
if (experts == null) {
return result;
}
Set<String> batchIdNos = new HashSet<String>();
Set<String> batchPhones = new HashSet<String>();
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<ExpertBankCardInfo> 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<ExpertBankCardInfo> 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<ExpertBankCardInfo> 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<String, Object> merge(Long targetExpertId, MergeExpertRequest request) {
assertExpertExists(targetExpertId);
assertExpertExists(request.getSourceExpertId());
if (targetExpertId.equals(request.getSourceExpertId())) {
throw new BusinessException(10001, "来源专家不能与目标专家相同");
}
List<ExpertBankCardInfo> 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<String, Object> data = new LinkedHashMap<String, Object>();
data.put("targetExpertId", targetExpertId);
data.put("sourceExpertId", request.getSourceExpertId());
data.put("status", "MERGED");
return data;
}
public Map<String, Object> export() {
List<ExpertInfo> list = list(null).getList();
Map<String, Object> data = new LinkedHashMap<String, Object>();
data.put("total", list.size());
data.put("records", list);
return data;
}
private ExpertInfo findById(Long id) {
List<ExpertInfo> 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<ExpertBankCardInfo> 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<DictionaryItem> 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<DictionaryItem> 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<String> batchIdNos, Set<String> 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;
}
}
}

View File

@ -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<PageResult<ExportTaskInfo>> list() {
return ApiResponse.success(exportTaskService.list());
}
@PostMapping
@RequirePermission(value = "export.task.manage", dataScope = DataScopeType.TENANT, auditAction = "EXPORT_TASK_CREATE")
public ApiResponse<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> download(@PathVariable("id") Long id,
@RequestParam("token") String token) {
return ApiResponse.success(exportTaskService.download(id, token));
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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<ExportTaskInfo> 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<ExportTaskInfo> list() {
List<ExportTaskInfo> 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<ExportTaskInfo>(list, list.size(), 1, 300);
}
@Transactional(rollbackFor = Exception.class)
public Map<String, Object> 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<String, Object> payload = new LinkedHashMap<String, Object>();
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<String, Object> result = new LinkedHashMap<String, Object>();
result.put("taskId", id);
result.put("status", "PENDING");
return result;
}
public void processTask(String payload) {
Long taskId = parseTaskId(payload);
Map<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> refreshDownloadToken(Long taskId) {
Map<String, Object> 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<String, Object> data = new LinkedHashMap<String, Object>();
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<String, Object> download(Long taskId, String token) {
if (token == null || token.trim().isEmpty()) {
throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "下载令牌不能为空");
}
Map<String, Object> 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<String, Object> data = new LinkedHashMap<String, Object>();
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<String, Object> map = objectMapper.readValue(payload, new TypeReference<Map<String, Object>>() {});
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<String, Object> findTask(Long taskId) {
List<Map<String, Object>> 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<String, Object> findTaskDetail(Long taskId) {
List<Map<String, Object>> 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<String, Object> findTaskWithOwner(Long taskId) {
List<Map<String, Object>> 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<String, Object> 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<String, Object> task) {
String taskCode = String.valueOf(task.get("task_code"));
Map<String, Object> 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<String, Object> parseJsonMap(Object raw) {
if (raw == null) {
return new LinkedHashMap<String, Object>();
}
String text = String.valueOf(raw).trim();
if (text.isEmpty()) {
return new LinkedHashMap<String, Object>();
}
try {
return objectMapper.readValue(text, new TypeReference<Map<String, Object>>() {});
} catch (Exception ex) {
throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "导出筛选条件格式不正确");
}
}
private String buildMeetingCsv(Long tenantId, Map<String, Object> 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<Object> args = new ArrayList<Object>();
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<Map<String, Object>> 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.<List<Object>>toList())
);
}
private String buildUserCsv(Long tenantId, Map<String, Object> 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<Object> args = new ArrayList<Object>();
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<Map<String, Object>> 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.<List<Object>>toList())
);
}
private String buildProjectCsv(Long tenantId, Map<String, Object> 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<Object> args = new ArrayList<Object>();
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<Map<String, Object>> 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.<List<Object>>toList())
);
}
private String buildNotificationTaskCsv(Long tenantId, Map<String, Object> 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<Object> args = new ArrayList<Object>();
args.add(tenantId);
appendDeletedFilter(sql, args, "is_deleted", filters);
sql.append(" ORDER BY id DESC");
List<Map<String, Object>> 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.<List<Object>>toList())
);
}
private void appendDeletedFilter(StringBuilder sql, List<Object> args, String column, Map<String, Object> 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<Object> 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<Object> 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<String> headers, List<List<Object>> rows) {
StringBuilder builder = new StringBuilder();
builder.append('\uFEFF');
builder.append(headers.stream().map(this::escapeCsv).collect(Collectors.joining(","))).append("\r\n");
for (List<Object> 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;
}
}
}

View File

@ -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<Map<String, Object>> 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<String, Object> result = new LinkedHashMap<>();
result.put("objectKey", objectKey);
result.put("signedUrl", signedUrl);
return ApiResponse.success(result);
}
}

View File

@ -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;
}
}
}

View File

@ -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<PageResult<Payment>> projects() {
return ApiResponse.success(financeService.listProjects());
}
@PostMapping("/payments")
@RequirePermission(value = "finance.payment.confirm", dataScope = DataScopeType.PROJECT, auditAction = "FINANCE_PAYMENT_CONFIRM")
public ApiResponse<Map<String, Object>> 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<Map<String, Object>> exportLedger() {
return ApiResponse.success(financeService.exportLedger());
}
@PostMapping("/reconciliation")
@RequirePermission(value = "finance.reconciliation", dataScope = DataScopeType.PROJECT, auditAction = "FINANCE_RECONCILIATION")
public ApiResponse<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<PageResult<FinanceMeetingBillInfo>> 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<FinanceMeetingBillInfo> upsertMeetingBill(@RequestBody @Valid UpsertFinanceMeetingBillRequest request) {
return ApiResponse.success(financeService.upsertMeetingBill(request));
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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; }
}

View File

@ -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; }
}

View File

@ -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;
}
}

View File

@ -0,0 +1,8 @@
package com.writeoff.module.finance.model;
public enum PaymentStatus {
SUBMITTED,
CONFIRMED,
PARTIAL,
SETTLED
}

View File

@ -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<Long, Payment> store = new ConcurrentHashMap<>();
private final ConcurrentHashMap<Long, Boolean> projectLockStore = new ConcurrentHashMap<>();
private final List<Map<String, Object>> reconciliationStore = new ArrayList<>();
private final List<Map<String, Object>> 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<Payment> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<Map<String, Object>> listReconciliations() {
return new ArrayList<>(reconciliationStore);
}
@Override
public List<Map<String, Object>> listLockLogs(Long projectId) {
if (projectId == null) {
return new ArrayList<>(lockLogStore);
}
List<Map<String, Object>> list = new ArrayList<>();
for (Map<String, Object> row : lockLogStore) {
if (projectId.equals(row.get("projectId"))) {
list.add(row);
}
}
return list;
}
}

View File

@ -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<Payment> 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<Payment> 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<String, Object> 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<String, Object> 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<Map<String, Object>> 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<String, Object> 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<Map<String, Object>> 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<String, Object> 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();
}
}

View File

@ -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<Payment> findAll();
boolean isProjectLocked(Long projectId);
void lockProject(Long projectId, String reason, Long operatorUserId);
void unlockProject(Long projectId, String reason, Long operatorUserId);
Map<String, Object> createReconciliation(Long projectId, Long expectedAmountCent, Long operatorUserId);
List<Map<String, Object>> listReconciliations();
List<Map<String, Object>> listLockLogs(Long projectId);
}

View File

@ -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<String, Long> paymentIdempotency = new ConcurrentHashMap<>();
private static final RowMapper<FinanceMeetingBillInfo> 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<Payment> listProjects() {
List<Payment> list = paymentRepository.findAll();
if (dataPermissionService != null) {
DataPermissionService.DataScope scope = dataPermissionService.resolveCurrentUserScope();
Set<Long> meetingIds = list.stream().map(Payment::getMeetingId).collect(Collectors.toCollection(HashSet::new));
Map<Long, Long> meetingCreatorMap = dataPermissionService.listMeetingCreators(meetingIds);
Map<Long, Long> meetingProjectMap = dataPermissionService.listMeetingProjectIds(meetingIds);
Set<Long> projectIds = new HashSet<>(meetingProjectMap.values());
Map<Long, Long> 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<String, Object> 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<String, Object> result = new LinkedHashMap<>();
result.put("paymentId", payment.getId());
result.put("paymentStatus", payment.getStatus().name());
return result;
}
public Map<String, Object> exportLedger() {
if (dataPermissionService != null && !dataPermissionService.canExportCurrentUser()) {
throw new BusinessException(ErrorCodes.NO_PERMISSION, "当前账号无导出权限");
}
List<Payment> list = listProjects().getList();
Map<String, Object> result = new LinkedHashMap<>();
result.put("total", list.size());
result.put("records", list);
return result;
}
public Map<String, Object> reconciliation(FinanceReconciliationRequest request) {
checkIdempotency(request.getIdempotencyKey(), request.getProjectId());
return paymentRepository.createReconciliation(request.getProjectId(), request.getExpectedAmountCent(), 0L);
}
public Map<String, Object> lock(FinanceLockRequest request) {
checkIdempotency(request.getIdempotencyKey(), request.getProjectId());
paymentRepository.lockProject(request.getProjectId(), request.getReason(), 0L);
Map<String, Object> result = new LinkedHashMap<>();
result.put("projectId", request.getProjectId());
result.put("lockStatus", "LOCKED");
return result;
}
public Map<String, Object> unlock(FinanceLockRequest request) {
checkIdempotency(request.getIdempotencyKey(), request.getProjectId());
paymentRepository.unlockProject(request.getProjectId(), request.getReason(), 0L);
Map<String, Object> result = new LinkedHashMap<>();
result.put("projectId", request.getProjectId());
result.put("lockStatus", "UNLOCKED");
return result;
}
public Map<String, Object> reconciliationList(Long projectId) {
Map<String, Object> result = new LinkedHashMap<>();
result.put("reconciliation", paymentRepository.listReconciliations());
result.put("lockLogs", paymentRepository.listLockLogs(projectId));
return result;
}
public PageResult<FinanceMeetingBillInfo> listMeetingBills(Long projectId) {
List<FinanceMeetingBillInfo> 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<FinanceMeetingBillInfo> 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;
}
}

View File

@ -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<PageResult<Meeting>> 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<PageResult<ExpertInfo>> 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<ExpertInfo> 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<ExpertBankCardInfo> 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<List<MeetingExpertBinding>> 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<List<MeetingExpertBinding>> 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<List<MeetingExpertBinding>> 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<DocumentExtractTaskSubmitResponse> 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<MeetingLaborAgreementExtractResult> 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<Map<String, Object>> 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<Meeting> 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<Meeting> 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<List<Map<String, Object>>> 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<Meeting> 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<Meeting> 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<Map<String, Object>> 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<Map<String, Object>> 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<String> 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<Map<String, Object>> cancel(@PathVariable("id") Long id,
@RequestBody Map<String, String> 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<PageResult<MeetingMaterial>> 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<MeetingMaterial> 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<MeetingMaterial> 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<Map<String, Object>> 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<MeetingMaterial> 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<PageResult<MeetingMaterialHistory>> 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<List<TemplateInfo>> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> exportMeetings(@RequestBody @Valid CreateExportTaskRequest request) {
request.setTaskCode("MEETING_EXPORT");
request.setBizType("MEETING");
return ApiResponse.success(exportTaskService.create(request));
}
}

View File

@ -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<Long> expertIds = new ArrayList<Long>();
public List<Long> getExpertIds() {
return expertIds;
}
public void setExpertIds(List<Long> expertIds) {
this.expertIds = expertIds;
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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<String> invoiceModules;
public List<String> getInvoiceModules() {
return invoiceModules;
}
public void setInvoiceModules(List<String> invoiceModules) {
this.invoiceModules = invoiceModules;
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -0,0 +1,8 @@
package com.writeoff.module.meeting.model;
public enum MeetingAuditStatus {
PENDING,
IN_REVIEW,
APPROVED,
REJECTED
}

View File

@ -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;
}
}

Some files were not shown because too many files have changed in this diff Show More