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:
@@ -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";
|
||||
}
|
||||
}
|
||||
+50
-7
@@ -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)
|
||||
{
|
||||
|
||||
+4
-2
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user