feat: 邀请函邮件发送 + 首次设密/密码为空 + 姓名单一可信源

邀请发送 (菜单暂隐藏, 待确认参数见 发送邀请-待确认参数.md):
- 后端: BizInvite / BizInviteRecipient + Controller/Service/Mapper/XML
- 邮件: InviteMailSender (spring-boot-starter-mail SMTP) + application*.yml 邮件配置
- 上传进度: UploadProgressRegistry + UploadProgressController
- 前端: InviteList / InviteNew / InviteDetail / InviteView + api/business/invite.js
- 原型: proto/html/components/invite-detail / new-invitation / send-invitation

登录/账号:
- 首次设密: /getInfo 返回 isPasswordEmpty, SysProfileController 密码为空时跳过旧密码校验, ForcePasswordDialog 强制弹窗
- 姓名单一可信源 resolveDisplayName: doctor→biz_expert.name, sponsor/executor→biz_person.name, 其余回退 nick_name
- OA compliance 门禁改为按手机号查 ecology 视图 (不再限定 manager/leader)

其它:
- OSS zip 在线查看 (列清单+取单文件, 公开只读) + SecurityConfig permitAll
- doctor 项目详情 ProjectDetail.vue
- 数据库/测试/设计文档 (md) 入库
This commit is contained in:
郭庆泰
2026-09-10 20:40:49 +08:00
parent 56615f9806
commit 5a0a892574
171 changed files with 11032 additions and 876 deletions
@@ -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<Map<String, Object>> 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<Map<String, Object>> readZipEntries(byte[] zipBytes, Charset charset) throws Exception
{
List<Map<String, Object>> 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<String, Object> overflow = new HashMap<>();
overflow.put("name", "…(文件过多, 仅显示前 500 个)");
overflow.put("path", "");
overflow.put("size", 0L);
overflow.put("kind", "other");
entries.add(overflow);
break;
}
Map<String, Object> 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";
}
}
@@ -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)
{
@@ -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("新密码不能与旧密码相同");
}
@@ -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/
@@ -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
@@ -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
+5
View File
@@ -34,6 +34,11 @@
<artifactId>aliyun-java-sdk-dysmsapi</artifactId>
<version>2.2.1</version>
</dependency>
<!-- 邮件发送 (发送邀请 email 渠道, 参考 xxl-job EmailJobAlarm) — 需 mvn install -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-mail</artifactId>
</dependency>
<dependency>
<groupId>com.aliyun</groupId>
<artifactId>aliyun-java-sdk-core</artifactId>
@@ -38,4 +38,15 @@ public class OcrExecutorConfig
{
return Executors.newFixedThreadPool(8);
}
/**
* zip 批量上传执行器 (劳务协议 / 专家照片 / 会务材料): 解压 zip + 逐文件上传 OSS (网络 IO 密集).
* <p>
* 上传接口改为"提交即返回 jobId" (处理移后台), 前端轮询进度; 4 线程并发处理不同用户的上传任务.
*/
@Bean(name = "zipUploadExecutor", destroyMethod = "shutdown")
public ExecutorService zipUploadExecutor()
{
return Executors.newFixedThreadPool(4);
}
}
@@ -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);
}
}
@@ -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 工作台).
* <p>独立于项目/会议, 项目编号为手输文本. 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<BizInvite> 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<String, Object> 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<String, Object> 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<String, Object> 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));
}
}
@@ -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<BizMeetingAttendee> 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<String, Object> result = attendeeService.uploadAgreements(file, meetingId);
String jobId = attendeeService.submitAgreementUpload(file, meetingId);
Map<String, Object> 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<String, Object> result = attendeeService.uploadExpertPhotos(file, meetingId);
String jobId = attendeeService.submitExpertPhotoUpload(file, meetingId);
Map<String, Object> 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 */
@@ -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.
* <p>用于建会/编辑时落 current_stage 初值 — 否则建会落 DB 默认 '0'、编辑时间后残留旧值,
* 项目列表「未执行」统计 (current_stage='NOT_STARTED') 会与会议列表「未执行」展示 (时间实时推导) 错位.
* <p>{@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) {
@@ -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).
* <p>
* 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<String, Object> result = new HashMap<>();
result.put("jobId", jobId);
return success(result);
}
/**
* 电子签到表 (L_ESIGN_IN) 多文件/zip 上传 (同步, 追加式保存).
* <p>
* 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<String, Object> result = bizMeetingMaterialService.uploadEsignIn(file, meetingId);
return success(result);
}
/** request body for batch download endpoints */
@@ -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<String, Object> data = bizOrgService.selectMySponsorCompany(uid);
Map<String, Object> data = bizOrgService.selectMyCompany(uid);
return success(data);
}
@@ -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<Long> 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);
@@ -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<String, Object> map = new HashMap<>();
BizProject publishedQuery = new BizProject();
publishedQuery.setIsPublished("1");
publishedQuery.setIsPublished("Y");
List<BizProject> announcements = projectService.selectList(publishedQuery);
List<BizProjectPlan> plans = projectPlanService.selectList(new BizProjectPlan());
map.put("announcements", announcements);
@@ -95,10 +96,11 @@ public class BizPublicController extends BaseController {
}
@GetMapping("/announcements")
public AjaxResult announcements() {
// 公示列表按发布时间倒序 (后端排序), 只返回已发布项目
List<BizProject> 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<String, Object> 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("确认成功");
}
}
@@ -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;
/**
* 查询某上传任务的实时进度.
* <p>
* 返回 { 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<String, Object> 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);
}
}
@@ -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; }
@@ -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; }
@@ -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.
* <p>独立于项目/会议: 此时项目在 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<BizInviteRecipient> 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<BizInviteRecipient> getRecipients() { return recipients; }
public void setRecipients(List<BizInviteRecipient> recipients) { this.recipients = recipients; }
}
@@ -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; }
}
@@ -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;
@@ -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 子类:
* <ul>
* <li>material_type: SERVICE=会务材料, LABOR=劳务材料, SERVICE_VOUCHER=会务凭证, LABOR_VOUCHER=劳务凭证</li>
* <li>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</li>
* <li>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</li>
* </ul>
* <p>
* 注意: 不继承 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<String> ossUrls;
/** 扫码拍照回传的脱敏版 OSS URL 列表 (与 ossUrls 一一对应, 仅 cameraUpload 端点入参) */
private List<String> 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<String> getOssUrls() { return ossUrls; }
public void setOssUrls(List<String> ossUrls) { this.ossUrls = ossUrls; }
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
public List<String> getExtraOssUrls() { return extraOssUrls; }
public void setExtraOssUrls(List<String> extraOssUrls) { this.extraOssUrls = extraOssUrls; }
public BigDecimal getAmount() { return amount; }
public void setAmount(BigDecimal amount) { this.amount = amount; }
@@ -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;
/** 是否已发布公示 01是 */
/** 是否已发布公示 NY是 */
private String isPublished;
/** 发布时间 */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
@@ -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; }
@@ -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;
/**
* 邀请邮件发送器.
* <p>参考 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;
}
}
}
@@ -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<BizInvite> 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);
}
@@ -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<BizInviteRecipient> selectByInviteId(Long inviteId);
BizInviteRecipient selectByToken(String token);
int insert(BizInviteRecipient entity);
int insertBatch(List<BizInviteRecipient> 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);
}
@@ -35,6 +35,11 @@ public interface BizMeetingAttendeeMapper {
List<BizMeetingAttendee> selectByMeetingId(Long meetingId);
List<BizMeetingAttendee> selectByUserId(Long userId);
BizMeetingAttendee selectById(Long id);
/**
* 按 (meetingId, userId) 查参会人行 id (导入覆盖更新用: 手机号已在该会议时拿旧行 id).
* 无匹配返回 null.
*/
Long selectIdByMeetingIdAndUserId(@Param("meetingId") Long meetingId, @Param("userId") Long userId);
List<BizMeetingAttendee> selectUnsignedByUserId(Long userId);
/** 当前用户的"待参加"会议 (已邀请参会 is_invited=1), 联表取会议名/时间 */
List<BizMeetingAttendee> selectInvitedByUserId(Long userId);
@@ -48,6 +48,12 @@ public interface BizMeetingMapper
* <p>由 MeetingStageScheduler 每分钟触发.
*/
int markFrozen();
/**
* 自动流转 (反向纠偏): start_time 被改到未来、但 current_stage 仍残留 IN_PROGRESS 的会议 → 置回 NOT_STARTED.
* <p>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 → 永不冻结).
* <p>排除 REJECTED 轨: 退回时截止时间已被重置为 now+天数 (锚点=退回时刻), 按 end_time 重算会错误缩短整改窗口.
* 已冻结/两轨均已提交的会议不动 (其截止时间已无意义).
*/
int recomputeSubmitDeadlinesByProject(@Param("projectId") Long projectId, @Param("days") Integer days);
}
@@ -24,12 +24,13 @@ public interface BizOrgMapper {
* 与 selectSponsorOrgOptions 区别: 不 JOIN sys_user (无主账号的 org 也可选), 返回 orgId + mainUserId
* 返回 Map: orgId / orgName / mainUserId (= biz_org.user_id, 可为 null 表示该企业暂无主账号) */
List<Map<String, Object>> 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<String, Object> 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<String, Object> 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);
@@ -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<BizProjectExecutorAssign> selectByProjectId(String projectId);
List<BizProjectExecutorAssign> 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<BizProjectExecutorAssign> selectByProjectIdAndOrg(@Param("projectId") Long projectId, @Param("executorOrgId") Long executorOrgId);
}
@@ -9,8 +9,9 @@ public interface BizProjectMapper
{
BizProject selectByPrimaryKey(Long projectId);
List<BizProject> selectList(BizProject entity);
/** 公开门户公示列表: is_published='1' 且未删除, 按 publish_time 倒序 (最新在前, 空值排最后) */
List<BizProject> selectPublicAnnouncements();
/** 公开门户公示列表: is_published='Y' 且未删除, 按 publish_time 倒序 (最新在前, 空值排最后).
* 仅查公示页展示的少量标量列, keyword 按项目名模糊过滤, 分页由 controller startPage 注入. */
List<BizProject> selectPublicAnnouncements(@org.apache.ibatis.annotations.Param("keyword") String keyword);
/** sponsor 端专属: projectIds + LEFT JOIN 当前 login 用户评分, 用于评分回显 */
List<BizProject> selectSponsorList(BizProject entity);
/** executor 端专属: JOIN biz_project_assign 过滤, 只返回分给当前 user 的项目 */
@@ -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();
@@ -87,4 +87,26 @@ public class MeetingStageScheduler
log.warn("[MeetingStageScheduler] 冻结异常 (跳过, 下分钟再试)", e);
}
}
/**
* 每分钟: start_time 被改到未来、但 current_stage 仍残留 IN_PROGRESS → 置回 NOT_STARTED.
* <p>{@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);
}
}
}
@@ -10,7 +10,7 @@ import lombok.extern.slf4j.Slf4j;
* 项目"开通"到期回收调度器 (每天 00:05 一次).
* <p>
* 开通状态流转: 默认 N → 经理"开通"置 Y + open_deadline → 到期(open_deadline &lt;= 今天)回收为 N.
* 结题(is_finished=1)且 open_status=N 的项目, sponsor 不可见
* 结题(is_finished=Y)且 open_status=N 的项目, sponsor 不可见
* (见 BizProjectMapper.selectSponsorList / BizMeetingMapper.selectList 的过滤).
* <p>
* 需要启动类 {@code @EnableScheduling} 才会生效 (RuoYiApplication 已有).
@@ -26,11 +26,16 @@ import com.ruoyi.system.service.ISysUserService;
* - type=2 → 项目负责人 → role_type=leader
* <p>
* 登录决策 (手机号 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 "账号角色已更新, 请使用手机验证码登录";
@@ -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<BizInvite> selectList(BizInvite entity);
BizInvite getById(Long id);
List<BizInviteRecipient> 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);
}
@@ -41,6 +41,14 @@ public interface IBizMeetingAttendeeService {
* @throws ServiceException phone 为空 / 用户已在会议中
*/
Long insertByPhoneWithProfile(BizMeetingAttendee body);
/**
* 导入参会人单行 upsert: 手机号已在该会议 → 覆盖更新已有行 (走 updateProfile 修改逻辑);
* 否则新增 (复用 insertByPhoneWithProfile 的查/建号 + 写档案 + 建报名占位).
* 与 {@link #insertByPhoneWithProfile} 的区别: 手机号重复不抛异常, 而是更新.
*/
Long upsertByPhoneWithProfile(BizMeetingAttendee body);
List<BizMeetingAttendee> selectByMeetingId(Long meetingId);
List<BizMeetingAttendee> 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<String, Object> 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<String, Object> uploadExpertPhotos(MultipartFile file, Long meetingId) throws Exception;
String submitExpertPhotoUpload(MultipartFile file, Long meetingId) throws Exception;
}
@@ -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<String> ossUrls, List<String> 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 上传 (同步, 追加式保存).
* <p>
* file 为单个 Excel (xls/xlsx/csv) 或 zip (后台解压并分析其中所有 Excel).
* 逐个解析脱敏 + 直传 OSS; 单行 L_ESIGN_IN 存多文件 (oss_url 存单 URL / JSON 数组,
* masked_json 存 {headers,rows} / {sheets:[...]}). 已有文件保留, 新文件追加到末尾.
* 任一文件解析失败 → 整体失败回滚.
*
* @return { count: 本次新增文件数, total: 合并后总文件数 }
*/
Map<String, Object> uploadEsignIn(MultipartFile file, Long meetingId) throws Exception;
}
@@ -49,4 +49,9 @@ public interface IBizMeetingService
* (参会人变化不影响会务费, 也不触发整体重算, 避免"统计中"等待 60s 调度器).
*/
void recomputeLaborFee(Long meetingId);
/**
* 项目 submit_deadline_days 变更时级联重算: 该项目下「从未退回、且仍有轨未提交」会议的 submit_deadline = end_time + days 天.
* <p>days 为 null → 置 NULL (永不冻结). 已冻结/两轨均已提交的会议不动; REJECTED 轨按退回时刻为锚点, 不在此重算.
*/
void recomputeSubmitDeadlinesByProject(Long projectId, Integer days);
}
@@ -19,8 +19,9 @@ public interface IBizOrgService {
List<Map<String, Object>> selectExecutorOrgOptions(BizOrg entity);
/** 支持方注册下拉选项 (匿名公开, 含无主账号 org): 返回 orgId / orgName / mainUserId */
List<Map<String, Object>> selectSponsorRegisterOptions(BizOrg entity);
/** 当前登录 sponsor 的所属公司 (主账号可改, 子账号只读) */
Map<String, Object> selectMySponsorCompany(Long userId);
/** 当前登录账号的所属公司 (sponsor + executor 共用, 只读展示)
* 返回 Map: orgId / orgName / orgType / isOwner (1=主账号, 0=子账号) */
Map<String, Object> selectMyCompany(Long userId);
/** user_id → org_id 单一可信源反查 (MAIN 走 biz_org, SUB 走 biz_person), 找不到返回 null */
Long selectOrgIdByUserId(Long userId);
@@ -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<BizPerson> selectList(BizPerson entity);
/** sponsor 专属: 走 BizPersonMapper.selectSponsorList, 用 sys_user.parent_user_id 做归属过滤 */
List<BizPerson> selectSponsorList(BizPerson entity);
@@ -8,6 +8,6 @@ public interface IBizProjectExecutorAssignService {
int insertAssign(BizProjectExecutorAssign entity);
/** 执行方多条分配 (一个项目 ↔ N 执行人: 先按 project_id 删, 再逐个 insert, 不会循环 delete) */
int assignStaffForProject(BizProjectExecutorAssign body, List<Long> staffUserIds);
List<BizProjectExecutorAssign> listByProjectId(String projectId);
int deleteByProjectId(String projectId);
List<BizProjectExecutorAssign> listByProjectId(Long projectId, Long executorOrgId);
int deleteByProjectId(Long projectId);
}
@@ -10,8 +10,8 @@ public interface IBizProjectService
{
BizProject getById(Long projectId);
List<BizProject> selectList(BizProject entity);
/** 公开门户公示列表: 已发布, 按发布时间倒序 */
List<BizProject> selectPublicAnnouncements();
/** 公开门户公示列表: 已发布, 按发布时间倒序, keyword 按项目名模糊过滤 (分页由 controller startPage 注入) */
List<BizProject> selectPublicAnnouncements(String keyword);
/** sponsor 端专属: LEFT JOIN 当前 login 用户评分回显 */
List<BizProject> selectSponsorList(BizProject entity);
/** executor 端专属: JOIN biz_project_assign 过滤, 只返回分给当前 user 的项目 */
@@ -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 实现.
* <p>独立于项目/会议: 项目编号等均为手输文本, 短信/邮件带公开邀请链接 (富文本内容 + 底部确认按钮).
*/
@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<BizInvite> selectList(BizInvite entity)
{
return inviteMapper.selectList(entity);
}
@Override
public BizInvite getById(Long id)
{
return inviteMapper.selectByPrimaryKey(id);
}
@Override
public List<BizInviteRecipient> 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<BizInviteRecipient> 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<BizInviteRecipient> 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<String> 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 "<div style='font-size:14px;color:#333;'>"
+ "<p>" + name + " 您好:</p>"
+ "<p>诚邀您参加相关项目,请点击以下链接查看邀请内容并确认:</p>"
+ "<p><a href='" + link + "'>" + link + "</a></p>"
+ "</div>";
}
@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);
}
}
@@ -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<String>)
List<SysUser> 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<Long> 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<SysUser> 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).
*
* <p>复用 {@link #insertByPhoneWithProfile} 做单行处理, 让"按手机号查/建 sys_user + 写 attendee"
* 逻辑和单条新增一致. 单行失败只计入 ngList, 不中断其它行.
* <p>复用 {@link #upsertByPhoneWithProfile} 做单行处理: 手机号已在该会议 → 覆盖更新已有行,
* 否则按手机号查/建 sys_user + 写 attendee. 单行失败只计入 ngList, 不中断其它行.
*
* <p>注意: 本方法只插数据, 不发 #5 通知. controller 在 import 前快照 userIds, import 后 diff,
* <p>注意: 本方法只落库(新增或覆盖更新), 不发 #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<String, Object> 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<String, Object> 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<String, Object> doUploadAgreements(byte[] zipBytes, Long meetingId, String jobId, String username) throws Exception {
List<BizMeetingAttendee> list = selectByMeetingId(meetingId);
if (list.isEmpty()) throw new ServiceException("该会议暂无参会人, 无法回填劳务协议");
LinkedHashMap<String, ZipFolder> folders = parseAttendeeZip(file.getBytes());
LinkedHashMap<String, ZipFolder> folders = parseAttendeeZip(zipBytes);
uploadProgressRegistry.setTotal(jobId, folders.size());
int updated = 0;
int processed = 0;
List<Map<String, Object>> results = new ArrayList<>();
for (ZipFolder f : folders.values()) {
processed++;
MatchResult m = matchAttendee(list, f);
Map<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> doUploadExpertPhotos(byte[] zipBytes, Long meetingId, String jobId, String username) throws Exception {
List<BizMeetingAttendee> list = selectByMeetingId(meetingId);
if (list.isEmpty()) throw new ServiceException("该会议暂无参会人, 无法回填专家照片");
LinkedHashMap<String, ZipFolder> folders = parseAttendeeZip(file.getBytes());
LinkedHashMap<String, ZipFolder> folders = parseAttendeeZip(zipBytes);
uploadProgressRegistry.setTotal(jobId, folders.size());
int updated = 0;
int processed = 0;
List<Map<String, Object>> results = new ArrayList<>();
for (ZipFolder f : folders.values()) {
processed++;
MatchResult m = matchAttendee(list, f);
Map<String, Object> 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);
@@ -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<String> 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<String> 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<String> 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<String, String> 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<String> ossUrls, List<String> 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<String> 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<BizMeetingMaterial> 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<String> 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<String, List<byte[]>> folderBytes = new HashMap<>();
Map<String, List<String>> 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<String, List<byte[]>> e : folderBytes.entrySet()) {
String subType = e.getKey();
List<byte[]> files = e.getValue();
if (files == null || files.isEmpty()) continue;
processed++;
List<String> 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<String, String> 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 返回.
* <p>
* 单文件: 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<String> 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<Map<String, Object>> sheets = new ArrayList<>();
for (String u : urls) {
byte[] bytes = downloadBytes(u);
Map<String, Object> 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<String, Object> parseSingleSigninSheet(byte[] bytes, String ext) {
List<List<String>> 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<String> headers = matrix.get(0);
// 敏感列 index → 脱敏类型
Map<Integer, String> 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<List<String>> rows = new ArrayList<>();
for (int i = 1; i < matrix.size(); i++) {
List<String> row = matrix.get(i);
boolean allEmpty = true;
for (String cell : row) {
if (cell != null && !cell.trim().isEmpty()) { allEmpty = false; break; }
}
if (allEmpty) continue;
List<String> masked = new ArrayList<>(row);
for (Map.Entry<Integer, String> 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<String, Object> result = new HashMap<>();
result.put("headers", headers);
result.put("rows", rows);
return result;
}
/** JSON 数组字符串 (前端 multiple 上传) → OSS URL 列表 */
private List<String> parseUrlArray(String jsonUrls) {
List<String> 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<Map<String, Object>> sheetsFromMaskedJson(String maskedJson) {
List<Map<String, Object>> 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 上传 (同步, 追加式保存).
* <p>
* 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<String, Object> 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<FileBytes> 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<Map<String, Object>> sheets = new ArrayList<>();
for (FileBytes fb : files) {
if (fb.bytes.length > ESIGN_MAX_FILE_SIZE) throw new ServiceException("文件「" + fb.name + "」超过 20MB 限制");
Map<String, Object> parsed = parseSingleSigninSheet(fb.bytes, detectExt(fb.name, null));
parsed.put("name", fb.name);
sheets.add(parsed);
}
// 全部解析通过后逐个直传 OSS
String subDir = "ry8080/meeting/" + meetingId + "/labor";
List<String> 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<BizMeetingMaterial> existing = bizMeetingMaterialMapper.selectByMeetingId(meetingId);
if (existing != null) {
for (BizMeetingMaterial m : existing) {
if ("L_ESIGN_IN".equals(m.getSubType())) { oldEsign = m; break; }
}
}
List<String> mergedUrls = new ArrayList<>();
List<Map<String, Object>> 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<String, Object> 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<FileBytes> unzipExcelFiles(byte[] zipBytes) {
List<FileBytes> 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<String, String> 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<List<String>> readExcelMatrix(InputStream is) throws Exception {
try (Workbook wb = WorkbookFactory.create(is)) {
Sheet sheet = wb.getSheetAt(0);
List<List<String>> matrix = new ArrayList<>();
int lastRow = sheet.getLastRowNum();
for (int r = 0; r <= lastRow; r++) {
Row row = sheet.getRow(r);
if (row == null) continue;
List<String> 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<List<String>> 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<List<String>> matrix = new ArrayList<>();
for (String line : text.split("\\r?\\n")) {
if (line.trim().isEmpty()) continue;
matrix.add(parseCsvLine(line));
}
return matrix;
}
/** 单行 CSV 解析 (逗号分隔, 支持双引号包裹与转义) */
private List<String> parseCsvLine(String line) {
List<String> 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();
}
}
@@ -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);
}
}
@@ -92,8 +92,8 @@ public class BizOrgServiceImpl implements IBizOrgService {
}
@Override
public Map<String, Object> selectMySponsorCompany(Long userId) {
return bizOrgMapper.selectMySponsorCompany(userId);
public Map<String, Object> 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
@@ -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<BizPerson> selectList(BizPerson entity)
{ return bizPersonMapper.selectList(entity); }
@@ -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<Long> 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<BizProjectExecutorAssign> listByProjectId(String projectId) {
return mapper.selectByProjectId(projectId);
public List<BizProjectExecutorAssign> 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);
}
}
@@ -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<BizProject> selectList(BizProject entity)
{ return bizProjectMapper.selectList(entity); }
@Override
public List<BizProject> selectPublicAnnouncements()
{ return bizProjectMapper.selectPublicAnnouncements(); }
public List<BizProject> selectPublicAnnouncements(String keyword)
{ return bizProjectMapper.selectPublicAnnouncements(keyword); }
@Override
public List<BizProject> selectSponsorList(BizProject entity)
{ return bizProjectMapper.selectSponsorList(entity); }
@@ -55,10 +56,10 @@ public class BizProjectServiceImpl implements IBizProjectService
public List<BizProject> 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
@@ -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);
}
}
@@ -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<String, String> 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
*/
@@ -23,7 +23,12 @@ import lombok.extern.slf4j.Slf4j;
* 2. biz_org — 企业档案 (org_type=executor)
* 3. biz_person — 联系人档案 (unit_type=executor, is_synced=1)
* <p>
* 状态同步: 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);
}
@@ -0,0 +1,101 @@
package com.ruoyi.business.upload;
import java.util.concurrent.ConcurrentHashMap;
import org.springframework.stereotype.Component;
/**
* 批量上传 (zip 解压 + 逐文件上传 OSS) 的实时进度登记表.
* <p>
* 前端 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<String, Progress> 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);
}
}
@@ -36,7 +36,7 @@
<select id="selectByType" parameterType="String" resultMap="BaseResultMap">
<include refid="selectFields"/>
where type = #{type} and status = '0'
where type = #{type} and status = 'Y'
limit 1
</select>
@@ -34,7 +34,7 @@
<select id="selectActive" resultMap="BizDepartmentResult">
<include refid="selectFields"/>
where d.status = '0'
where d.status = 'Y'
order by d.sort asc, d.dept_id asc
</select>
@@ -0,0 +1,102 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ruoyi.business.mapper.BizInviteMapper">
<resultMap type="BizInvite" id="BizInviteResult">
<id property="id" column="id" />
<result property="projectNo" column="project_no" />
<result property="meetingName" column="meeting_name" />
<result property="periodNo" column="period_no" />
<result property="meetingTime" column="meeting_time" />
<result property="sendTime" column="send_time" />
<result property="content" column="content" />
<result property="redheadFileUrl" column="redhead_file_url" />
<result property="sendChannel" column="send_channel" />
<result property="recipientCount" column="recipient_count" />
<result property="confirmedCount" column="confirmed_count" />
<result property="createBy" column="create_by" />
<result property="createTime" column="create_time" />
</resultMap>
<!-- 列表字段: 不含富文本 content (轻量分页), 附聚合列 -->
<sql id="selectListFields">
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
</sql>
<select id="selectByPrimaryKey" resultMap="BizInviteResult" parameterType="Long">
select i.id,
i.project_no,
i.meeting_name,
i.period_no,
i.meeting_time,
i.send_time,
i.content,
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
where i.id = #{id}
</select>
<select id="selectList" resultMap="BizInviteResult" parameterType="BizInvite">
<include refid="selectListFields"/>
<where>
<if test="projectNo != null and projectNo != ''">and i.project_no like concat('%', #{projectNo}, '%')</if>
<if test="meetingName != null and meetingName != ''">and i.meeting_name like concat('%', #{meetingName}, '%')</if>
<if test="createBy != null and createBy != ''">and i.create_by = #{createBy}</if>
</where>
order by i.id desc
</select>
<insert id="insert" parameterType="BizInvite">
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())
</insert>
<update id="updateByPrimaryKey" parameterType="BizInvite">
update biz_invite
<set>
<if test="projectNo != null">project_no = #{projectNo},</if>
<if test="meetingName != null">meeting_name = #{meetingName},</if>
<if test="periodNo != null">period_no = #{periodNo},</if>
<if test="meetingTime != null">meeting_time = #{meetingTime},</if>
<if test="content != null">content = #{content},</if>
<if test="redheadFileUrl != null">redhead_file_url = #{redheadFileUrl},</if>
<if test="sendChannel != null">send_channel = #{sendChannel},</if>
update_by = #{updateBy},
update_time = sysdate()
</set>
where id = #{id}
</update>
<update id="updateSendTime">
update biz_invite set send_time = #{sendTime} where id = #{id}
</update>
<delete id="deleteByPrimaryKey" parameterType="Long">
delete from biz_invite where id = #{id}
</delete>
<delete id="deleteByPrimaryKeys" parameterType="Long">
delete from biz_invite where id in
<foreach collection="array" item="id" open="(" separator="," close=")">
#{id}
</foreach>
</delete>
</mapper>
@@ -0,0 +1,69 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ruoyi.business.mapper.BizInviteRecipientMapper">
<resultMap type="BizInviteRecipient" id="BizInviteRecipientResult">
<id property="id" column="id" />
<result property="inviteId" column="invite_id" />
<result property="name" column="name" />
<result property="phone" column="phone" />
<result property="email" column="email" />
<result property="source" column="source" />
<result property="remark" column="remark" />
<result property="sendStatus" column="send_status" />
<result property="failReason" column="fail_reason" />
<result property="confirmStatus" column="confirm_status" />
<result property="confirmedAt" column="confirmed_at" />
<result property="token" column="token" />
<result property="createTime" column="create_time" />
</resultMap>
<sql id="selectFields">
select id, invite_id, name, phone, email, source, remark,
send_status, fail_reason, confirm_status, confirmed_at, token, create_time
from biz_invite_recipient
</sql>
<select id="selectByInviteId" resultMap="BizInviteRecipientResult" parameterType="Long">
<include refid="selectFields"/>
where invite_id = #{inviteId}
order by id asc
</select>
<select id="selectByToken" resultMap="BizInviteRecipientResult" parameterType="String">
<include refid="selectFields"/>
where token = #{token}
</select>
<insert id="insert" parameterType="BizInviteRecipient">
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>
<insert id="insertBatch">
insert into biz_invite_recipient(id, invite_id, name, phone, email, source, remark,
send_status, confirm_status, token, create_by, create_time)
values
<foreach collection="list" item="r" separator=",">
(#{r.id}, #{r.inviteId}, #{r.name}, #{r.phone}, #{r.email}, #{r.source}, #{r.remark},
#{r.sendStatus}, #{r.confirmStatus}, #{r.token}, #{r.createBy}, sysdate())
</foreach>
</insert>
<delete id="deleteByInviteId" parameterType="Long">
delete from biz_invite_recipient where invite_id = #{inviteId}
</delete>
<update id="updateConfirmByToken" parameterType="String">
update biz_invite_recipient
set confirm_status = '1', confirmed_at = sysdate()
where token = #{token} and (confirm_status is null or confirm_status != '1')
</update>
<update id="updateSendStatus">
update biz_invite_recipient
set send_status = #{sendStatus}, fail_reason = #{failReason}
where id = #{id}
</update>
</mapper>
@@ -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
</select>
<!-- 导入覆盖更新用: 按 (meeting_id, user_id) 拿已存在行的 id (无匹配返回 null) -->
<select id="selectIdByMeetingIdAndUserId" resultType="java.lang.Long">
select id from biz_meeting_attendee
where meeting_id = #{meetingId} and user_id = #{userId} and is_deleted = 0
limit 1
</select>
<!--
当前用户的"待签署协议"列表: 已推送电子签 (is_esigned=1) 且 任一未签 (handsign 或 labor_protocol 为空)
当前用户的"待签署协议"列表: 本人 labor_protocol 为空 (与 Meetings.signedStatus=unsigned 口径一致)
is_esigned 字段仍 select 出来, 前端根据 isEsigned 显示 "待签署" / "未推送" 区分可签状态
INNER JOIN biz_meeting 取会议名/时间/项目名, 用于 /doctor/home 工作台
字段别名 + resultMap 上面的 transient property 接收
两表都需 is_deleted=0 过滤: 删除会议后, 参会人的待签署列表也不显示
@@ -194,8 +201,7 @@
inner join biz_meeting m on m.meeting_id = a.meeting_id
where a.user_id = #{userId}
and a.is_deleted = 0 and m.is_deleted = 0
and a.is_esigned = 1
and (a.handsign is null or a.handsign = '' or a.labor_protocol is null or a.labor_protocol = '')
and (a.labor_protocol is null or a.labor_protocol = '')
order by m.start_time asc
</select>
<!--
@@ -95,6 +95,20 @@
<if test="startTime != null">and start_time &gt;= #{startTime}</if>
<if test="endTime != null">and end_time &lt;= #{endTime}</if>
<if test="userId != null">and exists (select 1 from biz_meeting_attendee a where a.meeting_id = biz_meeting.meeting_id and a.user_id = #{userId} and a.is_deleted = 0)</if>
<!--
签署状态筛选 (doctor Home "更多" 跳转过来带 ?signedStatus=unsigned):
- unsigned: 至少有一条本人参会记录 labor_protocol 为空 (待签署)
- signed : 全部本人参会记录 labor_protocol 都非空 (已签署)
- 不传 / 其他值: 不过滤 (全部, 含本人参会记录但都未签劳务的会议)
配合 userId 限定本人参会范围
-->
<if test="userId != null and params.signedStatus == 'unsigned'">
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 = ''))
</if>
<if test="userId != null and params.signedStatus == 'signed'">
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 != '')
</if>
<if test="params.sponsorAdminUserId != null">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)</if>
<if test="params.monitorUserId != null">and project_id in (select distinct project_id from biz_project_sponsor_assign where monitor_user_id = #{params.monitorUserId} and is_deleted = 0)</if>
<if test="params.sponsorAdminUserId != null or params.monitorUserId != null">
@@ -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'
)
</if>
<if test="params.executorUserId != null">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')
)</if>
<!-- 执行方会议可见性: 按会议级 execution_unit_id 隔离 (多执行方分摊项目时, 各执行方只看到自己 execution_unit_id 的会议, 不能按 project_id 全量返回) -->
<if test="params.executorUserId != null">and execution_unit_id = (select org_id from biz_org where user_id = #{params.executorUserId} and org_type = 'executor')</if>
<if test="params.executorCreatorUsername != null and params.executorCreatorUsername != ''">and create_by = #{params.executorCreatorUsername}</if>
<if test="params.managerCreateUserId != null">and project_id in (
select project_id from biz_project where create_user_id = #{params.managerCreateUserId} and is_deleted = 0
@@ -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'
)
</if>
<if test="params.leaderUserId != null">and project_id in (select project_id from biz_project where lead_user_id = #{params.leaderUserId} and is_deleted = 0)</if>
@@ -331,6 +342,18 @@
and submit_deadline is not null
and submit_deadline &lt;= NOW()
</update>
<!-- 自动流转 (反向纠偏): start_time 被改到未来、current_stage 却残留 IN_PROGRESS → 置回 NOT_STARTED (markInProgress 的逆操作).
markInProgress 是单向 NOT_STARTED→IN_PROGRESS, 编辑把 start_time 推到未来时无人回退, 由这里兜底自愈. -->
<update id="markNotStarted">
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 &gt; NOW()
</update>
<!-- 费用汇总调度器: 查 fee_calc_status=0 且未软删的会议 id -->
<select id="selectPendingFeeCalcIds" resultType="Long">
select meeting_id from biz_meeting where fee_calc_status = 0 and is_deleted = 0
@@ -355,4 +378,22 @@
total_fee = #{totalFee}
where meeting_id = #{meetingId}
</update>
<!-- 项目 submit_deadline_days 变更级联重算: 只动「从未退回、且仍有轨未提交」的会议 (未软删/未冻结).
days 为 null → 置 NULL (永不冻结); 否则 end_time + days 天.
排除 REJECTED 轨: 退回时 submit_deadline 已被重置为 now+天数 (锚点=退回时刻), 不能按 end_time 重算, 否则错误缩短整改窗口. -->
<update id="recomputeSubmitDeadlinesByProject">
update biz_meeting
set submit_deadline =
<choose>
<when test="days != null">DATE_ADD(end_time, INTERVAL #{days} DAY)</when>
<otherwise>NULL</otherwise>
</choose>
where is_deleted = 0
and is_frozen = 0
and project_id = #{projectId}
and labor_audit_stage != 'REJECTED'
and service_audit_stage != 'REJECTED'
and (labor_audit_stage = 'NOT_SUBMITTED'
or service_audit_stage = 'NOT_SUBMITTED')
</update>
</mapper>
@@ -10,6 +10,7 @@
<result property="fileName" column="file_name" />
<result property="ossUrl" column="oss_url" />
<result property="extraOssUrl" column="extra_oss_url" />
<result property="maskedJson" column="masked_json" />
<result property="amount" column="amount" />
<result property="creatorId" column="creator_id" />
<result property="createTime" column="create_time" />
@@ -18,7 +19,7 @@
</resultMap>
<sql id="selectFields">
select id, meeting_id, material_type, sub_type, file_name, oss_url, extra_oss_url, amount, creator_id, create_time, is_deleted, fee_status
select id, meeting_id, material_type, sub_type, file_name, oss_url, extra_oss_url, masked_json, amount, creator_id, create_time, is_deleted, fee_status
from biz_meeting_material
</sql>
@@ -48,9 +49,11 @@
<if test="fileName != null and fileName != ''">file_name,</if>
<if test="ossUrl != null and ossUrl != ''">oss_url,</if>
<if test="extraOssUrl != null and extraOssUrl != ''">extra_oss_url,</if>
<if test="maskedJson != null and maskedJson != ''">masked_json,</if>
<if test="amount != null">amount,</if>
<if test="creatorId != null">creator_id,</if>
<if test="createTime != null">create_time,</if>
<if test="feeStatus != null">fee_status,</if>
</trim>
<trim prefix="values (" suffix=")" suffixOverrides=",">
<if test="meetingId != null">#{meetingId},</if>
@@ -59,18 +62,20 @@
<if test="fileName != null and fileName != ''">#{fileName},</if>
<if test="ossUrl != null and ossUrl != ''">#{ossUrl},</if>
<if test="extraOssUrl != null and extraOssUrl != ''">#{extraOssUrl},</if>
<if test="maskedJson != null and maskedJson != ''">#{maskedJson},</if>
<if test="amount != null">#{amount},</if>
<if test="creatorId != null">#{creatorId},</if>
<if test="createTime != null">#{createTime},</if>
<if test="feeStatus != null">#{feeStatus},</if>
</trim>
</insert>
<insert id="insertBatch" parameterType="java.util.List">
insert into biz_meeting_material (meeting_id, material_type, sub_type, file_name, oss_url, extra_oss_url, amount, creator_id, create_time, fee_status)
insert into biz_meeting_material (meeting_id, material_type, sub_type, file_name, oss_url, extra_oss_url, masked_json, amount, creator_id, create_time, fee_status)
values
<foreach collection="list" item="item" separator=",">
(#{item.meetingId}, #{item.materialType}, #{item.subType}, #{item.fileName}, #{item.ossUrl},
#{item.extraOssUrl}, #{item.amount}, #{item.creatorId}, #{item.createTime}, #{item.feeStatus})
#{item.extraOssUrl}, #{item.maskedJson}, #{item.amount}, #{item.creatorId}, #{item.createTime}, #{item.feeStatus})
</foreach>
</insert>
@@ -188,19 +188,22 @@
</select>
<!--
当前登录 sponsor 的所属公司 (供 /sponsor/account 页面回显 + 主账号改名)
主账号 (sys_user.parent_user_id IS NULL): 用 own (biz_org.user_id = #{userId})
账号 (biz_person.user_id = #{userId}): 用 biz_person.org_id → biz_org
当前登录账号的所属公司 (供 /sponsor/account + /executor/account 页面回显, 只读)
通用: sponsor 与 executor 共用, 通过 sys_user.role_type 区分 (sponsor/executor)
账号 (biz_org.user_id = #{userId}): 用 own 拿 org
子账号 (biz_person.user_id = #{userId}): 用 biz_person.org_id → biz_org
COALESCE(own.org_id, p.org_id) 二选一, isOwner 标识主账号
返回 Map: orgId / orgName / isOwner (1=主账号, 0=子账号)
返回 Map: orgId / orgName / orgType / isOwner (1=主账号, 0=子账号)
注: 2026-09 起两角色账号信息页都只读展示, 主账号也不改 org_name
-->
<select id="selectMySponsorCompany" parameterType="Long" resultType="java.util.LinkedHashMap">
select o.org_id as orgId,
o.org_name as orgName,
<select id="selectMyCompany" parameterType="Long" resultType="java.util.LinkedHashMap">
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'
@@ -24,12 +24,18 @@
</insert>
<!-- 按 project_id 全删 (执行方分配策略: 先删后插) -->
<delete id="deleteByProjectId" parameterType="String">
<delete id="deleteByProjectId" parameterType="Long">
delete from biz_project_executor_assign where project_id = #{projectId}
</delete>
<!-- 软删除: 项目级联删除时按 project_id (String) 置 is_deleted=1 -->
<update id="softDeleteByProjectId" parameterType="String">
<!--(project_id + executor_org_id) 物理删除本执行方的执行人分配 (替代按 project 全删, 避免多执行方互相覆盖) -->
<delete id="deleteByProjectIdAndOrg">
delete from biz_project_executor_assign
where project_id = #{projectId} and executor_org_id = #{executorOrgId}
</delete>
<!-- 软删除: 项目级联删除时按 project_id 置 is_deleted=1 -->
<update id="softDeleteByProjectId" parameterType="Long">
update biz_project_executor_assign set is_deleted = 1 where project_id = #{projectId}
</update>
@@ -46,4 +52,21 @@
WHERE a.project_id = #{projectId} and a.is_deleted = 0
ORDER BY a.create_time DESC
</select>
<!-- 按 (project_id + executor_org_id) 查本执行方的执行人分配 (回显隔离, 避免串读别家执行人) -->
<select id="selectByProjectIdAndOrg" resultMap="BaseResultMap">
SELECT a.*,
COALESCE(NULLIF(ep.name, ''), e.nick_name) AS executor_user_name,
COALESCE(NULLIF(sp.name, ''), s.nick_name) AS staff_user_name
FROM biz_project_executor_assign a
LEFT JOIN biz_org o ON o.org_id = a.executor_org_id
LEFT JOIN sys_user e ON e.user_id = o.user_id
LEFT JOIN biz_person ep ON ep.user_id = o.user_id
LEFT JOIN sys_user s ON a.staff_user_id = s.user_id
LEFT JOIN biz_person sp ON sp.user_id = a.staff_user_id
WHERE a.project_id = #{projectId}
and a.executor_org_id = #{executorOrgId}
and a.is_deleted = 0
ORDER BY a.create_time DESC
</select>
</mapper>
@@ -52,7 +52,7 @@
</resultMap>
<sql id="selectFields">
select p.project_id, p.project_no, p.project_name, p.total_sessions, p.total_amount,
<!-- 已执行会议数: stage 走过 NOT_STARTED/IN_PROGRESS 就算 (含 FROZEN 已结算异常态) -->
<!-- 已执行会议数: 排除 NOT_STARTED(未执行)/IN_PROGRESS(执行中), 其余含 FROZEN(冻结) 都算已执行 -->
(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 @@
<include refid="selectFieldsForSponsor"/>
<where>
p.is_deleted = 0
<!-- 结题(is_finished=1)且未开通(open_status=N)的项目, sponsor 不可见 -->
and not (p.is_finished = '1' and p.open_status = 'N')
<!-- 结题(is_finished=Y)且未开通(open_status=N)的项目, sponsor 不可见 -->
and not (p.is_finished = 'Y' and p.open_status = 'N')
<if test="params.projectIds != null and params.projectIds.size() > 0">
and p.project_id in
<foreach collection="params.projectIds" item="id" open="(" separator="," close=")">
@@ -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,
<!-- 执行方级已执行: 同上 execution_unit_id 隔离, FROZEN 算已执行 -->
<!-- 执行方级已执行: 排除 NOT_STARTED/IN_PROGRESS (含 FROZEN) -->
(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,
<!-- 执行人视角: 仍按主账号 org_id 聚合 (执行人看公司数据不是个人), FROZEN 算已执行 -->
<!-- 执行人视角: 仍按主账号 org_id 聚合 (执行人看公司数据不是个人), FROZEN 算已执行 -->
(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
</select>
<!-- 公开门户公示列表: is_published='1' 且未删除, 按 publish_time 倒序 (最新发布在前, publish_time 为空排最后) -->
<!-- 公开门户公示列表: 只查公示页实际展示的少量标量列 (project_id/project_no/project_name/publish_time + 4 个公告 URL).
不 join、不跑 done/todo/金额/exec_org_names 等关联子查询 —— 原 selectFields 有 6 个 biz_meeting 关联子查询 + 6 个 LEFT JOIN,
公示页一条都用不到却每条都算, 数据量大时 O(N*子查询) 极慢. 分页由 controller startPage (PageHelper) 注入 LIMIT. -->
<select id="selectPublicAnnouncements" resultMap="BizProjectResult">
<include refid="selectFields"/>
where p.is_deleted = 0 and p.is_published = '1'
order by p.publish_time desc, p.project_id desc
select project_id, project_no, project_name, publish_time,
invitation_url, support_letter_url, publish_url, schedule_url
from biz_project
where is_deleted = 0 and is_published = 'Y'
<if test="keyword != null and keyword != ''">
and project_name like concat('%', #{keyword}, '%')
</if>
order by publish_time desc, project_id desc
</select>
<insert id="insert" parameterType="BizProject">
insert into biz_project
<trim prefix="(" suffix=")" suffixOverrides=",">
<if test="projectId != null and projectId != ''">project_id,</if>
<if test="projectId != null">project_id,</if>
<if test="projectNo != null and projectNo != ''">project_no,</if>
<if test="projectName != null and projectName != ''">project_name,</if>
<if test="totalSessions != null">total_sessions,</if>
@@ -450,7 +457,7 @@
<if test="openStatus != null and openStatus != ''">open_status,</if>
</trim>
<trim prefix="values (" suffix=")" suffixOverrides=",">
<if test="projectId != null and projectId != ''">#{projectId},</if>
<if test="projectId != null">#{projectId},</if>
<if test="projectNo != null and projectNo != ''">#{projectNo},</if>
<if test="projectName != null and projectName != ''">#{projectName},</if>
<if test="totalSessions != null">#{totalSessions},</if>
@@ -530,14 +537,14 @@
</trim>
where project_id = #{projectId}
</update>
<!-- 删除公告: 将 4 个公示 URL 字段置 NULL 并 is_published='0' (取消发布) -->
<!-- 删除公告: 将 4 个公示 URL 字段置 NULL 并 is_published='N' (取消发布) -->
<update id="clearAnnouncement" parameterType="Long">
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}
</update>
<!-- 开通到期回收: 每天 00:05 由 OpenStatusScheduler 调用, 到期(open_deadline <= 今天)的 Y 置回 N -->
@@ -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
@@ -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) {
// 字段可能叫别的, 跳过
@@ -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()
@@ -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("系统繁忙,请稍后再试或联系管理员");
}
/**
@@ -101,19 +101,19 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
-->
<select id="selectUserExtendList" parameterType="SysUserExtendVo" resultMap="SysUserExtendResult">
select u.user_id, u.dept_id, u.nick_name, u.user_name, u.email, u.avatar, u.phonenumber, u.sex, u.status, u.del_flag, u.login_ip, u.login_date, u.create_by, u.create_time, u.update_by, u.update_time, u.remark, d.dept_name, d.leader,
u.role_type as u_role_type,
u.role_type as u_role_type, u.account_type,
coalesce(
(select o.org_name from biz_org o where o.user_id = u.user_id limit 1),
(select o.org_name from biz_org o join biz_person p on p.org_id = o.org_id where p.user_id = u.user_id limit 1)
(select o.org_name from biz_org o where o.user_id = u.user_id and o.del_flag = '0' limit 1),
(select o.org_name from biz_org o join biz_person p on p.org_id = o.org_id where p.user_id = u.user_id and o.del_flag = '0' limit 1)
) as org_name
from sys_user u
left join sys_dept d on u.dept_id = d.dept_id
where u.del_flag = '0'
<!-- 支持方/执行: 单位不存在的过滤掉 (单位被硬删后 org_name 为 NULL, 孤儿账号不展示) -->
<!-- 支持方/执行: 单位不存在或已被软删除(del_flag='1')的过滤掉 (孤儿账号不展示) -->
AND (
coalesce(u.role_type, '') not in ('sponsor', 'executor')
OR exists (select 1 from biz_org o where o.user_id = u.user_id)
OR exists (select 1 from biz_person p join biz_org o2 on o2.org_id = p.org_id where p.user_id = u.user_id)
OR exists (select 1 from biz_org o where o.user_id = u.user_id and o.del_flag = '0')
OR exists (select 1 from biz_person p join biz_org o2 on o2.org_id = p.org_id where p.user_id = u.user_id and o2.del_flag = '0')
)
<if test="userId != null and userId != 0">
AND u.user_id = #{userId}