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