diff --git a/ry-api/ruoyi-admin/src/main/resources/application-druid.yml b/ry-api/ruoyi-admin/src/main/resources/application-druid.yml index 2398376..9ed08ce 100644 --- a/ry-api/ruoyi-admin/src/main/resources/application-druid.yml +++ b/ry-api/ruoyi-admin/src/main/resources/application-druid.yml @@ -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: diff --git a/ry-api/ruoyi-admin/src/main/resources/application-prod.yml b/ry-api/ruoyi-admin/src/main/resources/application-prod.yml new file mode 100644 index 0000000..125515b --- /dev/null +++ b/ry-api/ruoyi-admin/src/main/resources/application-prod.yml @@ -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/ diff --git a/ry-api/ruoyi-admin/src/main/resources/application-test.yml b/ry-api/ruoyi-admin/src/main/resources/application-test.yml new file mode 100644 index 0000000..10211c1 --- /dev/null +++ b/ry-api/ruoyi-admin/src/main/resources/application-test.yml @@ -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/ diff --git a/ry-api/ruoyi-admin/src/main/resources/application.yml b/ry-api/ruoyi-admin/src/main/resources/application.yml index fd4f0a5..60135b7 100644 --- a/ry-api/ruoyi-admin/src/main/resources/application.yml +++ b/ry-api/ruoyi-admin/src/main/resources/application.yml @@ -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: diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizMeetingAttendeeController.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizMeetingAttendeeController.java index 083815c..f5ba328 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizMeetingAttendeeController.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizMeetingAttendeeController.java @@ -72,10 +72,18 @@ public class BizMeetingAttendeeController extends BaseController { /** * 管理端: 某会议的全部参会人 (MeetingDetail 参会人 CRUD 用). * 返回全字段, 前端按需展示. + *

sponsor (监督方) 只读且敏感信息脱敏: 姓名/手机号/银行卡号/账户名称/身份证号在后端打码, + * 明文不下发 (前端遮罩只是显示层, 不是安全边界). */ @GetMapping("/list/{meetingId}") public AjaxResult listByMeetingId(@PathVariable("meetingId") Long meetingId) { - return success(attendeeService.selectByMeetingId(meetingId)); + List 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); + } + } \ No newline at end of file diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizMeetingController.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizMeetingController.java index 6992aba..197c58c 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizMeetingController.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizMeetingController.java @@ -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 list = bizMeetingService.selectList(bizMeeting); return getDataTable(list); diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizMeetingInvoiceController.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizMeetingInvoiceController.java index 798009b..c72beb1 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizMeetingInvoiceController.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizMeetingInvoiceController.java @@ -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; *

* 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; + /** * 提交发票识别 (后台执行, 立即返回) *

* 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 顶部「发票表」展示) + *

+ * 返回 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; } } diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizMeetingStageStatsController.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizMeetingStageStatsController.java index 6c20d66..2b647b2 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizMeetingStageStatsController.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizMeetingStageStatsController.java @@ -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 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 doStats(BizMeeting q) { + Map result = new HashMap<>(); for (Map r : bizMeetingService.selectStageStats(q)) { - // resultType=map 的 key 大小写随 JDBC 驱动, 做大小写不敏感匹配, 避免"统计恒 0" String stage = null; long cnt = 0L; for (Map.Entry e : r.entrySet()) { @@ -66,6 +77,6 @@ public class BizMeetingStageStatsController extends BaseController result.put(stage, cnt); } } - return success(result); + return result; } } diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizPersonController.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizPersonController.java index c74b6ae..4aa0c70 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizPersonController.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizPersonController.java @@ -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 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 visibleUserIds = new ArrayList<>(); + visibleUserIds.add(currentUserId); + if (subUserIds != null) { + visibleUserIds.addAll(subUserIds); + } + bizPerson.getParams().put("subUserIds", visibleUserIds); } startPage(); List list = bizPersonService.selectList(bizPerson); diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizProjectController.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizProjectController.java index ba271e8..7fe67fb 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizProjectController.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizProjectController.java @@ -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 list = bizProjectService.selectList(bizProject); @@ -192,6 +194,7 @@ public class BizProjectController extends BaseController List list = bizProjectService.selectExecutorList(bizProject); return getDataTable(list); } + @GetMapping("/{projectId}") public AjaxResult getInfo(@PathVariable("projectId") Long projectId) { diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizPublicController.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizPublicController.java index 92ba2a6..471b997 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizPublicController.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizPublicController.java @@ -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. + *

数据源: 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 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)); diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/BizMeetingInvoice.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/BizMeetingInvoice.java index 2a2ad3c..8c5cca7 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/BizMeetingInvoice.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/BizMeetingInvoice.java @@ -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; } } \ No newline at end of file diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/vo/BizMeetingAttendeeImportVo.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/vo/BizMeetingAttendeeImportVo.java index c436ed1..ee13ec8 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/vo/BizMeetingAttendeeImportVo.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/vo/BizMeetingAttendeeImportVo.java @@ -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; } diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/mapper/BizMeetingInvoiceMapper.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/mapper/BizMeetingInvoiceMapper.java index b5502bf..e0316c2 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/mapper/BizMeetingInvoiceMapper.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/mapper/BizMeetingInvoiceMapper.java @@ -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); } \ No newline at end of file diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/ocr/ZipExtractor.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/ocr/ZipExtractor.java index 6ddaba0..009d87d 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/ocr/ZipExtractor.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/ocr/ZipExtractor.java @@ -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 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 doExtract(File zipFile, File tmpRoot, Charset charset) throws IOException + { + List 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) { diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/pdf/SealStamper.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/pdf/SealStamper.java new file mode 100644 index 0000000..7d75b11 --- /dev/null +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/pdf/SealStamper.java @@ -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; + +/** + * 劳务协议盖章 (固定定位) + * + *

在指定页 (默认第 2 页) 固定坐标叠加公章: x 固定, y = 页面垂直中心偏上 yOffset. + * 使用 {@link PdfDocumentEvent#END_PAGE} 事件叠加在页面内容流之后, 公章 z 序在正文之上. + * + *

容错: 印章图片缺失 / 加载失败时只记日志并跳过, 不阻断 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(); + } + } +} diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/scheduler/SupplierAccountPullScheduler.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/scheduler/SupplierAccountPullScheduler.java index e40e4a9..1a54d01 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/scheduler/SupplierAccountPullScheduler.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/scheduler/SupplierAccountPullScheduler.java @@ -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"); diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/PdfService.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/PdfService.java index 5a41683..d3382e1 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/PdfService.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/PdfService.java @@ -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(); diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/StageDeriver.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/StageDeriver.java index 205f792..8ee1af9 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/StageDeriver.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/StageDeriver.java @@ -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 "待结算"; } diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizAdminUserServiceImpl.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizAdminUserServiceImpl.java index f320d28..87897d6 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizAdminUserServiceImpl.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizAdminUserServiceImpl.java @@ -26,7 +26,7 @@ import com.ruoyi.system.service.ISysUserService; @Service public class BizAdminUserServiceImpl implements IBizAdminUserService { - private static final Set ROLE_WHITELIST = Set.of("admin", "manager", "doctor", "executor", "sponsor"); + private static final Set 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); diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizMeetingMaterialServiceImpl.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizMeetingMaterialServiceImpl.java index b1a0ed9..9555cb0 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizMeetingMaterialServiceImpl.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizMeetingMaterialServiceImpl.java @@ -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 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 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 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 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 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++; } diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizSignServiceImpl.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizSignServiceImpl.java index 62fc987..fb3e959 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizSignServiceImpl.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizSignServiceImpl.java @@ -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("(?", + ""); + } + } + + // 姓名/账户名称: 中文文本无法按数字正则定位, 用已知原值精确替换 (长串优先, 避免姓名是账户名的子串时漏脱敏) + if (attendee != null) { + java.util.List 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); } } \ No newline at end of file diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/InvoiceOcrService.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/InvoiceOcrService.java index 7931abc..c98a4f1 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/InvoiceOcrService.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/InvoiceOcrService.java @@ -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 完成) + *

是否 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 *

* 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); } diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/sms/AliyunSmsSender.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/sms/AliyunSmsSender.java index 8ff50ea..f077162 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/sms/AliyunSmsSender.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/sms/AliyunSmsSender.java @@ -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}") diff --git a/ry-api/ruoyi-business/src/main/resources/mapper/business/BizMeetingInvoiceMapper.xml b/ry-api/ruoyi-business/src/main/resources/mapper/business/BizMeetingInvoiceMapper.xml index 075b186..88c961b 100644 --- a/ry-api/ruoyi-business/src/main/resources/mapper/business/BizMeetingInvoiceMapper.xml +++ b/ry-api/ruoyi-business/src/main/resources/mapper/business/BizMeetingInvoiceMapper.xml @@ -15,11 +15,15 @@ + + + + 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 @@ -45,9 +49,13 @@ @@ -64,6 +72,8 @@ recognize_status, error_msg, source_filename, + invoice_no, + invoice_date, #{meetingId}, @@ -77,6 +87,8 @@ #{recognizeStatus}, #{errorMsg}, #{sourceFilename}, + #{invoiceNo}, + #{invoiceDate}, @@ -150,4 +162,33 @@ where id = #{id} + + + update biz_meeting_invoice + set amount = #{amount}, + invoice_no = #{invoiceNo}, + invoice_date = #{invoiceDate}, + update_time = now() + where material_id = #{materialId} + + + + + update biz_meeting_invoice + set recognize_status = 'RECOGNIZED', + amount = #{amount}, + invoice_no = #{invoiceNo}, + invoice_date = #{invoiceDate}, + update_time = now() + where id = #{id} + + + + + update biz_meeting_invoice + set material_id = #{newMaterialId}, + update_time = now() + where material_id = #{oldMaterialId} + + \ No newline at end of file diff --git a/ry-api/ruoyi-business/src/main/resources/mapper/business/BizMeetingMapper.xml b/ry-api/ruoyi-business/src/main/resources/mapper/business/BizMeetingMapper.xml index 5e531ad..6fc7eae 100644 --- a/ry-api/ruoyi-business/src/main/resources/mapper/business/BizMeetingMapper.xml +++ b/ry-api/ruoyi-business/src/main/resources/mapper/business/BizMeetingMapper.xml @@ -114,6 +114,9 @@ and project_id in ( select project_id from biz_project where create_user_id = #{params.managerCreateUserId} and is_deleted = 0 ) + and project_id in ( + select project_id from biz_project where lead_user_id = #{params.leaderUserId} and is_deleted = 0 + ) order by meeting_id desc @@ -134,6 +137,7 @@ and p2.is_finished = '1' and p2.open_status = 'N' ) + and project_id in (select project_id from biz_project where lead_user_id = #{params.leaderUserId} and is_deleted = 0) group by current_stage diff --git a/ry-api/ruoyi-business/src/main/resources/mapper/business/BizProjectMapper.xml b/ry-api/ruoyi-business/src/main/resources/mapper/business/BizProjectMapper.xml index 3df9052..34a8766 100644 --- a/ry-api/ruoyi-business/src/main/resources/mapper/business/BizProjectMapper.xml +++ b/ry-api/ruoyi-business/src/main/resources/mapper/business/BizProjectMapper.xml @@ -362,6 +362,7 @@ p.is_deleted = 0 and create_user_id = #{params.createUserId} + and p.lead_user_id = #{params.leaderUserId} and p.is_published = #{isPublished} and project_id in diff --git a/ry-api/ruoyi-common/src/main/java/com/ruoyi/common/utils/http/HttpUtils.java b/ry-api/ruoyi-common/src/main/java/com/ruoyi/common/utils/http/HttpUtils.java index 534d21c..c6c6771 100644 --- a/ry-api/ruoyi-common/src/main/java/com/ruoyi/common/utils/http/HttpUtils.java +++ b/ry-api/ruoyi-common/src/main/java/com/ruoyi/common/utils/http/HttpUtils.java @@ -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)"); diff --git a/ry-api/ruoyi-framework/src/main/java/com/ruoyi/framework/config/SecurityConfig.java b/ry-api/ruoyi-framework/src/main/java/com/ruoyi/framework/config/SecurityConfig.java index ea33312..837b069 100644 --- a/ry-api/ruoyi-framework/src/main/java/com/ruoyi/framework/config/SecurityConfig.java +++ b/ry-api/ruoyi-framework/src/main/java/com/ruoyi/framework/config/SecurityConfig.java @@ -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(); diff --git a/ry-api/ruoyi-framework/src/main/java/com/ruoyi/framework/security/filter/LeaderReadOnlyFilter.java b/ry-api/ruoyi-framework/src/main/java/com/ruoyi/framework/security/filter/LeaderReadOnlyFilter.java new file mode 100644 index 0000000..c53359a --- /dev/null +++ b/ry-api/ruoyi-framework/src/main/java/com/ruoyi/framework/security/filter/LeaderReadOnlyFilter.java @@ -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) 角色只读拦截. + * + *

leader 是纯查看角色: 除了查看自己的资料/改密码 (账号信息页), 不允许任何业务写操作. + * 后端兜底 (前端隐藏按钮之外, 防 devtools 直接调接口): + *

+ * 与 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; + } +} diff --git a/ry-api/zhenghe.png b/ry-api/zhenghe.png new file mode 100644 index 0000000..6f3087d Binary files /dev/null and b/ry-api/zhenghe.png differ diff --git a/ry-h5/pages/signin/index.uvue b/ry-h5/pages/signin/index.uvue index 3eee089..2c0905c 100644 --- a/ry-h5/pages/signin/index.uvue +++ b/ry-h5/pages/signin/index.uvue @@ -66,9 +66,9 @@ import { useCamera } from '@/composables/useCamera' const videoId = 'signin-video' -// A4 纸: 从顶边到底边的一竖条, 水平位置在 70%~85% 宽度 (签到人手机号/身份证号所在列) 做高斯模糊脱敏 +// A4 纸: 从顶边到底边的一竖条, 水平位置在 60%~70% 宽度 (签到人手机号/身份证号所在列) 做高斯模糊脱敏 const { captured, errorMsg, startCamera, flipCamera, takePhoto, retake, confirm } = - useCamera(videoId, { start: 0.7, end: 0.85, radius: 20 }) + useCamera(videoId, { start: 0.6, end: 0.7, radius: 20 }) function goBack() { uni.navigateBack() diff --git a/ry-vue3/.env.production b/ry-vue3/.env.production index b2b9dbd..2325198 100644 --- a/ry-vue3/.env.production +++ b/ry-vue3/.env.production @@ -1,5 +1,5 @@ # 生产环境 API 基路径 (nginx 反向代理前缀) VITE_APP_BASE_API = '/hg-api' -# 生产环境静态资源子路径 (nginx 部署在 /hg/) -VITE_BASE = '/hg/' +# 生产环境静态资源子路径 (nginx 部署在根路径 /) +VITE_BASE = '/' diff --git a/ry-vue3/.env.test b/ry-vue3/.env.test new file mode 100644 index 0000000..eafed6e --- /dev/null +++ b/ry-vue3/.env.test @@ -0,0 +1,5 @@ +# 测试环境 API 基路径 (nginx 反向代理前缀) +VITE_APP_BASE_API = '/hegui-api' + +# 测试环境静态资源子路径 (nginx 部署在 /hegui/) +VITE_BASE = '/hegui/' diff --git a/ry-vue3/package.json b/ry-vue3/package.json index 9198359..2d96b67 100644 --- a/ry-vue3/package.json +++ b/ry-vue3/package.json @@ -4,7 +4,9 @@ "private": true, "scripts": { "dev": "vite", - "build": "vite build", + "build": "vite build --mode production", + "build:prod": "vite build --mode production", + "build:test": "vite build --mode test", "preview": "vite preview" }, "dependencies": { diff --git a/ry-vue3/src/api/system.js b/ry-vue3/src/api/system.js index 9d95cf4..32b043c 100644 --- a/ry-vue3/src/api/system.js +++ b/ry-vue3/src/api/system.js @@ -20,8 +20,8 @@ export function listByRole(query) { // 兼容旧名 (项目分配弹窗已不再使用, 保留避免破坏其它 import) export const listExecutor = (query) => listByRole({ roleType: 'executor', ...query }) export const listSupporters = (query) => listByRole({ roleType: 'sponsor', ...query }) -// 合规管理员 (sys_user.role_type='manager', 选项目负责人用) -export const listManagers = (query) => listByRole({ roleType: 'manager', ...query }) +// 项目负责人 (sys_user.role_type='leader', 选项目负责人用) +export const listLeaders = (query) => listByRole({ roleType: 'leader', ...query }) // 支持方下拉选项 (JOIN biz_org + sys_user): 返回 [{ orgId, orgName, userName }, ...] // 用于 manager 项目分配弹窗按公司名搜索 + 选公司 diff --git a/ry-vue3/src/assets/login.jpg b/ry-vue3/src/assets/login.jpg index c322520..c2b4cfb 100644 Binary files a/ry-vue3/src/assets/login.jpg and b/ry-vue3/src/assets/login.jpg differ diff --git a/ry-vue3/src/components/OssFileUploader.vue b/ry-vue3/src/components/OssFileUploader.vue index 8629f93..eba76ee 100644 --- a/ry-vue3/src/components/OssFileUploader.vue +++ b/ry-vue3/src/components/OssFileUploader.vue @@ -27,14 +27,14 @@
- {{ fileName }} + {{ fileName }} {{ ext.toUpperCase() }} · {{ fileSizeText }}
移除
- + {{ getFileType(modelValue) }} @@ -100,6 +100,18 @@ const isImage = computed(() => { if (!props.modelValue) return false return ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg'].includes(ext.value) }) +// PDF: 点击不下载, 走 /common/oss/proxy 后端代理重写 Content-Disposition 为 inline, 新窗口内嵌预览 +const isPdf = computed(() => ext.value.toLowerCase() === 'pdf') +// OSS 代理预览 (与 Preview.vue / PublicityDetail 同技术): bucket 默认 attachment, 直接打开会强制下载 +function proxyUrl(url) { + if (!url) return url + if (url.includes('hwtossbamlorgcn.oss-cn-beijing.aliyuncs.com')) { + return import.meta.env.VITE_APP_BASE_API + '/common/oss/proxy?url=' + encodeURIComponent(url) + '#toolbar=0&zoom=page-width' + } + return url +} +// 文件名/预览框点击目标: PDF → 代理预览(新窗口), 其余 → 原 URL (图片 el-image 预览, 其他下载) +const openHref = computed(() => isPdf.value ? proxyUrl(props.modelValue) : props.modelValue) // 文件类型标签 (PDF / DOC / XLS / ZIP 等) const typeLabelMap = { pdf: 'PDF', doc: 'DOC', docx: 'DOCX', diff --git a/ry-vue3/src/layout/AdminLayout.vue b/ry-vue3/src/layout/AdminLayout.vue index 9e2709a..1e9b192 100644 --- a/ry-vue3/src/layout/AdminLayout.vue +++ b/ry-vue3/src/layout/AdminLayout.vue @@ -138,7 +138,8 @@ const NOTICE_PATH = { manager: '/manager/messages', doctor: '/doctor/messages', executor: '/executor/messages', - sponsor: '/sponsor/messages' + sponsor: '/sponsor/messages', + leader: '/leader/messages' } function goNotice() { const p = NOTICE_PATH[role.value] || '/' @@ -151,7 +152,8 @@ const HOME_PATH = { manager: '/manager/workbench', doctor: '/doctor/home', executor: '/executor/overview', - sponsor: '/sponsor/home' + sponsor: '/sponsor/home', + leader: '/leader/home' } function goMyHome() { router.push(HOME_PATH[role.value] || '/') @@ -208,7 +210,7 @@ onBeforeUnmount(() => { if (stopWatchRoute) stopWatchRoute() }) -const ROLE_LABEL = { manager: '合规人员', admin: '后台管理员', doctor: '评审专家', executor: '执行人', sponsor: '支持方' } +const ROLE_LABEL = { manager: '合规人员', admin: '后台管理员', doctor: '评审专家', executor: '执行人', sponsor: '支持方', leader: '项目负责人' } const roleLabel = computed(() => ROLE_LABEL[role.value] || '工作台') const MENU = { @@ -272,6 +274,12 @@ const MENU = { { path: '/sponsor/people', title: '人员管理', icon: User, requireMain: true }, { path: '/sponsor/messages', title: '消息通知', icon: Bell }, { path: '/sponsor/account', title: '账号信息', icon: Setting } + ], + leader: [ + { path: '/leader/home', title: '首页', icon: House }, + { path: '/leader/projects', title: '项目列表', icon: Document }, + { path: '/leader/meetings', title: '会议列表', icon: Calendar }, + { path: '/leader/account', title: '账号信息', icon: Setting } ] } const menu = computed(() => (MENU[role.value] || []).filter(item => diff --git a/ry-vue3/src/router/index.js b/ry-vue3/src/router/index.js index 42d5f44..5b22dc7 100644 --- a/ry-vue3/src/router/index.js +++ b/ry-vue3/src/router/index.js @@ -153,6 +153,17 @@ const routes = [ { path: 'messages', name: 'sponsor-messages', component: () => import('@/views/messages/Messages.vue'), meta: { title: '消息通知' } } ] }, + { path: '/leader', component: AdminLayout, meta: { role: 'leader' }, children: [ + { path: '', redirect: { name: 'leader-home' } }, + { path: 'home', name: 'leader-home', component: () => import('@/views/leader/Home.vue'), meta: { title: '首页' } }, + { path: 'projects', name: 'leader-projects', component: () => import('@/views/manager/Projects.vue'), meta: { title: '项目列表' } }, + { path: 'projects/detail/:projectId', name: 'leader-projects-detail', component: () => import('@/views/manager/ManagerProjectDetail.vue'), meta: { title: '项目详情', readonly: true } }, + { path: 'meetings', name: 'leader-meetings', component: () => import('@/views/meetings/Meetings.vue'), meta: { title: '会议列表' } }, + { path: 'meetings/view/:meetingId', name: 'leader-meetings-view', component: () => import('@/views/meetings/MeetingDetail.vue'), meta: { title: '会议详情', readonly: true } }, + { path: 'messages', name: 'leader-messages', component: () => import('@/views/messages/Messages.vue'), meta: { title: '消息通知' } }, + { path: 'account', name: 'leader-account', component: () => import('@/views/leader/Account.vue'), meta: { title: '账号信息' } } + ] + }, { path: '/:pathMatch(.*)*', redirect: '/' } ] @@ -167,7 +178,8 @@ const ROLE_HOME = { manager: '/manager/workbench', doctor: '/doctor/home', executor: '/executor/overview', - sponsor: '/sponsor/home' + sponsor: '/sponsor/home', + leader: '/leader/home' } router.beforeEach((to, from, next) => { diff --git a/ry-vue3/src/utils/meetingStage.js b/ry-vue3/src/utils/meetingStage.js index 5f09eed..27c7bf8 100644 --- a/ry-vue3/src/utils/meetingStage.js +++ b/ry-vue3/src/utils/meetingStage.js @@ -85,10 +85,10 @@ function render(role, s, phase) { if (phase === 1) return '执行中' return '已执行' case 2: // C0 合规审中 - if (role === 'sponsor') return '已执行未传材料' // 只读 return '待审核' case 3: // C1 支持方审中 if (role === 'manager') return '审核通过' + if (role === 'sponsor') return '待监察' return '待审核' default: // 4 = A 已通过 return '待结算' @@ -142,7 +142,7 @@ export function deriveStage(role, row) { else { track = 'service'; label = sl } } // 仅 待审核/审核通过/待整改/待结算 四类加前缀 - if (label === '待审核' || label === '审核通过' || label === '待整改' || label === '待结算') { + if (label === '待审核' || label === '审核通过' || label === '待整改' || label === '待结算' || label === '待监察') { return (track === 'labor' ? '劳务' : '会务') + label } return label @@ -166,7 +166,7 @@ export function stageLabel(role, row) { * 展示阶段名 → 颜色映射 (class + el-tag type), 与 render() 措辞一一对应. * 颜色跟随「各角色看到的展示阶段」而非物理阶段, 避免文案与颜色错位 * (如 sponsor 看「待审核」却因物理阶段 RECTIFYING 显示红色). - * 规则: 待整改=红, 未执行=灰, 执行中/已执行/已执行未传材料=蓝, 待审核=橙, 通过/待结算/完结=绿. + * 规则: 待整改=红, 未执行=灰, 执行中/已执行=蓝, 待审核/待监察=橙, 通过/待结算/完结=绿. */ const STAGE_STYLE = { '冻结中': { cls: 'frozen', tag: 'info' }, @@ -176,12 +176,14 @@ const STAGE_STYLE = { '未执行': { cls: 'pending', tag: 'info' }, '执行中': { cls: 'running', tag: 'primary' }, '已执行': { cls: 'running', tag: 'primary' }, - '已执行未传材料': { cls: 'running', tag: 'primary' }, '待审核': { cls: 'reviewing', tag: 'warning' }, + '待监察': { cls: 'reviewing', tag: 'warning' }, '审核通过': { cls: 'done', tag: 'success' }, '待结算': { cls: 'done', tag: 'success' }, '劳务待审核': { cls: 'reviewing', tag: 'warning' }, '会务待审核': { cls: 'reviewing', tag: 'warning' }, + '劳务待监察': { cls: 'reviewing', tag: 'warning' }, + '会务待监察': { cls: 'reviewing', tag: 'warning' }, '劳务审核通过': { cls: 'done', tag: 'success' }, '会务审核通过': { cls: 'done', tag: 'success' }, '劳务待整改': { cls: 'waiting', tag: 'danger' }, @@ -204,6 +206,20 @@ export function stageTag(role, row) { return stageStyle(role, row).tag } +/** + * 阶段标签悬停注释 (title): 「待结算」态区分劳务/会务. + * 结算为会议级 (两轨都 APPROVED 才进入), 但两笔费用各自独立: 已 APPROVED 的那一轨即「待结算」. + * 返回空串表示无注释, 前端据此不挂 title 属性, 避免空 tooltip. + */ +export function stageTitle(row) { + if (!row) return '' + if (isTrue(row.isSettled) || isTrue(row.isFinished) || isTrue(row.isFrozen)) return '' + const parts = [] + if (laborState(row) === 4) parts.push('劳务费待结算') + if (serviceState(row) === 4) parts.push('会务费待结算') + return parts.join('、') +} + /** * 物理状态机全量 10 值 (用于筛选下拉: label=中性名, value=physical code). * 筛选按物理状态精确匹配, 显示层才按角色折叠, 避免多值 IN 复杂化. diff --git a/ry-vue3/src/views/admin/Users.vue b/ry-vue3/src/views/admin/Users.vue index 39cfa17..f256b57 100644 --- a/ry-vue3/src/views/admin/Users.vue +++ b/ry-vue3/src/views/admin/Users.vue @@ -189,7 +189,7 @@ const userStore = useUserStore() const ROLE_LABEL = { admin: '后台管理员', manager: '合规人员', - doctor: '评审专家', executor: '执行人', sponsor: '支持方' + doctor: '评审专家', executor: '执行人', sponsor: '支持方', leader: '项目负责人' } const roleOptions = Object.entries(ROLE_LABEL).map(([value, label]) => ({ value, label })) @@ -299,7 +299,7 @@ const createForm = reactive({ accountType: 'MAIN', orgId: null, orgName: '', businessNature: '', contactName: '', contactPhone: '' }) -const isSimpleRole = computed(() => createForm.roleType === 'admin' || createForm.roleType === 'manager') +const isSimpleRole = computed(() => createForm.roleType === 'admin' || createForm.roleType === 'manager' || createForm.roleType === 'leader') const isUnitRole = computed(() => createForm.roleType === 'executor' || createForm.roleType === 'sponsor') function openCreate() { diff --git a/ry-vue3/src/views/auth/Login.vue b/ry-vue3/src/views/auth/Login.vue index 3179f6b..21b5a00 100644 --- a/ry-vue3/src/views/auth/Login.vue +++ b/ry-vue3/src/views/auth/Login.vue @@ -345,6 +345,7 @@ const roleHome = { doctor: '/doctor/home', executor: '/executor/overview', sponsor: '/sponsor/home', + leader: '/leader/home', } /** redirect 是否属于当前角色: 防止 doctor 拿到 manager 的 redirect 后跳过去被踢回 login ("现在评审专家登录后不跳转" 的根本原因) */ @@ -371,9 +372,14 @@ function confirmRegister() { ElMessage.warning('请选择用户类别') return } + // 执行方(供应商)注册: 跳转到独立供应商注册系统, 不走本系统注册页 + if (registerUserType.value === 'executor') { + showRoleModal.value = false + window.location.href = 'https://zbsuppliertest.guojustar.com/' + return + } const routeMap = { expert: '/register-expert', - executor: '/register-executor', sponsor: '/register-sponsor' } const target = routeMap[registerUserType.value] diff --git a/ry-vue3/src/views/leader/Account.vue b/ry-vue3/src/views/leader/Account.vue new file mode 100644 index 0000000..37710bc --- /dev/null +++ b/ry-vue3/src/views/leader/Account.vue @@ -0,0 +1,100 @@ + + + + + diff --git a/ry-vue3/src/views/leader/Home.vue b/ry-vue3/src/views/leader/Home.vue new file mode 100644 index 0000000..97513c0 --- /dev/null +++ b/ry-vue3/src/views/leader/Home.vue @@ -0,0 +1,153 @@ + + + + + diff --git a/ry-vue3/src/views/manager/Plans.vue b/ry-vue3/src/views/manager/Plans.vue index af89321..6692d27 100644 --- a/ry-vue3/src/views/manager/Plans.vue +++ b/ry-vue3/src/views/manager/Plans.vue @@ -98,9 +98,7 @@ - - - + @@ -157,15 +155,6 @@ async function loadPlanDirectionOptions() { } catch { planDirectionOptions.value = [] } } -// 项目编号下拉选项: [{projectNo, projectName}], 审核通过时选择用 -const projectNoOptions = ref([]) -async function loadProjectNoOptions() { - try { - const { data } = await request.get('/business/project/projectNoOptions') - projectNoOptions.value = (data && data.data) || data || [] - } catch { projectNoOptions.value = [] } -} - const router = useRouter() const q = ref({ planName: '', projectNo: '', planDirectionId: '', planCategory: '', status: '', isSettled: '', remark: '' }) const rows = ref([]); const selection = ref([]) @@ -230,7 +219,6 @@ function onAudit(row) { auditForm.result = '2' auditForm.projectNo = '' auditForm.opinion = '' - loadProjectNoOptions() auditOpen.value = true } async function submitAudit() { diff --git a/ry-vue3/src/views/manager/Projects.vue b/ry-vue3/src/views/manager/Projects.vue index 36bb15d..0d0328a 100644 --- a/ry-vue3/src/views/manager/Projects.vue +++ b/ry-vue3/src/views/manager/Projects.vue @@ -1,6 +1,6 @@