diff --git a/_self/manager_sponsor_orgs.md b/_self/manager_sponsor_orgs.md index 0fd4152..7796ff0 100644 --- a/_self/manager_sponsor_orgs.md +++ b/_self/manager_sponsor_orgs.md @@ -212,7 +212,7 @@ COMMENT='֧�ֵ�λ��'; | Key | 列 | 类型 | 用途覆盖 | |---|---|---|---| | PRIMARY | `org_id` | UNIQUE BTREE | ✅ 主键查询 `WHERE org_id = ?` | -| `uk_org_user_type` | `(user_id, org_type)` | UNIQUE BTREE | ✅ "一用户一类型一公司" 强制; `selectMySponsorCompany` 用 ✓ | +| `uk_org_user_type` | `(user_id, org_type)` | UNIQUE BTREE | ✅ "一用户一类型一公司" 强制; `selectMyCompany` 用 ✓ | | `idx_org_type_status` | `(org_type, status)` | BTREE | ✅ 列表筛选 `WHERE org_type='sponsor' AND status=?` 命中 | | `idx_org_type_name` | `(org_type, org_name)` | BTREE | ✅ 列表筛选 `WHERE org_type='sponsor' AND org_name LIKE ?` 命中 | | `idx_org_user_type_name` | `(user_id, org_type, org_name)` | BTREE | ✅ `selectSponsorOrgOptions` / `selectExecutorOrgOptions` JOIN sys_user 命中 | diff --git a/_self/sponsor_people.md b/_self/sponsor_people.md index 58ea384..5eb511b 100644 --- a/_self/sponsor_people.md +++ b/_self/sponsor_people.md @@ -339,7 +339,7 @@ CREATE TABLE `biz_person` ( | uk_org_user_type | `user_id`,`org_type` (UNIQUE) | ✅ 一用户一类型一公司 | | idx_org_type_status | `org_type`,`status` | ✅ | | idx_org_type_name | `org_type`,`org_name` | ✅ | -| idx_org_user_type_name | `user_id`,`org_type`,`org_name` | ✅ selectMySponsorCompany | +| idx_org_user_type_name | `user_id`,`org_type`,`org_name` | ✅ selectMyCompany | > **结论**: 索引已覆盖 mapper 里所有查询路径, 无需新增. diff --git a/proto/html/components/invite-detail.html b/proto/html/components/invite-detail.html new file mode 100644 index 0000000..f14182d --- /dev/null +++ b/proto/html/components/invite-detail.html @@ -0,0 +1,339 @@ + + + + + + + 邀请详情 - 项目管理系统 + + + +
+ + + + + +

邀请详情

+ + +
+ +
+ 项目编号: + ZH-2026-658 +
+
+ 会议名称: + 小牛血清创新应用研讨会 +
+
+ 期数: + 第 5 期 +
+ +
+ 发送数量: + 6 +
+
+ 成功数量: + 5 +
+
+ 确认数量: + 2 +
+
+ + +

*短信和邮箱收到的是同一邀请链接,无论点击哪个进行确认,确认状态都是统一的

+ + +
+
邀请专家列表
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
专家姓名手机号邮箱地址发送时间发送状态确认状态确认时间备注
张三13548976548zhengwg8976@126.com2026.09.032026.09.05已确认发送成功
李四1354897123413548976@qq.com2026.09.03未确认发送成功
张三13548976548zhengwg8976@126.com2026.09.032026.09.05已确认发送成功1
张三13548976548zhengwg8976@126.com2026.09.03未确认发送成功
张三13548976548zhengwg8976@126.com2026.09.03未确认发送成功
张三2026.09.03已确认发送失败未查到联系方式
+
+ + +

*备注为空的,发送状态默认为发送成功,确认状态默认为已确认

+
+ + +
+ + +
+

*导出表项与专家列表表项一致,导出文件名称为:项目编号+会议名称+期数

+ +
+ + + + diff --git a/proto/html/components/new-invitation.html b/proto/html/components/new-invitation.html new file mode 100644 index 0000000..7da0ec1 --- /dev/null +++ b/proto/html/components/new-invitation.html @@ -0,0 +1,584 @@ + + + + + + + 新建邀请 - 项目管理系统 + + + +
+ + + + + +

新建邀请

+ + +
+
+ +
+ *会议名称: + +
+ + +
+ *项目编号: + +
+ + +
+ *期数: + +
+ + +
+ *会议时间: + +
+ + +
+ *发送时间: + +
+ + +
+ *专家: + + +
+ + +
+ *邀请内容: + +
+ + +
*发送时间不能晚于会议时间,如果设置的发送时间晚于会议时间,则弹出报错提示
+
*会议时间和发送时间到年月日即可
+
*发送时间默认为当前时间,支持修改,该时间只显示在详情列表里,不做定时发送
+ + +
+ 红头文件: + + +
+ + +
+ 邀请形式: +
+ + +
+
+
*提示:如邀请形式全部勾选,则两种方式均会发送,发送前请确认专家是否有该联系方式
+ +
+ + +
+ + + +
+
+ +
+ + + + + + + + + + + + + \ No newline at end of file diff --git a/proto/html/components/send-invitation.html b/proto/html/components/send-invitation.html new file mode 100644 index 0000000..1a56a5b --- /dev/null +++ b/proto/html/components/send-invitation.html @@ -0,0 +1,431 @@ + + + + + + 发送邀请 - 项目管理系统 + + + +
+ + + + + + +
+
+
+ 项目编号: + +
+
+ 会议名称: + +
+
+ 期数: + +
+
+ + +
+
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
项目编号会议名称会议时间期数邀请专家数已确认数发送时间操作
ZH-2026-658小牛血清创新应用研讨会2026.05.11 11:30第 3 期26182026.09.03 14:20 + 查看 + 再次发送 +
ZH-2026-658小牛血清创新应用研讨会2026.05.11 11:30第 3 期26262026.09.03 14:20 + 查看 + 再次发送 +
ZH-2026-658小牛血清创新应用研讨会2026.05.11 11:30第 3 期26122026.09.03 14:20 + 查看 + 再次发送 +
ZH-2026-658小牛血清创新应用研讨会2026.05.11 11:30第 3 期26202026.09.03 14:20 + 查看 + 再次发送 +
ZH-2026-658小牛血清创新应用研讨会2026.05.11 11:30第 3 期26262026.09.03 14:20 + 查看 + 再次发送 +
ZH-2026-658小牛血清创新应用研讨会2026.05.11 11:30第 3 期2692026.09.03 14:20 + 查看 + 再次发送 +
ZH-2026-658小牛血清创新应用研讨会2026.05.11 11:30第 3 期26152026.09.03 14:20 + 查看 + 再次发送 +
ZH-2026-658小牛血清创新应用研讨会2026.05.11 11:30第 3 期26222026.09.03 14:20 + 查看 + 再次发送 +
+ +

+ *点击「再次发送」,跳转至新建邀请页面,会将之前邀请的信息带入,与复制相同 +

+
+ +
+ + + + \ No newline at end of file diff --git a/proto/html/manager.html b/proto/html/manager.html index 6ee2db2..52e595f 100644 --- a/proto/html/manager.html +++ b/proto/html/manager.html @@ -210,6 +210,7 @@
  • 支持单位管理
  • 支持意向
  • 执行意向
  • +
  • 发送邀请
  • 账号管理
  • diff --git a/ry-api/ruoyi-admin/src/main/java/com/ruoyi/web/controller/common/OssController.java b/ry-api/ruoyi-admin/src/main/java/com/ruoyi/web/controller/common/OssController.java index 944838f..6e1f70e 100644 --- a/ry-api/ruoyi-admin/src/main/java/com/ruoyi/web/controller/common/OssController.java +++ b/ry-api/ruoyi-admin/src/main/java/com/ruoyi/web/controller/common/OssController.java @@ -16,6 +16,16 @@ import com.ruoyi.common.config.RuoYiConfig; import com.ruoyi.common.config.RuoYiConfig.OssProperties; import com.ruoyi.common.core.domain.AjaxResult; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.nio.charset.Charset; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; + /** * 阿里云 OSS 签名服务 * @@ -167,4 +177,180 @@ public class OssController response.getWriter().write("FILE_NOT_FOUND"); } } + + // ==================== zip 在线查看 (方案 B: 服务端解压, 前端只拉清单 + 按需取单文件) ==================== + + /** + * 读取 OSS 上某个 zip 的文件清单 (服务端解压, 前端按需拉单文件, 不整包下载到浏览器). + * 公开访问 (SecurityConfig /common/oss/zip/** 公开), 仅代理 hwtossbamlorgcn bucket 防 SSRF. + * 返回: [{ name, path, size, kind }], kind ∈ image/pdf/docx/excel/pptx/txt/other. + */ + @GetMapping("/zip/entries") + public AjaxResult zipEntries(@RequestParam String url) throws Exception + { + if (url == null || !url.contains("hwtossbamlorgcn.oss-cn-beijing.aliyuncs.com")) + { + return AjaxResult.error("非法 URL"); + } + byte[] zipBytes = fetchOssBytes(url); + List> entries; + try + { + entries = readZipEntries(zipBytes, StandardCharsets.UTF_8); + } + catch (Exception e) + { + // GBK 文件名被 UTF-8 解码会抛非法字节序列异常 → 回退 GBK 重读 + entries = readZipEntries(zipBytes, Charset.forName("GBK")); + } + return AjaxResult.success(entries); + } + + /** + * 读取 OSS 上某个 zip 里的单个文件字节, 按扩展名设置 Content-Type 返回. + * download=true 时 Content-Disposition 为 attachment (前端"下载单个文件"), 否则 inline (页内预览). + */ + @GetMapping("/zip/file") + public void zipFile(@RequestParam String url, @RequestParam String entry, + @RequestParam(required = false, defaultValue = "false") boolean download, + HttpServletResponse response) throws Exception + { + if (url == null || !url.contains("hwtossbamlorgcn.oss-cn-beijing.aliyuncs.com")) + { + response.setStatus(HttpServletResponse.SC_FORBIDDEN); + return; + } + byte[] zipBytes = fetchOssBytes(url); + byte[] data; + try + { + data = readZipEntry(zipBytes, entry, StandardCharsets.UTF_8); + } + catch (Exception e) + { + data = readZipEntry(zipBytes, entry, Charset.forName("GBK")); + } + if (data == null) + { + response.setStatus(HttpServletResponse.SC_NOT_FOUND); + response.setContentType("text/plain;charset=utf-8"); + response.getWriter().write("ENTRY_NOT_FOUND"); + return; + } + String filename = entry.substring(entry.lastIndexOf('/') + 1); + response.setContentType(contentTypeOf(filename)); + response.setHeader("Content-Disposition", (download ? "attachment" : "inline") + "; filename=\"" + filename + "\""); + response.setHeader("Cache-Control", "public, max-age=3600"); + response.getOutputStream().write(data); + } + + /** 下载 OSS 文件字节到内存 (zip 场景专用, 与 /proxy 同源但返回 byte[]) */ + private byte[] fetchOssBytes(String url) throws Exception + { + URI uri = URI.create(url); + URLConnection conn = uri.toURL().openConnection(); + conn.setConnectTimeout(10000); + conn.setReadTimeout(60000); + try (InputStream in = conn.getInputStream()) + { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + byte[] buf = new byte[8192]; + int n; + while ((n = in.read(buf)) > 0) + { + out.write(buf, 0, n); + } + return out.toByteArray(); + } + } + + /** 解压 zip, 返回文件清单 (跳过目录/__MACOSX/.DS_Store, 上限 500 个防 zip 炸弹) */ + private List> readZipEntries(byte[] zipBytes, Charset charset) throws Exception + { + List> entries = new ArrayList<>(); + int count = 0; + try (ZipInputStream zis = new ZipInputStream(new ByteArrayInputStream(zipBytes), charset)) + { + ZipEntry entry; + while ((entry = zis.getNextEntry()) != null) + { + if (entry.isDirectory()) continue; + String path = entry.getName(); + if (path.contains("__MACOSX") || path.endsWith(".DS_Store")) continue; + count++; + if (count > 500) + { + Map overflow = new HashMap<>(); + overflow.put("name", "…(文件过多, 仅显示前 500 个)"); + overflow.put("path", ""); + overflow.put("size", 0L); + overflow.put("kind", "other"); + entries.add(overflow); + break; + } + Map m = new HashMap<>(); + m.put("name", path.substring(path.lastIndexOf('/') + 1)); + m.put("path", path); + m.put("size", entry.getSize()); + m.put("kind", kindOf(path)); + entries.add(m); + } + } + return entries; + } + + /** 从 zip 里取单个 entry 的字节; 找不到返回 null */ + private byte[] readZipEntry(byte[] zipBytes, String targetPath, Charset charset) throws Exception + { + try (ZipInputStream zis = new ZipInputStream(new ByteArrayInputStream(zipBytes), charset)) + { + ZipEntry entry; + while ((entry = zis.getNextEntry()) != null) + { + if (entry.isDirectory()) continue; + if (targetPath.equals(entry.getName())) + { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + byte[] buf = new byte[8192]; + int n; + while ((n = zis.read(buf)) > 0) + { + out.write(buf, 0, n); + } + return out.toByteArray(); + } + } + } + return null; + } + + private static String kindOf(String path) + { + String lower = path.toLowerCase(); + if (lower.endsWith(".png") || lower.endsWith(".jpg") || lower.endsWith(".jpeg") + || lower.endsWith(".gif") || lower.endsWith(".webp") || lower.endsWith(".bmp") || lower.endsWith(".svg")) return "image"; + if (lower.endsWith(".pdf")) return "pdf"; + if (lower.endsWith(".docx")) return "docx"; + if (lower.endsWith(".xlsx") || lower.endsWith(".xls")) return "excel"; + if (lower.endsWith(".pptx")) return "pptx"; + if (lower.endsWith(".txt")) return "txt"; + return "other"; + } + + private static String contentTypeOf(String filename) + { + String lower = filename.toLowerCase(); + if (lower.endsWith(".pdf")) return "application/pdf"; + if (lower.endsWith(".png")) return "image/png"; + if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg"; + if (lower.endsWith(".gif")) return "image/gif"; + if (lower.endsWith(".webp")) return "image/webp"; + if (lower.endsWith(".svg")) return "image/svg+xml"; + if (lower.endsWith(".docx")) return "application/vnd.openxmlformats-officedocument.wordprocessingml.document"; + if (lower.endsWith(".xlsx")) return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; + if (lower.endsWith(".xls")) return "application/vnd.ms-excel"; + if (lower.endsWith(".pptx")) return "application/vnd.openxmlformats-officedocument.presentationml.presentation"; + if (lower.endsWith(".txt")) return "text/plain;charset=utf-8"; + return "application/octet-stream"; + } } \ No newline at end of file diff --git a/ry-api/ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/SysLoginController.java b/ry-api/ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/SysLoginController.java index abbe8db..e4a62c2 100644 --- a/ry-api/ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/SysLoginController.java +++ b/ry-api/ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/SysLoginController.java @@ -25,8 +25,10 @@ import com.ruoyi.system.service.ISysConfigService; import com.ruoyi.system.service.ISysMenuService; import com.ruoyi.system.service.ISysUserService; import com.ruoyi.business.domain.BizExpert; +import com.ruoyi.business.domain.BizPerson; import com.ruoyi.business.service.ComplianceLoginService; import com.ruoyi.business.service.IBizExpertService; +import com.ruoyi.business.service.IBizPersonService; /** * 登录验证 @@ -57,6 +59,9 @@ public class SysLoginController @Autowired private IBizExpertService expertService; + @Autowired + private IBizPersonService personService; + @Autowired private ComplianceLoginService complianceLoginService; @@ -112,7 +117,7 @@ public class SysLoginController } /** - * 密码登录门禁: 用户名命中 manager/leader 时校验其仍存在于 ecology 视图. + * 密码登录门禁: 按登录用户的手机号查 ecology 视图, 命中即校验身份 (不限于 manager/leader). * 返回 null=放行, 非 null=错误提示 (直接报错, 不走密码登录). */ private String complianceGate(String username) @@ -130,11 +135,6 @@ public class SysLoginController { return null; } - String role = probe.getRoleType(); - if (!"manager".equals(role) && !"leader".equals(role)) - { - return null; - } return complianceLoginService.gateForPasswordLogin(probe); } @@ -159,17 +159,45 @@ public class SysLoginController } AjaxResult ajax = AjaxResult.success(); ajax.put("user", user); + // 姓名单一可信源: biz_person.name (sponsor/executor) / biz_expert.name (doctor), 其余角色前端回退 nick_name + ajax.put("name", resolveDisplayName(user)); ajax.put("roles", roles); ajax.put("permissions", permissions); ajax.put("pwdChrtype", getSysAccountChrtype()); ajax.put("isDefaultModifyPwd", initPasswordIsModify(user.getPwdUpdateDate())); ajax.put("isPasswordExpired", passwordIsExpiration(user.getPwdUpdateDate())); + ajax.put("isPasswordEmpty", isPasswordEmpty(user.getUserId())); return ajax; } + /** + * 解析当前登录用户姓名 (navbar 显示用). + * 单一可信源: doctor → biz_expert.name, sponsor/executor → biz_person.name; + * 其余角色 (admin/manager/leader) 返回 null, 前端回退 sys_user.nick_name. + */ + private String resolveDisplayName(SysUser user) + { + if (user == null || user.getUserId() == null) + { + return null; + } + String roleType = user.getRoleType(); + if ("doctor".equals(roleType)) + { + BizExpert expert = expertService.getByUserId(user.getUserId()); + return expert != null ? expert.getName() : null; + } + if ("sponsor".equals(roleType) || "executor".equals(roleType)) + { + BizPerson person = personService.getByUserId(user.getUserId()); + return person != null ? person.getName() : null; + } + return null; + } + /** * 获取路由信息 - * + * * @return 路由信息 */ @GetMapping("getRouters") @@ -193,6 +221,21 @@ public class SysLoginController return initPasswordModify != null && initPasswordModify == 1 && pwdUpdateDate == null; } + // 密码是否为空 (password 与 password2 均为空 → 无任何可用密码, 仅短信登录). + // 注意: 不能读缓存 LoginUser 里的 password/password2 —— 二者标注 @JsonProperty(WRITE_ONLY), + // 序列化进 Redis 时会被清空, 读回恒为 null; 必须回查 DB 取真实值, 否则"设置密码后仍弹窗". + public boolean isPasswordEmpty(Long userId) + { + if (userId == null) + { + return false; + } + SysUser fresh = userService.selectUserById(userId); + return fresh != null + && StringUtils.isEmpty(fresh.getPassword()) + && StringUtils.isEmpty(fresh.getPassword2()); + } + // 检查密码是否过期 public boolean passwordIsExpiration(Date pwdUpdateDate) { diff --git a/ry-api/ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/SysProfileController.java b/ry-api/ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/SysProfileController.java index dd2926f..96c0a61 100644 --- a/ry-api/ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/SysProfileController.java +++ b/ry-api/ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/SysProfileController.java @@ -103,11 +103,13 @@ public class SysProfileController extends BaseController Long userId = loginUser.getUserId(); SysUser user = userService.selectUserById(userId); String password = user.getPassword(); - if (!SecurityUtils.matchesPassword(oldPassword, password)) + // 密码为空 (password 与 password2 均为空) 时, 跳过旧密码校验 (首次设置密码场景, 仅短信登录用户) + boolean passwordEmpty = StringUtils.isEmpty(password) && StringUtils.isEmpty(user.getPassword2()); + if (!passwordEmpty && !SecurityUtils.matchesPassword(oldPassword, password)) { return error("修改密码失败,旧密码错误"); } - if (SecurityUtils.matchesPassword(newPassword, password)) + if (!passwordEmpty && SecurityUtils.matchesPassword(newPassword, password)) { return error("新密码不能与旧密码相同"); } diff --git a/ry-api/ruoyi-admin/src/main/resources/application-prod.yml b/ry-api/ruoyi-admin/src/main/resources/application-prod.yml index 9e32349..5610de3 100644 --- a/ry-api/ruoyi-admin/src/main/resources/application-prod.yml +++ b/ry-api/ruoyi-admin/src/main/resources/application-prod.yml @@ -23,6 +23,9 @@ ruoyi: # 生产环境: aliyun 真发短信 mock-enabled: false esignBaseUrl: https://hegui.bahim.org.cn + # 发送邀请 公开邀请链接 (生产域名, 待确认) + invite: + baseUrl: https://hegui.bahim.org.cn # 扫码拍照 (ry-h5 相机网页地址, 前端二维码目标 URL) camera: base-url: https://hegui.bahim.org.cn/camera/ diff --git a/ry-api/ruoyi-admin/src/main/resources/application-test.yml b/ry-api/ruoyi-admin/src/main/resources/application-test.yml index 37ff0f8..c33043b 100644 --- a/ry-api/ruoyi-admin/src/main/resources/application-test.yml +++ b/ry-api/ruoyi-admin/src/main/resources/application-test.yml @@ -19,12 +19,15 @@ ruoyi: # 测试环境: 验证码固定 1234 且不真发短信 mock-enabled: true esignBaseUrl: https://risingdoctor.com/hg + # 发送邀请 公开邀请链接 (测试域名) + invite: + baseUrl: https://risingdoctor.com/hg # 扫码拍照 (ry-h5 相机网页地址, 前端二维码目标 URL) camera: base-url: https://risingdoctor.com/camera/ supplier-account-api: - base-url: https://zbsupplier.guojustar.com/supplier-api/bidding/supplier/openapi/accounts + base-url: https://zbsuppliertest8rwiphmrenrtsh35.guojustar.com/supplier-api/bidding/supplier/openapi/accounts supplier-account-api-aes: key-id: supplier-api-key diff --git a/ry-api/ruoyi-admin/src/main/resources/application.yml b/ry-api/ruoyi-admin/src/main/resources/application.yml index 66c520b..936e228 100644 --- a/ry-api/ruoyi-admin/src/main/resources/application.yml +++ b/ry-api/ruoyi-admin/src/main/resources/application.yml @@ -15,9 +15,9 @@ spring: servlet: multipart: # 单个文件大小 - max-file-size: 10MB + max-file-size: 200MB # 设置总上传的文件大小 - max-request-size: 20MB + max-request-size: 200MB jackson: time-zone: GMT+8 date-format: yyyy-MM-dd HH:mm:ss @@ -49,6 +49,21 @@ spring: max-active: 8 # #连接池最大阻塞等待时间(使用负值表示没有限制) max-wait: -1ms + # 邮件发送 (spring-boot-starter-mail, 参考 xxl-job EmailJobAlarm) + # host/port/password 待确认后回填 (username 已按原型稿 contactus@bahim.org.cn 占位) + mail: + host: 待确认 + port: 465 + username: contactus@bahim.org.cn + password: 待确认 + protocol: smtp + default-encoding: UTF-8 + properties: + mail: + smtp: + auth: true + ssl: + enable: true # token配置 token: @@ -138,8 +153,13 @@ ruoyi: signName: 北京整合医学学会 template: SMS_291440833 esignTemplate: SMS_512040098 + # 发送邀请短信模板 (新增独立模板, 不复用 esignTemplate; 占位符 ${link}=邀请链接) — 待确认 + inviteTemplate: 待填 endpoint: dysmsapi.aliyuncs.com regionId: cn-hangzhou + # 发送邀请 公开邀请链接 baseUrl (按环境拆分到 application-test/prod.yml) + invite: + baseUrl: https://risingdoctor.com/hg # 发票 OCR (本地 Java 识别, PaddleOCR ONNX Runtime, 替代原 ry-ocr Python 微服务) ocr: version: 0.1.0 diff --git a/ry-api/ruoyi-business/pom.xml b/ry-api/ruoyi-business/pom.xml index 4859ee1..128a9eb 100644 --- a/ry-api/ruoyi-business/pom.xml +++ b/ry-api/ruoyi-business/pom.xml @@ -34,6 +34,11 @@ aliyun-java-sdk-dysmsapi 2.2.1 + + + org.springframework.boot + spring-boot-starter-mail + com.aliyun aliyun-java-sdk-core diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/config/OcrExecutorConfig.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/config/OcrExecutorConfig.java index 97b31e4..d45a96f 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/config/OcrExecutorConfig.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/config/OcrExecutorConfig.java @@ -38,4 +38,15 @@ public class OcrExecutorConfig { return Executors.newFixedThreadPool(8); } + + /** + * zip 批量上传执行器 (劳务协议 / 专家照片 / 会务材料): 解压 zip + 逐文件上传 OSS (网络 IO 密集). + *

    + * 上传接口改为"提交即返回 jobId" (处理移后台), 前端轮询进度; 4 线程并发处理不同用户的上传任务. + */ + @Bean(name = "zipUploadExecutor", destroyMethod = "shutdown") + public ExecutorService zipUploadExecutor() + { + return Executors.newFixedThreadPool(4); + } } \ No newline at end of file diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizAuthController.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizAuthController.java index 9cb6489..020809d 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizAuthController.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizAuthController.java @@ -281,7 +281,7 @@ public class BizAuthController extends BaseController { self.setUpdateBy(username); bizPersonMapper.insert(self); - return success("注册成功, 请等待审核").put("userId", userId).put("orgId", orgId); + return success("注册成功").put("userId", userId).put("orgId", orgId); } /** @@ -384,6 +384,6 @@ public class BizAuthController extends BaseController { self.setUpdateBy(username); bizPersonMapper.insert(self); - return success("注册成功, 请等待审核").put("userId", userId).put("orgId", orgId); + return success("注册成功").put("userId", userId).put("orgId", orgId); } } diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizInviteController.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizInviteController.java new file mode 100644 index 0000000..bd3a255 --- /dev/null +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizInviteController.java @@ -0,0 +1,90 @@ +package com.ruoyi.business.controller; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; +import com.ruoyi.common.annotation.Log; +import com.ruoyi.common.core.controller.BaseController; +import com.ruoyi.common.core.domain.AjaxResult; +import com.ruoyi.common.core.page.TableDataInfo; +import com.ruoyi.common.enums.BusinessType; +import com.ruoyi.business.domain.BizInvite; +import com.ruoyi.business.domain.BizInviteRecipient; +import com.ruoyi.business.service.IBizInviteService; + +/** + * 发送邀请 Controller (manager 工作台). + *

    独立于项目/会议, 项目编号为手输文本. manager 是业务角色, 不走 RuoYi RBAC 权限. + */ +@RestController +@RequestMapping("/business/invite") +public class BizInviteController extends BaseController +{ + @Autowired + private IBizInviteService inviteService; + + @GetMapping("/list") + public TableDataInfo list(BizInvite bizInvite) + { + startPage(); + List list = inviteService.selectList(bizInvite); + return getDataTable(list); + } + + @GetMapping("/{id}") + public AjaxResult getInfo(@PathVariable("id") Long id) + { + BizInvite invite = inviteService.getById(id); + if (invite == null) + { + return error("邀请不存在"); + } + Map data = new HashMap<>(); + data.put("invite", invite); + data.put("recipients", inviteService.listRecipients(id)); + return success(data); + } + + /** + * 新建邀请 (仅保存草稿, 不发送). + * 返回 {id}, 前端拿 data.id 继续编辑或调用 /{id}/send 发送. + */ + @Log(title = "发送邀请", businessType = BusinessType.INSERT) + @PostMapping + public AjaxResult add(@RequestBody BizInvite bizInvite) + { + Long id = inviteService.create(bizInvite, false); + Map data = new HashMap<>(); + data.put("id", id); + return success(data); + } + + /** + * 发送邀请 (对 remark=1 的收件人触发短信/邮件), 返回实际发送条数. + */ + @Log(title = "发送邀请", businessType = BusinessType.UPDATE) + @PostMapping("/{id}/send") + public AjaxResult send(@PathVariable("id") Long id) + { + try + { + int sent = inviteService.send(id); + Map data = new HashMap<>(); + data.put("sent", sent); + return success(data); + } + catch (RuntimeException e) + { + return error(e.getMessage()); + } + } + + @Log(title = "发送邀请", businessType = BusinessType.DELETE) + @DeleteMapping("/{ids}") + public AjaxResult remove(@PathVariable Long[] ids) + { + return toAjax(inviteService.deleteByPrimaryKeys(ids)); + } +} 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 29320a9..3b8369f 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 @@ -10,6 +10,7 @@ import org.springframework.web.multipart.MultipartFile; import com.ruoyi.common.annotation.Log; import com.ruoyi.common.core.controller.BaseController; import com.ruoyi.common.core.domain.AjaxResult; +import com.ruoyi.common.core.page.TableDataInfo; import com.ruoyi.common.enums.BusinessType; import com.ruoyi.common.exception.ServiceException; import com.ruoyi.common.utils.SecurityUtils; @@ -54,13 +55,15 @@ public class BizMeetingAttendeeController extends BaseController { /** * 当前登录用户的"待签署协议"列表 (已推送电子签 is_esigned=1 且 任一未签) - * 用于 /doctor/home 工作台 + * 用于 /doctor/home 工作台, 支持分页 (前端按 5 条/页) + * pageNum / pageSize 从 request param 读 (RuoYi 标准做法, TableSupport 自动解析) */ @GetMapping("/unsigned") - public AjaxResult listUnsigned() { + public TableDataInfo listUnsigned() { Long userId = SecurityUtils.getUserId(); + startPage(); // PageHelper 仅拦截同一线程的下一条 select, 所以放 service.list() 之前 List rows = attendeeService.selectUnsignedByUserId(userId); - return success(rows); + return getDataTable(rows); } /** @@ -342,7 +345,9 @@ public class BizMeetingAttendeeController extends BaseController { @PostMapping("/uploadAgreements") public AjaxResult uploadAgreements(@RequestParam("file") MultipartFile file, @RequestParam("meetingId") Long meetingId) throws Exception { - Map result = attendeeService.uploadAgreements(file, meetingId); + String jobId = attendeeService.submitAgreementUpload(file, meetingId); + Map result = new HashMap<>(); + result.put("jobId", jobId); return success(result); } @@ -367,7 +372,9 @@ public class BizMeetingAttendeeController extends BaseController { @PostMapping("/uploadExpertPhotos") public AjaxResult uploadExpertPhotos(@RequestParam("file") MultipartFile file, @RequestParam("meetingId") Long meetingId) throws Exception { - Map result = attendeeService.uploadExpertPhotos(file, meetingId); + String jobId = attendeeService.submitExpertPhotoUpload(file, meetingId); + Map result = new HashMap<>(); + result.put("jobId", jobId); return success(result); } @@ -406,13 +413,14 @@ public class BizMeetingAttendeeController extends BaseController { 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); + if (s.length() == 1) return "*"; + if (s.length() == 2) return s.charAt(0) + "*"; + return s.charAt(0) + "*".repeat(s.length() - 2) + s.charAt(s.length() - 1); } /** 手机号脱敏: 前3 + **** + 后4 */ 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 197c58c..e7fbba2 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 @@ -74,11 +74,18 @@ public class BizMeetingController extends BaseController { private PosterService posterService; @GetMapping("/list") - public TableDataInfo list(BizMeeting bizMeeting) { + public TableDataInfo list(BizMeeting bizMeeting, + @RequestParam(value = "signedStatus", required = false) String signedStatus) { Long uid = SecurityUtils.getUserId(); String roleType = SecurityUtils.getLoginUser().getUser().getRoleType(); - // 医生/专家角色: 后端兜底只查"我参加的会议" (走 biz_meeting_attendee 中间表) - if ("doctor".equals(roleType) || "expert".equals(roleType)) { + // doctor 列表页"签署状态"下拉: unsigned=待签署 / signed=已签署 / 其他=不过滤 + // URL 不会自动绑到 BaseEntity.params, controller 显式注入供 mapper 读取 + if (signedStatus != null && !signedStatus.isEmpty() + && ("unsigned".equals(signedStatus) || "signed".equals(signedStatus))) { + bizMeeting.getParams().put("signedStatus", signedStatus); + } + // 医生角色: 后端兜底只查"我参加的会议" (走 biz_meeting_attendee 中间表) + if ("doctor".equals(roleType)) { bizMeeting.setUserId(uid); } // sponsor 数据权限: 只看"我的项目"下的会议 (MAIN 走 sponsor_org_id, SUB 走 sponsor_assign.monitor_user_id). @@ -196,6 +203,8 @@ public class BizMeetingController extends BaseController { validateMeetingWithinProject(bizMeeting, proj); } } + // 物理阶段初值: 建会即落 current_stage (NOT_STARTED / IN_PROGRESS), 否则 DB 默认 '0' 使项目列表「未执行」统计永远 0 + bizMeeting.setCurrentStage(computePhysicalStage(bizMeeting)); int rows = bizMeetingService.insert(bizMeeting); Long[] attendeeUserIds = bizMeeting.getAttendeeUserIds(); if (attendeeUserIds != null && attendeeUserIds.length > 0) { @@ -218,6 +227,26 @@ public class BizMeetingController extends BaseController { } } + /** + * 会议物理阶段 (含时间窗口): 事实推导 + start_time~end_time 窗口补 IN_PROGRESS. + *

    用于建会/编辑时落 current_stage 初值 — 否则建会落 DB 默认 '0'、编辑时间后残留旧值, + * 项目列表「未执行」统计 (current_stage='NOT_STARTED') 会与会议列表「未执行」展示 (时间实时推导) 错位. + *

    {@link StageDeriver#derivePhysicalStage} 只产 NOT_STARTED/RUNNING 不产 IN_PROGRESS + * (时间窗口态原由 scheduler 写), 此处补上窗口, 让 add/edit 一落地即准确. + */ + private String computePhysicalStage(BizMeeting m) { + String stage = stageDeriver.derivePhysicalStage(m); + // 仅「未执行 + 无材料」这一物理态需要补时间窗口: start_time 已到且 end_time 未到 → 执行中 + if ("NOT_STARTED".equals(stage)) { + Date now = new Date(); + if (m.getStartTime() != null && !m.getStartTime().after(now) + && (m.getEndTime() == null || m.getEndTime().after(now))) { + stage = "IN_PROGRESS"; + } + } + return stage; + } + @Log(title = "会议", businessType = BusinessType.UPDATE) @PutMapping public AjaxResult edit(@RequestBody BizMeeting bizMeeting) { @@ -258,6 +287,24 @@ public class BizMeetingController extends BaseController { validateMeetingWithinProject(bizMeeting, proj); } } + // 编辑: 时间变更后重算物理阶段 (加载完整事实, 用请求中的新时间覆盖后再推导), + // 否则 start_time 推到未来后 current_stage 残留 IN_PROGRESS 等旧值, 项目列表「未执行」统计错位. + BizMeeting existing = bizMeetingService.getById(bizMeeting.getMeetingId()); + if (existing != null) { + Date oldEndTime = existing.getEndTime(); + if (bizMeeting.getStartTime() != null) existing.setStartTime(bizMeeting.getStartTime()); + if (bizMeeting.getEndTime() != null) existing.setEndTime(bizMeeting.getEndTime()); + bizMeeting.setCurrentStage(computePhysicalStage(existing)); + // 结束时间变更 → 重算提交截止时间 (end_time + 项目天数), 让冻结判定跟着新时间走 (否则改时间后 deadline 仍是建会时的旧值, 冻结不触发). + // 已冻结 / 已退回 (锚点=退回时刻) 的不重算, 避免错误缩短整改窗口. + boolean endChanged = bizMeeting.getEndTime() != null && !bizMeeting.getEndTime().equals(oldEndTime); + boolean frozen = existing.getIsFrozen() != null && existing.getIsFrozen() == 1; + boolean rejected = "REJECTED".equals(existing.getLaborAuditStage()) + || "REJECTED".equals(existing.getServiceAuditStage()); + if (endChanged && !frozen && !rejected) { + bizMeeting.setSubmitDeadline(computeSubmitDeadline(existing.getProjectId(), bizMeeting.getEndTime())); + } + } int rows = bizMeetingService.updateByPrimaryKey(bizMeeting); Long[] attendeeUserIds = bizMeeting.getAttendeeUserIds(); if (attendeeUserIds != null && attendeeUserIds.length > 0) { diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizMeetingMaterialController.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizMeetingMaterialController.java index 5ed7b55..8ec6cd2 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizMeetingMaterialController.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizMeetingMaterialController.java @@ -1,7 +1,9 @@ package com.ruoyi.business.controller; import java.io.InputStream; +import java.util.HashMap; import java.util.List; +import java.util.Map; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; @@ -92,12 +94,12 @@ public class BizMeetingMaterialController extends BaseController { /** * 扫码拍照回传 (公开端点, ry-h5 手机端拍照直传 OSS 后回传 URL). *

    - * body: { meetingId, subType, ossUrl }. 后端白名单 subType + 会议存在校验. - * 照片类 NON_OCR, 不影响会议费用. + * body: { meetingId, subType, ossUrls[], extraOssUrls[] }. 后端白名单 subType + 会议存在校验. + * 签到表连拍多张传多个 URL, 前后全景传单个. 照片类 NON_OCR, 不影响会议费用. */ @PostMapping("/cameraUpload") public AjaxResult cameraUpload(@RequestBody BizMeetingMaterial body) { - bizMeetingMaterialService.upsertFromCamera(body.getMeetingId(), body.getSubType(), body.getOssUrl(), body.getExtraOssUrl()); + bizMeetingMaterialService.upsertFromCamera(body.getMeetingId(), body.getSubType(), body.getOssUrls(), body.getExtraOssUrls()); return success(); } @@ -197,12 +199,25 @@ public class BizMeetingMaterialController extends BaseController { @PostMapping("/uploadServiceMaterials") public AjaxResult uploadServiceMaterials(@RequestParam("file") MultipartFile file, @RequestParam("meetingId") Long meetingId) throws Exception { - int updated = bizMeetingMaterialService.uploadServiceMaterials(file, meetingId); - // 仅当真有材料被替换 (内容变化, updated>0) 才立即重算会务费; 全量未变不置"统计中" - if (updated > 0) { - bizMeetingService.recomputeMeetingFee(meetingId); - } - return success(updated); + String jobId = bizMeetingMaterialService.submitServiceMaterialsUpload(file, meetingId); + Map result = new HashMap<>(); + result.put("jobId", jobId); + return success(result); + } + + /** + * 电子签到表 (L_ESIGN_IN) 多文件/zip 上传 (同步, 追加式保存). + *

    + * file 为单个 Excel (xls/xlsx/csv) 或 zip (后台解压并分析其中所有 Excel). + * 后端逐个解析脱敏 + 直传 OSS, 单行 L_ESIGN_IN 存多文件 (oss_url 存 JSON 数组). + * 已有文件保留, 新文件追加到末尾. 任一文件解析失败 → 整体失败. 仅材料可编辑角色可上传. + */ + @Log(title = "电子签到表上传", businessType = BusinessType.UPDATE) + @PostMapping("/uploadEsignIn") + public AjaxResult uploadEsignIn(@RequestParam("file") MultipartFile file, + @RequestParam("meetingId") Long meetingId) throws Exception { + Map result = bizMeetingMaterialService.uploadEsignIn(file, meetingId); + return success(result); } /** request body for batch download endpoints */ diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizOrgController.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizOrgController.java index 6664379..f9c4584 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizOrgController.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizOrgController.java @@ -69,14 +69,15 @@ public class BizOrgController extends BaseController { } /** - * 当前登录 sponsor 的所属公司 (供 /sponsor/account 页面回显) - * 返回 { orgId, orgName, isOwner: 1|0 } - * 主账号 isOwner=1 可改名, 子账号 isOwner=0 只读 + * 当前登录账号的所属公司 (sponsor + executor 共用, 供 /sponsor/account + /executor/account 页面回显) + * 根据 sys_user.role_type 自动选 sponsor/executor + * 返回 { orgId, orgName, orgType, isOwner: 1|0 } + * 注: 账号信息页只读展示, 主账号也不可改单位名称 */ @GetMapping("/myCompany") public AjaxResult myCompany() { Long uid = SecurityUtils.getUserId(); - Map data = bizOrgService.selectMySponsorCompany(uid); + Map data = bizOrgService.selectMyCompany(uid); return success(data); } 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 8ba151c..f0567b0 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 @@ -31,6 +31,7 @@ import com.ruoyi.business.service.IBizExpertService; import com.ruoyi.business.service.IBizOrgService; import com.ruoyi.business.service.IBizProjectService; import com.ruoyi.business.service.IBizExecutionIntentService; +import com.ruoyi.business.service.IBizMeetingService; import com.ruoyi.business.domain.BizProjectSponsorAssign; import com.ruoyi.business.domain.BizProjectExecutorAssign; import com.ruoyi.business.notify.BizNotifyService; @@ -75,6 +76,8 @@ public class BizProjectController extends BaseController private IBizOrgService bizOrgService; @Autowired private IBizExpertService bizExpertService; + @Autowired + private IBizMeetingService bizMeetingService; /** * 我报名的项目 (当前用户在 biz_execution_intent 里有意向的项目) @@ -230,7 +233,22 @@ public class BizProjectController extends BaseController @PutMapping public AjaxResult edit(@RequestBody BizProject bizProject) { - return toAjax(bizProjectService.updateByPrimaryKey(bizProject)); + // submit_deadline_days 变更 → 级联重算该项目存量「从未退回、且仍有轨未提交」会议的提交截止时间. + // 建会时 deadline 一次性落库, 改项目天数本不影响已有会议; 这里补上级联, 避免"改了天数会议却不按新天数冻结". + Integer newDays = bizProject.getSubmitDeadlineDays(); + boolean daysChanged = false; + if (bizProject.getProjectId() != null && newDays != null) + { + BizProject old = bizProjectService.getById(bizProject.getProjectId()); + Integer oldDays = old == null ? null : old.getSubmitDeadlineDays(); + daysChanged = !Objects.equals(oldDays, newDays); + } + int rows = bizProjectService.updateByPrimaryKey(bizProject); + if (daysChanged) + { + bizMeetingService.recomputeSubmitDeadlinesByProject(bizProject.getProjectId(), newDays); + } + return toAjax(rows); } @Log(title = "项目", businessType = BusinessType.DELETE) @DeleteMapping("/{ids}") @@ -358,7 +376,7 @@ public class BizProjectController extends BaseController if (projectId == null) throw new ServiceException("项目不存在"); BizProject p = bizProjectService.getById(projectId); if (p == null) throw new ServiceException("项目不存在"); - if ("1".equals(p.getIsFinished())) throw new ServiceException("已结题项目不能分配"); + if ("Y".equals(p.getIsFinished())) throw new ServiceException("已结题项目不能分配"); return p; } @@ -531,7 +549,7 @@ public class BizProjectController extends BaseController return error("projectId 必填"); } // 已结题项目不能分配 - requireAssignableProject(parseProjectId(body.getProjectId())); + requireAssignableProject(body.getProjectId()); java.util.List sids = body.getStaffUserIds(); if (sids == null || sids.isEmpty()) { // 向后兼容: 单值 staffUserId @@ -551,11 +569,13 @@ public class BizProjectController extends BaseController /** * 查询项目已分配的执行人列表 (供前端 dialog 重开时回显) * GET /business/project/{projectId}/executorAssigns + * 按当前登录执行方的 executor_org_id 隔离, 只回显本执行方自己的执行人 (避免串读别家执行人显示成裸 user_id) */ @GetMapping("/{projectId}/executorAssigns") - public AjaxResult listExecutorAssigns(@PathVariable("projectId") String projectId) + public AjaxResult listExecutorAssigns(@PathVariable("projectId") Long projectId) { - return success(bizProjectExecutorAssignService.listByProjectId(projectId)); + Long executorOrgId = bizOrgService.selectOrgIdByUserId(SecurityUtils.getUserId()); + return success(bizProjectExecutorAssignService.listByProjectId(projectId, executorOrgId)); } /** @@ -659,7 +679,7 @@ public class BizProjectController extends BaseController v.setSponsorOrgName(p.getSponsorOrgName()); v.setExecOrgNames(p.getExecOrgNames()); v.setProjectForm(p.getProjectForm()); - v.setIsFinished("1".equals(p.getIsFinished()) ? "已结题" : "未结题"); + v.setIsFinished("Y".equals(p.getIsFinished()) ? "已结题" : "未结题"); v.setStartTime(p.getStartTime()); v.setEndTime(p.getEndTime()); exportList.add(v); 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 d35250c..0940da4 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 @@ -5,14 +5,11 @@ 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.web.bind.annotation.*; import org.springframework.core.env.Environment; import com.ruoyi.common.core.controller.BaseController; import com.ruoyi.common.core.domain.AjaxResult; +import com.ruoyi.common.core.page.TableDataInfo; import com.ruoyi.common.utils.http.HttpUtils; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; @@ -23,10 +20,13 @@ import com.ruoyi.business.domain.BizProject; import com.ruoyi.business.domain.BizSupportLetter; import com.ruoyi.business.domain.BizInvitation; import com.ruoyi.business.domain.BizProjectPlan; +import com.ruoyi.business.domain.BizInvite; +import com.ruoyi.business.domain.BizInviteRecipient; import com.ruoyi.business.service.IBizArticleService; import com.ruoyi.business.service.IBizProjectService; import com.ruoyi.business.service.IBizSupportLetterService; import com.ruoyi.business.service.IBizInvitationService; +import com.ruoyi.business.service.IBizInviteService; import com.ruoyi.business.service.IBizProjectPlanService; import com.ruoyi.business.service.IBizSpecialPlanService; import com.ruoyi.business.service.WxShareService; @@ -42,6 +42,7 @@ public class BizPublicController extends BaseController { @Autowired private IBizProjectService projectService; @Autowired private IBizSupportLetterService supportLetterService; @Autowired private IBizInvitationService invitationService; + @Autowired private IBizInviteService inviteService; @Autowired private IBizProjectPlanService projectPlanService; @Autowired private IBizArticleService articleService; @Autowired private IBizSpecialPlanService specialPlanService; @@ -85,7 +86,7 @@ public class BizPublicController extends BaseController { public AjaxResult index() { Map map = new HashMap<>(); BizProject publishedQuery = new BizProject(); - publishedQuery.setIsPublished("1"); + publishedQuery.setIsPublished("Y"); List announcements = projectService.selectList(publishedQuery); List plans = projectPlanService.selectList(new BizProjectPlan()); map.put("announcements", announcements); @@ -95,10 +96,11 @@ public class BizPublicController extends BaseController { } @GetMapping("/announcements") - public AjaxResult announcements() { - // 公示列表按发布时间倒序 (后端排序), 只返回已发布项目 - List list = projectService.selectPublicAnnouncements(); - return success(list); + public TableDataInfo announcements(@RequestParam(required = false) String keyword) + { + // 公示列表: 只查公示页展示列 (不跑关联子查询), PageHelper 分页, keyword 按项目名模糊 + startPage(); + return getDataTable(projectService.selectPublicAnnouncements(keyword)); } @GetMapping("/project/{projectId}") @@ -183,4 +185,38 @@ public class BizPublicController extends BaseController { public AjaxResult apply(String annId) { return success(); } + + /** + * 公开邀请函 (发送邀请 短信/邮件里的链接): 按 token 查邀请内容 + 收件人确认状态. + * 无需登录, token 即唯一凭证. + */ + @GetMapping("/invite/{token}") + public AjaxResult inviteByToken(@PathVariable("token") String token) { + BizInviteRecipient recipient = inviteService.getRecipientByToken(token); + if (recipient == null) { + return error("邀请链接无效"); + } + BizInvite invite = inviteService.getById(recipient.getInviteId()); + if (invite == null) { + return error("邀请不存在"); + } + Map data = new HashMap<>(); + data.put("invite", invite); + data.put("recipient", recipient); + return success(data); + } + + /** + * 公开确认 (粘在底部的确认按钮): 置确认状态=已确认, 不可逆. + * 幂等: 重复确认返回成功 (已确认态不再翻转). + */ + @PostMapping("/invite/{token}/confirm") + public AjaxResult inviteConfirm(@PathVariable("token") String token) { + BizInviteRecipient recipient = inviteService.getRecipientByToken(token); + if (recipient == null) { + return error("邀请链接无效"); + } + inviteService.confirmByToken(token); + return success("确认成功"); + } } diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/UploadProgressController.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/UploadProgressController.java new file mode 100644 index 0000000..a353fea --- /dev/null +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/UploadProgressController.java @@ -0,0 +1,48 @@ +package com.ruoyi.business.controller; + +import java.util.HashMap; +import java.util.Map; +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.RestController; +import com.ruoyi.business.upload.UploadProgressRegistry; +import com.ruoyi.common.core.controller.BaseController; +import com.ruoyi.common.core.domain.AjaxResult; + +/** + * 批量上传进度轮询端点 (zip 上传 → 后台解压/回填 → 前端轮询) + */ +@RestController +@RequestMapping("/business/upload") +public class UploadProgressController extends BaseController +{ + @Autowired + private UploadProgressRegistry uploadProgressRegistry; + + /** + * 查询某上传任务的实时进度. + *

    + * 返回 { jobId, done, total, finished, error, message, result }; + * 任务不存在 (尚未登记或已清理) 返回 success() (data=null), 前端视为"准备中". + */ + @GetMapping("/progress/{jobId}") + public AjaxResult progress(@PathVariable("jobId") String jobId) + { + UploadProgressRegistry.Progress p = uploadProgressRegistry.get(jobId); + if (p == null) + { + return success(); + } + Map out = new HashMap<>(); + out.put("jobId", p.jobId); + out.put("done", p.done); + out.put("total", p.total); + out.put("finished", p.finished); + out.put("error", p.error); + out.put("message", p.message); + out.put("result", p.result); + return success(out); + } +} diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/BizArticle.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/BizArticle.java index 44b629d..e1d8529 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/BizArticle.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/BizArticle.java @@ -24,8 +24,8 @@ public class BizArticle extends BaseEntity @Excel(name = "正文") private String content; - /** 状态 0启用 1停用 */ - @Excel(name = "状态", readConverterExp = "0=启用,1=停用") + /** 状态 Y启用 N停用 */ + @Excel(name = "状态", readConverterExp = "Y=启用,N=停用") private String status; public Long getId() { return id; } diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/BizDepartment.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/BizDepartment.java index 76921b2..f671ca3 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/BizDepartment.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/BizDepartment.java @@ -17,8 +17,8 @@ public class BizDepartment extends BaseEntity @Excel(name = "排序") private Integer sort; - /** 0=启用 1=停用 */ - @Excel(name = "状态", readConverterExp = "0=启用,1=停用") + /** Y=启用 N=停用 */ + @Excel(name = "状态", readConverterExp = "Y=启用,N=停用") private String status; public Long getDeptId() { return deptId; } diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/BizInvite.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/BizInvite.java new file mode 100644 index 0000000..ecf5e26 --- /dev/null +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/BizInvite.java @@ -0,0 +1,68 @@ +package com.ruoyi.business.domain; + +import java.util.Date; +import java.util.List; +import com.fasterxml.jackson.annotation.JsonFormat; +import com.ruoyi.common.core.domain.BaseEntity; + +/** + * 发送邀请 主表对象 biz_invite. + *

    独立于项目/会议: 此时项目在 OA 尚未创建, 项目编号/会议名称/期数/会议时间 均为手输文本, + * 不关联 biz_project / biz_meeting, 也不复用 biz_invitation (那是另一套邀请函逻辑). + */ +public class BizInvite extends BaseEntity +{ + private static final long serialVersionUID = 1L; + + /** 主键 (雪花 ID, 调用方生成) */ + private Long id; + /** 项目编号 (手输) */ + private String projectNo; + /** 会议名称 (手输) */ + private String meetingName; + /** 期数 (手输) */ + private String periodNo; + /** 会议时间 (手输) */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date meetingTime; + /** 发送时间 (发送动作回填) */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date sendTime; + /** 邀请内容 (富文本 HTML) */ + private String content; + /** 红头文件/附件 URL */ + private String redheadFileUrl; + /** 发送渠道: sms / email / both */ + private String sendChannel; + /** 邀请专家数 (聚合, 非库列) */ + private Integer recipientCount; + /** 已确认数 (聚合, 非库列) */ + private Integer confirmedCount; + /** 收件人明细 (前端提交用, 非库列) */ + private List recipients; + + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + public String getProjectNo() { return projectNo; } + public void setProjectNo(String projectNo) { this.projectNo = projectNo; } + public String getMeetingName() { return meetingName; } + public void setMeetingName(String meetingName) { this.meetingName = meetingName; } + public String getPeriodNo() { return periodNo; } + public void setPeriodNo(String periodNo) { this.periodNo = periodNo; } + public Date getMeetingTime() { return meetingTime; } + public void setMeetingTime(Date meetingTime) { this.meetingTime = meetingTime; } + public Date getSendTime() { return sendTime; } + public void setSendTime(Date sendTime) { this.sendTime = sendTime; } + public String getContent() { return content; } + public void setContent(String content) { this.content = content; } + public String getRedheadFileUrl() { return redheadFileUrl; } + public void setRedheadFileUrl(String redheadFileUrl) { this.redheadFileUrl = redheadFileUrl; } + public String getSendChannel() { return sendChannel; } + public void setSendChannel(String sendChannel) { this.sendChannel = sendChannel; } + public Integer getRecipientCount() { return recipientCount; } + public void setRecipientCount(Integer recipientCount) { this.recipientCount = recipientCount; } + public Integer getConfirmedCount() { return confirmedCount; } + public void setConfirmedCount(Integer confirmedCount) { this.confirmedCount = confirmedCount; } + public List getRecipients() { return recipients; } + public void setRecipients(List recipients) { this.recipients = recipients; } +} diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/BizInviteRecipient.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/BizInviteRecipient.java new file mode 100644 index 0000000..bab264a --- /dev/null +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/BizInviteRecipient.java @@ -0,0 +1,64 @@ +package com.ruoyi.business.domain; + +import java.util.Date; +import com.fasterxml.jackson.annotation.JsonFormat; +import com.ruoyi.common.core.domain.BaseEntity; + +/** + * 发送邀请 收件人明细对象 biz_invite_recipient. + */ +public class BizInviteRecipient extends BaseEntity +{ + private static final long serialVersionUID = 1L; + + /** 主键 (雪花 ID, 调用方生成) */ + private Long id; + /** 邀请主表 id */ + private Long inviteId; + /** 专家姓名 */ + private String name; + /** 手机号 */ + private String phone; + /** 邮箱 */ + private String email; + /** 来源: E=专家库 / I=导入 */ + private String source; + /** 备注: 1=发送 / 空=不发送 */ + private String remark; + /** 发送状态: 0=未发送 / 1=成功 / 2=失败 */ + private String sendStatus; + /** 发送失败原因 */ + private String failReason; + /** 确认状态: 0=未确认 / 1=已确认 */ + private String confirmStatus; + /** 确认时间 */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date confirmedAt; + /** 公开邀请链接 token (uuid) */ + private String token; + + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + public Long getInviteId() { return inviteId; } + public void setInviteId(Long inviteId) { this.inviteId = inviteId; } + public String getName() { return name; } + public void setName(String name) { this.name = name; } + public String getPhone() { return phone; } + public void setPhone(String phone) { this.phone = phone; } + public String getEmail() { return email; } + public void setEmail(String email) { this.email = email; } + public String getSource() { return source; } + public void setSource(String source) { this.source = source; } + public String getRemark() { return remark; } + public void setRemark(String remark) { this.remark = remark; } + public String getSendStatus() { return sendStatus; } + public void setSendStatus(String sendStatus) { this.sendStatus = sendStatus; } + public String getFailReason() { return failReason; } + public void setFailReason(String failReason) { this.failReason = failReason; } + public String getConfirmStatus() { return confirmStatus; } + public void setConfirmStatus(String confirmStatus) { this.confirmStatus = confirmStatus; } + public Date getConfirmedAt() { return confirmedAt; } + public void setConfirmedAt(Date confirmedAt) { this.confirmedAt = confirmedAt; } + public String getToken() { return token; } + public void setToken(String token) { this.token = token; } +} diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/BizMeeting.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/BizMeeting.java index 6e461fa..5b11d0b 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/BizMeeting.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/BizMeeting.java @@ -120,7 +120,7 @@ public class BizMeeting extends BaseEntity { private String scheduleUrl; /** 生成的海报URL (生成海报按钮产出) */ private String posterUrl; - /** 签署劳务 0未签 1已签 */ + /** 签署劳务 N未签 Y已签 */ private String laborSigned; /** 参会人 user_id (非持久化字段, 仅用于 mapper WHERE 过滤; controller 注入, 配合 biz_meeting_attendee 中间表) */ private transient Long userId; diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/BizMeetingMaterial.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/BizMeetingMaterial.java index f285fc8..bb93b28 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/BizMeetingMaterial.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/BizMeetingMaterial.java @@ -2,7 +2,9 @@ package com.ruoyi.business.domain; import java.math.BigDecimal; import java.util.Date; +import java.util.List; import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonProperty; /** * 会议材料对象 biz_meeting_material (单表) @@ -10,7 +12,7 @@ import com.fasterxml.jackson.annotation.JsonFormat; * 包含 4 大类 17 子类: *

      *
    • material_type: SERVICE=会务材料, LABOR=劳务材料, SERVICE_VOUCHER=会务凭证, LABOR_VOUCHER=劳务凭证
    • - *
    • sub_type: M_MATERIAL / M_HOTEL / M_TRAFFIC_BIG / M_TRAFFIC_SMALL / M_EXECUTION / M_DESIGN / M_OTHER / M_SETTLEMENT / M_INVOICE / L_DETAIL / L_AGREEMENT / L_ENTERPRISE_BENEFIT / L_SIGN_IN / L_PANORAMA / L_EXPERT_PHOTO / SV_PAYMENT / LV_PAYMENT
    • + *
    • sub_type: M_MATERIAL / M_HOTEL / M_TRAFFIC_BIG / M_TRAFFIC_SMALL / M_EXECUTION / M_DESIGN / M_OTHER / M_SETTLEMENT / M_INVOICE / L_DETAIL / L_AGREEMENT / L_ENTERPRISE_BENEFIT / L_ESIGN_IN / L_SIGN_IN / L_PANORAMA / L_EXPERT_PHOTO / SV_PAYMENT / LV_PAYMENT
    • *
    *

    * 注意: 不继承 BaseEntity — 不要 create_by / update_by / update_time 字段. @@ -41,6 +43,15 @@ public class BizMeetingMaterial { /** 脱敏版 OSS URL (签到表拍照时额外生成的高斯模糊版, sponsor 只看这个以隐藏手机号/身份证号) */ private String extraOssUrl; + /** 电子签到表解析后的脱敏 JSON (表头+行, sponsor 预览用; 保存时后端解析生成) */ + private String maskedJson; + + /** 扫码拍照回传的 OSS URL 列表 (仅 cameraUpload 端点入参; 签到表连拍多张, 前后全景单张). 存库时 L_SIGN_IN 序列化为 JSON 数组字符串写 ossUrl */ + private List ossUrls; + + /** 扫码拍照回传的脱敏版 OSS URL 列表 (与 ossUrls 一一对应, 仅 cameraUpload 端点入参) */ + private List extraOssUrls; + /** 金额 (发票专用, 其他类型 = 0) */ private BigDecimal amount; @@ -78,6 +89,17 @@ public class BizMeetingMaterial { public String getExtraOssUrl() { return extraOssUrl; } public void setExtraOssUrl(String extraOssUrl) { this.extraOssUrl = extraOssUrl; } + public String getMaskedJson() { return maskedJson; } + public void setMaskedJson(String maskedJson) { this.maskedJson = maskedJson; } + + @JsonProperty(access = JsonProperty.Access.WRITE_ONLY) + public List getOssUrls() { return ossUrls; } + public void setOssUrls(List ossUrls) { this.ossUrls = ossUrls; } + + @JsonProperty(access = JsonProperty.Access.WRITE_ONLY) + public List getExtraOssUrls() { return extraOssUrls; } + public void setExtraOssUrls(List extraOssUrls) { this.extraOssUrls = extraOssUrls; } + public BigDecimal getAmount() { return amount; } public void setAmount(BigDecimal amount) { this.amount = amount; } diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/BizProject.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/BizProject.java index e79193a..24ae2df 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/BizProject.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/BizProject.java @@ -10,7 +10,7 @@ import com.ruoyi.common.core.domain.BaseEntity; /** 项目对象 BizProject */ public class BizProject extends BaseEntity { private static final long serialVersionUID = 1L; - /** projectId (与 DB bigint 对齐, AUTO_INCREMENT) */ + /** projectId (雪花 ID, 与 DB bigint 对齐) */ private Long projectId; /** project_no */ @Excel(name = "project_no") @@ -126,7 +126,7 @@ public class BizProject extends BaseEntity { private String publishUrl; /** 日程文件URL */ private String scheduleUrl; - /** 是否已发布公示 0否 1是 */ + /** 是否已发布公示 N否 Y是 */ private String isPublished; /** 发布时间 */ @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/BizProjectExecutorAssign.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/BizProjectExecutorAssign.java index 0651e88..67bac64 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/BizProjectExecutorAssign.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/BizProjectExecutorAssign.java @@ -8,7 +8,7 @@ import com.ruoyi.common.core.domain.BaseEntity; public class BizProjectExecutorAssign extends BaseEntity { private static final long serialVersionUID = 1L; private Long id; - private String projectId; + private Long projectId; /** 分配方企业 org_id (biz_org.org_id, 分配人审计; 原 executor_user_id) */ private Long executorOrgId; /** 执行人 user_id (被分配) */ @@ -26,15 +26,15 @@ public class BizProjectExecutorAssign extends BaseEntity { @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") private Date createTime; - /** 软删除标记 0否1是 (项目级联删除时按 project_id (String) 置1, 查询需 WHERE is_deleted=0) */ + /** 软删除标记 0否1是 (项目级联删除时按 project_id 置1, 查询需 WHERE is_deleted=0) */ private Integer isDeleted; public Integer getIsDeleted() { return isDeleted; } public void setIsDeleted(Integer isDeleted) { this.isDeleted = isDeleted; } public Long getId() { return id; } public void setId(Long id) { this.id = id; } - public String getProjectId() { return projectId; } - public void setProjectId(String projectId) { this.projectId = projectId; } + public Long getProjectId() { return projectId; } + public void setProjectId(Long projectId) { this.projectId = projectId; } public Long getExecutorOrgId() { return executorOrgId; } public void setExecutorOrgId(Long executorOrgId) { this.executorOrgId = executorOrgId; } public Long getStaffUserId() { return staffUserId; } diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/mail/InviteMailSender.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/mail/InviteMailSender.java new file mode 100644 index 0000000..79d4a67 --- /dev/null +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/mail/InviteMailSender.java @@ -0,0 +1,56 @@ +package com.ruoyi.business.mail; + +import jakarta.mail.internet.MimeMessage; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.MimeMessageHelper; +import org.springframework.stereotype.Service; + +/** + * 邀请邮件发送器. + *

    参考 xxl-job {@code EmailJobAlarm} 模式, 走 spring-boot-starter-mail (JavaMailSender + MimeMessageHelper). + * 发件账号/主题/正文由调用方传入; 发件人 from 取 spring.mail.username (SMTP 账号本身). + */ +@Service +public class InviteMailSender +{ + private static final Logger log = LoggerFactory.getLogger(InviteMailSender.class); + + @Autowired(required = false) + private JavaMailSender mailSender; + + @Value("${spring.mail.username:}") + private String emailFrom; + + /** + * 发送 HTML 邀请邮件. 成功返回 true, 失败返回 false (不抛, 由调用方回写发送状态). + */ + public boolean sendInvite(String to, String subject, String html) + { + if (mailSender == null) + { + log.warn("[邮件] 未配置 spring.mail (JavaMailSender 不存在), 跳过发送 to={}", to); + return false; + } + try + { + MimeMessage mimeMessage = mailSender.createMimeMessage(); + MimeMessageHelper helper = new MimeMessageHelper(mimeMessage, true, "UTF-8"); + helper.setFrom(emailFrom); + helper.setTo(to); + helper.setSubject(subject == null ? "" : subject); + helper.setText(html == null ? "" : html, true); + mailSender.send(mimeMessage); + log.info("[邮件] 邀请邮件发送成功 to={}", to); + return true; + } + catch (Exception e) + { + log.error("[邮件] 邀请邮件发送失败 to={}", to, e); + return false; + } + } +} diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/mapper/BizInviteMapper.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/mapper/BizInviteMapper.java new file mode 100644 index 0000000..eb00e1a --- /dev/null +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/mapper/BizInviteMapper.java @@ -0,0 +1,22 @@ +package com.ruoyi.business.mapper; + +import java.util.Date; +import java.util.List; +import org.apache.ibatis.annotations.Param; +import com.ruoyi.business.domain.BizInvite; + +/** + * 发送邀请主表 Mapper 接口 + */ +public interface BizInviteMapper +{ + BizInvite selectByPrimaryKey(Long id); + /** 列表 (含聚合列 recipient_count / confirmed_count), 不含富文本 content */ + List selectList(BizInvite entity); + int insert(BizInvite entity); + int updateByPrimaryKey(BizInvite entity); + int deleteByPrimaryKey(Long id); + int deleteByPrimaryKeys(Long[] ids); + /** 发送后回填 send_time */ + int updateSendTime(@Param("id") Long id, @Param("sendTime") Date sendTime); +} diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/mapper/BizInviteRecipientMapper.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/mapper/BizInviteRecipientMapper.java new file mode 100644 index 0000000..f4fad0b --- /dev/null +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/mapper/BizInviteRecipientMapper.java @@ -0,0 +1,21 @@ +package com.ruoyi.business.mapper; + +import java.util.List; +import org.apache.ibatis.annotations.Param; +import com.ruoyi.business.domain.BizInviteRecipient; + +/** + * 发送邀请收件人明细 Mapper 接口 + */ +public interface BizInviteRecipientMapper +{ + List selectByInviteId(Long inviteId); + BizInviteRecipient selectByToken(String token); + int insert(BizInviteRecipient entity); + int insertBatch(List list); + int deleteByInviteId(Long inviteId); + /** 公开确认: 置 confirm_status=1 + confirmed_at=sysdate() */ + int updateConfirmByToken(String token); + /** 发送结果回写: send_status + fail_reason */ + int updateSendStatus(@Param("id") Long id, @Param("sendStatus") String sendStatus, @Param("failReason") String failReason); +} diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/mapper/BizMeetingAttendeeMapper.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/mapper/BizMeetingAttendeeMapper.java index 221b270..7d19cae 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/mapper/BizMeetingAttendeeMapper.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/mapper/BizMeetingAttendeeMapper.java @@ -35,6 +35,11 @@ public interface BizMeetingAttendeeMapper { List selectByMeetingId(Long meetingId); List selectByUserId(Long userId); BizMeetingAttendee selectById(Long id); + /** + * 按 (meetingId, userId) 查参会人行 id (导入覆盖更新用: 手机号已在该会议时拿旧行 id). + * 无匹配返回 null. + */ + Long selectIdByMeetingIdAndUserId(@Param("meetingId") Long meetingId, @Param("userId") Long userId); List selectUnsignedByUserId(Long userId); /** 当前用户的"待参加"会议 (已邀请参会 is_invited=1), 联表取会议名/时间 */ List selectInvitedByUserId(Long userId); diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/mapper/BizMeetingMapper.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/mapper/BizMeetingMapper.java index fc0048d..a1a3bde 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/mapper/BizMeetingMapper.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/mapper/BizMeetingMapper.java @@ -48,6 +48,12 @@ public interface BizMeetingMapper *

    由 MeetingStageScheduler 每分钟触发. */ int markFrozen(); + /** + * 自动流转 (反向纠偏): start_time 被改到未来、但 current_stage 仍残留 IN_PROGRESS 的会议 → 置回 NOT_STARTED. + *

    markInProgress 只做单向 (NOT_STARTED→IN_PROGRESS), 编辑把 start_time 推到未来时无人回退; 本方法由 + * MeetingStageScheduler 每分钟触发, 与 markInProgress 互为逆操作, 保证时间窗口态双向一致. + */ + int markNotStarted(); /** * 费用汇总调度器用: 查 fee_calc_status=0 且未软删的会议 id 列表. */ @@ -71,4 +77,11 @@ public interface BizMeetingMapper int updateLaborFee(@Param("meetingId") Long meetingId, @Param("laborFee") BigDecimal laborFee, @Param("totalFee") BigDecimal totalFee); + /** + * 项目 submit_deadline_days 变更时级联重算: 该项目下「从未退回、且仍有轨未提交」的会议 (未软删/未冻结), + * 提交截止时间 = end_time + days 天 (days 为 null 则置 NULL → 永不冻结). + *

    排除 REJECTED 轨: 退回时截止时间已被重置为 now+天数 (锚点=退回时刻), 按 end_time 重算会错误缩短整改窗口. + * 已冻结/两轨均已提交的会议不动 (其截止时间已无意义). + */ + int recomputeSubmitDeadlinesByProject(@Param("projectId") Long projectId, @Param("days") Integer days); } \ No newline at end of file diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/mapper/BizOrgMapper.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/mapper/BizOrgMapper.java index 0ff4193..6f1b541 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/mapper/BizOrgMapper.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/mapper/BizOrgMapper.java @@ -24,12 +24,13 @@ public interface BizOrgMapper { * 与 selectSponsorOrgOptions 区别: 不 JOIN sys_user (无主账号的 org 也可选), 返回 orgId + mainUserId * 返回 Map: orgId / orgName / mainUserId (= biz_org.user_id, 可为 null 表示该企业暂无主账号) */ List> selectSponsorRegisterOptions(BizOrg entity); - /** 当前登录 sponsor 的所属公司 - * 主账号 (sys_user.parent_user_id IS NULL): biz_org.user_id = #{userId} - * 子账号 (有 biz_person 记录): biz_org.org_id = biz_person.org_id - * 返回 Map: orgId / orgName / isOwner (1=主账号, 0=子账号) - * 用于 /sponsor/account 页面回显 + 主账号修改 org_name */ - Map selectMySponsorCompany(Long userId); + /** 当前登录账号的所属公司 (sponsor + executor 共用) + * 根据 sys_user.role_type 自动选 sponsor/executor + * 主账号 (biz_org.user_id = #{userId}): 走 own + * 子账号 (有 biz_person 记录): biz_org.org_id = biz_person.org_id + * 返回 Map: orgId / orgName / orgType / isOwner (1=主账号, 0=子账号) + * 用于 /sponsor/account + /executor/account 页面只读回显单位名称 (主账号也不可改) */ + Map selectMyCompany(Long userId); /** user_id → org_id 单一可信源反查: COALESCE(biz_org.user_id, biz_person.user_id) * MAIN 主账号走 biz_org.user_id; SUB 子账号走 biz_person.org_id; 都没有返回 null */ Long selectOrgIdByUserId(Long userId); diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/mapper/BizProjectExecutorAssignMapper.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/mapper/BizProjectExecutorAssignMapper.java index 48d41a1..625f33f 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/mapper/BizProjectExecutorAssignMapper.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/mapper/BizProjectExecutorAssignMapper.java @@ -1,13 +1,18 @@ package com.ruoyi.business.mapper; import java.util.List; +import org.apache.ibatis.annotations.Param; import com.ruoyi.business.domain.BizProjectExecutorAssign; public interface BizProjectExecutorAssignMapper { int insertAssign(BizProjectExecutorAssign entity); - List selectByProjectId(String projectId); + List selectByProjectId(Long projectId); /** 按 project_id 全删 (执行方分配: 先删后插策略) */ - int deleteByProjectId(String projectId); - /** 软删除: 项目级联删除时按 project_id (String) 置 is_deleted=1 */ - int softDeleteByProjectId(String projectId); + int deleteByProjectId(Long projectId); + /** 软删除: 项目级联删除时按 project_id 置 is_deleted=1 */ + int softDeleteByProjectId(Long projectId); + /** 按 (project_id + executor_org_id) 物理删除本执行方的执行人分配 (替代按 project 全删, 避免多执行方互相覆盖) */ + int deleteByProjectIdAndOrg(@Param("projectId") Long projectId, @Param("executorOrgId") Long executorOrgId); + /** 按 (project_id + executor_org_id) 查本执行方的执行人分配 (回显隔离, 避免串读别家执行人) */ + List selectByProjectIdAndOrg(@Param("projectId") Long projectId, @Param("executorOrgId") Long executorOrgId); } diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/mapper/BizProjectMapper.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/mapper/BizProjectMapper.java index c2f0d18..740b3e2 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/mapper/BizProjectMapper.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/mapper/BizProjectMapper.java @@ -9,8 +9,9 @@ public interface BizProjectMapper { BizProject selectByPrimaryKey(Long projectId); List selectList(BizProject entity); - /** 公开门户公示列表: is_published='1' 且未删除, 按 publish_time 倒序 (最新在前, 空值排最后) */ - List selectPublicAnnouncements(); + /** 公开门户公示列表: is_published='Y' 且未删除, 按 publish_time 倒序 (最新在前, 空值排最后). + * 仅查公示页展示的少量标量列, keyword 按项目名模糊过滤, 分页由 controller startPage 注入. */ + List selectPublicAnnouncements(@org.apache.ibatis.annotations.Param("keyword") String keyword); /** sponsor 端专属: projectIds + LEFT JOIN 当前 login 用户评分, 用于评分回显 */ List selectSponsorList(BizProject entity); /** executor 端专属: JOIN biz_project_assign 过滤, 只返回分给当前 user 的项目 */ diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/oss/OssUploader.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/oss/OssUploader.java index 8f7cfb6..0dc51ba 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/oss/OssUploader.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/oss/OssUploader.java @@ -9,6 +9,7 @@ import java.io.ByteArrayInputStream; import java.text.SimpleDateFormat; import java.util.Date; import java.util.UUID; +import java.util.concurrent.ThreadLocalRandom; /** * 服务端 OSS 上传器 (copy 自 hwt-serve/ruoyi-common-base/.../third/AliOssService,精简) @@ -70,6 +71,57 @@ public class OssUploader return url; } + /** + * 上传字节到 OSS, key 保留原文件名 (原名_13位时间戳_6位随机.扩展名), 供前端从 URL 还原原名显示. + * 与前端 uploadToOss 的 key 命名规则对齐, 用于电子签到表 zip 解压后逐个文件上传. + */ + public String uploadWithOriginalName(byte[] data, String originalFilename, String subDir) + { + if (data == null || data.length == 0) throw new IllegalArgumentException("上传字节为空"); + if (originalFilename == null) throw new IllegalArgumentException("originalFilename 不能为空"); + log.info("OSS 上传开始(原名): name={} size={}B subDir={}", originalFilename, data.length, subDir); + + int dot = originalFilename.lastIndexOf('.'); + String ext = dot > 0 ? originalFilename.substring(dot) : ""; + String base = dot > 0 ? originalFilename.substring(0, dot) : originalFilename; + // 与前端 uploadToOss 一致: 扩展名剔除危险字符, 基础名危险字符替换为下划线 + ext = ext.replaceAll("[\\\\/:*?\"<>|%\\s]+", ""); + base = base.replaceAll("[\\\\/:*?\"<>|%\\s]+", "_"); + if (base.isEmpty()) base = "file"; + + String date = new SimpleDateFormat("yyyyMM").format(new Date()); + String rand = random6(); + String key = (subDir == null ? "" : subDir + "/") + date + "/" + base + "_" + System.currentTimeMillis() + "_" + rand + ext; + + OSSClient client = new OSSClient( + ossConfMeta.getEndpoint(), + ossConfMeta.getAccessKeyId(), + ossConfMeta.getAccessKeySecret()); + try + { + ObjectMetadata meta = buildObjectMeta(originalFilename, ext); + client.putObject(ossConfMeta.getBucket(), key, new ByteArrayInputStream(data), meta); + } + finally + { + client.shutdown(); + } + + String url = "https://" + ossConfMeta.getBucket() + "." + ossConfMeta.getEndpoint() + "/" + key; + log.info("OSS 上传完成(原名): {}", url); + return url; + } + + /** 6 位小写字母数字随机串 (对应前端 Math.random().toString(36).slice(2,8)) */ + private static String random6() + { + String alphabet = "abcdefghijklmnopqrstuvwxyz0123456789"; + char[] chars = new char[6]; + ThreadLocalRandom rnd = ThreadLocalRandom.current(); + for (int i = 0; i < 6; i++) chars[i] = alphabet.charAt(rnd.nextInt(alphabet.length())); + return new String(chars); + } + private static ObjectMetadata buildObjectMeta(String filename, String ext) { ObjectMetadata meta = new ObjectMetadata(); diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/scheduler/MeetingStageScheduler.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/scheduler/MeetingStageScheduler.java index 61e667e..f164523 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/scheduler/MeetingStageScheduler.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/scheduler/MeetingStageScheduler.java @@ -87,4 +87,26 @@ public class MeetingStageScheduler log.warn("[MeetingStageScheduler] 冻结异常 (跳过, 下分钟再试)", e); } } + + /** + * 每分钟: start_time 被改到未来、但 current_stage 仍残留 IN_PROGRESS → 置回 NOT_STARTED. + *

    {@code markInProgress} 只做单向 NOT_STARTED→IN_PROGRESS, 编辑把 start_time 推到未来时无人回退, + * 由这里兜底自愈 (与 {@code markInProgress} 互为逆操作). + */ + @Scheduled(fixedRate = 60_000, initialDelay = 30_000) + public void markNotStarted() + { + try + { + int affected = meetingMapper.markNotStarted(); + if (affected > 0) + { + log.info("[MeetingStageScheduler] 自动置回未执行: 本次更新 {} 行", affected); + } + } + catch (Exception e) + { + log.warn("[MeetingStageScheduler] 置回未执行异常 (跳过, 下分钟再试)", e); + } + } } diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/scheduler/OpenStatusScheduler.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/scheduler/OpenStatusScheduler.java index d6fbdf6..3132d5e 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/scheduler/OpenStatusScheduler.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/scheduler/OpenStatusScheduler.java @@ -10,7 +10,7 @@ import lombok.extern.slf4j.Slf4j; * 项目"开通"到期回收调度器 (每天 00:05 一次). *

    * 开通状态流转: 默认 N → 经理"开通"置 Y + open_deadline → 到期(open_deadline <= 今天)回收为 N. - * 结题(is_finished=1)且 open_status=N 的项目, sponsor 不可见 + * 结题(is_finished=Y)且 open_status=N 的项目, sponsor 不可见 * (见 BizProjectMapper.selectSponsorList / BizMeetingMapper.selectList 的过滤). *

    * 需要启动类 {@code @EnableScheduling} 才会生效 (RuoYiApplication 已有). diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/ComplianceLoginService.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/ComplianceLoginService.java index a302013..26b73be 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/ComplianceLoginService.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/ComplianceLoginService.java @@ -26,11 +26,16 @@ import com.ruoyi.system.service.ISysUserService; * - type=2 → 项目负责人 → role_type=leader *

    * 登录决策 (手机号 vs 视图 vs sys_user): - * - 已注册且非 manager/leader → 不查视图, 直接放行 (走原逻辑) - * - 视图里没有 → 报"用户不存在" - * - 视图里有 + sys_user 没有 → 入库(sys_user + biz_person, 登录名=手机号, 密码留空) 后放行 - * - 视图里有 + sys_user 有 + 角色匹配 → 放行 - * - 视图里有 + sys_user 有 + 角色不匹配 → 软删旧 + 建新 后放行 + * - 白名单 (manager01/leader01) → 直接放行 + * - 每次手机号登录都先查视图 (视图是合规/负责人身份的权威来源) + * - 视图未命中: + * - 未注册 → 报"用户不存在" + * - 已注册且为 manager/leader (已不在视图) → 报"用户不存在" + * - 其他角色 → 放行 (走原逻辑) + * - 视图命中: + * - sys_user 没有 → 入库(sys_user + biz_person, 登录名=手机号, 密码留空) 后放行 + * - sys_user 有 + 角色匹配 → 放行 + * - sys_user 有 + 角色不匹配 (含 manager/leader 之外的角色被命中) → 软删旧 + 建新 后放行 */ @Service public class ComplianceLoginService @@ -68,18 +73,25 @@ public class ComplianceLoginService { return user; } - String role = user == null ? null : user.getRoleType(); - boolean needEcology = user == null || "manager".equals(role) || "leader".equals(role); - if (!needEcology) + + // 视图查询提前: 每次手机号登录都先查 ecology 视图, 命中即按视图权威身份处理 + EcologyPerson p = queryEcology(phone); + + if (p == null) { + // 视图未命中: 未注册 → 拦; 已注册的 manager/leader (已不在视图) → 拦; 其他角色正常放行 + if (user == null) + { + throw new ServiceException("用户不存在"); + } + String role = user.getRoleType(); + if ("manager".equals(role) || "leader".equals(role)) + { + throw new ServiceException("用户不存在"); + } return user; } - EcologyPerson p = queryEcology(phone); - if (p == null) - { - throw new ServiceException("用户不存在"); - } String expectedRole = p.type == 1 ? "manager" : "leader"; if (user == null) @@ -87,19 +99,19 @@ public class ComplianceLoginService // sys_user 没有 → 自动入库 return provision(p, phone, expectedRole); } - if (expectedRole.equals(role)) + if (expectedRole.equals(user.getRoleType())) { // 角色匹配 → 直接放行 return user; } - // 角色不匹配 → 软删旧 + 建新 + // 角色不匹配 (含 manager/leader 之外的角色被视图命中) → 软删旧 + 建新 userService.deleteUserByIds(new Long[] { user.getUserId() }); return provision(p, phone, expectedRole); } /** - * 密码登录门禁: 已解析出的 manager/leader 校验其是否仍在视图、角色是否一致. - * 返回 null=放行; 非 null=错误提示 (此时调用方应直接报错, 不再走密码登录). + * 密码登录门禁: 按登录用户的手机号查 ecology 视图, 命中即按视图权威身份处理. + * 返回 null=放行 (继续正常密码登录); 非 null=错误提示 (此时调用方应直接报错, 不再走密码登录). */ @Transactional(rollbackFor = Exception.class) public String gateForPasswordLogin(SysUser user) @@ -112,14 +124,20 @@ public class ComplianceLoginService EcologyPerson p = queryEcology(phone == null ? "" : phone); if (p == null) { - return "用户不存在"; + // 视图未命中: manager/leader 已不在视图 → 拦; 其他角色正常密码登录 + String role = user.getRoleType(); + if ("manager".equals(role) || "leader".equals(role)) + { + return "用户不存在"; + } + return null; } String expectedRole = p.type == 1 ? "manager" : "leader"; if (expectedRole.equals(user.getRoleType())) { return null; } - // 角色不匹配 → 软删旧 + 建新 (新账号密码留空, 只能短信登录) + // 角色不匹配 (含 manager/leader 之外的角色被视图命中) → 软删旧 + 建新 (新账号密码留空, 只能短信登录) userService.deleteUserByIds(new Long[] { user.getUserId() }); provision(p, phone, expectedRole); return "账号角色已更新, 请使用手机验证码登录"; diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/IBizInviteService.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/IBizInviteService.java new file mode 100644 index 0000000..7afb821 --- /dev/null +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/IBizInviteService.java @@ -0,0 +1,34 @@ +package com.ruoyi.business.service; + +import java.util.List; +import com.ruoyi.business.domain.BizInvite; +import com.ruoyi.business.domain.BizInviteRecipient; + +/** + * 发送邀请 Service 接口 + */ +public interface IBizInviteService +{ + List selectList(BizInvite entity); + BizInvite getById(Long id); + List listRecipients(Long inviteId); + + /** + * 新建邀请: 写主表 + 收件人明细 (生成 token). + * @param invite 主表字段 + recipients 明细 + * @param sendNow true=保存后立即发送, false=仅保存草稿 + * @return 邀请 id + */ + Long create(BizInvite invite, boolean sendNow); + + /** 发送已保存的邀请 (对 remark=1 的收件人触发短信/邮件), 返回实际发送条数 */ + int send(Long inviteId); + + int deleteByPrimaryKeys(Long[] ids); + + /** 公开: 按 token 查收件人 */ + BizInviteRecipient getRecipientByToken(String token); + + /** 公开: 确认 (不可逆), 返回受影响行数 (0=已确认过或 token 无效) */ + int confirmByToken(String token); +} diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/IBizMeetingAttendeeService.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/IBizMeetingAttendeeService.java index 8c1c9de..5b4fc7c 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/IBizMeetingAttendeeService.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/IBizMeetingAttendeeService.java @@ -41,6 +41,14 @@ public interface IBizMeetingAttendeeService { * @throws ServiceException phone 为空 / 用户已在会议中 */ Long insertByPhoneWithProfile(BizMeetingAttendee body); + + /** + * 导入参会人单行 upsert: 手机号已在该会议 → 覆盖更新已有行 (走 updateProfile 修改逻辑); + * 否则新增 (复用 insertByPhoneWithProfile 的查/建号 + 写档案 + 建报名占位). + * 与 {@link #insertByPhoneWithProfile} 的区别: 手机号重复不抛异常, 而是更新. + */ + Long upsertByPhoneWithProfile(BizMeetingAttendee body); + List selectByMeetingId(Long meetingId); List selectByUserId(Long userId); BizMeetingAttendee selectById(Long id); @@ -130,15 +138,15 @@ public interface IBizMeetingAttendeeService { byte[] buildAgreementTemplateZip(Long meetingId); /** - * 上传"劳务协议" zip, 解压后按 劳务协议/{序号}_{姓名}/ 目录匹配参会人 (姓名唯一命中优先, 序号兜底), - * 取目录内第一个文件上传 OSS 并回填 labor_protocol (覆盖式). + * 提交"劳务协议" zip 上传任务 (异步): 请求线程读字节 + 登记进度后立即返回 jobId, + * 后台线程解压匹配参会人并回填 labor_protocol, 前端轮询 /business/upload/progress/{jobId} 拿进度与结果. * * @param file 用户重新压缩的 zip * @param meetingId 会议 id (决定 OSS 子目录 + 参会人列表) - * @return { updated: 成功回填数, total: 参会人数, skipped: 跳过数, results: 每个目录的匹配明细 } - * @throws Exception 文件为空 / 无参会人 / zip 解析异常 + * @return jobId (进度/结果轮询键) + * @throws Exception 文件为空 / 读字节失败 */ - Map uploadAgreements(MultipartFile file, Long meetingId) throws Exception; + String submitAgreementUpload(MultipartFile file, Long meetingId) throws Exception; /** * 生成"专家照片"空目录模板 zip (专家照片 "下载目录模板" 按钮). @@ -150,13 +158,13 @@ public interface IBizMeetingAttendeeService { byte[] buildExpertPhotoTemplateZip(Long meetingId); /** - * 上传"专家照片" zip, 解压后按 专家照片/{序号}_{姓名}/ 目录匹配参会人 (姓名唯一命中优先, 序号兜底), - * 目录内所有文件上传 OSS 后逗号拼接, 覆盖式回填 on_site_photos. + * 提交"专家照片" zip 上传任务 (异步): 请求线程读字节 + 登记进度后立即返回 jobId, + * 后台线程解压匹配参会人并回填 on_site_photos, 前端轮询 /business/upload/progress/{jobId} 拿进度与结果. * * @param file 用户重新压缩的 zip * @param meetingId 会议 id - * @return { updated, total, skipped, results } (同 uploadAgreements) - * @throws Exception 文件为空 / 无参会人 / zip 解析异常 + * @return jobId (进度/结果轮询键) + * @throws Exception 文件为空 / 读字节失败 */ - Map uploadExpertPhotos(MultipartFile file, Long meetingId) throws Exception; + String submitExpertPhotoUpload(MultipartFile file, Long meetingId) throws Exception; } \ No newline at end of file diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/IBizMeetingMaterialService.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/IBizMeetingMaterialService.java index 66b364a..0609654 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/IBizMeetingMaterialService.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/IBizMeetingMaterialService.java @@ -1,6 +1,8 @@ package com.ruoyi.business.service; import java.util.List; +import java.util.Map; + import org.springframework.web.multipart.MultipartFile; import com.ruoyi.business.domain.BizMeetingMaterial; @@ -52,10 +54,11 @@ public interface IBizMeetingMaterialService { /** * 扫码拍照回传: ry-h5 手机端拍照直传 OSS 后, 回传 URL 到此存库. * 按 (meetingId, subType) upsert 单行 (存在改 ossUrl, 不存在 insert). - * extraOssUrl: 签到表拍照时额外生成的高斯模糊版 URL (sponsor 只看这个), 其他 subType 传空. + * 签到表 (L_SIGN_IN) 连拍多张: ossUrls/extraOssUrls 为多张, 存库时序列化为 JSON 数组字符串; + * 前后全景单张: 取第一个. extraOssUrls 是高斯模糊版 (sponsor 只看这个), 其他 subType 传空. * 白名单 subType + 会议存在校验 (公开端点防滥用). */ - void upsertFromCamera(Long meetingId, String subType, String ossUrl, String extraOssUrl); + void upsertFromCamera(Long meetingId, String subType, List ossUrls, List extraOssUrls); /** * 结算时保存付款凭证 (LV_PAYMENT + SV_PAYMENT, 合规人员上传). @@ -114,13 +117,26 @@ public interface IBizMeetingMaterialService { byte[] buildServiceTemplateZip(Long meetingId); /** - * 上传"会务材料" zip, 解压后按 会务材料/{材料类型中文名}/ 目录匹配 subType, - * 单文件直接上传 OSS, 多文件先打 zip 再上传 OSS, 按 (meetingId, SERVICE, subType) upsert 回填. + * 提交"会务材料" zip 上传任务 (异步): 请求线程读字节 + 登记进度后立即返回 jobId, + * 后台线程解压按 会务材料/{材料类型中文名}/ 目录匹配 subType 并回填, 前端轮询 + * /business/upload/progress/{jobId} 拿进度与结果 (result = 回填的材料类数). * * @param file 用户重新压缩的 zip * @param meetingId 会议 id (决定 OSS 子目录) - * @return 成功回填的材料类数 - * @throws Exception 文件为空 / zip 解析异常 / 未找到有效目录 + * @return jobId (进度/结果轮询键) + * @throws Exception 文件为空 / 读字节失败 */ - int uploadServiceMaterials(MultipartFile file, Long meetingId) throws Exception; + String submitServiceMaterialsUpload(MultipartFile file, Long meetingId) throws Exception; + + /** + * 电子签到表 (L_ESIGN_IN) 多文件/zip 上传 (同步, 追加式保存). + *

    + * file 为单个 Excel (xls/xlsx/csv) 或 zip (后台解压并分析其中所有 Excel). + * 逐个解析脱敏 + 直传 OSS; 单行 L_ESIGN_IN 存多文件 (oss_url 存单 URL / JSON 数组, + * masked_json 存 {headers,rows} / {sheets:[...]}). 已有文件保留, 新文件追加到末尾. + * 任一文件解析失败 → 整体失败回滚. + * + * @return { count: 本次新增文件数, total: 合并后总文件数 } + */ + Map uploadEsignIn(MultipartFile file, Long meetingId) throws Exception; } \ No newline at end of file diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/IBizMeetingService.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/IBizMeetingService.java index 3dc09fc..7a7610e 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/IBizMeetingService.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/IBizMeetingService.java @@ -49,4 +49,9 @@ public interface IBizMeetingService * (参会人变化不影响会务费, 也不触发整体重算, 避免"统计中"等待 60s 调度器). */ void recomputeLaborFee(Long meetingId); + /** + * 项目 submit_deadline_days 变更时级联重算: 该项目下「从未退回、且仍有轨未提交」会议的 submit_deadline = end_time + days 天. + *

    days 为 null → 置 NULL (永不冻结). 已冻结/两轨均已提交的会议不动; REJECTED 轨按退回时刻为锚点, 不在此重算. + */ + void recomputeSubmitDeadlinesByProject(Long projectId, Integer days); } diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/IBizOrgService.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/IBizOrgService.java index e4f0405..3317ca2 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/IBizOrgService.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/IBizOrgService.java @@ -19,8 +19,9 @@ public interface IBizOrgService { List> selectExecutorOrgOptions(BizOrg entity); /** 支持方注册下拉选项 (匿名公开, 含无主账号 org): 返回 orgId / orgName / mainUserId */ List> selectSponsorRegisterOptions(BizOrg entity); - /** 当前登录 sponsor 的所属公司 (主账号可改, 子账号只读) */ - Map selectMySponsorCompany(Long userId); + /** 当前登录账号的所属公司 (sponsor + executor 共用, 只读展示) + * 返回 Map: orgId / orgName / orgType / isOwner (1=主账号, 0=子账号) */ + Map selectMyCompany(Long userId); /** user_id → org_id 单一可信源反查 (MAIN 走 biz_org, SUB 走 biz_person), 找不到返回 null */ Long selectOrgIdByUserId(Long userId); diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/IBizPersonService.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/IBizPersonService.java index a6a2c87..3999bf7 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/IBizPersonService.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/IBizPersonService.java @@ -14,6 +14,8 @@ import com.ruoyi.common.core.domain.entity.SysUser; public interface IBizPersonService { BizPerson getById(String personId); + /** 按 user_id 反查人员档案 (登录态拿姓名/手机号, navbar 显示姓名用) */ + BizPerson getByUserId(Long userId); List selectList(BizPerson entity); /** sponsor 专属: 走 BizPersonMapper.selectSponsorList, 用 sys_user.parent_user_id 做归属过滤 */ List selectSponsorList(BizPerson entity); diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/IBizProjectExecutorAssignService.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/IBizProjectExecutorAssignService.java index eab90a4..191fcbd 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/IBizProjectExecutorAssignService.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/IBizProjectExecutorAssignService.java @@ -8,6 +8,6 @@ public interface IBizProjectExecutorAssignService { int insertAssign(BizProjectExecutorAssign entity); /** 执行方多条分配 (一个项目 ↔ N 执行人: 先按 project_id 删, 再逐个 insert, 不会循环 delete) */ int assignStaffForProject(BizProjectExecutorAssign body, List staffUserIds); - List listByProjectId(String projectId); - int deleteByProjectId(String projectId); + List listByProjectId(Long projectId, Long executorOrgId); + int deleteByProjectId(Long projectId); } diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/IBizProjectService.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/IBizProjectService.java index 62a2781..aadb54c 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/IBizProjectService.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/IBizProjectService.java @@ -10,8 +10,8 @@ public interface IBizProjectService { BizProject getById(Long projectId); List selectList(BizProject entity); - /** 公开门户公示列表: 已发布, 按发布时间倒序 */ - List selectPublicAnnouncements(); + /** 公开门户公示列表: 已发布, 按发布时间倒序, keyword 按项目名模糊过滤 (分页由 controller startPage 注入) */ + List selectPublicAnnouncements(String keyword); /** sponsor 端专属: LEFT JOIN 当前 login 用户评分回显 */ List selectSponsorList(BizProject entity); /** executor 端专属: JOIN biz_project_assign 过滤, 只返回分给当前 user 的项目 */ diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizInviteServiceImpl.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizInviteServiceImpl.java new file mode 100644 index 0000000..9f04106 --- /dev/null +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizInviteServiceImpl.java @@ -0,0 +1,186 @@ +package com.ruoyi.business.service.impl; + +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import com.ruoyi.common.utils.SecurityUtils; +import com.ruoyi.common.utils.id.IdGenerator; +import com.ruoyi.business.domain.BizInvite; +import com.ruoyi.business.domain.BizInviteRecipient; +import com.ruoyi.business.mapper.BizInviteMapper; +import com.ruoyi.business.mapper.BizInviteRecipientMapper; +import com.ruoyi.business.mail.InviteMailSender; +import com.ruoyi.business.service.IBizInviteService; +import com.ruoyi.business.sms.AliyunSmsSender; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * 发送邀请 Service 实现. + *

    独立于项目/会议: 项目编号等均为手输文本, 短信/邮件带公开邀请链接 (富文本内容 + 底部确认按钮). + */ +@Service +public class BizInviteServiceImpl implements IBizInviteService +{ + private static final Logger log = LoggerFactory.getLogger(BizInviteServiceImpl.class); + + @Autowired private BizInviteMapper inviteMapper; + @Autowired private BizInviteRecipientMapper recipientMapper; + @Autowired private AliyunSmsSender smsSender; + @Autowired private InviteMailSender mailSender; + + @Override + public List selectList(BizInvite entity) + { + return inviteMapper.selectList(entity); + } + + @Override + public BizInvite getById(Long id) + { + return inviteMapper.selectByPrimaryKey(id); + } + + @Override + public List listRecipients(Long inviteId) + { + return recipientMapper.selectByInviteId(inviteId); + } + + @Override + @Transactional + public Long create(BizInvite invite, boolean sendNow) + { + String username = SecurityUtils.getUsername(); + invite.setId(IdGenerator.generateId()); + invite.setCreateBy(username); + inviteMapper.insert(invite); + + if (invite.getRecipients() != null && !invite.getRecipients().isEmpty()) + { + List list = new ArrayList<>(invite.getRecipients().size()); + for (BizInviteRecipient r : invite.getRecipients()) + { + r.setId(IdGenerator.generateId()); + r.setInviteId(invite.getId()); + r.setToken(IdGenerator.uuid()); + if (r.getSendStatus() == null) r.setSendStatus("0"); + if (r.getConfirmStatus() == null) r.setConfirmStatus("0"); + r.setCreateBy(username); + list.add(r); + } + recipientMapper.insertBatch(list); + } + + if (sendNow) + { + send(invite.getId()); + } + return invite.getId(); + } + + @Override + public int send(Long inviteId) + { + BizInvite invite = inviteMapper.selectByPrimaryKey(inviteId); + if (invite == null) + { + throw new RuntimeException("邀请不存在"); + } + List recipients = recipientMapper.selectByInviteId(inviteId); + int sent = 0; + for (BizInviteRecipient r : recipients) + { + // 只发备注=1 的收件人, 且未成功过 + if (r.getRemark() == null || !"1".equals(r.getRemark())) + { + continue; + } + String link = smsSender.inviteLink(r.getToken()); + String failReason = sendToRecipient(invite, r, link); + boolean ok = failReason == null; + recipientMapper.updateSendStatus(r.getId(), ok ? "1" : "2", failReason); + if (ok) sent++; + } + inviteMapper.updateSendTime(inviteId, new Date()); + return sent; + } + + /** 发送单条收件人, 返回 null=成功, 非空=失败原因 */ + private String sendToRecipient(BizInvite invite, BizInviteRecipient r, String link) + { + String channel = invite.getSendChannel() == null ? "both" : invite.getSendChannel(); + boolean wantSms = channel.contains("sms"); + boolean wantEmail = channel.contains("email"); + + boolean ok = false; + List fails = new ArrayList<>(); + + if (wantEmail) + { + if (r.getEmail() != null && !r.getEmail().trim().isEmpty()) + { + String subject = "诚邀您参加\"" + (invite.getMeetingName() == null ? "" : invite.getMeetingName()) + "\"项目"; + String html = buildInviteEmail(invite, r, link); + if (mailSender.sendInvite(r.getEmail(), subject, html)) ok = true; + else fails.add("邮件发送失败"); + } + else + { + fails.add("缺少邮箱"); + } + } + if (wantSms) + { + if (r.getPhone() != null && !r.getPhone().trim().isEmpty()) + { + if (smsSender.sendInvite(r.getPhone(), link)) ok = true; + else fails.add("短信发送失败"); + } + else + { + fails.add("缺少手机号"); + } + } + + return ok ? null : String.join(";", fails); + } + + /** 邮件正文 (待定稿): 简版提示 + 邀请链接; 完整富文本内容放在公开邀请页渲染 */ + private String buildInviteEmail(BizInvite invite, BizInviteRecipient r, String link) + { + String name = r.getName() == null ? "" : r.getName(); + return "

    " + + "

    " + name + " 您好:

    " + + "

    诚邀您参加相关项目,请点击以下链接查看邀请内容并确认:

    " + + "

    " + link + "

    " + + "
    "; + } + + @Override + public int deleteByPrimaryKeys(Long[] ids) + { + int rows = 0; + for (Long id : ids) + { + recipientMapper.deleteByInviteId(id); + rows += inviteMapper.deleteByPrimaryKey(id); + } + return rows; + } + + @Override + public BizInviteRecipient getRecipientByToken(String token) + { + return recipientMapper.selectByToken(token); + } + + @Override + public int confirmByToken(String token) + { + return recipientMapper.updateConfirmByToken(token); + } +} diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizMeetingAttendeeServiceImpl.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizMeetingAttendeeServiceImpl.java index 5b9b2a8..84bac83 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizMeetingAttendeeServiceImpl.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizMeetingAttendeeServiceImpl.java @@ -19,6 +19,8 @@ import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ExecutorService; import java.util.concurrent.ThreadLocalRandom; import java.util.zip.ZipEntry; import java.util.zip.ZipInputStream; @@ -27,6 +29,7 @@ import org.apache.poi.xssf.streaming.SXSSFWorkbook; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; import com.fasterxml.jackson.databind.JsonNode; @@ -51,6 +54,7 @@ import com.ruoyi.business.service.IBizMeetingAttendeeService; import com.ruoyi.business.service.IBizMeetingService; import com.ruoyi.business.service.InvitationPdfService; import com.ruoyi.business.sms.AliyunSmsSender; +import com.ruoyi.business.upload.UploadProgressRegistry; import com.ruoyi.common.core.domain.entity.SysUser; import com.ruoyi.common.exception.ServiceException; import com.ruoyi.common.utils.SecurityUtils; @@ -92,6 +96,11 @@ public class BizMeetingAttendeeServiceImpl implements IBizMeetingAttendeeService private OssUploader ossUploader; @Autowired private InvitationPdfService invitationPdfService; + @Autowired + private UploadProgressRegistry uploadProgressRegistry; + @Autowired + @Qualifier("zipUploadExecutor") + private ExecutorService zipUploadExecutor; @Override public int insert(BizMeetingAttendee entity) { @@ -129,49 +138,85 @@ public class BizMeetingAttendeeServiceImpl implements IBizMeetingAttendeeService */ @Override public Long insertByPhoneWithProfile(BizMeetingAttendee body) { - String phone = body.getPhone(); - if (phone == null || phone.trim().isEmpty()) { - throw new ServiceException("手机号不能为空"); - } - phone = phone.trim(); + String phone = requirePhone(body); + Long userId = resolveUserIdByPhone(phone, body.getName()); - // 1. 按 phone 查 sys_user (单条 IN 查, selectByPhoneList 接受 List) - List hits = sysUserMapper.selectByPhoneList(Collections.singletonList(phone)); - Long userId; - SysUser existed = (hits != null && !hits.isEmpty()) ? hits.get(0) : null; - if (existed != null) { - userId = existed.getUserId(); - } else { - // 2. 查不到 → 建 sys_user (用户名=phone, 密码=phone, role_type='doctor') - SysUser newUser = new SysUser(); - newUser.setUserName(phone); - newUser.setNickName(body.getName() != null && !body.getName().isEmpty() ? body.getName() : phone); - newUser.setPhonenumber(phone); - newUser.setPassword(SecurityUtils.encryptPassword(phone)); - newUser.setStatus("0"); - newUser.setDelFlag("0"); - newUser.setCreateBy(SecurityUtils.getUsername()); - sysUserService.insertUser(newUser); - userId = newUser.getUserId(); - if (userId == null) { - // 兜底: insertUser 用了 useGeneratedKeys, 正常能拿到; 拿不到时按 phone 再查一次 - SysUser re = sysUserMapper.selectUserByUserName(phone); - if (re == null) throw new ServiceException("建账号失败, 请重试"); - userId = re.getUserId(); - } - // 显式设 role_type='doctor' (DB 默认 'executor', 参会人应为 doctor 视角) - sysUserService.updateRoleType(userId, "doctor"); - log.info("[attendee] 新建 sys_user (phone={}, userId={}, roleType=doctor)", phone, userId); - } - - // 3. 检查 (meetingId, userId) 是否已存在 → 友好提示 + // 检查 (meetingId, userId) 是否已存在 → 友好提示 // 仅 SELECT, 不 DELETE (deleteByMeetingIdAndUserId 是破坏性的, 不能误用做探测) java.util.Set existing = new java.util.HashSet<>(mapper.selectUserIdsByMeetingId(body.getMeetingId())); if (existing.contains(userId)) { throw new ServiceException("该手机号参会人已在会议中, 无需重复添加"); } - // 4. 写完整档案行 (attendee.id 用雪花 ID, 不走 DB 自增) + return insertNewAttendee(body, userId, phone); + } + + /** + * 导入参会人单行 upsert: 手机号已在该会议 → 覆盖更新已有行 (走 updateProfile 修改逻辑), + * 不抛"已在会议中"重复异常; 否则与 insertByPhoneWithProfile 一致地新增. + */ + @Override + public Long upsertByPhoneWithProfile(BizMeetingAttendee body) { + String phone = requirePhone(body); + Long userId = resolveUserIdByPhone(phone, body.getName()); + + Long existingId = mapper.selectIdByMeetingIdAndUserId(body.getMeetingId(), userId); + if (existingId != null) { + // 覆盖已有行: 复用编辑逻辑 updateProfile (选择性更新, 未填字段保留旧值) + body.setId(existingId); + body.setUpdateBy(SecurityUtils.getUsername()); + mapper.updateProfile(body); + log.info("[attendee] 覆盖更新参会人 meetingId={} userId={} attendeeId={}", body.getMeetingId(), userId, existingId); + return existingId; + } + + return insertNewAttendee(body, userId, phone); + } + + /** 校验手机号非空并 trim 后返回, 空则抛 */ + private String requirePhone(BizMeetingAttendee body) { + String phone = body.getPhone(); + if (phone == null || phone.trim().isEmpty()) { + throw new ServiceException("手机号不能为空"); + } + return phone.trim(); + } + + /** + * 按 phone 定位/新建 sys_user, 返回 userId (用户名=phone, 密码=phone, roleType='doctor'). + * 查到复用, 查不到新建. + */ + private Long resolveUserIdByPhone(String phone, String name) { + List hits = sysUserMapper.selectByPhoneList(Collections.singletonList(phone)); + SysUser existed = (hits != null && !hits.isEmpty()) ? hits.get(0) : null; + if (existed != null) { + return existed.getUserId(); + } + SysUser newUser = new SysUser(); + newUser.setUserName(phone); + newUser.setNickName(name != null && !name.isEmpty() ? name : phone); + newUser.setPhonenumber(phone); + newUser.setPassword(SecurityUtils.encryptPassword(phone)); + newUser.setStatus("0"); + newUser.setDelFlag("0"); + newUser.setCreateBy(SecurityUtils.getUsername()); + sysUserService.insertUser(newUser); + Long userId = newUser.getUserId(); + if (userId == null) { + // 兜底: insertUser 用了 useGeneratedKeys, 正常能拿到; 拿不到时按 phone 再查一次 + SysUser re = sysUserMapper.selectUserByUserName(phone); + if (re == null) throw new ServiceException("建账号失败, 请重试"); + userId = re.getUserId(); + } + // 显式设 role_type='doctor' (DB 默认 'executor', 参会人应为 doctor 视角) + sysUserService.updateRoleType(userId, "doctor"); + log.info("[attendee] 新建 sys_user (phone={}, userId={}, roleType=doctor)", phone, userId); + return userId; + } + + /** 新增参会人行: 写完整档案 + 同步报名占位记录, 返回新 attendee.id */ + private Long insertNewAttendee(BizMeetingAttendee body, Long userId, String phone) { + // 写完整档案行 (attendee.id 用雪花 ID, 不走 DB 自增) body.setUserId(userId); body.setCreateBy(SecurityUtils.getUsername()); body.setId(IdGenerator.generateId()); @@ -179,7 +224,7 @@ public class BizMeetingAttendeeServiceImpl implements IBizMeetingAttendeeService Long newId = body.getId(); log.info("[attendee] 新增参会人 meetingId={} userId={} attendeeId={}", body.getMeetingId(), userId, newId); - // 5. 同步一条"报名项目"幽灵占位记录 (del_flag='1'): 新增/导入都走这里, 一处覆盖两入口 + // 同步一条"报名项目"幽灵占位记录 (del_flag='1'): 新增/导入都走这里, 一处覆盖两入口 createFakeSignup(body.getMeetingId(), userId, phone, body); return newId; } @@ -421,10 +466,10 @@ public class BizMeetingAttendeeServiceImpl implements IBizMeetingAttendeeService /** * 批量导入参会人 (Excel → biz_meeting_attendee). * - *

    复用 {@link #insertByPhoneWithProfile} 做单行处理, 让"按手机号查/建 sys_user + 写 attendee" - * 逻辑和单条新增一致. 单行失败只计入 ngList, 不中断其它行. + *

    复用 {@link #upsertByPhoneWithProfile} 做单行处理: 手机号已在该会议 → 覆盖更新已有行, + * 否则按手机号查/建 sys_user + 写 attendee. 单行失败只计入 ngList, 不中断其它行. * - *

    注意: 本方法只插数据, 不发 #5 通知. controller 在 import 前快照 userIds, import 后 diff, + *

    注意: 本方法只落库(新增或覆盖更新), 不发 #5 通知. controller 在 import 前快照 userIds, import 后 diff, * 只对"新加入"的 userId 调 notify, 避免给已存在的参会人重发邀请. */ @Override @@ -472,6 +517,13 @@ public class BizMeetingAttendeeServiceImpl implements IBizMeetingAttendeeService result.fail(rowNo, "身份证号应为18位(末位可为X): " + idCard); continue; } + // 角色(laborForm) 必填校验 (与前端 dialog 必填规则保持一致: 角色单选 radio 不能为空; + // 2026-09-08 executor 在 2609010002 会议导入时漏校验, 单条无角色行被静默入库) + String laborForm = vo.getLaborForm() == null ? null : vo.getLaborForm().trim(); + if (laborForm == null || laborForm.isEmpty()) { + result.fail(rowNo, "角色不能为空"); + continue; + } // 构造 attendee 实体, 复用 insertByPhoneWithProfile (内部查/建 sys_user + 写 attendee) BizMeetingAttendee body = new BizMeetingAttendee(); @@ -489,7 +541,7 @@ public class BizMeetingAttendeeServiceImpl implements IBizMeetingAttendeeService body.setBankRegion(vo.getBankRegion()); body.setBankAddress(vo.getBankAddress()); body.setIdCardAttachments(vo.getIdCardAttachments()); - body.setLaborForm(vo.getLaborForm()); + body.setLaborForm(laborForm); // 金额联动补算 (照搬 hwt importLaborData): 已有值优先, 空白才按链补算, 避免覆盖人工填写 BigDecimal fee = vo.getFee(); BigDecimal tax = vo.getTax(); @@ -524,7 +576,7 @@ public class BizMeetingAttendeeServiceImpl implements IBizMeetingAttendeeService body.setFee(fee); body.setSummary(vo.getSummary()); - insertByPhoneWithProfile(body); // 失败抛 ServiceException, 被 catch + upsertByPhoneWithProfile(body); // 重复手机号 → 覆盖更新; 其它失败抛 ServiceException, 被 catch result.ok(); } catch (Exception e) { String msg = e.getMessage(); @@ -800,18 +852,40 @@ public class BizMeetingAttendeeServiceImpl implements IBizMeetingAttendeeService } @Override - public Map uploadAgreements(MultipartFile file, Long meetingId) throws Exception { + public String submitAgreementUpload(MultipartFile file, Long meetingId) throws Exception { if (meetingId == null) throw new ServiceException("meetingId 不能为空"); if (file == null || file.isEmpty()) throw new ServiceException("请选择要上传的 zip 文件"); + String jobId = UUID.randomUUID().toString().replace("-", ""); + byte[] zipBytes = file.getBytes(); + // 后台线程无 SecurityContext, 在请求线程提前取好操作人 + String username = SecurityUtils.getUsername(); + uploadProgressRegistry.start(jobId); + zipUploadExecutor.submit(() -> { + try { + Map result = doUploadAgreements(zipBytes, meetingId, jobId, username); + uploadProgressRegistry.finish(jobId, result); + } + catch (Exception e) { + log.warn("[attendee] 劳务协议后台回填失败 meetingId={} err={}", meetingId, e.getMessage(), e); + uploadProgressRegistry.fail(jobId, e.getMessage() == null ? "上传失败" : e.getMessage()); + } + }); + return jobId; + } + + private Map doUploadAgreements(byte[] zipBytes, Long meetingId, String jobId, String username) throws Exception { List list = selectByMeetingId(meetingId); if (list.isEmpty()) throw new ServiceException("该会议暂无参会人, 无法回填劳务协议"); - LinkedHashMap folders = parseAttendeeZip(file.getBytes()); + LinkedHashMap folders = parseAttendeeZip(zipBytes); + uploadProgressRegistry.setTotal(jobId, folders.size()); int updated = 0; + int processed = 0; List> results = new ArrayList<>(); for (ZipFolder f : folders.values()) { + processed++; MatchResult m = matchAttendee(list, f); Map r = new HashMap<>(); r.put("dir", f.dir); @@ -821,6 +895,7 @@ public class BizMeetingAttendeeServiceImpl implements IBizMeetingAttendeeService r.put("reason", m.reason); results.add(r); log.warn("[attendee] 协议目录 {} 无法定位参会人, 跳过", f.dir); + uploadProgressRegistry.update(jobId, processed); continue; } // labor_protocol 是单 URL, 每目录只取第一个文件作为协议 @@ -830,31 +905,54 @@ public class BizMeetingAttendeeServiceImpl implements IBizMeetingAttendeeService BizMeetingAttendee entity = new BizMeetingAttendee(); entity.setId(m.attendee.getId()); entity.setLaborProtocol(url); - entity.setUpdateBy(SecurityUtils.getUsername()); + entity.setUpdateBy(username); updateLaborProtocol(entity); updated++; r.put("matched", true); r.put("attendeeName", m.attendee.getName()); results.add(r); + uploadProgressRegistry.update(jobId, processed); } log.info("[attendee] 劳务协议回填完成 meetingId={} 共{}个 (跳过{}个)", meetingId, updated, folders.size() - updated); return buildZipResult(updated, list.size(), results); } @Override - public Map uploadExpertPhotos(MultipartFile file, Long meetingId) throws Exception { + public String submitExpertPhotoUpload(MultipartFile file, Long meetingId) throws Exception { if (meetingId == null) throw new ServiceException("meetingId 不能为空"); if (file == null || file.isEmpty()) throw new ServiceException("请选择要上传的 zip 文件"); + String jobId = UUID.randomUUID().toString().replace("-", ""); + byte[] zipBytes = file.getBytes(); + // 后台线程无 SecurityContext, 在请求线程提前取好操作人 + String username = SecurityUtils.getUsername(); + uploadProgressRegistry.start(jobId); + zipUploadExecutor.submit(() -> { + try { + Map result = doUploadExpertPhotos(zipBytes, meetingId, jobId, username); + uploadProgressRegistry.finish(jobId, result); + } + catch (Exception e) { + log.warn("[attendee] 专家照片后台回填失败 meetingId={} err={}", meetingId, e.getMessage(), e); + uploadProgressRegistry.fail(jobId, e.getMessage() == null ? "上传失败" : e.getMessage()); + } + }); + return jobId; + } + + private Map doUploadExpertPhotos(byte[] zipBytes, Long meetingId, String jobId, String username) throws Exception { List list = selectByMeetingId(meetingId); if (list.isEmpty()) throw new ServiceException("该会议暂无参会人, 无法回填专家照片"); - LinkedHashMap folders = parseAttendeeZip(file.getBytes()); + LinkedHashMap folders = parseAttendeeZip(zipBytes); + uploadProgressRegistry.setTotal(jobId, folders.size()); int updated = 0; + int processed = 0; List> results = new ArrayList<>(); for (ZipFolder f : folders.values()) { + processed++; MatchResult m = matchAttendee(list, f); Map r = new HashMap<>(); r.put("dir", f.dir); @@ -864,6 +962,7 @@ public class BizMeetingAttendeeServiceImpl implements IBizMeetingAttendeeService r.put("reason", m.reason); results.add(r); log.warn("[attendee] 专家照片目录 {} 无法定位参会人, 跳过", f.dir); + uploadProgressRegistry.update(jobId, processed); continue; } // 专家照片可多张: 目录内所有文件都上传, 逗号拼接覆盖式回填 on_site_photos @@ -878,13 +977,14 @@ public class BizMeetingAttendeeServiceImpl implements IBizMeetingAttendeeService BizMeetingAttendee entity = new BizMeetingAttendee(); entity.setId(m.attendee.getId()); entity.setOnSitePhotos(sb.toString()); - entity.setUpdateBy(SecurityUtils.getUsername()); + entity.setUpdateBy(username); updateProfile(entity); updated++; r.put("matched", true); r.put("attendeeName", m.attendee.getName()); results.add(r); + uploadProgressRegistry.update(jobId, processed); } log.info("[attendee] 专家照片回填完成 meetingId={} 共{}个 (跳过{}个)", meetingId, updated, folders.size() - updated); return buildZipResult(updated, list.size(), results); 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 9555cb0..14d75fc 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 @@ -20,17 +20,31 @@ import java.io.InputStream; import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; import java.util.Collections; +import java.util.UUID; +import java.util.concurrent.ExecutorService; import java.util.zip.ZipEntry; import java.util.zip.ZipInputStream; import java.util.zip.ZipOutputStream; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.context.annotation.Lazy; import org.springframework.dao.DuplicateKeyException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.multipart.MultipartFile; +import org.apache.poi.ss.usermodel.Cell; +import org.apache.poi.ss.usermodel.CellType; +import org.apache.poi.ss.usermodel.DataFormatter; +import org.apache.poi.ss.usermodel.DateUtil; +import org.apache.poi.ss.usermodel.Row; +import org.apache.poi.ss.usermodel.Sheet; +import org.apache.poi.ss.usermodel.Workbook; +import org.apache.poi.ss.usermodel.WorkbookFactory; +import cn.hutool.json.JSONArray; +import cn.hutool.json.JSONObject; +import cn.hutool.json.JSONUtil; import com.ruoyi.common.exception.ServiceException; import com.ruoyi.common.utils.SecurityUtils; import com.ruoyi.business.domain.BizMeeting; @@ -43,13 +57,15 @@ import com.ruoyi.business.oss.OssUploader; import com.ruoyi.business.oss.OssZipService; import com.ruoyi.business.service.IBizMeetingAttendeeService; import com.ruoyi.business.service.IBizMeetingMaterialService; +import com.ruoyi.business.service.IBizMeetingService; +import com.ruoyi.business.upload.UploadProgressRegistry; @Service public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService { /** 非发票 subType (与前端 MeetingDetail.NON_OCR_SUBTYPES 对齐): 现场照片/签到表等不 OCR, 避免误识别脏 amount */ private static final Set NON_OCR_SUBTYPES = new HashSet<>(Arrays.asList( - "L_ENTERPRISE_BENEFIT", "L_SIGN_IN", "L_PANORAMA_FRONT", "L_PANORAMA_BACK", "L_EXPERT_PHOTO")); + "L_ENTERPRISE_BENEFIT", "L_ESIGN_IN", "L_SIGN_IN", "L_PANORAMA_FRONT", "L_PANORAMA_BACK", "L_EXPERT_PHOTO")); /** 扫码拍照白名单 subType (公开端点只允许这三类照片回传) */ private static final Set CAMERA_SUBTYPES = new HashSet<>(Arrays.asList( @@ -72,6 +88,7 @@ public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService SERVICE_SUBTYPE_LABEL.put("M_DESIGN", "设计费"); SERVICE_SUBTYPE_LABEL.put("M_OTHER", "其他"); SERVICE_SUBTYPE_LABEL.put("M_SETTLEMENT", "总结算单"); + SERVICE_SUBTYPE_LABEL.put("M_SETTLEMENT_STAMP", "总结算单(盖章)"); SERVICE_SUBTYPE_LABEL.put("M_INVOICE", "总发票"); SERVICE_SUBTYPE_LABEL.put("SV_PAYMENT", "会务付款凭证"); } @@ -79,7 +96,7 @@ public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService /** 会务材料 (SERVICE) 子类顺序 (与前端 ROW_CONFIG SERVICE 行对齐), 用于"打包上传"下载空目录模板 + 回填匹配 */ private static final List SERVICE_MATERIAL_SUBTYPES = Arrays.asList( "M_MATERIAL", "M_HOTEL", "M_TRAFFIC_BIG", "M_TRAFFIC_SMALL", - "M_EXECUTION", "M_DESIGN", "M_OTHER", "M_SETTLEMENT", "M_INVOICE"); + "M_EXECUTION", "M_DESIGN", "M_OTHER", "M_SETTLEMENT", "M_SETTLEMENT_STAMP", "M_INVOICE"); /** 会务材料中文名 → subType (仅 M_* 会务材料, 不含 SV_PAYMENT 付款凭证), 用于上传 zip 按目录名匹配 */ private static final Map SERVICE_LABEL_TO_SUBTYPE = new HashMap<>(); @@ -101,6 +118,7 @@ public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService LABOR_SUBTYPE_LABEL.put("L_DETAIL", "劳务明细表"); LABOR_SUBTYPE_LABEL.put("L_AGREEMENT", "劳务协议"); LABOR_SUBTYPE_LABEL.put("L_ENTERPRISE_BENEFIT", "企业权益"); + LABOR_SUBTYPE_LABEL.put("L_ESIGN_IN", "电子签到表"); LABOR_SUBTYPE_LABEL.put("L_SIGN_IN", "签到表"); LABOR_SUBTYPE_LABEL.put("L_PANORAMA_FRONT", "前全景"); LABOR_SUBTYPE_LABEL.put("L_PANORAMA_BACK", "后全景"); @@ -125,6 +143,14 @@ public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService @Autowired @Lazy private InvoiceOcrService invoiceOcrService; + @Autowired + @Lazy + private IBizMeetingService bizMeetingService; + @Autowired + private UploadProgressRegistry uploadProgressRegistry; + @Autowired + @Qualifier("zipUploadExecutor") + private ExecutorService zipUploadExecutor; private static final Logger log = LoggerFactory.getLogger(BizMeetingMaterialServiceImpl.class); @@ -193,15 +219,20 @@ public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService BizMeetingMaterial old = oldBySubType.get(m.getSubType()); boolean unchanged = old != null && Objects.equals(old.getOssUrl(), m.getOssUrl()); if (unchanged) { - // 未变: 回填旧金额, 已计算; 同时保留脱敏版 URL (签到表高斯模糊版, 前端全删全插不传 extraOssUrl) + // 未变: 回填旧金额, 已计算; 同时保留脱敏版 URL (签到表高斯模糊版) 与电子签到表解析结果 (前端全删全插不传) m.setAmount(old.getAmount()); m.setFeeStatus(1); m.setExtraOssUrl(old.getExtraOssUrl()); + m.setMaskedJson(old.getMaskedJson()); unchangedOldId.put(m.getSubType(), old.getId()); } else { // 新增/替换: 金额清零, 会 OCR 的发票标记待计算 m.setAmount(null); m.setFeeStatus(needsOcr(m) ? 0 : 1); + // 电子签到表 (L_ESIGN_IN): 保存时解析 + 脱敏 (解析失败抛异常回滚整个保存) + if ("L_ESIGN_IN".equals(m.getSubType()) && m.getOssUrl() != null && !m.getOssUrl().trim().isEmpty()) { + m.setMaskedJson(parseAndMaskSigninSheet(m.getOssUrl(), m.getFileName())); + } } } try { @@ -258,6 +289,7 @@ public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService subs.addAll(newUrl.keySet()); subs.remove("M_INVOICE"); subs.remove("M_SETTLEMENT"); + subs.remove("M_SETTLEMENT_STAMP"); for (String sub : subs) { if (!Objects.equals(oldUrl.get(sub), newUrl.get(sub))) return true; } @@ -285,18 +317,29 @@ public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService * 扫码拍照回传: ry-h5 手机端拍照直传 OSS 后回传 URL, 按 (meetingId, subType) upsert 单行. * 公开端点 (匿名) — 白名单 subType + 会议存在校验兜底. * 照片类 NON_OCR, 不触发 OCR, 不影响会议费用, 故不触发费用重算. - * extraOssUrl: 签到表(L_SIGN_IN)拍照时额外生成的高斯模糊版 URL, sponsor 只看这个; 其他 subType 传空. + * 签到表 (L_SIGN_IN) 连拍多张: ossUrl/extraOssUrl 存 JSON 数组字符串; 前后全景单张取第一个. + * extraOssUrl: 签到表拍照时额外生成的高斯模糊版 URL, sponsor 只看这个; 其他 subType 传空. */ @Override @Transactional(rollbackFor = Exception.class) - public void upsertFromCamera(Long meetingId, String subType, String ossUrl, String extraOssUrl) { + public void upsertFromCamera(Long meetingId, String subType, List ossUrls, List extraOssUrls) { if (meetingId == null) throw new ServiceException("缺少 meetingId"); if (subType == null || !CAMERA_SUBTYPES.contains(subType)) throw new ServiceException("非法的拍照类型"); - if (ossUrl == null || ossUrl.isEmpty()) throw new ServiceException("缺少照片 URL"); + if (ossUrls == null || ossUrls.isEmpty()) throw new ServiceException("缺少照片 URL"); BizMeeting meeting = bizMeetingMapper.selectByPrimaryKey(meetingId); if (meeting == null) throw new ServiceException("会议不存在"); + boolean multi = "L_SIGN_IN".equals(subType); + String ossUrl = multi ? JSONUtil.toJsonStr(ossUrls) : ossUrls.get(0); + String extraOssUrl; + if (multi) { + List masked = (extraOssUrls == null) ? Collections.emptyList() : extraOssUrls; + extraOssUrl = JSONUtil.toJsonStr(masked); + } else { + extraOssUrl = (extraOssUrls != null && !extraOssUrls.isEmpty()) ? extraOssUrls.get(0) : ""; + } + // 找该 subType 现有行 (同会议同 subType 唯一) BizMeetingMaterial row = null; List existing = bizMeetingMaterialMapper.selectByMeetingId(meetingId); @@ -508,6 +551,11 @@ public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService { if (m.getOssUrl() == null || m.getOssUrl().isEmpty()) continue; if (m.getMaterialType() == null || !LABOR_MATERIAL_TYPES.contains(m.getMaterialType())) continue; + // 签到表连拍多张: oss_url 是 JSON 数组字符串, 逐个 copy (签到表-1.jpg ...); 老数据单 URL 由 copyJsonUrls 兜底 + if ("L_SIGN_IN".equals(m.getSubType()) && m.getOssUrl().trim().startsWith("[")) { + copied += copyJsonUrls(m.getOssUrl(), prefix + folderName + "/" + safeName(laborFolderName("L_SIGN_IN")) + "/", laborFileLabel("L_SIGN_IN")); + continue; + } String srcKey = ossZipService.extractKey(m.getOssUrl()); if (srcKey == null || srcKey.isEmpty()) continue; String folder = laborFolderName(m.getSubType()); @@ -565,6 +613,38 @@ public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService return copied; } + /** JSON 数组 OSS URL (签到表连拍多张) → 逐个 copy 到 dstDir, 文件名 = baseName-i{ext}; 返回 copy 数 */ + private int copyJsonUrls(String jsonUrls, String dstDir, String baseName) + { + if (jsonUrls == null || jsonUrls.trim().isEmpty()) return 0; + List urls; + try { + JSONArray arr = JSONUtil.parseArray(jsonUrls); + urls = new ArrayList<>(); + for (int i = 0; i < arr.size(); i++) { + String u = arr.getStr(i); + if (u != null && !u.trim().isEmpty()) urls.add(u.trim()); + } + } catch (Exception e) { + // 老数据是单 URL 字符串 (非 JSON), 退回单张 copy + String srcKey = ossZipService.extractKey(jsonUrls.trim()); + if (srcKey == null || srcKey.isEmpty()) return 0; + String fileName = safeName(baseName) + extOf(srcKey); + ossZipService.copyObject(srcKey, dstDir + fileName); + return 1; + } + int copied = 0; + for (int i = 0; i < urls.size(); i++) + { + String srcKey = ossZipService.extractKey(urls.get(i)); + 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) { @@ -727,18 +807,35 @@ public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService } /** - * 上传"会务材料" zip, 解压后按 会务材料/{材料类型中文名}/ 目录匹配 subType, - * 单文件直接上传 OSS, 多文件先打 zip 再上传 OSS, 按 (meetingId, SERVICE, subType) upsert 回填. - * 替换文件会清空 amount; 单文件可识别 (jpg/png/pdf) 或多文件打 zip → 直接后台触发 OCR 回写金额. + * 提交"会务材料" zip 上传任务 (异步): 请求线程读字节 + 登记进度后立即返回 jobId, + * 后台线程解压按 会务材料/{材料类型中文名}/ 目录匹配 subType 并 upsert 回填 (单文件直传 OSS / + * 多文件先打 zip; 内容未变且已计算过则跳过, 否则替换文件清空 amount 并后台触发 OCR), + * 前端轮询 /business/upload/progress/{jobId} 拿进度与结果 (result = 回填的材料类数). */ @Override - @Transactional(rollbackFor = Exception.class) - public int uploadServiceMaterials(MultipartFile file, Long meetingId) throws Exception { + public String submitServiceMaterialsUpload(MultipartFile file, Long meetingId) throws Exception { if (meetingId == null) throw new ServiceException("meetingId 不能为空"); if (file == null || file.isEmpty()) throw new ServiceException("请选择要上传的 zip 文件"); + String jobId = UUID.randomUUID().toString().replace("-", ""); byte[] zipBytes = file.getBytes(); + // 后台线程无 SecurityContext, 在请求线程提前取好操作人 + Long userId = SecurityUtils.getUserId(); + uploadProgressRegistry.start(jobId); + zipUploadExecutor.submit(() -> { + try { + int updated = doUploadServiceMaterials(zipBytes, meetingId, jobId, userId); + uploadProgressRegistry.finish(jobId, updated); + } + catch (Exception e) { + log.warn("[material] 会务材料后台回填失败 meetingId={} err={}", meetingId, e.getMessage(), e); + uploadProgressRegistry.fail(jobId, e.getMessage() == null ? "上传失败" : e.getMessage()); + } + }); + return jobId; + } + private int doUploadServiceMaterials(byte[] zipBytes, Long meetingId, String jobId, Long userId) throws Exception { // subType -> 该目录下文件字节 + 文件名 Map> folderBytes = new HashMap<>(); Map> folderNames = new HashMap<>(); @@ -762,12 +859,16 @@ public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService } } + uploadProgressRegistry.setTotal(jobId, folderBytes.size()); + String subDir = "ry8080/meeting/" + meetingId + "/service"; int updated = 0; + int processed = 0; for (Map.Entry> e : folderBytes.entrySet()) { String subType = e.getKey(); List files = e.getValue(); if (files == null || files.isEmpty()) continue; + processed++; List names = folderNames.get(subType); String label = SERVICE_SUBTYPE_LABEL.getOrDefault(subType, subType); BizMeetingMaterial old = existingBySubType.get(subType); @@ -788,6 +889,7 @@ public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService if (old != null && old.getFeeStatus() != null && old.getFeeStatus() == 1 && contentEquals(old.getOssUrl(), toUpload)) { log.info("[material] 会务材料 {} 内容未变, 跳过重传重 OCR", subType); + uploadProgressRegistry.update(jobId, processed); continue; } @@ -812,7 +914,7 @@ public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService ins.setFileName(fileName); ins.setAmount(null); ins.setFeeStatus(feeStatus); - ins.setCreatorId(SecurityUtils.getUserId()); + ins.setCreatorId(userId); ins.setCreateTime(new Date()); bizMeetingMaterialMapper.insertBatch(Collections.singletonList(ins)); // foreach 批量插入 useGeneratedKeys 不可靠, 按 subType 查回真实 id @@ -825,6 +927,10 @@ public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService invoiceOcrService.submitRecognition(materialId, meetingId, ossUrl, oldMaterialId); } updated++; + uploadProgressRegistry.update(jobId, processed); + } + if (updated > 0) { + bizMeetingService.recomputeMeetingFee(meetingId); } log.info("[material] 会务材料回填完成 meetingId={} 共{}类", meetingId, updated); return updated; @@ -914,4 +1020,494 @@ public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService if (m.getSubType() != null && NON_OCR_SUBTYPES.contains(m.getSubType())) return false; return RECOGNIZABLE.matcher(m.getOssUrl()).find(); } + + // =================================================================== + // 电子签到表 (L_ESIGN_IN) 解析 + 脱敏 (保存时调用, 结果存 masked_json) + // =================================================================== + + /** 电子签到表敏感表头 → 脱敏类型 */ + private static final Map SIGNIN_SENSITIVE_HEADERS = new HashMap<>(); + static { + SIGNIN_SENSITIVE_HEADERS.put("姓名", "NAME"); + SIGNIN_SENSITIVE_HEADERS.put("名字", "NAME"); + SIGNIN_SENSITIVE_HEADERS.put("医生", "NAME"); + SIGNIN_SENSITIVE_HEADERS.put("name", "NAME"); + SIGNIN_SENSITIVE_HEADERS.put("手机", "PHONE"); + SIGNIN_SENSITIVE_HEADERS.put("手机号", "PHONE"); + SIGNIN_SENSITIVE_HEADERS.put("电话", "PHONE"); + SIGNIN_SENSITIVE_HEADERS.put("联系方式", "PHONE"); + SIGNIN_SENSITIVE_HEADERS.put("phone", "PHONE"); + SIGNIN_SENSITIVE_HEADERS.put("mobile", "PHONE"); + SIGNIN_SENSITIVE_HEADERS.put("身份证", "ID_CARD"); + SIGNIN_SENSITIVE_HEADERS.put("身份证号", "ID_CARD"); + SIGNIN_SENSITIVE_HEADERS.put("银行卡", "BANK_CARD"); + SIGNIN_SENSITIVE_HEADERS.put("银行卡号", "BANK_CARD"); + SIGNIN_SENSITIVE_HEADERS.put("邮箱", "EMAIL"); + SIGNIN_SENSITIVE_HEADERS.put("邮件", "EMAIL"); + SIGNIN_SENSITIVE_HEADERS.put("email", "EMAIL"); + SIGNIN_SENSITIVE_HEADERS.put("mail", "EMAIL"); + } + + /** + * 下载电子签到表文件并解析脱敏, 序列化为 JSON 返回. + *

    + * 单文件: ossUrl 为普通 URL → {@code {"headers":[], "rows":[]}} (与历史数据一致). + * 多文件: ossUrl 为 JSON 数组字符串 (多个 Excel 分别直传 OSS) → {@code {"sheets":[{"name","headers","rows"}, ...]}}. + * 解析/下载失败抛 ServiceException (保存回滚). + */ + private String parseAndMaskSigninSheet(String ossUrl, String fileName) { + // 多文件 (前端 multiple 上传, ossUrl 存 JSON 数组字符串) + if (ossUrl != null && ossUrl.trim().startsWith("[")) { + List urls = parseUrlArray(ossUrl); + if (urls.isEmpty()) throw new ServiceException("电子签到表文件列表为空"); + if (urls.size() == 1) { + byte[] bytes = downloadBytes(urls.get(0)); + return JSONUtil.toJsonStr(parseSingleSigninSheet(bytes, detectExt(null, urls.get(0)))); + } + List> sheets = new ArrayList<>(); + for (String u : urls) { + byte[] bytes = downloadBytes(u); + Map parsed = parseSingleSigninSheet(bytes, detectExt(null, u)); + parsed.put("name", nameFromUrl(u)); + sheets.add(parsed); + } + return JSONUtil.toJsonStr(Collections.singletonMap("sheets", sheets)); + } + byte[] bytes = downloadBytes(ossUrl); + return JSONUtil.toJsonStr(parseSingleSigninSheet(bytes, detectExt(fileName, ossUrl))); + } + + /** + * 解析单个签到表字节 (首个 sheet 首行作表头), 按敏感表头脱敏, 返回 {headers, rows}. + * 解析失败抛 ServiceException (整体失败回滚). + */ + private Map parseSingleSigninSheet(byte[] bytes, String ext) { + List> matrix; + try { + if ("csv".equals(ext)) { + matrix = readCsvMatrix(bytes); + } else { + matrix = readExcelMatrix(new ByteArrayInputStream(bytes)); + } + } catch (Exception e) { + log.warn("[material] 电子签到表解析失败 ext={} err={}", ext, e.getMessage()); + throw new ServiceException("电子签到表解析失败, 请确认上传的是有效的 xls/xlsx/csv 文件"); + } + if (matrix.isEmpty()) { + throw new ServiceException("电子签到表为空, 无法解析"); + } + List headers = matrix.get(0); + // 敏感列 index → 脱敏类型 + Map maskByCol = new HashMap<>(); + for (int c = 0; c < headers.size(); c++) { + String type = detectMaskType(headers.get(c)); + if (type != null) maskByCol.put(c, type); + } + // 逐行脱敏 (过滤全空行) + List> rows = new ArrayList<>(); + for (int i = 1; i < matrix.size(); i++) { + List row = matrix.get(i); + boolean allEmpty = true; + for (String cell : row) { + if (cell != null && !cell.trim().isEmpty()) { allEmpty = false; break; } + } + if (allEmpty) continue; + List masked = new ArrayList<>(row); + for (Map.Entry e : maskByCol.entrySet()) { + int c = e.getKey(); + if (c < masked.size() && masked.get(c) != null && !masked.get(c).trim().isEmpty()) { + masked.set(c, maskCell(e.getValue(), masked.get(c))); + } + } + rows.add(masked); + } + Map result = new HashMap<>(); + result.put("headers", headers); + result.put("rows", rows); + return result; + } + + /** JSON 数组字符串 (前端 multiple 上传) → OSS URL 列表 */ + private List parseUrlArray(String jsonUrls) { + List urls = new ArrayList<>(); + try { + JSONArray arr = JSONUtil.parseArray(jsonUrls); + for (int i = 0; i < arr.size(); i++) { + String u = arr.getStr(i); + if (u != null && !u.trim().isEmpty()) urls.add(u.trim()); + } + } catch (Exception e) { + log.warn("[material] 电子签到表 URL 列表解析失败: {}", jsonUrls); + throw new ServiceException("电子签到表文件列表解析失败"); + } + return urls; + } + + /** 从 OSS URL 还原原文件名 (前端 key 格式 原名_13位时间戳_6位随机.扩展名 → 原名.扩展名; 否则取末段) */ + private String nameFromUrl(String url) { + if (url == null) return ""; + String path = url.contains("?") ? url.substring(0, url.indexOf('?')) : url; + int i = path.lastIndexOf('/'); + String last = i >= 0 ? path.substring(i + 1) : path; + try { last = java.net.URLDecoder.decode(last, "UTF-8"); } catch (Exception e) { /* 已解码, 原样 */ } + java.util.regex.Matcher m = Pattern.compile("^(.+)_\\d{13}_[a-z0-9]{6}(\\.[^.]+)?$").matcher(last); + if (m.find()) return m.group(1) + (m.group(2) == null ? "" : m.group(2)); + return last; + } + + /** 旧 masked_json → sheets 列表 (单文件 {headers,rows} 视为 1 个 sheet; 多文件取 sheets 数组) */ + private List> sheetsFromMaskedJson(String maskedJson) { + List> sheets = new ArrayList<>(); + if (maskedJson == null || maskedJson.trim().isEmpty()) return sheets; + try { + JSONObject obj = JSONUtil.parseObj(maskedJson); + JSONArray arr = obj.getJSONArray("sheets"); + if (arr != null && !arr.isEmpty()) { + for (int i = 0; i < arr.size(); i++) { + JSONObject s = arr.getJSONObject(i); + if (s != null) sheets.add(s); + } + } else if (obj.containsKey("headers")) { + sheets.add(obj); + } + } catch (Exception e) { + log.warn("[material] 电子签到表旧 masked_json 解析失败, 忽略旧脱敏数据: {}", e.getMessage()); + } + return sheets; + } + + // =================================================================== + // 电子签到表 (L_ESIGN_IN) 多文件 + zip 上传 (同步, 追加式保存) + // =================================================================== + + /** 单次上传允许的最多签到表文件数 */ + private static final int ESIGN_MAX_FILES = 50; + /** 单个签到表文件最大字节 (20MB) */ + private static final long ESIGN_MAX_FILE_SIZE = 20L * 1024 * 1024; + + /** 签到表文件扩展名白名单 (xls/xlsx/csv) */ + private static boolean isExcelName(String name) { + if (name == null) return false; + String ext = detectExt(name, null); + return "xls".equals(ext) || "xlsx".equals(ext) || "csv".equals(ext); + } + + private static boolean isZipName(String name) { + return name != null && "zip".equals(detectExt(name, null)); + } + + /** + * 电子签到表 (L_ESIGN_IN) 多文件/zip 上传 (同步, 追加式保存). + *

    + * file 为单个 Excel (xls/xlsx/csv) 或 zip (后台解压并分析其中所有 Excel). + * 逐个解析脱敏 + 直传 OSS; 单行 L_ESIGN_IN 存多文件: + * oss_url 存单 URL (1 个) / JSON 数组 (>1 个), masked_json 存 {headers,rows} / {sheets:[...]}. + * 已有 L_ESIGN_IN 文件保留, 新文件追加到末尾 (合并旧 ossUrl/maskedJson 后删旧插新). + * 任一文件解析失败/超限 → 抛异常整体失败 (事务回滚, 不落任何数据). + * + * @return { count: 本次新增文件数, total: 合并后总文件数 } + */ + @Override + @Transactional(rollbackFor = Exception.class) + public Map uploadEsignIn(MultipartFile file, Long meetingId) throws Exception { + if (meetingId == null) throw new ServiceException("meetingId 不能为空"); + if (file == null || file.isEmpty()) throw new ServiceException("请选择要上传的电子签到表文件"); + BizMeeting meeting = bizMeetingMapper.selectByPrimaryKey(meetingId); + if (meeting == null) throw new ServiceException("会议不存在"); + + String originalName = file.getOriginalFilename(); + byte[] bytes = file.getBytes(); + + // 收集 (文件名, 字节) 列表: zip 解压所有 Excel / 单文件直接入列 + List files = new ArrayList<>(); + if (isZipName(originalName)) { + files = unzipExcelFiles(bytes); + if (files.isEmpty()) throw new ServiceException("未在压缩包中找到 Excel (xls/xlsx/csv) 文件"); + } else { + if (!isExcelName(originalName)) throw new ServiceException("仅支持 xls / xlsx / csv 或 zip 文件"); + files.add(new FileBytes(originalName, bytes)); + } + if (files.size() > ESIGN_MAX_FILES) throw new ServiceException("一次最多上传 " + ESIGN_MAX_FILES + " 个签到表文件"); + + // 逐个解析 (整体失败: 任一解析失败/超限抛异常, 此时尚未上传任何文件) + List> sheets = new ArrayList<>(); + for (FileBytes fb : files) { + if (fb.bytes.length > ESIGN_MAX_FILE_SIZE) throw new ServiceException("文件「" + fb.name + "」超过 20MB 限制"); + Map parsed = parseSingleSigninSheet(fb.bytes, detectExt(fb.name, null)); + parsed.put("name", fb.name); + sheets.add(parsed); + } + + // 全部解析通过后逐个直传 OSS + String subDir = "ry8080/meeting/" + meetingId + "/labor"; + List urls = new ArrayList<>(); + for (FileBytes fb : files) { + urls.add(ossUploader.uploadWithOriginalName(fb.bytes, fb.name, subDir)); + } + + // 追加模式: 读取已有 L_ESIGN_IN 行的 ossUrl + maskedJson, 与新文件合并后删旧插新 + BizMeetingMaterial oldEsign = null; + List existing = bizMeetingMaterialMapper.selectByMeetingId(meetingId); + if (existing != null) { + for (BizMeetingMaterial m : existing) { + if ("L_ESIGN_IN".equals(m.getSubType())) { oldEsign = m; break; } + } + } + + List mergedUrls = new ArrayList<>(); + List> mergedSheets = new ArrayList<>(); + if (oldEsign != null && oldEsign.getOssUrl() != null && !oldEsign.getOssUrl().trim().isEmpty()) { + String oldOss = oldEsign.getOssUrl().trim(); + if (oldOss.startsWith("[")) { + mergedUrls.addAll(parseUrlArray(oldOss)); + } else { + mergedUrls.add(oldOss); + } + mergedSheets.addAll(sheetsFromMaskedJson(oldEsign.getMaskedJson())); + } + mergedUrls.addAll(urls); + mergedSheets.addAll(sheets); + if (mergedUrls.size() > ESIGN_MAX_FILES) { + throw new ServiceException("电子签到表最多 " + ESIGN_MAX_FILES + " 个文件, 当前已 " + mergedUrls.size() + " 个"); + } + + String mergedOssUrl = mergedUrls.size() == 1 ? mergedUrls.get(0) : JSONUtil.toJsonStr(mergedUrls); + String mergedMaskedJson = mergedSheets.size() == 1 + ? JSONUtil.toJsonStr(mergedSheets.get(0)) + : JSONUtil.toJsonStr(Collections.singletonMap("sheets", mergedSheets)); + + // 删旧插新 (updateByPrimaryKey 不更新 masked_json, 直接删插最稳) + if (oldEsign != null) bizMeetingMaterialMapper.deleteByPrimaryKey(oldEsign.getId()); + BizMeetingMaterial ins = new BizMeetingMaterial(); + ins.setMeetingId(meetingId); + ins.setMaterialType("LABOR"); + ins.setSubType("L_ESIGN_IN"); + ins.setOssUrl(mergedOssUrl); + ins.setFileName(originalName); + ins.setMaskedJson(mergedMaskedJson); + ins.setAmount(null); + ins.setFeeStatus(1); // 非发票, 不参与费用汇总 + ins.setCreatorId(SecurityUtils.getUserId()); + ins.setCreateTime(new Date()); + bizMeetingMaterialMapper.insert(ins); + + Map result = new HashMap<>(); + result.put("count", files.size()); // 本次新增文件数 + result.put("total", mergedUrls.size()); // 合并后总文件数 + return result; + } + + /** zip 解压所有 Excel 条目, 返回 (文件名, 字节) 列表. + * 文件名用 ISO-8859-1 无损读原始字节, 再按 UTF-8/GBK 解码 (GBK 中文名直接按 UTF-8 读会出乱码). */ + private List unzipExcelFiles(byte[] zipBytes) { + List result = new ArrayList<>(); + try (ZipInputStream zis = new ZipInputStream(new ByteArrayInputStream(zipBytes), StandardCharsets.ISO_8859_1)) { + ZipEntry entry; + while ((entry = zis.getNextEntry()) != null) { + if (entry.isDirectory()) continue; + String path = decodeZipName(entry.getName()); + if (path.contains("__MACOSX") || path.endsWith(".DS_Store")) continue; + String name = lastSegment(path); + if (!isExcelName(name)) continue; // 只收 Excel, 其他文件忽略 + byte[] data = readAllBytes(zis); + if (data.length == 0) continue; + result.add(new FileBytes(name, data)); + } + } catch (IOException e) { + throw new ServiceException("电子签到表 zip 解压失败: " + e.getMessage()); + } + return result; + } + + /** ISO-8859-1 无损读到的条目名 → 真实文件名: UTF-8 优先, 含替换符 � (非法 UTF-8 字节) 则回退 GBK */ + private String decodeZipName(String raw) { + byte[] bytes = raw.getBytes(StandardCharsets.ISO_8859_1); + String utf8 = new String(bytes, StandardCharsets.UTF_8); + if (utf8.indexOf('�') >= 0) { + return new String(bytes, Charset.forName("GBK")); + } + return utf8; + } + + /** zip/上传时的文件名 + 字节对 */ + private static final class FileBytes { + final String name; + final byte[] bytes; + FileBytes(String name, byte[] bytes) { this.name = name; this.bytes = bytes; } + } + + /** 表头 → 脱敏类型 (contains 匹配, 不区分大小写) */ + private String detectMaskType(String header) { + if (header == null) return null; + String h = header.trim().toLowerCase(); + for (Map.Entry e : SIGNIN_SENSITIVE_HEADERS.entrySet()) { + if (h.contains(e.getKey())) return e.getValue(); + } + return null; + } + + private String maskCell(String type, String value) { + switch (type) { + case "NAME": return maskName(value); + case "PHONE": return maskPhone(value); + case "ID_CARD": return maskIdCard(value); + case "BANK_CARD": return maskBankCard(value); + case "EMAIL": return maskEmail(value); + default: return value; + } + } + + /** 姓名脱敏: 保留首尾, 中间打码 (张三 → 张*, 李小明 → 李*明) */ + private String maskName(String v) { + if (v == null) return null; + String s = v.trim(); + if (s.isEmpty()) return v; + if (s.length() == 1) return "*"; + if (s.length() == 2) return s.charAt(0) + "*"; + return s.charAt(0) + "*".repeat(s.length() - 2) + s.charAt(s.length() - 1); + } + + /** 手机号脱敏: 前3 + **** + 后4 */ + private String maskPhone(String v) { + if (v == null) return v; + String s = v.trim(); + if (s.length() != 11) return v; + return s.substring(0, 3) + "****" + s.substring(7); + } + + /** 银行卡号脱敏: 前4 + ******** + 后4 */ + private String maskBankCard(String v) { + if (v == null) return v; + String s = v.trim(); + if (s.length() < 8) return v; + return s.substring(0, 4) + "********" + s.substring(s.length() - 4); + } + + /** 身份证号脱敏: 前6 + ******** + 后4 */ + private String maskIdCard(String v) { + if (v == null) return v; + String s = v.trim(); + if (s.length() != 18) return v; + return s.substring(0, 6) + "********" + s.substring(14); + } + + /** 邮箱脱敏: 保留本地部分前 1~2 字符 + "***" + @ + 完整域名 + * (zhangsan@x.com → zh***@x.com; 1~2 字符本地部分全保留; 无 @ 原样返回) */ + private String maskEmail(String v) { + if (v == null) return v; + String s = v.trim(); + int at = s.indexOf('@'); + if (at <= 0) return v; // 无 @ 或 @ 在首位 → 不算邮箱, 原样返回 + String local = s.substring(0, at); + String domain = s.substring(at); // 含 @ + if (local.length() <= 2) return local + "***" + domain; + return local.substring(0, 2) + "***" + domain; + } + + /** Excel (xls/xlsx) → 首 sheet 的二维矩阵 (第 0 行=表头) */ + private List> readExcelMatrix(InputStream is) throws Exception { + try (Workbook wb = WorkbookFactory.create(is)) { + Sheet sheet = wb.getSheetAt(0); + List> matrix = new ArrayList<>(); + int lastRow = sheet.getLastRowNum(); + for (int r = 0; r <= lastRow; r++) { + Row row = sheet.getRow(r); + if (row == null) continue; + List cells = new ArrayList<>(); + int lastCell = row.getLastCellNum(); + for (int c = 0; c < lastCell; c++) { + cells.add(cellString(row.getCell(c))); + } + matrix.add(cells); + } + return matrix; + } + } + + /** 单元格 → 字符串: 数字格用 BigDecimal 还原整串避免科学计数法, 日期格走 DataFormatter */ + private String cellString(Cell cell) { + if (cell == null) return ""; + CellType type = cell.getCellType(); + if (type == CellType.STRING) return cell.getStringCellValue(); + if (type == CellType.NUMERIC) { + if (DateUtil.isCellDateFormatted(cell)) { + return new DataFormatter().formatCellValue(cell); + } + double d = cell.getNumericCellValue(); + if (d == Math.rint(d) && !Double.isInfinite(d) && !Double.isNaN(d)) { + return BigDecimal.valueOf(d).toPlainString(); + } + return new DataFormatter().formatCellValue(cell); + } + if (type == CellType.BOOLEAN) return String.valueOf(cell.getBooleanCellValue()); + if (type == CellType.FORMULA) return cell.getCellFormula(); + return ""; + } + + /** CSV → 二维矩阵 (UTF-8 优先, 乱码回退 GBK; 去掉 BOM) */ + private List> readCsvMatrix(byte[] bytes) { + String text = new String(bytes, StandardCharsets.UTF_8); + if (text.indexOf('�') >= 0) { + text = new String(bytes, Charset.forName("GBK")); + } + if (text.startsWith("")) text = text.substring(1); + List> matrix = new ArrayList<>(); + for (String line : text.split("\\r?\\n")) { + if (line.trim().isEmpty()) continue; + matrix.add(parseCsvLine(line)); + } + return matrix; + } + + /** 单行 CSV 解析 (逗号分隔, 支持双引号包裹与转义) */ + private List parseCsvLine(String line) { + List cells = new ArrayList<>(); + StringBuilder cur = new StringBuilder(); + boolean inQuotes = false; + for (int i = 0; i < line.length(); i++) { + char c = line.charAt(i); + if (inQuotes) { + if (c == '"') { + if (i + 1 < line.length() && line.charAt(i + 1) == '"') { + cur.append('"'); i++; + } else { + inQuotes = false; + } + } else { + cur.append(c); + } + } else { + if (c == '"') inQuotes = true; + else if (c == ',') { cells.add(cur.toString().trim()); cur.setLength(0); } + else cur.append(c); + } + } + cells.add(cur.toString().trim()); + return cells; + } + + /** 从 OSS URL 下载文件字节 (单文件, 有超时) */ + private byte[] downloadBytes(String url) { + try { + java.net.URLConnection conn = URI.create(url).toURL().openConnection(); + conn.setConnectTimeout(10000); + conn.setReadTimeout(30000); + try (InputStream in = conn.getInputStream()) { + return readAllBytes(in); + } + } catch (Exception e) { + log.warn("[material] 电子签到表下载失败 url={} err={}", url, e.getMessage()); + throw new ServiceException("电子签到表下载失败, 请稍后重试"); + } + } + + /** 文件扩展名 (优先 fileName, 兜底 ossUrl; 小写) */ + private static String detectExt(String fileName, String ossUrl) { + String src = (fileName != null && !fileName.trim().isEmpty()) ? fileName : ossUrl; + if (src == null) return ""; + String path = src.contains("?") ? src.substring(0, src.indexOf('?')) : src; + int i = path.lastIndexOf('.'); + if (i < 0) return ""; + return path.substring(i + 1).toLowerCase(); + } } diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizMeetingServiceImpl.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizMeetingServiceImpl.java index 348f214..15ca878 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizMeetingServiceImpl.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizMeetingServiceImpl.java @@ -214,7 +214,7 @@ public class BizMeetingServiceImpl implements IBizMeetingService BigDecimal sum = BigDecimal.ZERO; for (BizMeetingMaterial m : mats) { if (m.getSubType() == null || !m.getSubType().startsWith("M_")) continue; - if ("M_INVOICE".equals(m.getSubType()) || "M_SETTLEMENT".equals(m.getSubType())) continue; + if ("M_INVOICE".equals(m.getSubType()) || "M_SETTLEMENT".equals(m.getSubType()) || "M_SETTLEMENT_STAMP".equals(m.getSubType())) continue; if (m.getAmount() != null) sum = sum.add(m.getAmount()); } return sum; @@ -232,4 +232,9 @@ public class BizMeetingServiceImpl implements IBizMeetingService BigDecimal totalFee = laborFee.add(meetingFee); bizMeetingMapper.updateLaborFee(meetingId, laborFee, totalFee); } + @Override + public void recomputeSubmitDeadlinesByProject(Long projectId, Integer days) { + if (projectId == null) return; + bizMeetingMapper.recomputeSubmitDeadlinesByProject(projectId, days); + } } diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizOrgServiceImpl.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizOrgServiceImpl.java index 82d103b..355d09a 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizOrgServiceImpl.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizOrgServiceImpl.java @@ -92,8 +92,8 @@ public class BizOrgServiceImpl implements IBizOrgService { } @Override - public Map selectMySponsorCompany(Long userId) { - return bizOrgMapper.selectMySponsorCompany(userId); + public Map selectMyCompany(Long userId) { + return bizOrgMapper.selectMyCompany(userId); } @Override @@ -111,9 +111,14 @@ public class BizOrgServiceImpl implements IBizOrgService { @Override @Transactional public int toggleStatus(Long orgId, String newBizOrgStatus) { - // 1. 查 org 取主账号 user_id + // 1. 查 org 取主账号 user_id + org_type BizOrg org = bizOrgMapper.selectByPrimaryKey(orgId); if (org == null) throw new ServiceException("组织不存在"); + // 2026-09-08: 执行方状态由供应商系统管理, 本系统不再支持手动启停 + // 走 SupplierAccountSyncService 把 supplier 端 disabled/deleted 翻译成 sys_user.del_flag + biz_org.del_flag + if ("executor".equals(org.getOrgType())) { + throw new ServiceException("执行方状态由供应商系统管理,本系统不支持手动启停"); + } if (org.getUserId() == null) throw new ServiceException("该组织未关联主账号"); // 2. UPDATE biz_org.status diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizPersonServiceImpl.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizPersonServiceImpl.java index a166ba0..8adb69b 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizPersonServiceImpl.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizPersonServiceImpl.java @@ -28,6 +28,10 @@ public class BizPersonServiceImpl implements IBizPersonService public BizPerson getById(String personId) { return bizPersonMapper.selectByPrimaryKey(personId); } + @Override + public BizPerson getByUserId(Long userId) + { return bizPersonMapper.selectByUserId(userId); } + @Override public List selectList(BizPerson entity) { return bizPersonMapper.selectList(entity); } diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizProjectExecutorAssignServiceImpl.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizProjectExecutorAssignServiceImpl.java index c0e2d2b..b9a756f 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizProjectExecutorAssignServiceImpl.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizProjectExecutorAssignServiceImpl.java @@ -14,8 +14,8 @@ public class BizProjectExecutorAssignServiceImpl implements IBizProjectExecutorA @Override public int insertAssign(BizProjectExecutorAssign entity) { - // 执行方分配策略: 先按 project_id 删, 再插 - mapper.deleteByProjectId(entity.getProjectId()); + // 执行方分配策略: 先按 (project_id + executor_org_id) 删本执行方旧分配, 再插 (避免覆盖别家执行方) + mapper.deleteByProjectIdAndOrg(entity.getProjectId(), entity.getExecutorOrgId()); return mapper.insertAssign(entity); } @@ -29,8 +29,8 @@ public class BizProjectExecutorAssignServiceImpl implements IBizProjectExecutorA public int assignStaffForProject(BizProjectExecutorAssign body, java.util.List staffUserIds) { if (body == null || body.getProjectId() == null) return 0; if (staffUserIds == null || staffUserIds.isEmpty()) return 0; - // 一次性清旧, 不在循环里清 - mapper.deleteByProjectId(body.getProjectId()); + // 一次性清旧 (只清本执行方 executor_org_id 的, 不在循环里清) + mapper.deleteByProjectIdAndOrg(body.getProjectId(), body.getExecutorOrgId()); int inserted = 0; for (Long sid : staffUserIds) { BizProjectExecutorAssign item = new BizProjectExecutorAssign(); @@ -46,12 +46,12 @@ public class BizProjectExecutorAssignServiceImpl implements IBizProjectExecutorA } @Override - public List listByProjectId(String projectId) { - return mapper.selectByProjectId(projectId); + public List listByProjectId(Long projectId, Long executorOrgId) { + return mapper.selectByProjectIdAndOrg(projectId, executorOrgId); } @Override - public int deleteByProjectId(String projectId) { + public int deleteByProjectId(Long projectId) { return mapper.deleteByProjectId(projectId); } } diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizProjectServiceImpl.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizProjectServiceImpl.java index b22412f..44338dc 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizProjectServiceImpl.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizProjectServiceImpl.java @@ -15,6 +15,7 @@ import com.ruoyi.business.mapper.BizMeetingMapper; import com.ruoyi.business.service.IBizProjectService; import com.ruoyi.business.service.IBizMeetingService; import com.ruoyi.common.utils.SecurityUtils; +import com.ruoyi.common.utils.id.SnowflakeId; @Service public class BizProjectServiceImpl implements IBizProjectService @@ -43,8 +44,8 @@ public class BizProjectServiceImpl implements IBizProjectService public List selectList(BizProject entity) { return bizProjectMapper.selectList(entity); } @Override - public List selectPublicAnnouncements() - { return bizProjectMapper.selectPublicAnnouncements(); } + public List selectPublicAnnouncements(String keyword) + { return bizProjectMapper.selectPublicAnnouncements(keyword); } @Override public List selectSponsorList(BizProject entity) { return bizProjectMapper.selectSponsorList(entity); } @@ -55,10 +56,10 @@ public class BizProjectServiceImpl implements IBizProjectService public List selectExecutorStaffList(BizProject entity) { return bizProjectMapper.selectExecutorStaffList(entity); } @Override - // 注: biz_project.project_id 用 DB AUTO_INCREMENT, 不需要 SnowflakeId 注入; - // 项目 ID 用 Long 后, SnowflakeId.injectIfEmpty 反射 setProjectId(String) 会 NoSuchMethodException 被吞掉 (SnowflakeId.java:30-31), 行为安全. + // 雪花 ID 主键: biz_project.project_id 由应用生成 53-bit 雪花 ID (不再走 DB AUTO_INCREMENT). // create_user_id 走当前登录用户 (前台 API 无 @DataScope, 不会被过滤; 后台 @PreAuthorize 受角色限制) public int insert(BizProject entity) { + SnowflakeId.injectIfEmpty(entity, "projectId"); if (entity.getCreateUserId() == null) { entity.setCreateUserId(SecurityUtils.getUserId()); } @@ -113,8 +114,8 @@ public class BizProjectServiceImpl implements IBizProjectService bizProjectAssignMapper.softDeleteByProjectId(projectId); // 5) sponsor assign (String) bizProjectSponsorAssignMapper.softDeleteByProjectId(String.valueOf(projectId)); - // 5.5) executor assign (String) - bizProjectExecutorAssignMapper.softDeleteByProjectId(String.valueOf(projectId)); + // 5.5) executor assign + bizProjectExecutorAssignMapper.softDeleteByProjectId(projectId); // 6) rating bizProjectRatingMapper.softDeleteByProjectId(projectId); // 7) 会议链: 查项目下所有 meeting → 调 BizMeetingService.softDeleteCascadeBatch 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 6842659..3ce9ae8 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 @@ -430,12 +430,13 @@ public class BizSignServiceImpl implements BizSignService { 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); + if (s.length() == 1) return "*"; + if (s.length() == 2) return s.charAt(0) + "*"; + return s.charAt(0) + "*".repeat(s.length() - 2) + s.charAt(s.length() - 1); } } \ No newline at end of file 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 f077162..58880b4 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,9 +45,15 @@ public class AliyunSmsSender { @Value("${ruoyi.sms.esignTemplate}") private String esignTemplate; + @Value("${ruoyi.sms.inviteTemplate:}") + private String inviteTemplate; + @Value("${ruoyi.sms.esignBaseUrl:https://hegui.bahim.org.cn}") private String esignBaseUrl; + @Value("${ruoyi.invite.baseUrl:}") + private String inviteBaseUrl; + @Value("${ruoyi.sms.regionId:cn-hangzhou}") private String regionId; @@ -128,6 +134,42 @@ public class AliyunSmsSender { return esignBaseUrl + "/#/doctor/sign-fill?attendeeId=" + attendeeId; } + /** + * 发送邀请短信 (新增独立模板 ruoyi.sms.inviteTemplate, 不复用 SMS_512040098). + * 占位符 ${link}=邀请链接, 打开后展示富文本邀请内容 + 底部确认按钮. + */ + public boolean sendInvite(String phone, String link) { + try { + SendSmsRequest req = new SendSmsRequest(); + req.setPhoneNumbers(phone); + req.setSignName(signName); + req.setTemplateCode(inviteTemplate); + Map params = new LinkedHashMap<>(); + params.put("link", link != null ? link : ""); + req.setTemplateParam(JSONUtil.toJsonStr(params)); + SendSmsResponse resp = getClient().getAcsResponse(req); + if ("OK".equalsIgnoreCase(resp.getCode())) { + log.info("[SMS] 邀请短信发送成功 phone={}, bizId={}", phone, resp.getBizId()); + return true; + } + log.error("[SMS] 邀请短信发送失败 phone={}, code={}, msg={}, requestId={}", + phone, resp.getCode(), resp.getMessage(), resp.getRequestId()); + return false; + } catch (Exception e) { + log.error("[SMS] 邀请短信异常 phone={}", phone, e); + return false; + } + } + + /** + * 拼公开邀请链接: {inviteBaseUrl}/#/invite/{token} + * (前端 Vue Router 是 hash 模式, Java 只拼 #/invite/{token} 路由 + token, + * 域名/子路径由 ruoyi.invite.baseUrl 提供, 参照 {@link #esignLink}). + */ + public String inviteLink(String token) { + return inviteBaseUrl + "/#/invite/" + token; + } + /** * 真发送短信验证码, 成功返回 true */ diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/supplier/SupplierAccountSyncService.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/supplier/SupplierAccountSyncService.java index f269bb9..d940e22 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/supplier/SupplierAccountSyncService.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/supplier/SupplierAccountSyncService.java @@ -23,7 +23,12 @@ import lombok.extern.slf4j.Slf4j; * 2. biz_org — 企业档案 (org_type=executor) * 3. biz_person — 联系人档案 (unit_type=executor, is_synced=1) *

    - * 状态同步: disabled → sys_user.status='1' + biz_org.status='1'; deleted → sys_user.del_flag='2'. + * 状态翻译 (2026-09-08 改造): + * supplier 端 disabled / deleted → 本系统 del_flag 软删 (等同删除) + * - sys_user.del_flag = '2' (UserStatus.DELETED 语义) + * - biz_org.del_flag = '1' (biz_org 软删) + * status 字段对 executor 维度永远是 '0' (本系统不再用 status 表达执行方启用/禁用) + * update 分支显式覆盖 del_flag, 支持 supplier 端 "禁用→启用" 的状态回滚 * 幂等: 每分钟重跑, 按邮箱 upsert, 单条失败只 log 不影响后续. */ @Slf4j @@ -72,8 +77,12 @@ public class SupplierAccountSyncService log.warn("[SupplierAccountSync] 账号缺邮箱, 跳过 supplierCode={}", a.getSupplierCode()); return; } - String status = Boolean.TRUE.equals(a.getDisabled()) ? "1" : "0"; - String delFlag = Boolean.TRUE.equals(a.getDeleted()) ? "2" : "0"; + // 2026-09-08: 翻译成 del_flag 软删, 废弃 status 维度 + // supplier 端 disabled / deleted 都视为"未通过认证", sys_user.status 永远是 '0' + boolean isBlocked = Boolean.TRUE.equals(a.getDisabled()) || Boolean.TRUE.equals(a.getDeleted()); + String sysUserDelFlag = isBlocked ? "2" : "0"; + String bizOrgDelFlag = isBlocked ? "1" : "0"; + String status = "0"; // 供应商侧部分账号缺联系人/电话, 做非空回退 + 按列宽截断 (避免 NOT NULL/UNIQUE/超长报错) String nickName = truncate(firstNonBlank(a.getContactName(), a.getEnterpriseName(), email), 30); // sys_user.nick_name @@ -98,7 +107,7 @@ public class SupplierAccountSyncService nu.setPhonenumber(phonenumber); nu.setPassword2(a.getPassword()); nu.setStatus(status); - nu.setDelFlag(delFlag); + nu.setDelFlag(sysUserDelFlag); nu.setAccountType("MAIN"); nu.setRoleType("executor"); sysUserMapper.insertSyncedUser(nu); @@ -114,7 +123,8 @@ public class SupplierAccountSyncService upd.setPhonenumber(phonenumber); upd.setEmail(email); upd.setStatus(status); - upd.setDelFlag(delFlag); + // update 分支显式覆盖 del_flag, 允许 supplier 端 "禁用→启用" 的状态回滚 + upd.setDelFlag(sysUserDelFlag); sysUserMapper.updateSyncedUser(upd); } @@ -147,6 +157,7 @@ public class SupplierAccountSyncService no.setContactName(a.getContactName()); no.setContactPhone(a.getContactPhone()); no.setStatus(status); + no.setDelFlag(bizOrgDelFlag); no.setIsSynced(1); bizOrgMapper.insert(no); orgId = no.getOrgId(); @@ -175,7 +186,7 @@ public class SupplierAccountSyncService { sysUserMapper.updateAccountType(userId, "SUB", mainUserId); } - // 刷新 org 档案字段 (含补写税号 / 主账号指向) + // 刷新 org 档案字段 (含 del_flag 覆盖 → 允许从软删恢复, 含补写税号 / 主账号指向) org.setUserId(newMainUserId); org.setOrgName(orgName); org.setBusinessNature(businessNature); @@ -184,6 +195,8 @@ public class SupplierAccountSyncService org.setContactName(a.getContactName()); org.setContactPhone(a.getContactPhone()); org.setStatus(status); + // 覆盖 del_flag, 允许 supplier 端 "禁用→启用" 的状态回滚 + org.setDelFlag(bizOrgDelFlag); bizOrgMapper.updateByPrimaryKey(org); } diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/upload/UploadProgressRegistry.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/upload/UploadProgressRegistry.java new file mode 100644 index 0000000..81874f8 --- /dev/null +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/upload/UploadProgressRegistry.java @@ -0,0 +1,101 @@ +package com.ruoyi.business.upload; + +import java.util.concurrent.ConcurrentHashMap; +import org.springframework.stereotype.Component; + +/** + * 批量上传 (zip 解压 + 逐文件上传 OSS) 的实时进度登记表. + *

    + * 前端 POST 上传 zip 后, 后端立即返回 jobId (处理移到后台线程), 前端轮询 + * GET /business/upload/progress/{jobId} 读取 done/total/finished/result. + * 进度只驻内存 (任务短命, 不落库), 已完成且 10 分钟未再访问的条目会被清理. + */ +@Component +public class UploadProgressRegistry +{ + /** 单个上传任务的实时进度 */ + public static class Progress + { + public final String jobId; + public volatile int total = -1; // -1 = 尚未解析出目录数 (解析中) + public volatile int done = 0; // 已处理的目录 / 材料类数 + public volatile boolean finished = false; + public volatile boolean error = false; + public volatile String message; // error 时的错误信息 + public volatile Object result; // 成功时的最终结果 (Map 或 Integer) + public volatile long updateTime = System.currentTimeMillis(); + + Progress(String jobId) + { + this.jobId = jobId; + } + } + + private static final long EXPIRE_MS = 10 * 60 * 1000L; + + private final ConcurrentHashMap map = new ConcurrentHashMap<>(); + + /** 登记一个新任务 (在异步 submit 前调用, 保证轮询始终能读到条目) */ + public void start(String jobId) + { + if (jobId == null || jobId.isEmpty()) return; + sweepStale(); + map.put(jobId, new Progress(jobId)); + } + + public void setTotal(String jobId, int total) + { + Progress p = map.get(jobId); + if (p != null) + { + p.total = total; + p.updateTime = System.currentTimeMillis(); + } + } + + public void update(String jobId, int done) + { + Progress p = map.get(jobId); + if (p != null) + { + p.done = done; + p.updateTime = System.currentTimeMillis(); + } + } + + public void finish(String jobId, Object result) + { + Progress p = map.get(jobId); + if (p != null) + { + p.result = result; + p.finished = true; + p.updateTime = System.currentTimeMillis(); + } + } + + public void fail(String jobId, String message) + { + Progress p = map.get(jobId); + if (p != null) + { + p.error = true; + p.finished = true; + p.message = message; + p.updateTime = System.currentTimeMillis(); + } + } + + public Progress get(String jobId) + { + if (jobId == null || jobId.isEmpty()) return null; + return map.get(jobId); + } + + /** 惰性清理: 已完成且超时的条目 (上传低频, 在下次 start 时顺带清扫) */ + private void sweepStale() + { + long now = System.currentTimeMillis(); + map.entrySet().removeIf(e -> e.getValue().finished && now - e.getValue().updateTime > EXPIRE_MS); + } +} diff --git a/ry-api/ruoyi-business/src/main/resources/mapper/business/BizArticleMapper.xml b/ry-api/ruoyi-business/src/main/resources/mapper/business/BizArticleMapper.xml index 2ef5451..174d80a 100644 --- a/ry-api/ruoyi-business/src/main/resources/mapper/business/BizArticleMapper.xml +++ b/ry-api/ruoyi-business/src/main/resources/mapper/business/BizArticleMapper.xml @@ -36,7 +36,7 @@ diff --git a/ry-api/ruoyi-business/src/main/resources/mapper/business/BizDepartmentMapper.xml b/ry-api/ruoyi-business/src/main/resources/mapper/business/BizDepartmentMapper.xml index eceb552..9f79006 100644 --- a/ry-api/ruoyi-business/src/main/resources/mapper/business/BizDepartmentMapper.xml +++ b/ry-api/ruoyi-business/src/main/resources/mapper/business/BizDepartmentMapper.xml @@ -34,7 +34,7 @@ diff --git a/ry-api/ruoyi-business/src/main/resources/mapper/business/BizInviteMapper.xml b/ry-api/ruoyi-business/src/main/resources/mapper/business/BizInviteMapper.xml new file mode 100644 index 0000000..977087c --- /dev/null +++ b/ry-api/ruoyi-business/src/main/resources/mapper/business/BizInviteMapper.xml @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + select i.id, + i.project_no, + i.meeting_name, + i.period_no, + i.meeting_time, + i.send_time, + i.redhead_file_url, + i.send_channel, + i.create_by, + i.create_time, + (select count(*) from biz_invite_recipient r where r.invite_id = i.id) as recipient_count, + (select count(*) from biz_invite_recipient r where r.invite_id = i.id and r.confirm_status = '1') as confirmed_count + from biz_invite i + + + + + + + + insert into biz_invite(id, project_no, meeting_name, period_no, meeting_time, content, + redhead_file_url, send_channel, create_by, create_time) + values(#{id}, #{projectNo}, #{meetingName}, #{periodNo}, #{meetingTime}, #{content}, + #{redheadFileUrl}, #{sendChannel}, #{createBy}, sysdate()) + + + + update biz_invite + + project_no = #{projectNo}, + meeting_name = #{meetingName}, + period_no = #{periodNo}, + meeting_time = #{meetingTime}, + content = #{content}, + redhead_file_url = #{redheadFileUrl}, + send_channel = #{sendChannel}, + update_by = #{updateBy}, + update_time = sysdate() + + where id = #{id} + + + + update biz_invite set send_time = #{sendTime} where id = #{id} + + + + delete from biz_invite where id = #{id} + + + + delete from biz_invite where id in + + #{id} + + + diff --git a/ry-api/ruoyi-business/src/main/resources/mapper/business/BizInviteRecipientMapper.xml b/ry-api/ruoyi-business/src/main/resources/mapper/business/BizInviteRecipientMapper.xml new file mode 100644 index 0000000..4efb1d3 --- /dev/null +++ b/ry-api/ruoyi-business/src/main/resources/mapper/business/BizInviteRecipientMapper.xml @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + + + + + + + + select id, invite_id, name, phone, email, source, remark, + send_status, fail_reason, confirm_status, confirmed_at, token, create_time + from biz_invite_recipient + + + + + + + + insert into biz_invite_recipient(id, invite_id, name, phone, email, source, remark, + send_status, confirm_status, token, create_by, create_time) + values(#{id}, #{inviteId}, #{name}, #{phone}, #{email}, #{source}, #{remark}, + #{sendStatus}, #{confirmStatus}, #{token}, #{createBy}, sysdate()) + + + + insert into biz_invite_recipient(id, invite_id, name, phone, email, source, remark, + send_status, confirm_status, token, create_by, create_time) + values + + (#{r.id}, #{r.inviteId}, #{r.name}, #{r.phone}, #{r.email}, #{r.source}, #{r.remark}, + #{r.sendStatus}, #{r.confirmStatus}, #{r.token}, #{r.createBy}, sysdate()) + + + + + delete from biz_invite_recipient where invite_id = #{inviteId} + + + + update biz_invite_recipient + set confirm_status = '1', confirmed_at = sysdate() + where token = #{token} and (confirm_status is null or confirm_status != '1') + + + + update biz_invite_recipient + set send_status = #{sendStatus}, fail_reason = #{failReason} + where id = #{id} + + diff --git a/ry-api/ruoyi-business/src/main/resources/mapper/business/BizMeetingAttendeeMapper.xml b/ry-api/ruoyi-business/src/main/resources/mapper/business/BizMeetingAttendeeMapper.xml index b8a78ae..5fba0c1 100644 --- a/ry-api/ruoyi-business/src/main/resources/mapper/business/BizMeetingAttendeeMapper.xml +++ b/ry-api/ruoyi-business/src/main/resources/mapper/business/BizMeetingAttendeeMapper.xml @@ -179,8 +179,15 @@ select id, meeting_id, user_id, name, phone, work_unit, department, title, id_card, bank_card, bank_name, bank_branch, bank_region, bank_address, account_name, id_card_attachments, labor_form, fee_pre_tax, tax, fee, vat_and_surcharge, summary, on_site_photos, signed_at, signed_ip, handsign, labor_protocol, invitation_url, create_by, create_time, is_deleted, is_esigned, is_invited from biz_meeting_attendee where id = #{id} and is_deleted = 0 + + + + and exists (select 1 from biz_meeting_attendee a2 where a2.meeting_id = biz_meeting.meeting_id and a2.user_id = #{userId} and a2.is_deleted = 0 and (a2.labor_protocol is null or a2.labor_protocol = '')) + + + and not exists (select 1 from biz_meeting_attendee a2 where a2.meeting_id = biz_meeting.meeting_id and a2.user_id = #{userId} and a2.is_deleted = 0 and (a2.labor_protocol is null or a2.labor_protocol = '')) + and exists (select 1 from biz_meeting_attendee a3 where a3.meeting_id = biz_meeting.meeting_id and a3.user_id = #{userId} and a3.is_deleted = 0 and a3.labor_protocol is not null and a3.labor_protocol != '') + and project_id in (select project_id from biz_project where sponsor_org_id = (select org_id from biz_org where user_id = #{params.sponsorAdminUserId} and org_type = 'sponsor') and is_deleted = 0) and project_id in (select distinct project_id from biz_project_sponsor_assign where monitor_user_id = #{params.monitorUserId} and is_deleted = 0) @@ -102,14 +116,11 @@ select 1 from biz_project p2 where p2.project_id = biz_meeting.project_id and p2.is_deleted = 0 - and p2.is_finished = '1' and p2.open_status = 'N' + and p2.is_finished = 'Y' and p2.open_status = 'N' ) - and project_id in ( - select distinct a.project_id from biz_project_assign a - where a.is_deleted = 0 - and a.execution_unit_id = (select org_id from biz_org where user_id = #{params.executorUserId} and org_type = 'executor') - ) + + and execution_unit_id = (select org_id from biz_org where user_id = #{params.executorUserId} and org_type = 'executor') and create_by = #{params.executorCreatorUsername} and project_id in ( select project_id from biz_project where create_user_id = #{params.managerCreateUserId} and is_deleted = 0 @@ -134,7 +145,7 @@ select 1 from biz_project p2 where p2.project_id = biz_meeting.project_id and p2.is_deleted = 0 - and p2.is_finished = '1' and p2.open_status = 'N' + and p2.is_finished = 'Y' 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) @@ -331,6 +342,18 @@ and submit_deadline is not null and submit_deadline <= NOW() + + + update biz_meeting + set current_stage = 'NOT_STARTED' + where is_deleted = 0 + and current_stage = 'IN_PROGRESS' + and is_executed = 0 + and is_frozen = 0 + and start_time is not null + and start_time > NOW() + - + select o.org_id as orgId, + o.org_name as orgName, + o.org_type as orgType, case when p.user_id is null then 1 else 0 end as isOwner from sys_user u - left join biz_person p on p.user_id = u.user_id and p.unit_type = 'sponsor' - left join biz_org own on own.user_id = u.user_id and own.org_type = 'sponsor' and own.del_flag = '0' + left join biz_person p on p.user_id = u.user_id and p.unit_type = u.role_type + left join biz_org own on own.user_id = u.user_id and own.org_type = u.role_type and own.del_flag = '0' left join biz_org o on o.org_id = COALESCE(own.org_id, p.org_id) where u.user_id = #{userId} and o.del_flag = '0' diff --git a/ry-api/ruoyi-business/src/main/resources/mapper/business/BizProjectExecutorAssignMapper.xml b/ry-api/ruoyi-business/src/main/resources/mapper/business/BizProjectExecutorAssignMapper.xml index 9d0a4c9..7efcf26 100644 --- a/ry-api/ruoyi-business/src/main/resources/mapper/business/BizProjectExecutorAssignMapper.xml +++ b/ry-api/ruoyi-business/src/main/resources/mapper/business/BizProjectExecutorAssignMapper.xml @@ -24,12 +24,18 @@ - + delete from biz_project_executor_assign where project_id = #{projectId} - - + + + delete from biz_project_executor_assign + where project_id = #{projectId} and executor_org_id = #{executorOrgId} + + + + update biz_project_executor_assign set is_deleted = 1 where project_id = #{projectId} @@ -46,4 +52,21 @@ WHERE a.project_id = #{projectId} and a.is_deleted = 0 ORDER BY a.create_time DESC + + + 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 34a8766..7ed4b42 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 @@ -52,7 +52,7 @@ select p.project_id, p.project_no, p.project_name, p.total_sessions, p.total_amount, - + (select count(*) from biz_meeting m where m.project_id = p.project_id and m.is_deleted = 0 and m.current_stage not in ('NOT_STARTED','IN_PROGRESS')) as done_sessions, @@ -129,8 +129,8 @@ p.is_deleted = 0 - - and not (p.is_finished = '1' and p.open_status = 'N') + + and not (p.is_finished = 'Y' and p.open_status = 'N') and p.project_id in @@ -233,7 +233,7 @@ and m.execution_unit_id = (select org_id from biz_org where user_id = #{params.executorUserId} and org_type = 'executor')) as available_amount, (select count(*) from biz_meeting m where m.project_id = p.project_id and m.is_deleted = 0 and m.execution_unit_id = (select org_id from biz_org where user_id = #{params.executorUserId} and org_type = 'executor')) as meeting_count, - + (select count(*) from biz_meeting m where m.project_id = p.project_id and m.is_deleted = 0 and m.current_stage not in ('NOT_STARTED','IN_PROGRESS') @@ -324,7 +324,7 @@ and m.execution_unit_id = (select org_id from biz_org where user_id = #{params.executorUserId} and org_type = 'executor')) as available_amount, (select count(*) from biz_meeting m where m.project_id = p.project_id and m.is_deleted = 0 and m.execution_unit_id = (select org_id from biz_org where user_id = #{params.executorUserId} and org_type = 'executor')) as meeting_count, - + (select count(*) from biz_meeting m where m.project_id = p.project_id and m.is_deleted = 0 and m.current_stage not in ('NOT_STARTED','IN_PROGRESS') @@ -403,17 +403,24 @@ order by project_id desc - + insert into biz_project - project_id, + project_id, project_no, project_name, total_sessions, @@ -450,7 +457,7 @@ open_status, - #{projectId}, + #{projectId}, #{projectNo}, #{projectName}, #{totalSessions}, @@ -530,14 +537,14 @@ where project_id = #{projectId} - + update biz_project set invitation_url = null, support_letter_url = null, publish_url = null, schedule_url = null, - is_published = '0' + is_published = 'N' where project_id = #{projectId} diff --git a/ry-api/ruoyi-common/src/main/java/com/ruoyi/common/utils/file/FileUploadUtils.java b/ry-api/ruoyi-common/src/main/java/com/ruoyi/common/utils/file/FileUploadUtils.java index cc1b6f4..0d5cab0 100644 --- a/ry-api/ruoyi-common/src/main/java/com/ruoyi/common/utils/file/FileUploadUtils.java +++ b/ry-api/ruoyi-common/src/main/java/com/ruoyi/common/utils/file/FileUploadUtils.java @@ -26,7 +26,7 @@ public class FileUploadUtils /** * 默认大小 50M */ - public static final long DEFAULT_MAX_SIZE = 50 * 1024 * 1024L; + public static final long DEFAULT_MAX_SIZE = 200 * 1024 * 1024L; /** * 默认的文件名最大长度 100 diff --git a/ry-api/ruoyi-common/src/main/java/com/ruoyi/common/utils/id/SnowflakeId.java b/ry-api/ruoyi-common/src/main/java/com/ruoyi/common/utils/id/SnowflakeId.java index 726f1ea..3a0b53f 100644 --- a/ry-api/ruoyi-common/src/main/java/com/ruoyi/common/utils/id/SnowflakeId.java +++ b/ry-api/ruoyi-common/src/main/java/com/ruoyi/common/utils/id/SnowflakeId.java @@ -4,13 +4,13 @@ import java.lang.reflect.Method; /** * 雪花 ID 注入工具 - 业务 entity insert 前若主键为空则自动填雪花 ID. - * 约定: 主键 getter/setter 命名 must be getXxxId()/setXxxId(String) (BizXxxId 风格). + * 约定: 主键 getter/setter 命名 getXxxId()/setXxxId(...), 支持 String 或 Long 主键 (BizXxxId 风格). */ public class SnowflakeId { /** - * 主键为空时, 雪花 ID 注入 id field (支持 String 类型主键). - * 通过反射调用 setXxxId(String.valueOf(IdGenerator.generateId())). + * 主键为空时, 雪花 ID 注入 id field (支持 String / Long 类型主键). + * 通过反射调用 setXxxId(String.valueOf(IdGenerator.generateId())) 或 setXxxId(Long). * * @param entity 业务对象 * @param idFieldName 主键字段名, 如 "projectId" / "annId" / "id" (注意 BizMeetingSettlement 是 "id") @@ -23,9 +23,15 @@ public class SnowflakeId { Method getM = entity.getClass().getMethod(getter); Object current = getM.invoke(entity); if (current == null || (current instanceof String && ((String) current).isEmpty())) { - String snowId = String.valueOf(IdGenerator.generateId()); - Method setM = entity.getClass().getMethod(setter, String.class); - setM.invoke(entity, snowId); + long snowId = IdGenerator.generateId(); + // 优先 String setter (老约定 setXxxId(String)), 其次 Long (setXxxId(Long), 如 BizProject.projectId) + try { + Method setM = entity.getClass().getMethod(setter, String.class); + setM.invoke(entity, String.valueOf(snowId)); + } catch (NoSuchMethodException e) { + Method setM = entity.getClass().getMethod(setter, Long.class); + setM.invoke(entity, snowId); + } } } catch (NoSuchMethodException e) { // 字段可能叫别的, 跳过 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 837b069..cb0a8ef 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 @@ -61,6 +61,8 @@ public class SecurityConfig requests.requestMatchers("/login", "/register", "/captchaImage").permitAll() // OSS 文件代理 (PDF/图片内嵌预览, 重写 Content-Disposition 为 inline) .requestMatchers(HttpMethod.GET, "/common/oss/proxy").permitAll() + // zip 在线查看 (服务端解压: 列清单 + 取单文件), 公开只读 + .requestMatchers(HttpMethod.GET, "/common/oss/zip/entries", "/common/oss/zip/file").permitAll() // OSS 直传签名 (注册场景需匿名访问: 专家/执行方/支持方上传证书时还没 token) // 安全性: OssController 已用 policy 限定 dir 前缀 + 文件大小, key 含时间戳+随机串防覆盖 .requestMatchers(HttpMethod.GET, "/common/oss/sign").permitAll() diff --git a/ry-api/ruoyi-framework/src/main/java/com/ruoyi/framework/web/exception/GlobalExceptionHandler.java b/ry-api/ruoyi-framework/src/main/java/com/ruoyi/framework/web/exception/GlobalExceptionHandler.java index 6778c5b..e13fc23 100644 --- a/ry-api/ruoyi-framework/src/main/java/com/ruoyi/framework/web/exception/GlobalExceptionHandler.java +++ b/ry-api/ruoyi-framework/src/main/java/com/ruoyi/framework/web/exception/GlobalExceptionHandler.java @@ -50,7 +50,8 @@ public class GlobalExceptionHandler { String requestURI = request.getRequestURI(); log.error("请求地址'{}',不支持'{}'请求", requestURI, e.getMethod()); - return AjaxResult.error(e.getMessage()); + // 不暴露 e.getMessage() (含 supported methods 等内部细节), 统一友好兜底 + return AjaxResult.error("请求方式不支持"); } /** @@ -113,7 +114,8 @@ public class GlobalExceptionHandler { String requestURI = request.getRequestURI(); log.error("请求地址'{}',发生未知异常.", requestURI, e); - return AjaxResult.error(e.getMessage()); + // 不暴露 e.getMessage() (NPE/IllegalState/SQL 等 Java 内部细节), 统一友好兜底; 详情已落日志 + return AjaxResult.error("系统繁忙,请稍后再试或联系管理员"); } /** @@ -124,7 +126,8 @@ public class GlobalExceptionHandler { String requestURI = request.getRequestURI(); log.error("请求地址'{}',发生系统异常.", requestURI, e); - return AjaxResult.error(e.getMessage()); + // 同上: 不暴露 e.getMessage(), 统一友好兜底; 详情已落日志 + return AjaxResult.error("系统繁忙,请稍后再试或联系管理员"); } /** diff --git a/ry-api/ruoyi-system/src/main/resources/mapper/system/SysUserMapper.xml b/ry-api/ruoyi-system/src/main/resources/mapper/system/SysUserMapper.xml index ae10285..c9942f0 100644 --- a/ry-api/ruoyi-system/src/main/resources/mapper/system/SysUserMapper.xml +++ b/ry-api/ruoyi-system/src/main/resources/mapper/system/SysUserMapper.xml @@ -101,19 +101,19 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" -->