提交所有代码
This commit is contained in:
@@ -4,10 +4,7 @@ spring:
|
||||
type: com.alibaba.druid.pool.DruidDataSource
|
||||
driverClassName: com.mysql.cj.jdbc.Driver
|
||||
druid:
|
||||
master:
|
||||
url: jdbc:mysql://mmos.uvwcloud.com:3306/guoju0808?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
|
||||
username: root
|
||||
password: Qljl1rh_
|
||||
# master 数据源 url/username/password 按环境拆分到 application-test.yml / application-prod.yml
|
||||
slave:
|
||||
enabled: false
|
||||
url:
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
# 生产环境数据源 (47.95.12.80 / hegui)
|
||||
# 生产端口 8082 (测试端口 8080 为 application.yml 默认值)
|
||||
server:
|
||||
port: 8082
|
||||
|
||||
spring:
|
||||
datasource:
|
||||
druid:
|
||||
master:
|
||||
url: jdbc:mysql://47.95.12.80:3306/hegui?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
|
||||
username: hegui
|
||||
password: C55zwt5L2TeB4kLB
|
||||
|
||||
# 生产环境外部接口地址
|
||||
ruoyi:
|
||||
sms:
|
||||
esignBaseUrl: https://hegui.bahim.org.cn
|
||||
# 扫码拍照 (ry-h5 相机网页地址, 前端二维码目标 URL)
|
||||
camera:
|
||||
base-url: https://hegui.bahim.org.cn/camera/
|
||||
|
||||
supplier-account-api:
|
||||
# base-url: https://zbsupplier.guojustar.com/supplier-api/bidding/supplier/openapi/accounts
|
||||
base-url: https://zbsuppliertest.guojustar.com/supplier-api/bidding/supplier/openapi/accounts
|
||||
|
||||
supplier-account-api-aes:
|
||||
key-id: supplier-api-key
|
||||
key: 'kY0+oIO/laeaaaYDd+9TCYbCQR0b/vqMeJ9sUnSVd9U='
|
||||
|
||||
bidding-api:
|
||||
# notice-list-url: https://zbpublic.guojustar.com/portal-api/bidding/public/notice/list
|
||||
# detail-url: https://zbpublic.guojustar.com/#/detail/
|
||||
notice-list-url: https://zbpublictest.guojustar.com/portal-api/bidding/public/notice/list
|
||||
detail-url: https://zbpublictest.guojustar.com/#/detail/
|
||||
@@ -0,0 +1,27 @@
|
||||
# 测试环境数据源 (mmos.uvwcloud.com / guoju0808)
|
||||
spring:
|
||||
datasource:
|
||||
druid:
|
||||
master:
|
||||
url: jdbc:mysql://mmos.uvwcloud.com:3306/guoju0808?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
|
||||
username: root
|
||||
password: Qljl1rh_
|
||||
|
||||
# 测试环境外部接口地址
|
||||
ruoyi:
|
||||
sms:
|
||||
esignBaseUrl: https://risingdoctor.com/hg
|
||||
# 扫码拍照 (ry-h5 相机网页地址, 前端二维码目标 URL)
|
||||
camera:
|
||||
base-url: https://risingdoctor.com/camera/
|
||||
|
||||
supplier-account-api:
|
||||
base-url: https://zbsuppliertest.guojustar.com/supplier-api/bidding/supplier/openapi/accounts
|
||||
|
||||
supplier-account-api-aes:
|
||||
key-id: supplier-api-key
|
||||
key: 'kY0+oIO/laeaaaYDd+9TCYbCQR0b/vqMeJ9sUnSVd9U='
|
||||
|
||||
bidding-api:
|
||||
notice-list-url: https://zbpublictest.guojustar.com/portal-api/bidding/public/notice/list
|
||||
detail-url: https://zbpublictest.guojustar.com/#/detail/
|
||||
@@ -33,7 +33,6 @@ ruoyi:
|
||||
signName: 北京整合医学学会
|
||||
template: SMS_291440833
|
||||
esignTemplate: SMS_512040098
|
||||
esignBaseUrl: https://hegui.bahim.org.cn
|
||||
endpoint: dysmsapi.aliyuncs.com
|
||||
regionId: cn-hangzhou
|
||||
# 发票 OCR (本地 Java 识别, PaddleOCR ONNX Runtime, 替代原 ry-ocr Python 微服务)
|
||||
@@ -55,21 +54,15 @@ ruoyi:
|
||||
upload:
|
||||
max-mb: 20
|
||||
pdf-dpi: 150
|
||||
# 扫码拍照 (ry-h5 相机网页地址, 前端二维码目标 URL)
|
||||
camera:
|
||||
base-url: https://risingdoctor.com/camera/
|
||||
|
||||
# 供应商账号接口 (拉取/解密) 配置
|
||||
# 供应商账号接口 (拉取/解密) 配置 (base-url 按环境拆分到 application-test/prod.yml)
|
||||
supplier-account-api:
|
||||
base-url: https://zbsuppliertest.guojustar.com/supplier-api/bidding/supplier/openapi/accounts
|
||||
# 每页条数
|
||||
page-size: 20
|
||||
# 每次拉取最近 N 分钟更新的数据
|
||||
pull-minutes: 5
|
||||
# 供应商账号接口 AES 密钥 (数据解密用, 测试环境密钥)
|
||||
supplier-account-api-aes:
|
||||
key-id: supplier-api-key
|
||||
key: 'kY0+oIO/laeaaaYDd+9TCYbCQR0b/vqMeJ9sUnSVd9U='
|
||||
# 供应商账号接口 AES 密钥 (supplier-account-api-aes.key 按环境拆分到 application-test/prod.yml)
|
||||
|
||||
# 招标公告查询接口 (notice-list-url / detail-url 按环境拆分到 application-test/prod.yml)
|
||||
|
||||
# 开发环境配置
|
||||
server:
|
||||
@@ -103,7 +96,11 @@ spring:
|
||||
# 国际化资源文件路径
|
||||
basename: i18n/messages
|
||||
profiles:
|
||||
active: druid
|
||||
# 默认 prod 环境; 本地测试用 --spring.profiles.active=test (group 自动展开为 druid,test)
|
||||
active: prod
|
||||
group:
|
||||
test: druid,test
|
||||
prod: druid,prod
|
||||
# 文件上传
|
||||
servlet:
|
||||
multipart:
|
||||
|
||||
+55
-1
@@ -72,10 +72,18 @@ public class BizMeetingAttendeeController extends BaseController {
|
||||
/**
|
||||
* 管理端: 某会议的全部参会人 (MeetingDetail 参会人 CRUD 用).
|
||||
* 返回全字段, 前端按需展示.
|
||||
* <p>sponsor (监督方) 只读且敏感信息脱敏: 姓名/手机号/银行卡号/账户名称/身份证号在后端打码,
|
||||
* 明文不下发 (前端遮罩只是显示层, 不是安全边界).
|
||||
*/
|
||||
@GetMapping("/list/{meetingId}")
|
||||
public AjaxResult listByMeetingId(@PathVariable("meetingId") Long meetingId) {
|
||||
return success(attendeeService.selectByMeetingId(meetingId));
|
||||
List<BizMeetingAttendee> list = attendeeService.selectByMeetingId(meetingId);
|
||||
if (isSponsor()) {
|
||||
for (BizMeetingAttendee a : list) {
|
||||
maskForSponsor(a);
|
||||
}
|
||||
}
|
||||
return success(list);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -324,4 +332,50 @@ public class BizMeetingAttendeeController extends BaseController {
|
||||
}
|
||||
}
|
||||
|
||||
/** 当前登录用户是否为 sponsor (监督方只读, 敏感信息需脱敏) */
|
||||
private boolean isSponsor() {
|
||||
try {
|
||||
return "sponsor".equals(SecurityUtils.getLoginUser().getUser().getRoleType());
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** sponsor 脱敏: 姓名/手机号/银行卡号/账户名称/身份证号 (对齐 desensitizeHtml 口径) */
|
||||
private void maskForSponsor(BizMeetingAttendee a) {
|
||||
if (a == null) return;
|
||||
a.setName(maskName(a.getName()));
|
||||
a.setPhone(maskPhone(a.getPhone()));
|
||||
a.setBankCard(maskBankCard(a.getBankCard()));
|
||||
a.setAccountName(maskName(a.getAccountName()));
|
||||
a.setIdCard(maskIdCard(a.getIdCard()));
|
||||
}
|
||||
|
||||
/** 姓名脱敏: 保留首字 (姓), 其余打码 (张三 → 张*) */
|
||||
private String maskName(String v) {
|
||||
if (v == null) return null;
|
||||
String s = v.trim();
|
||||
if (s.isEmpty()) return v;
|
||||
if (s.length() <= 1) return "*";
|
||||
return s.charAt(0) + "*".repeat(s.length() - 1);
|
||||
}
|
||||
|
||||
/** 手机号脱敏: 前3 + **** + 后4 */
|
||||
private String maskPhone(String v) {
|
||||
if (v == null || v.length() != 11) return v;
|
||||
return v.substring(0, 3) + "****" + v.substring(7);
|
||||
}
|
||||
|
||||
/** 银行卡号脱敏: 前4 + ******** + 后4 */
|
||||
private String maskBankCard(String v) {
|
||||
if (v == null || v.length() < 8) return v;
|
||||
return v.substring(0, 4) + "********" + v.substring(v.length() - 4);
|
||||
}
|
||||
|
||||
/** 身份证号脱敏: 前6 + ******** + 后4 */
|
||||
private String maskIdCard(String v) {
|
||||
if (v == null || v.length() != 18) return v;
|
||||
return v.substring(0, 6) + "********" + v.substring(14);
|
||||
}
|
||||
|
||||
}
|
||||
+4
@@ -111,6 +111,10 @@ public class BizMeetingController extends BaseController {
|
||||
else if ("manager".equals(roleType)) {
|
||||
bizMeeting.getParams().put("managerCreateUserId", uid);
|
||||
}
|
||||
// 项目负责人(leader) 数据权限: 只看"本人负责的项目"下的会议 (project_id ∈ lead_user_id = 自己)
|
||||
else if ("leader".equals(roleType)) {
|
||||
bizMeeting.getParams().put("leaderUserId", uid);
|
||||
}
|
||||
startPage();
|
||||
List<BizMeeting> list = bizMeetingService.selectList(bizMeeting);
|
||||
return getDataTable(list);
|
||||
|
||||
+18
-8
@@ -4,6 +4,7 @@ import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import com.ruoyi.common.core.controller.BaseController;
|
||||
import com.ruoyi.common.core.domain.AjaxResult;
|
||||
import com.ruoyi.business.service.IBizMeetingInvoiceService;
|
||||
import com.ruoyi.business.service.impl.InvoiceOcrService;
|
||||
|
||||
/**
|
||||
@@ -11,7 +12,7 @@ import com.ruoyi.business.service.impl.InvoiceOcrService;
|
||||
* <p>
|
||||
* v3 变更:
|
||||
* - 端点改为"提交即返回 SUBMITTED", OCR 在后台 ExecutorService 跑
|
||||
* - 新增 isZip 字段: true → zip 路径(解压 → 重传 OSS → 写多行 invoice)
|
||||
* - zip 由后端按 ossUrl 后缀判断 (解压 → 重传 OSS → 写多行 invoice), 前端无需传 isZip
|
||||
* - 新增 oldMaterialId 字段: 替换场景, 后端先清旧 invoice + material.amount=0
|
||||
* - 不再走旧的同步 recognizeAndSave, 已被 submitRecognition 取代
|
||||
*/
|
||||
@@ -23,14 +24,16 @@ public class BizMeetingInvoiceController extends BaseController
|
||||
@Autowired
|
||||
private InvoiceOcrService invoiceOcrService;
|
||||
|
||||
@Autowired
|
||||
private IBizMeetingInvoiceService invoiceService;
|
||||
|
||||
/**
|
||||
* 提交发票识别 (后台执行, 立即返回)
|
||||
* <p>
|
||||
* body: {
|
||||
* "materialId": 123,
|
||||
* "meetingId": 456,
|
||||
* "ossUrl": "https://...",
|
||||
* "isZip": false, // 新增
|
||||
* "ossUrl": "https://...", // .zip 结尾走 zip 解压, 否则按单文件
|
||||
* "oldMaterialId": null // 新增, 替换时携带原 materialId
|
||||
* }
|
||||
*/
|
||||
@@ -41,19 +44,28 @@ public class BizMeetingInvoiceController extends BaseController
|
||||
body.getMaterialId(),
|
||||
body.getMeetingId(),
|
||||
body.getOssUrl(),
|
||||
body.isZip(),
|
||||
body.getOldMaterialId());
|
||||
return success(r);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询某会议下所有发票明细 (会务材料 tab 顶部「发票表」展示)
|
||||
* <p>
|
||||
* 返回 BizMeetingInvoice 列表, 含 materialSubType / materialFileName (join biz_meeting_material 带出),
|
||||
* 供前端按所属材料类型渲染发票表.
|
||||
*/
|
||||
@GetMapping("/list")
|
||||
public AjaxResult list(@RequestParam("meetingId") Long meetingId)
|
||||
{
|
||||
return success(invoiceService.selectByMeetingId(meetingId));
|
||||
}
|
||||
|
||||
/** request body wrapper */
|
||||
public static class RecognizeBody
|
||||
{
|
||||
private Long materialId;
|
||||
private Long meetingId;
|
||||
private String ossUrl;
|
||||
/** v3 新增: true=zip 路径(解压遍历), false=单文件路径 */
|
||||
private boolean isZip;
|
||||
/** v3 新增: 替换场景携带的旧 materialId, 后端先 DELETE invoice WHERE material_id=old + material.amount=0 */
|
||||
private Long oldMaterialId;
|
||||
|
||||
@@ -63,8 +75,6 @@ public class BizMeetingInvoiceController extends BaseController
|
||||
public void setMeetingId(Long meetingId) { this.meetingId = meetingId; }
|
||||
public String getOssUrl() { return ossUrl; }
|
||||
public void setOssUrl(String ossUrl) { this.ossUrl = ossUrl; }
|
||||
public boolean isZip() { return isZip; }
|
||||
public void setZip(boolean zip) { isZip = zip; }
|
||||
public Long getOldMaterialId() { return oldMaterialId; }
|
||||
public void setOldMaterialId(Long oldMaterialId) { this.oldMaterialId = oldMaterialId; }
|
||||
}
|
||||
|
||||
+16
-5
@@ -31,26 +31,37 @@ public class BizMeetingStageStatsController extends BaseController
|
||||
private SysUserMapper sysUserMapper;
|
||||
|
||||
/**
|
||||
* 按 current_stage 分组计数, 只做 sponsor 数据权限隔离 (MAIN=本支持单位全部, SUB=自己负责的项目).
|
||||
* 按 current_stage 分组计数, 做 sponsor / leader 数据权限隔离 (sponsor MAIN=本支持单位全部, SUB=自己负责的项目; leader=本人负责的项目).
|
||||
* 返回 { stage: cnt, ... }, 前端据此算: 未执行=NOT_STARTED, 已执行=排除 NOT_STARTED/IN_PROGRESS 的其余 stage (含 FROZEN).
|
||||
* 其他角色的统计端点单独实现, 此端点非 sponsor 调用返回空 map (不泄露全量数据).
|
||||
* 其他角色的统计端点单独实现, 此端点非 sponsor/leader 调用返回空 map (不泄露全量数据).
|
||||
*/
|
||||
@GetMapping("/stageStats")
|
||||
public AjaxResult stageStats() {
|
||||
Map<String, Long> result = new HashMap<>();
|
||||
if (!"sponsor".equals(SecurityUtils.getLoginUser().getUser().getRoleType())) {
|
||||
String roleType = SecurityUtils.getLoginUser().getUser().getRoleType();
|
||||
if (!"sponsor".equals(roleType) && !"leader".equals(roleType)) {
|
||||
return success(result);
|
||||
}
|
||||
BizMeeting q = new BizMeeting();
|
||||
Long uid = SecurityUtils.getUserId();
|
||||
if ("leader".equals(roleType)) {
|
||||
// 项目负责人: 统计范围 = 本人负责的项目 (lead_user_id = 自己)
|
||||
q.getParams().put("leaderUserId", uid);
|
||||
return success(doStats(q));
|
||||
}
|
||||
SysUser current = uid == null ? null : sysUserMapper.selectUserById(uid);
|
||||
if (current != null && "SUB".equals(current.getAccountType())) {
|
||||
q.getParams().put("monitorUserId", uid);
|
||||
} else {
|
||||
q.getParams().put("sponsorAdminUserId", uid);
|
||||
}
|
||||
return success(doStats(q));
|
||||
}
|
||||
|
||||
/** 按 current_stage 分组计数, 结果 key 大小写随 JDBC 驱动, 做大小写不敏感匹配, 避免"统计恒 0". */
|
||||
private Map<String, Long> doStats(BizMeeting q) {
|
||||
Map<String, Long> result = new HashMap<>();
|
||||
for (Map<String, Object> r : bizMeetingService.selectStageStats(q)) {
|
||||
// resultType=map 的 key 大小写随 JDBC 驱动, 做大小写不敏感匹配, 避免"统计恒 0"
|
||||
String stage = null;
|
||||
long cnt = 0L;
|
||||
for (Map.Entry<String, Object> e : r.entrySet()) {
|
||||
@@ -66,6 +77,6 @@ public class BizMeetingStageStatsController extends BaseController
|
||||
result.put(stage, cnt);
|
||||
}
|
||||
}
|
||||
return success(result);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
+9
-5
@@ -48,7 +48,7 @@ public class BizPersonController extends BaseController
|
||||
@GetMapping("/list")
|
||||
public TableDataInfo list(BizPerson bizPerson)
|
||||
{
|
||||
// 当前登录用户是 MAIN 业务账号 → 自动注入 subUserIds, 隔离到本主账号的子账号对应人员
|
||||
// 当前登录用户是 MAIN 业务账号 → 自动注入 subUserIds, 隔离到「本主账号自己 + 其子账号」对应人员
|
||||
Long currentUserId = getUserId();
|
||||
SysUser currentUser = currentUserId == null ? null : sysUserMapper.selectUserById(currentUserId);
|
||||
if (currentUser != null
|
||||
@@ -56,10 +56,14 @@ public class BizPersonController extends BaseController
|
||||
&& currentUser.getRoleType() != null
|
||||
&& BUSINESS_MAIN_ROLES.contains(currentUser.getRoleType())) {
|
||||
List<Long> subUserIds = sysUserMapper.selectUserIdsByParent(currentUserId);
|
||||
// 即使没子账号也强制注入, 避免漏出 sys_user.parent_user_id=NULL 的孤立 person
|
||||
bizPerson.getParams().put("subUserIds", subUserIds == null || subUserIds.isEmpty()
|
||||
? java.util.Collections.singletonList(-1L)
|
||||
: subUserIds);
|
||||
// 隔离范围 = 主账号自己 + 子账号. 主账号自己也要能看到自己的 biz_person
|
||||
// (公益支持/项目执行意向回显 工作单位/部门/职务 用), 同时仍不漏出 parent_user_id=NULL 的孤立 person
|
||||
List<Long> visibleUserIds = new ArrayList<>();
|
||||
visibleUserIds.add(currentUserId);
|
||||
if (subUserIds != null) {
|
||||
visibleUserIds.addAll(subUserIds);
|
||||
}
|
||||
bizPerson.getParams().put("subUserIds", visibleUserIds);
|
||||
}
|
||||
startPage();
|
||||
List<BizPerson> list = bizPersonService.selectList(bizPerson);
|
||||
|
||||
+4
-1
@@ -106,10 +106,12 @@ public class BizProjectController extends BaseController
|
||||
@GetMapping("/list")
|
||||
public TableDataInfo list(BizProject bizProject)
|
||||
{
|
||||
// 合规人员(manager): 只看本人创建的项目 (create_user_id = 当前用户); admin 不限制
|
||||
// 数据权限: manager 只看本人创建(create_user_id), leader 只看本人负责(lead_user_id), admin 不限制
|
||||
String roleType = SecurityUtils.getLoginUser().getUser().getRoleType();
|
||||
if ("manager".equals(roleType)) {
|
||||
bizProject.getParams().put("createUserId", SecurityUtils.getUserId());
|
||||
} else if ("leader".equals(roleType)) {
|
||||
bizProject.getParams().put("leaderUserId", SecurityUtils.getUserId());
|
||||
}
|
||||
startPage();
|
||||
List<BizProject> list = bizProjectService.selectList(bizProject);
|
||||
@@ -192,6 +194,7 @@ public class BizProjectController extends BaseController
|
||||
List<BizProject> list = bizProjectService.selectExecutorList(bizProject);
|
||||
return getDataTable(list);
|
||||
}
|
||||
|
||||
@GetMapping("/{projectId}")
|
||||
public AjaxResult getInfo(@PathVariable("projectId") Long projectId)
|
||||
{
|
||||
|
||||
+61
@@ -3,13 +3,21 @@ package com.ruoyi.business.controller;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.net.URLEncoder;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.core.env.Environment;
|
||||
import com.ruoyi.common.core.controller.BaseController;
|
||||
import com.ruoyi.common.core.domain.AjaxResult;
|
||||
import com.ruoyi.common.utils.http.HttpUtils;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import com.ruoyi.business.domain.BizArticle;
|
||||
import com.ruoyi.business.domain.BizProject;
|
||||
import com.ruoyi.business.domain.BizSupportLetter;
|
||||
@@ -37,6 +45,10 @@ public class BizPublicController extends BaseController {
|
||||
@Autowired private IBizArticleService articleService;
|
||||
@Autowired private IBizSpecialPlanService specialPlanService;
|
||||
|
||||
@Autowired private Environment environment;
|
||||
private static final Logger log = LoggerFactory.getLogger(BizPublicController.class);
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
/**
|
||||
* 按类型获取已发布的协议/政策 (供注册页底部链接打开)
|
||||
* type: agreement | privacy
|
||||
@@ -92,6 +104,55 @@ public class BizPublicController extends BaseController {
|
||||
return success(projectService.getById(projectId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 招标公告查询: 按项目编号查外部招标平台的 noticeId, 返回详情跳转 URL.
|
||||
* <p>数据源: GET {bidding-api.notice-list-url}?pageNum=1&pageSize=200&keyword={projectNo}&projectType=10¬iceType=10
|
||||
* 取响应 rows[0].noticeId, 拼接 {bidding-api.detail-url}{noticeId} 返回, 前端 window.open 跳转.
|
||||
*/
|
||||
@GetMapping("/biddingNotice")
|
||||
public AjaxResult biddingNotice(@RequestParam String projectNo)
|
||||
{
|
||||
String listUrl = environment.getProperty("bidding-api.notice-list-url",
|
||||
"https://zbpublic.guojustar.com/portal-api/bidding/public/notice/list");
|
||||
String detailUrl = environment.getProperty("bidding-api.detail-url",
|
||||
"https://zbpublic.guojustar.com/#/detail/");
|
||||
if (projectNo == null || projectNo.trim().isEmpty())
|
||||
{
|
||||
return error("项目编号不能为空");
|
||||
}
|
||||
try
|
||||
{
|
||||
String param = "pageNum=1&pageSize=200&keyword=" + URLEncoder.encode(projectNo.trim(), "UTF-8")
|
||||
+ "&projectType=10¬iceType=10";
|
||||
String resp = HttpUtils.sendGet(listUrl, param);
|
||||
if (resp == null || resp.isEmpty())
|
||||
{
|
||||
return error("招标公告查询失败");
|
||||
}
|
||||
JsonNode root = objectMapper.readTree(resp);
|
||||
JsonNode rows = root.path("rows");
|
||||
if (!rows.isArray() || rows.size() == 0)
|
||||
{
|
||||
return error("未查到该项目的招标公告");
|
||||
}
|
||||
long noticeId = rows.get(0).path("noticeId").asLong(0L);
|
||||
if (noticeId == 0L)
|
||||
{
|
||||
return error("招标公告 noticeId 缺失");
|
||||
}
|
||||
// 注意: 不能用 success(url) —— BaseController.success(String) 会把 URL 当 msg、data 为 null
|
||||
// 用 Map 承载, 前端取 data.detailUrl
|
||||
Map<String, Object> data = new HashMap<>();
|
||||
data.put("detailUrl", detailUrl + noticeId);
|
||||
return success(data);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
log.error("招标公告查询失败, projectNo={}", projectNo, e);
|
||||
return error("招标公告查询异常");
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/supportLetter/{annId}")
|
||||
public AjaxResult supportLetterDetail(@PathVariable String annId) {
|
||||
return success(supportLetterService.getById(annId));
|
||||
|
||||
@@ -58,6 +58,18 @@ public class BizMeetingInvoice {
|
||||
/** 原始文件名 (zip 解压时记录, 单文件路径可空) */
|
||||
private String sourceFilename;
|
||||
|
||||
/** 所属材料类型 sub_type (join biz_meeting_material 带出, 发票表展示用) */
|
||||
private String materialSubType;
|
||||
|
||||
/** 所属材料原始文件名 (join biz_meeting_material 带出, 单文件发票 source_filename 为空时兜底) */
|
||||
private String materialFileName;
|
||||
|
||||
/** 发票号码 (OCR 识别) */
|
||||
private String invoiceNo;
|
||||
|
||||
/** 开票日期 (OCR 识别, YYYY-MM-DD) */
|
||||
private String invoiceDate;
|
||||
|
||||
public Long getId() { return id; }
|
||||
public void setId(Long id) { this.id = id; }
|
||||
|
||||
@@ -93,4 +105,16 @@ public class BizMeetingInvoice {
|
||||
|
||||
public String getSourceFilename() { return sourceFilename; }
|
||||
public void setSourceFilename(String sourceFilename) { this.sourceFilename = sourceFilename; }
|
||||
|
||||
public String getMaterialSubType() { return materialSubType; }
|
||||
public void setMaterialSubType(String materialSubType) { this.materialSubType = materialSubType; }
|
||||
|
||||
public String getMaterialFileName() { return materialFileName; }
|
||||
public void setMaterialFileName(String materialFileName) { this.materialFileName = materialFileName; }
|
||||
|
||||
public String getInvoiceNo() { return invoiceNo; }
|
||||
public void setInvoiceNo(String invoiceNo) { this.invoiceNo = invoiceNo; }
|
||||
|
||||
public String getInvoiceDate() { return invoiceDate; }
|
||||
public void setInvoiceDate(String invoiceDate) { this.invoiceDate = invoiceDate; }
|
||||
}
|
||||
+2
-2
@@ -93,8 +93,8 @@ public class BizMeetingAttendeeImportVo {
|
||||
@Excel(name = "银行详细地址", sort = 18)
|
||||
private String bankAddress;
|
||||
|
||||
/** 身份证附件(正反面 URL CSV) */
|
||||
@Excel(name = "身份证附件", sort = 19)
|
||||
/** 身份证附件(正反面 URL CSV)— 仅导出,不进入导入模板(附件文件无法通过 Excel 导入) */
|
||||
@Excel(name = "身份证附件", sort = 19, type = Excel.Type.EXPORT)
|
||||
private String idCardAttachments;
|
||||
|
||||
public String getPhone() { return phone; }
|
||||
|
||||
+22
@@ -51,6 +51,22 @@ public interface BizMeetingInvoiceMapper {
|
||||
int updateAmountByMaterial(@org.apache.ibatis.annotations.Param("materialId") Long materialId,
|
||||
@org.apache.ibatis.annotations.Param("amount") java.math.BigDecimal amount);
|
||||
|
||||
/**
|
||||
* 单文件 OCR 完成: 更新金额 + 发票号 + 开票日期 (按 material_id, 与状态更新分开调用)
|
||||
*/
|
||||
int updateOcrResultByMaterial(@org.apache.ibatis.annotations.Param("materialId") Long materialId,
|
||||
@org.apache.ibatis.annotations.Param("amount") java.math.BigDecimal amount,
|
||||
@org.apache.ibatis.annotations.Param("invoiceNo") String invoiceNo,
|
||||
@org.apache.ibatis.annotations.Param("invoiceDate") String invoiceDate);
|
||||
|
||||
/**
|
||||
* 兜底 OCR 完成: 更新状态(RECOGNIZED) + 金额 + 发票号 + 开票日期 (按主键)
|
||||
*/
|
||||
int updateOcrResultByPrimaryKey(@org.apache.ibatis.annotations.Param("id") Long id,
|
||||
@org.apache.ibatis.annotations.Param("amount") java.math.BigDecimal amount,
|
||||
@org.apache.ibatis.annotations.Param("invoiceNo") String invoiceNo,
|
||||
@org.apache.ibatis.annotations.Param("invoiceDate") String invoiceDate);
|
||||
|
||||
/**
|
||||
* 兜底 OCR: 按主键更新 (状态 + 金额 + 错误信息)
|
||||
*/
|
||||
@@ -58,4 +74,10 @@ public interface BizMeetingInvoiceMapper {
|
||||
@org.apache.ibatis.annotations.Param("recognizeStatus") String recognizeStatus,
|
||||
@org.apache.ibatis.annotations.Param("amount") java.math.BigDecimal amount,
|
||||
@org.apache.ibatis.annotations.Param("errorMsg") String errorMsg);
|
||||
|
||||
/**
|
||||
* 材料全删全插后, 未变材料的发票 material_id 需从旧 id 迁移到新 id (否则发票表 join 不到材料类型)
|
||||
*/
|
||||
int updateInvoiceMaterialId(@org.apache.ibatis.annotations.Param("oldMaterialId") Long oldMaterialId,
|
||||
@org.apache.ibatis.annotations.Param("newMaterialId") Long newMaterialId);
|
||||
}
|
||||
@@ -7,6 +7,8 @@ import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.Charset;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.zip.ZipEntry;
|
||||
@@ -39,31 +41,17 @@ public class ZipExtractor
|
||||
if (size <= 0) throw new IOException("OSS zip 下载失败: " + zipUrl);
|
||||
log.info("zip 下载: url={} size={}B tmp={}", zipUrl, size, zipFile.getAbsolutePath());
|
||||
|
||||
List<File> out = new ArrayList<>();
|
||||
try (ZipInputStream zin = new ZipInputStream(new FileInputStream(zipFile)))
|
||||
// 优先 UTF-8 解包; 若 entry 名非 UTF-8 (常见 GBK, Windows 打包) 会抛
|
||||
// IllegalArgumentException (malformed input) → 回退 GBK 重试
|
||||
try
|
||||
{
|
||||
ZipEntry e;
|
||||
while ((e = zin.getNextEntry()) != null)
|
||||
{
|
||||
if (e.isDirectory()) continue;
|
||||
String name = e.getName();
|
||||
String lower = name.toLowerCase();
|
||||
if (!(lower.endsWith(".png") || lower.endsWith(".jpg")
|
||||
|| lower.endsWith(".jpeg") || lower.endsWith(".pdf")))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
// 子目录展平: a/b/c.png → tmpRoot/a_b_c.png
|
||||
File outFile = new File(tmpRoot, name.replace("/", "_"));
|
||||
try (FileOutputStream fos = new FileOutputStream(outFile))
|
||||
{
|
||||
zin.transferTo(fos);
|
||||
}
|
||||
out.add(outFile);
|
||||
}
|
||||
return doExtract(zipFile, tmpRoot, StandardCharsets.UTF_8);
|
||||
}
|
||||
catch (IllegalArgumentException e)
|
||||
{
|
||||
log.warn("zip 文件名非 UTF-8, 回退 GBK 重试: {}", e.getMessage());
|
||||
return doExtract(zipFile, tmpRoot, Charset.forName("GBK"));
|
||||
}
|
||||
log.info("zip 解压完成: 文件数={}", out.size());
|
||||
return out;
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -72,6 +60,38 @@ public class ZipExtractor
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 按指定 charset 解压 zip, 返回图片/PDF 文件列表
|
||||
*/
|
||||
private static List<File> doExtract(File zipFile, File tmpRoot, Charset charset) throws IOException
|
||||
{
|
||||
List<File> out = new ArrayList<>();
|
||||
try (ZipInputStream zin = new ZipInputStream(new FileInputStream(zipFile), charset))
|
||||
{
|
||||
ZipEntry e;
|
||||
while ((e = zin.getNextEntry()) != null)
|
||||
{
|
||||
if (e.isDirectory()) continue;
|
||||
String name = e.getName();
|
||||
String lower = name.toLowerCase();
|
||||
if (!(lower.endsWith(".png") || lower.endsWith(".jpg")
|
||||
|| lower.endsWith(".jpeg") || lower.endsWith(".pdf")))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
// 子目录展平: a/b/c.png → tmpRoot/a_b_c.png
|
||||
File outFile = new File(tmpRoot, name.replace("/", "_"));
|
||||
try (FileOutputStream fos = new FileOutputStream(outFile))
|
||||
{
|
||||
zin.transferTo(fos);
|
||||
}
|
||||
out.add(outFile);
|
||||
}
|
||||
}
|
||||
log.info("zip 解压完成: charset={} 文件数={}", charset, out.size());
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 清理整个解压临时目录 */
|
||||
public static void cleanup(File tmpRoot)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package com.ruoyi.business.pdf;
|
||||
|
||||
import com.itextpdf.io.image.ImageDataFactory;
|
||||
import com.itextpdf.kernel.events.Event;
|
||||
import com.itextpdf.kernel.events.IEventHandler;
|
||||
import com.itextpdf.kernel.events.PdfDocumentEvent;
|
||||
import com.itextpdf.kernel.geom.Rectangle;
|
||||
import com.itextpdf.kernel.pdf.PdfDocument;
|
||||
import com.itextpdf.kernel.pdf.PdfPage;
|
||||
import com.itextpdf.kernel.pdf.canvas.PdfCanvas;
|
||||
import com.itextpdf.layout.Canvas;
|
||||
import com.itextpdf.layout.element.Image;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
/**
|
||||
* 劳务协议盖章 (固定定位)
|
||||
*
|
||||
* <p>在指定页 (默认第 2 页) 固定坐标叠加公章: x 固定, y = 页面垂直中心偏上 yOffset.
|
||||
* 使用 {@link PdfDocumentEvent#END_PAGE} 事件叠加在页面内容流之后, 公章 z 序在正文之上.
|
||||
*
|
||||
* <p>容错: 印章图片缺失 / 加载失败时只记日志并跳过, 不阻断 PDF 生成.
|
||||
*/
|
||||
public class SealStamper implements IEventHandler {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(SealStamper.class);
|
||||
|
||||
/** 印章图片相对路径 (运行目录, 与 simsun.ttc 同级) */
|
||||
private final String sealImagePath;
|
||||
/** 印章水平坐标 (iText 坐标 x 从左往右) */
|
||||
private final float x;
|
||||
/** 印章垂直: 页面垂直中心偏上的偏移量 (正=往上) */
|
||||
private final float yOffsetFromCenter;
|
||||
/** 印章尺寸 (宽高, px) */
|
||||
private final float width;
|
||||
private final float height;
|
||||
/** 盖章页码 (从 1 起) */
|
||||
private final int pageNumber;
|
||||
|
||||
public SealStamper(String sealImagePath, float x, float yOffsetFromCenter,
|
||||
float width, float height, int pageNumber) {
|
||||
this.sealImagePath = sealImagePath;
|
||||
this.x = x;
|
||||
this.yOffsetFromCenter = yOffsetFromCenter;
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
this.pageNumber = pageNumber;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleEvent(Event event) {
|
||||
PdfDocumentEvent documentEvent = (PdfDocumentEvent) event;
|
||||
PdfDocument document = documentEvent.getDocument();
|
||||
int n = document.getPageNumber(documentEvent.getPage());
|
||||
// 只盖指定页
|
||||
if (n != pageNumber) {
|
||||
return;
|
||||
}
|
||||
|
||||
Image image;
|
||||
try {
|
||||
image = new Image(ImageDataFactory.create(sealImagePath));
|
||||
} catch (Exception e) {
|
||||
log.warn("[seal] 印章图片加载失败, 跳过盖章: {}", sealImagePath, e);
|
||||
return;
|
||||
}
|
||||
image.setWidth(width);
|
||||
image.setHeight(height);
|
||||
|
||||
PdfPage page = documentEvent.getPage();
|
||||
Rectangle pageSize = page.getPageSize();
|
||||
// y = 页面垂直中心偏上 yOffsetFromCenter (iText 坐标 y 从下往上)
|
||||
float y = pageSize.getHeight() / 2 + yOffsetFromCenter;
|
||||
image.setFixedPosition(x, y);
|
||||
|
||||
PdfCanvas pdfCanvas = new PdfCanvas(page.newContentStreamAfter(), page.getResources(), document);
|
||||
Canvas canvas = new Canvas(pdfCanvas, pageSize);
|
||||
try {
|
||||
canvas.add(image);
|
||||
} finally {
|
||||
canvas.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -49,7 +49,7 @@ public class SupplierAccountPullScheduler
|
||||
private static final String FULL_PULL_DAYS = "supplier-account-api.full-pull-days";
|
||||
|
||||
private static final String DEFAULT_BASE_URL =
|
||||
"https://zbsuppliertest.guojustar.com/supplier-api/bidding/supplier/openapi/accounts";
|
||||
"https://zbsupplier.guojustar.com/supplier-api/bidding/supplier/openapi/accounts";
|
||||
|
||||
private static final DateTimeFormatter TIME_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.ruoyi.business.service;
|
||||
import com.itextpdf.html2pdf.ConverterProperties;
|
||||
import com.itextpdf.html2pdf.HtmlConverter;
|
||||
import com.itextpdf.io.font.PdfEncodings;
|
||||
import com.itextpdf.kernel.events.PdfDocumentEvent;
|
||||
import com.itextpdf.kernel.font.PdfFont;
|
||||
import com.itextpdf.kernel.font.PdfFontFactory;
|
||||
import com.itextpdf.kernel.geom.PageSize;
|
||||
@@ -10,6 +11,7 @@ import com.itextpdf.kernel.pdf.PdfDocument;
|
||||
import com.itextpdf.kernel.pdf.PdfWriter;
|
||||
import com.itextpdf.layout.font.FontProvider;
|
||||
import com.ruoyi.business.oss.OssUploader;
|
||||
import com.ruoyi.business.pdf.SealStamper;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@@ -28,6 +30,18 @@ public class PdfService {
|
||||
@Autowired
|
||||
private OssUploader ossUploader;
|
||||
|
||||
/** 公章图片相对路径 (运行目录, 与 simsun.ttc 同级) */
|
||||
private static final String SEAL_IMAGE_PATH = "zhenghe.png";
|
||||
/** 公章水平坐标 (保持不动) */
|
||||
private static final float SEAL_X = 80f;
|
||||
/** 公章垂直: 页面正中间偏上 15 */
|
||||
private static final float SEAL_Y_OFFSET = 15f;
|
||||
/** 公章尺寸 (宽高, px) */
|
||||
private static final float SEAL_WIDTH = 120f;
|
||||
private static final float SEAL_HEIGHT = 120f;
|
||||
/** 盖章页码 (第 2 页) */
|
||||
private static final int SEAL_PAGE = 2;
|
||||
|
||||
/**
|
||||
* HTML 字符串 → PDF 字节流
|
||||
*/
|
||||
@@ -48,6 +62,10 @@ public class PdfService {
|
||||
fontProvider.addFont(cjkFont.getFontProgram(), PdfEncodings.IDENTITY_H);
|
||||
properties.setFontProvider(fontProvider);
|
||||
|
||||
// 盖章: 第 2 页固定坐标叠加公章 (END_PAGE 事件, 必须在 convertToPdf 之前注册, 否则 document 已关闭)
|
||||
pdfDocument.addEventHandler(PdfDocumentEvent.END_PAGE,
|
||||
new SealStamper(SEAL_IMAGE_PATH, SEAL_X, SEAL_Y_OFFSET, SEAL_WIDTH, SEAL_HEIGHT, SEAL_PAGE));
|
||||
|
||||
HtmlConverter.convertToPdf(fullHtml, pdfDocument, properties);
|
||||
pdfDocument.close();
|
||||
return baos.toByteArray();
|
||||
|
||||
@@ -119,7 +119,7 @@ public class StageDeriver
|
||||
}
|
||||
|
||||
// 仅 待审核/审核通过/待整改/待结算 四类加前缀
|
||||
if ("待审核".equals(label) || "审核通过".equals(label) || "待整改".equals(label) || "待结算".equals(label))
|
||||
if ("待审核".equals(label) || "审核通过".equals(label) || "待整改".equals(label) || "待结算".equals(label) || "待监察".equals(label))
|
||||
{
|
||||
return ("labor".equals(track) ? "劳务" : "会务") + label;
|
||||
}
|
||||
@@ -179,11 +179,11 @@ public class StageDeriver
|
||||
if (phase == 1) return "执行中";
|
||||
return "已执行";
|
||||
case 2: // C0 合规审中
|
||||
if ("sponsor".equals(role)) return "已执行未传材料"; // 只读
|
||||
return "待审核"; // executor / manager / admin
|
||||
return "待审核"; // executor / manager / sponsor / admin
|
||||
case 3: // C1 支持方审中
|
||||
if ("manager".equals(role)) return "审核通过";
|
||||
return "待审核"; // executor / sponsor / admin
|
||||
if ("sponsor".equals(role)) return "待监察";
|
||||
return "待审核"; // executor / admin
|
||||
default: // 4 = A 已通过
|
||||
return "待结算";
|
||||
}
|
||||
|
||||
+2
-1
@@ -26,7 +26,7 @@ import com.ruoyi.system.service.ISysUserService;
|
||||
@Service
|
||||
public class BizAdminUserServiceImpl implements IBizAdminUserService {
|
||||
|
||||
private static final Set<String> ROLE_WHITELIST = Set.of("admin", "manager", "doctor", "executor", "sponsor");
|
||||
private static final Set<String> ROLE_WHITELIST = Set.of("admin", "manager", "doctor", "executor", "sponsor", "leader");
|
||||
|
||||
@Autowired
|
||||
private ISysUserService sysUserService;
|
||||
@@ -79,6 +79,7 @@ public class BizAdminUserServiceImpl implements IBizAdminUserService {
|
||||
switch (role) {
|
||||
case "admin":
|
||||
case "manager":
|
||||
case "leader": // 项目负责人: 纯 sys_user (姓名落 sys_user.nick_name, 供 lead_user_name 反查兜底)
|
||||
return insertPlain(b, role, encPwd);
|
||||
case "doctor":
|
||||
return insertDoctor(b, encPwd);
|
||||
|
||||
+93
-3
@@ -37,6 +37,7 @@ import com.ruoyi.business.domain.BizMeeting;
|
||||
import com.ruoyi.business.domain.BizMeetingAttendee;
|
||||
import com.ruoyi.business.domain.BizMeetingMaterial;
|
||||
import com.ruoyi.business.mapper.BizMeetingMapper;
|
||||
import com.ruoyi.business.mapper.BizMeetingInvoiceMapper;
|
||||
import com.ruoyi.business.mapper.BizMeetingMaterialMapper;
|
||||
import com.ruoyi.business.oss.OssUploader;
|
||||
import com.ruoyi.business.oss.OssZipService;
|
||||
@@ -54,8 +55,8 @@ public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService
|
||||
private static final Set<String> CAMERA_SUBTYPES = new HashSet<>(Arrays.asList(
|
||||
"L_SIGN_IN", "L_PANORAMA_FRONT", "L_PANORAMA_BACK"));
|
||||
|
||||
/** 可识别文件扩展名 (与前端 isRecognizable 对齐): 图片/PDF 才触发 OCR */
|
||||
private static final Pattern RECOGNIZABLE = Pattern.compile("\\.(jpe?g|png|pdf)$", Pattern.CASE_INSENSITIVE);
|
||||
/** 可识别文件扩展名 (与前端 isRecognizable 对齐): 图片/PDF/zip 才触发 OCR (zip 走解压分析发票) */
|
||||
private static final Pattern RECOGNIZABLE = Pattern.compile("\\.(jpe?g|png|pdf|zip)$", Pattern.CASE_INSENSITIVE);
|
||||
|
||||
/** 会务下载范围: 会务材料 (SERVICE) + 会务凭证 (SERVICE_VOUCHER) */
|
||||
private static final Set<String> SERVICE_MATERIAL_TYPES = new HashSet<>(Arrays.asList("SERVICE", "SERVICE_VOUCHER"));
|
||||
@@ -114,6 +115,8 @@ public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService
|
||||
@Autowired
|
||||
private BizMeetingMapper bizMeetingMapper;
|
||||
@Autowired
|
||||
private BizMeetingInvoiceMapper bizMeetingInvoiceMapper;
|
||||
@Autowired
|
||||
private OssZipService ossZipService;
|
||||
@Autowired
|
||||
private IBizMeetingAttendeeService attendeeService;
|
||||
@@ -162,11 +165,28 @@ public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService
|
||||
}
|
||||
}
|
||||
|
||||
// 被移除的 subType (旧有、新无) 的发票一并删除, 否则发票表残留孤儿发票行 (material_id 指向已删材料)
|
||||
Set<String> newSubTypes = new HashSet<>();
|
||||
if (list != null) {
|
||||
for (BizMeetingMaterial m : list) {
|
||||
if (m.getSubType() != null) newSubTypes.add(m.getSubType());
|
||||
}
|
||||
}
|
||||
if (oldList != null) {
|
||||
for (BizMeetingMaterial o : oldList) {
|
||||
if (o.getSubType() != null && o.getId() != null && !newSubTypes.contains(o.getSubType())) {
|
||||
bizMeetingInvoiceMapper.deleteByMaterialId(o.getId());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bizMeetingMaterialMapper.deleteByMeetingId(meetingId);
|
||||
if (list == null || list.isEmpty()) {
|
||||
return list;
|
||||
}
|
||||
// 不接受前端传的 id, 走 AUTO_INCREMENT; meetingId 兜底由路径提供
|
||||
// 未变材料的旧 id (subType -> 旧 material id), 全删全插后迁移其发票 material_id 到新 id
|
||||
Map<String, Long> unchangedOldId = new HashMap<>();
|
||||
for (BizMeetingMaterial m : list) {
|
||||
m.setId(null);
|
||||
m.setMeetingId(meetingId);
|
||||
@@ -177,6 +197,7 @@ public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService
|
||||
m.setAmount(old.getAmount());
|
||||
m.setFeeStatus(1);
|
||||
m.setExtraOssUrl(old.getExtraOssUrl());
|
||||
unchangedOldId.put(m.getSubType(), old.getId());
|
||||
} else {
|
||||
// 新增/替换: 金额清零, 会 OCR 的发票标记待计算
|
||||
m.setAmount(null);
|
||||
@@ -199,6 +220,11 @@ public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService
|
||||
for (BizMeetingMaterial m : list) {
|
||||
if (m.getSubType() != null) {
|
||||
m.setId(idBySubType.get(m.getSubType()));
|
||||
// 未变材料: 材料全删全插导致 id 变化, 迁移其发票 material_id 旧 id → 新 id
|
||||
Long oldId = unchangedOldId.get(m.getSubType());
|
||||
if (oldId != null && m.getId() != null && !m.getId().equals(oldId)) {
|
||||
bizMeetingInvoiceMapper.updateInvoiceMaterialId(oldId, m.getId());
|
||||
}
|
||||
}
|
||||
}
|
||||
return list;
|
||||
@@ -500,10 +526,74 @@ public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService
|
||||
byte[] excel = attendeeService.buildAttendeeInfoExcel(attendees);
|
||||
ossZipService.putObject(prefix + folderName + "/参会人信息/参会人信息.xlsx", excel, XLSX_CONTENT_TYPE);
|
||||
copied++;
|
||||
// 补参会人照片/身份证照/劳务协议 (存 attendee 表, 材料表扫不到, 单独 copy 进劳务 zip)
|
||||
copied += stageAttendeeFiles(attendees, prefix, folderName);
|
||||
}
|
||||
return copied;
|
||||
}
|
||||
|
||||
/** 把参会人照片(on_site_photos)/身份证照(id_card_attachments)/劳务协议(labor_protocol) copy 进劳务 zip, 返回写入文件数 */
|
||||
private int stageAttendeeFiles(List<BizMeetingAttendee> attendees, String prefix, String folderName)
|
||||
{
|
||||
int copied = 0;
|
||||
for (int i = 0; i < attendees.size(); i++)
|
||||
{
|
||||
BizMeetingAttendee a = attendees.get(i);
|
||||
// 序号 = 列表 1 起下标, 与前端表格 index 及"下载目录模板"的 {序号}_{姓名} 命名对齐
|
||||
String person = (i + 1) + "_" + safeName(a.getName());
|
||||
copied += copyCsvUrls(a.getOnSitePhotos(), prefix + folderName + "/参会人照片/" + person + "/", "照片");
|
||||
copied += copyIdCardFiles(a.getIdCardAttachments(), prefix + folderName + "/参会人身份证/" + person + "/");
|
||||
copied += copySingleUrl(a.getLaborProtocol(), prefix + folderName + "/劳务协议/", person);
|
||||
}
|
||||
return copied;
|
||||
}
|
||||
|
||||
/** 逗号分隔 OSS URL → 逐个 copy 到 dstDir, 文件名 = baseName-i{ext}; 返回 copy 数 */
|
||||
private int copyCsvUrls(String csvUrls, String dstDir, String baseName)
|
||||
{
|
||||
if (csvUrls == null || csvUrls.trim().isEmpty()) return 0;
|
||||
String[] urls = csvUrls.split(",");
|
||||
int copied = 0;
|
||||
for (int i = 0; i < urls.length; i++)
|
||||
{
|
||||
String srcKey = ossZipService.extractKey(urls[i].trim());
|
||||
if (srcKey == null || srcKey.isEmpty()) continue;
|
||||
String fileName = safeName(baseName) + "-" + (i + 1) + extOf(srcKey);
|
||||
ossZipService.copyObject(srcKey, dstDir + fileName);
|
||||
copied++;
|
||||
}
|
||||
return copied;
|
||||
}
|
||||
|
||||
/** 身份证照 (正/反两张, 顺序固定 front,back) copy 到 dstDir, 文件名 身份证-正面/身份证-反面{ext}; 返回 copy 数 */
|
||||
private int copyIdCardFiles(String csvUrls, String dstDir)
|
||||
{
|
||||
if (csvUrls == null || csvUrls.trim().isEmpty()) return 0;
|
||||
String[] urls = csvUrls.split(",");
|
||||
String[] labels = { "身份证-正面", "身份证-反面" };
|
||||
int copied = 0;
|
||||
for (int i = 0; i < urls.length && i < labels.length; i++)
|
||||
{
|
||||
String srcKey = ossZipService.extractKey(urls[i].trim());
|
||||
if (srcKey == null || srcKey.isEmpty()) continue;
|
||||
String fileName = labels[i] + extOf(srcKey);
|
||||
ossZipService.copyObject(srcKey, dstDir + fileName);
|
||||
copied++;
|
||||
}
|
||||
return copied;
|
||||
}
|
||||
|
||||
/** 单个 OSS URL → copy 到 dstDir, 文件名 = baseName{ext}; 返回 copy 数 (0/1) */
|
||||
private int copySingleUrl(String url, String dstDir, String baseName)
|
||||
{
|
||||
if (url == null || url.trim().isEmpty()) return 0;
|
||||
String srcKey = ossZipService.extractKey(url.trim());
|
||||
if (srcKey == null || srcKey.isEmpty()) return 0;
|
||||
String fileName = safeName(baseName) + extOf(srcKey);
|
||||
ossZipService.copyObject(srcKey, dstDir + fileName);
|
||||
return 1;
|
||||
}
|
||||
|
||||
/** 把会议日程海报 (biz_meeting.schedule_url) staging 到 zip 的"日程海报"目录, 返回写入的文件数 (0 = 无日程海报) */
|
||||
private int stageSchedulePoster(BizMeeting meeting, String prefix, String folderName)
|
||||
{
|
||||
@@ -732,7 +822,7 @@ public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService
|
||||
}
|
||||
// 与前端"保存"流程一致, 后台触发 OCR (识别为发票回写 amount → 会议费用汇总)
|
||||
if (ocrNeeded) {
|
||||
invoiceOcrService.submitRecognition(materialId, meetingId, ossUrl, isZip, oldMaterialId);
|
||||
invoiceOcrService.submitRecognition(materialId, meetingId, ossUrl, oldMaterialId);
|
||||
}
|
||||
updated++;
|
||||
}
|
||||
|
||||
+39
-3
@@ -329,7 +329,7 @@ public class BizSignServiceImpl implements BizSignService {
|
||||
throw new ServiceException("手写签名不能为空");
|
||||
}
|
||||
String fullPdfUrl = pdfService.htmlToPdfFile(contentHtml, "labor/" + attendee.getMeetingId());
|
||||
String maskedHtml = desensitizeHtml(contentHtml);
|
||||
String maskedHtml = desensitizeHtml(contentHtml, attendee);
|
||||
String maskedPdfUrl = pdfService.htmlToPdfFile(maskedHtml, "labor/" + attendee.getMeetingId() + "/masked");
|
||||
BizMeetingAttendee update = new BizMeetingAttendee();
|
||||
update.setId(attendeeId);
|
||||
@@ -346,7 +346,7 @@ public class BizSignServiceImpl implements BizSignService {
|
||||
return result;
|
||||
}
|
||||
|
||||
private String desensitizeHtml(String html) {
|
||||
private String desensitizeHtml(String html, BizMeetingAttendee attendee) {
|
||||
if (html == null) return null;
|
||||
java.util.regex.Pattern idCardP = java.util.regex.Pattern.compile("(?<!\\d)(\\d{17}[\\dXx])(?!\\d)");
|
||||
java.util.regex.Matcher m = idCardP.matcher(html);
|
||||
@@ -378,6 +378,42 @@ public class BizSignServiceImpl implements BizSignService {
|
||||
m.appendReplacement(sb, java.util.regex.Matcher.quoteReplacement(masked));
|
||||
}
|
||||
m.appendTail(sb);
|
||||
return sb.toString();
|
||||
html = sb.toString();
|
||||
|
||||
// sponsor 脱敏 PDF: 身份证附件是正反面照片无法文本打码, 直接移除 (不携带身份证)
|
||||
if (attendee != null && attendee.getIdCardAttachments() != null && !attendee.getIdCardAttachments().isEmpty()) {
|
||||
for (String u : attendee.getIdCardAttachments().split(",")) {
|
||||
String url = u.trim();
|
||||
if (url.isEmpty()) continue;
|
||||
html = html.replace(
|
||||
"<div style=\"margin:8px auto;text-align:center;\"><img src='" + url + "' style=\"max-width:240px;\"/></div>",
|
||||
"");
|
||||
}
|
||||
}
|
||||
|
||||
// 姓名/账户名称: 中文文本无法按数字正则定位, 用已知原值精确替换 (长串优先, 避免姓名是账户名的子串时漏脱敏)
|
||||
if (attendee != null) {
|
||||
java.util.List<String> texts = new java.util.ArrayList<>();
|
||||
if (attendee.getName() != null && !attendee.getName().trim().isEmpty()) texts.add(attendee.getName().trim());
|
||||
if (attendee.getAccountName() != null && !attendee.getAccountName().trim().isEmpty()) texts.add(attendee.getAccountName().trim());
|
||||
texts.sort((a, b) -> Integer.compare(b.length(), a.length()));
|
||||
for (String t : texts) {
|
||||
if (t.length() < 2) continue; // 单字姓名/账户名跳过, 避免误替换正文中的同字
|
||||
String masked = maskName(t);
|
||||
if (!masked.equals(t)) {
|
||||
html = html.replace(t, masked);
|
||||
}
|
||||
}
|
||||
}
|
||||
return html;
|
||||
}
|
||||
|
||||
/** 姓名脱敏: 保留首字, 其余打码 (张三 → 张*) */
|
||||
private String maskName(String v) {
|
||||
if (v == null) return null;
|
||||
String s = v.trim();
|
||||
if (s.isEmpty()) return v;
|
||||
if (s.length() <= 1) return "*";
|
||||
return s.charAt(0) + "*".repeat(s.length() - 1);
|
||||
}
|
||||
}
|
||||
+17
-8
@@ -74,13 +74,13 @@ public class InvoiceOcrService
|
||||
* @param materialId biz_meeting_material.id
|
||||
* @param meetingId 会议 ID
|
||||
* @param ossUrl OSS URL (单文件: 原图;ZIP: zip 包)
|
||||
* @param isZip true → ZIP 路径;false → 单文件路径
|
||||
* @param oldMaterialId 替换场景携带, 后端先 DELETE invoice WHERE material_id=old + material.amount=0;
|
||||
* null → 新增场景, 不做清理
|
||||
* @return 提交摘要 (立即返回, 不等 OCR 完成)
|
||||
* <p>是否 zip 不再由前端传, 后端按 ossUrl 后缀 (转小写) 判断, 避免前后端不一致.
|
||||
*/
|
||||
public RecognizeResult submitRecognition(Long materialId, Long meetingId, String ossUrl,
|
||||
boolean isZip, Long oldMaterialId)
|
||||
Long oldMaterialId)
|
||||
{
|
||||
RecognizeResult out = new RecognizeResult();
|
||||
if (materialId == null || meetingId == null || ossUrl == null || ossUrl.isEmpty())
|
||||
@@ -89,6 +89,11 @@ public class InvoiceOcrService
|
||||
out.setErrorMsg("参数缺失");
|
||||
return out;
|
||||
}
|
||||
// 是否 zip: 按 ossUrl 后缀 (转小写) 判断, 不信任前端传参
|
||||
boolean isZip = ossUrl.toLowerCase().endsWith(".zip");
|
||||
|
||||
// 提前取登录用户 ID: OCR 在后台线程池跑, 无 SecurityContext, 必须在此 (HTTP 线程) 取好
|
||||
final Long creatorId = SecurityUtils.getUserId();
|
||||
|
||||
// 1. 替换场景: 先清旧 (deleteByMaterialId + amount=0)
|
||||
if (oldMaterialId != null)
|
||||
@@ -107,7 +112,7 @@ public class InvoiceOcrService
|
||||
placeholder.setOssUrl(ossUrl);
|
||||
placeholder.setInvoiceType("SUB");
|
||||
placeholder.setRecognizeStatus("UNRECOGNIZED");
|
||||
placeholder.setCreatorId(SecurityUtils.getUserId());
|
||||
placeholder.setCreatorId(creatorId);
|
||||
placeholder.setCreateTime(new Date());
|
||||
placeholder.setUpdateTime(new Date());
|
||||
invoiceMapper.insert(placeholder);
|
||||
@@ -121,7 +126,7 @@ public class InvoiceOcrService
|
||||
{
|
||||
if (isZip)
|
||||
{
|
||||
recognizeZip(materialId, meetingId, url);
|
||||
recognizeZip(materialId, meetingId, url, creatorId);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -172,7 +177,8 @@ public class InvoiceOcrService
|
||||
? BigDecimal.valueOf(ir.getFields().getAmount())
|
||||
: BigDecimal.ZERO;
|
||||
invoiceMapper.updateStatusByMaterial(materialId, "RECOGNIZED", null);
|
||||
invoiceMapper.updateAmountByMaterial(materialId, amount);
|
||||
invoiceMapper.updateOcrResultByMaterial(materialId, amount,
|
||||
ir.getFields().getInvoiceNo(), ir.getFields().getInvoiceDate());
|
||||
materialService.updateAmount(materialId, amount);
|
||||
log.info("单文件识别成功: materialId={} amount={} elapsed={}ms",
|
||||
materialId, amount, ir.getElapsedMs());
|
||||
@@ -196,7 +202,7 @@ public class InvoiceOcrService
|
||||
* <p>
|
||||
* material 表只动一次 (末尾的 updateAmount), 即使 0 张发票也照写; 非发票文件不进任何统计
|
||||
*/
|
||||
void recognizeZip(Long materialId, Long meetingId, String zipUrl) throws Exception
|
||||
void recognizeZip(Long materialId, Long meetingId, String zipUrl, Long creatorId) throws Exception
|
||||
{
|
||||
File tmpRoot = null;
|
||||
try
|
||||
@@ -239,9 +245,11 @@ public class InvoiceOcrService
|
||||
inv.setOssUrl(newUrl);
|
||||
inv.setInvoiceType("SUB");
|
||||
inv.setAmount(amount);
|
||||
inv.setInvoiceNo(ir.getFields().getInvoiceNo());
|
||||
inv.setInvoiceDate(ir.getFields().getInvoiceDate());
|
||||
inv.setRecognizeStatus("RECOGNIZED");
|
||||
inv.setSourceFilename(f.getName());
|
||||
inv.setCreatorId(SecurityUtils.getUserId());
|
||||
inv.setCreatorId(creatorId);
|
||||
inv.setCreateTime(new Date());
|
||||
inv.setUpdateTime(new Date());
|
||||
invoiceMapper.insert(inv);
|
||||
@@ -287,7 +295,8 @@ public class InvoiceOcrService
|
||||
BigDecimal amount = ir.getFields().getAmount() != null
|
||||
? BigDecimal.valueOf(ir.getFields().getAmount())
|
||||
: BigDecimal.ZERO;
|
||||
invoiceMapper.updateStatusAndAmountByPrimaryKey(inv.getId(), "RECOGNIZED", amount, null);
|
||||
invoiceMapper.updateOcrResultByPrimaryKey(inv.getId(), amount,
|
||||
ir.getFields().getInvoiceNo(), ir.getFields().getInvoiceDate());
|
||||
materialService.updateAmount(inv.getMaterialId(), amount);
|
||||
log.info("兜底识别成功: invoiceId={} amount={}", inv.getId(), amount);
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ public class AliyunSmsSender {
|
||||
@Value("${ruoyi.sms.esignTemplate}")
|
||||
private String esignTemplate;
|
||||
|
||||
@Value("${ruoyi.sms.esignBaseUrl:https://risingdoctor.com/hg}")
|
||||
@Value("${ruoyi.sms.esignBaseUrl:https://hegui.bahim.org.cn}")
|
||||
private String esignBaseUrl;
|
||||
|
||||
@Value("${ruoyi.sms.regionId:cn-hangzhou}")
|
||||
|
||||
+45
-4
@@ -15,11 +15,15 @@
|
||||
<result property="recognizeStatus" column="recognize_status" />
|
||||
<result property="errorMsg" column="error_msg" />
|
||||
<result property="sourceFilename" column="source_filename" />
|
||||
<result property="materialSubType" column="material_sub_type" />
|
||||
<result property="materialFileName" column="material_file_name" />
|
||||
<result property="invoiceNo" column="invoice_no" />
|
||||
<result property="invoiceDate" column="invoice_date" />
|
||||
</resultMap>
|
||||
|
||||
<sql id="selectFields">
|
||||
select id, meeting_id, material_id, oss_url, invoice_type, amount, creator_id, create_time, update_time,
|
||||
recognize_status, error_msg, source_filename
|
||||
recognize_status, error_msg, source_filename, invoice_no, invoice_date
|
||||
from biz_meeting_invoice
|
||||
</sql>
|
||||
|
||||
@@ -45,9 +49,13 @@
|
||||
</select>
|
||||
|
||||
<select id="selectByMeetingId" resultMap="BizMeetingInvoiceResult" parameterType="Long">
|
||||
<include refid="selectFields"/>
|
||||
where meeting_id = #{meetingId}
|
||||
order by id desc
|
||||
select i.id, i.meeting_id, i.material_id, i.oss_url, i.invoice_type, i.amount, i.creator_id, i.create_time, i.update_time,
|
||||
i.recognize_status, i.error_msg, i.source_filename, i.invoice_no, i.invoice_date,
|
||||
m.sub_type as material_sub_type, m.file_name as material_file_name
|
||||
from biz_meeting_invoice i
|
||||
inner join biz_meeting_material m on m.id = i.material_id
|
||||
where i.meeting_id = #{meetingId}
|
||||
order by i.id desc
|
||||
</select>
|
||||
|
||||
<insert id="insert" parameterType="BizMeetingInvoice" useGeneratedKeys="true" keyProperty="id">
|
||||
@@ -64,6 +72,8 @@
|
||||
<if test="recognizeStatus != null and recognizeStatus != ''">recognize_status,</if>
|
||||
<if test="errorMsg != null">error_msg,</if>
|
||||
<if test="sourceFilename != null">source_filename,</if>
|
||||
<if test="invoiceNo != null and invoiceNo != ''">invoice_no,</if>
|
||||
<if test="invoiceDate != null and invoiceDate != ''">invoice_date,</if>
|
||||
</trim>
|
||||
<trim prefix="values (" suffix=")" suffixOverrides=",">
|
||||
<if test="meetingId != null">#{meetingId},</if>
|
||||
@@ -77,6 +87,8 @@
|
||||
<if test="recognizeStatus != null and recognizeStatus != ''">#{recognizeStatus},</if>
|
||||
<if test="errorMsg != null">#{errorMsg},</if>
|
||||
<if test="sourceFilename != null">#{sourceFilename},</if>
|
||||
<if test="invoiceNo != null and invoiceNo != ''">#{invoiceNo},</if>
|
||||
<if test="invoiceDate != null and invoiceDate != ''">#{invoiceDate},</if>
|
||||
</trim>
|
||||
</insert>
|
||||
|
||||
@@ -150,4 +162,33 @@
|
||||
where id = #{id}
|
||||
</update>
|
||||
|
||||
<!-- 单文件 OCR 完成: 更新金额 + 发票号 + 开票日期 (按 material_id, 与状态更新分开调用) -->
|
||||
<update id="updateOcrResultByMaterial">
|
||||
update biz_meeting_invoice
|
||||
set amount = #{amount},
|
||||
invoice_no = #{invoiceNo},
|
||||
invoice_date = #{invoiceDate},
|
||||
update_time = now()
|
||||
where material_id = #{materialId}
|
||||
</update>
|
||||
|
||||
<!-- 兜底 OCR 完成: 更新状态(RECOGNIZED) + 金额 + 发票号 + 开票日期 (按主键) -->
|
||||
<update id="updateOcrResultByPrimaryKey">
|
||||
update biz_meeting_invoice
|
||||
set recognize_status = 'RECOGNIZED',
|
||||
amount = #{amount},
|
||||
invoice_no = #{invoiceNo},
|
||||
invoice_date = #{invoiceDate},
|
||||
update_time = now()
|
||||
where id = #{id}
|
||||
</update>
|
||||
|
||||
<!-- 材料全删全插后, 未变材料的发票 material_id 迁移旧 id → 新 id -->
|
||||
<update id="updateInvoiceMaterialId">
|
||||
update biz_meeting_invoice
|
||||
set material_id = #{newMaterialId},
|
||||
update_time = now()
|
||||
where material_id = #{oldMaterialId}
|
||||
</update>
|
||||
|
||||
</mapper>
|
||||
@@ -114,6 +114,9 @@
|
||||
<if test="params.managerCreateUserId != null">and project_id in (
|
||||
select project_id from biz_project where create_user_id = #{params.managerCreateUserId} and is_deleted = 0
|
||||
)</if>
|
||||
<if test="params.leaderUserId != null">and project_id in (
|
||||
select project_id from biz_project where lead_user_id = #{params.leaderUserId} and is_deleted = 0
|
||||
)</if>
|
||||
</where>
|
||||
order by meeting_id desc
|
||||
</select>
|
||||
@@ -134,6 +137,7 @@
|
||||
and p2.is_finished = '1' and p2.open_status = 'N'
|
||||
)
|
||||
</if>
|
||||
<if test="params.leaderUserId != null">and project_id in (select project_id from biz_project where lead_user_id = #{params.leaderUserId} and is_deleted = 0)</if>
|
||||
</where>
|
||||
group by current_stage
|
||||
</select>
|
||||
|
||||
@@ -362,6 +362,7 @@
|
||||
<where>
|
||||
p.is_deleted = 0
|
||||
<if test="params.createUserId != null">and create_user_id = #{params.createUserId}</if>
|
||||
<if test="params.leaderUserId != null">and p.lead_user_id = #{params.leaderUserId}</if>
|
||||
<if test="isPublished != null and isPublished != ''">and p.is_published = #{isPublished}</if>
|
||||
<if test="params.projectIds != null and params.projectIds.size() > 0">
|
||||
and project_id in
|
||||
|
||||
@@ -73,6 +73,9 @@ public class HttpUtils
|
||||
log.info("sendGet - {}", urlNameString);
|
||||
URL realUrl = new URL(urlNameString);
|
||||
URLConnection connection = realUrl.openConnection();
|
||||
// 连接/读超时: 防止外部接口不可达时无限挂起 (后台子任务会占死单线程调度器)
|
||||
connection.setConnectTimeout(10000);
|
||||
connection.setReadTimeout(30000);
|
||||
connection.setRequestProperty("accept", "*/*");
|
||||
connection.setRequestProperty("connection", "Keep-Alive");
|
||||
connection.setRequestProperty("user-agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64)");
|
||||
|
||||
@@ -16,6 +16,7 @@ import org.springframework.security.web.authentication.logout.LogoutFilter;
|
||||
import org.springframework.web.filter.CorsFilter;
|
||||
import com.ruoyi.framework.config.properties.PermitAllUrlProperties;
|
||||
import com.ruoyi.framework.security.filter.JwtAuthenticationTokenFilter;
|
||||
import com.ruoyi.framework.security.filter.LeaderReadOnlyFilter;
|
||||
import com.ruoyi.framework.security.handle.AuthenticationEntryPointImpl;
|
||||
import com.ruoyi.framework.security.handle.LogoutSuccessHandlerImpl;
|
||||
|
||||
@@ -80,6 +81,8 @@ public class SecurityConfig
|
||||
})
|
||||
.logout(logout -> logout.logoutUrl("/logout").logoutSuccessHandler(logoutSuccessHandler))
|
||||
.addFilterBefore(authenticationTokenFilter, UsernamePasswordAuthenticationFilter.class)
|
||||
// leader 只读拦截: 在 JWT 鉴权之后 (SecurityContext 已就绪), 任何业务写操作前拦下
|
||||
.addFilterAfter(new LeaderReadOnlyFilter(), JwtAuthenticationTokenFilter.class)
|
||||
.addFilterBefore(corsFilter, JwtAuthenticationTokenFilter.class)
|
||||
.addFilterBefore(corsFilter, LogoutFilter.class)
|
||||
.build();
|
||||
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
package com.ruoyi.framework.security.filter;
|
||||
|
||||
import java.io.IOException;
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
import com.alibaba.fastjson2.JSON;
|
||||
import com.ruoyi.common.constant.HttpStatus;
|
||||
import com.ruoyi.common.core.domain.AjaxResult;
|
||||
import com.ruoyi.common.utils.SecurityUtils;
|
||||
import com.ruoyi.common.utils.ServletUtils;
|
||||
import com.ruoyi.common.utils.StringUtils;
|
||||
|
||||
/**
|
||||
* 「项目负责人」(leader) 角色只读拦截.
|
||||
*
|
||||
* <p>leader 是纯查看角色: 除了查看自己的资料/改密码 (账号信息页), 不允许任何业务写操作.
|
||||
* 后端兜底 (前端隐藏按钮之外, 防 devtools 直接调接口):
|
||||
* <ul>
|
||||
* <li>GET 请求一律放行.</li>
|
||||
* <li>非 GET (POST/PUT/DELETE) 仅放行本人资料维护: {@code /system/user/profile} 与
|
||||
* {@code /system/user/profile/updatePwd} (SysProfileController 的 GET/PUT 与改密码).</li>
|
||||
* <li>其余非 GET 一律 403.</li>
|
||||
* </ul>
|
||||
* 与 JwtAuthenticationTokenFilter 之后注册 (SecurityConfig), 依赖其已写入 SecurityContext.
|
||||
*/
|
||||
public class LeaderReadOnlyFilter extends OncePerRequestFilter
|
||||
{
|
||||
/** 放行的本人资料维护路径 (账号信息页) */
|
||||
private static final String[] SELF_SERVICE_ALLOWED = {
|
||||
"/system/user/profile",
|
||||
"/system/user/profile/updatePwd",
|
||||
};
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
|
||||
throws ServletException, IOException
|
||||
{
|
||||
if (HttpMethod.GET.matches(request.getMethod())
|
||||
|| !isLeader()
|
||||
|| isSelfService(request.getRequestURI()))
|
||||
{
|
||||
chain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
// leader 非 GET 且非本人资料维护 → 只读拒绝
|
||||
response.setStatus(HttpStatus.FORBIDDEN);
|
||||
ServletUtils.renderString(response, JSON.toJSONString(
|
||||
AjaxResult.error(HttpStatus.FORBIDDEN, "项目负责人无操作权限")));
|
||||
}
|
||||
|
||||
/** 当前登录人是否为 leader (role_type 单一可信源), 未登录/异常视为非 leader (放行交给后续鉴权) */
|
||||
private boolean isLeader()
|
||||
{
|
||||
try
|
||||
{
|
||||
return "leader".equals(SecurityUtils.getLoginUser().getUser().getRoleType());
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 是否命中本人资料维护白名单 (账号信息页: 查看/修改资料 + 改密码) */
|
||||
private boolean isSelfService(String uri)
|
||||
{
|
||||
if (StringUtils.isEmpty(uri))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
for (String path : SELF_SERVICE_ALLOWED)
|
||||
{
|
||||
if (uri.equals(path) || uri.startsWith(path + "/"))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
Reference in New Issue
Block a user