批量推送代码

This commit is contained in:
郭庆泰
2026-08-26 23:14:46 +08:00
parent 18f7e93d5b
commit 36528b6e0c
36 changed files with 933 additions and 779 deletions
@@ -48,6 +48,17 @@ public class BizExpertController extends BaseController
{
return success(bizExpertService.getByUserId(SecurityUtils.getUserId()));
}
/**
* 按手机号查专家 (参会人 dialog 手机号放大镜回填用): 命中返回专家档案, 未命中返回 null
*/
@GetMapping("/byPhone/{phone}")
public AjaxResult getByPhone(@PathVariable String phone)
{
BizExpert q = new BizExpert();
q.setPhone(phone);
List<BizExpert> list = bizExpertService.selectList(q);
return success(list != null && !list.isEmpty() ? list.get(0) : null);
}
/**
* admin 创建专家: 同时创建 sys_user (用户名=手机号, 密码=手机号)
* 返回 SysUser (含明文 password 给前端 toast 用)
@@ -259,6 +259,9 @@ public class BizProjectController extends BaseController
@PostMapping("/{projectId}/assigns")
public AjaxResult saveAssigns(@PathVariable("projectId") Long projectId, @RequestBody List<BizProjectAssign> assigns)
{
// 已结题项目不能再分配
BizProject project = requireAssignableProject(projectId);
if (assigns == null) assigns = new ArrayList<>();
bizProjectAssignService.validateSum(projectId, assigns);
// #3 通知去重: 拉旧数据按 executionUnitId 索引, 同 (executionUnitId, sessions, amount) → 无变化 → 跳过
@@ -270,8 +273,7 @@ public class BizProjectController extends BaseController
}
bizProjectAssignService.deleteByProjectId(projectId);
// #3 通知: 查一次项目名, 避免循环里重复查 DB
BizProject project = bizProjectService.getById(projectId);
String projectName = project != null ? project.getProjectName() : null;
String projectName = project.getProjectName();
for (BizProjectAssign a : assigns) {
a.setProjectId(projectId);
if (a.getStatus() == null) a.setStatus("0");
@@ -331,6 +333,15 @@ public class BizProjectController extends BaseController
try { return Long.parseLong(s); } catch (NumberFormatException e) { return null; }
}
/** 校验项目存在且未结题 — 已结题项目禁止一切分配操作, 返回项目供取项目名. */
private BizProject requireAssignableProject(Long projectId) {
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("已结题项目不能分配");
return p;
}
@Log(title = "项目执行方分配", businessType = BusinessType.DELETE)
@DeleteMapping("/{projectId}/assigns")
public AjaxResult clearAssigns(@PathVariable("projectId") Long projectId)
@@ -406,6 +417,8 @@ public class BizProjectController extends BaseController
if (body.getProjectId() == null) {
return error("projectId 必填");
}
// 已结题项目不能分配
BizProject project = requireAssignableProject(parseProjectId(body.getProjectId()));
java.util.List<Long> mids = body.getMonitorUserIds();
if (mids == null || mids.isEmpty()) {
// 向后兼容: 单值 monitorUserId
@@ -430,8 +443,7 @@ public class BizProjectController extends BaseController
// 通知被分配的监察员 (新增 / 说明或积分变化才发). projectId 在 sponsor_assign 是 String, 转 Long 查主表
Long projectIdLong = parseProjectId(body.getProjectId());
BizProject project = projectIdLong != null ? bizProjectService.getById(projectIdLong) : null;
String projectName = project != null ? project.getProjectName() : null;
String projectName = project.getProjectName();
for (Long mid : mids) {
if (mid == null) continue;
BizProjectSponsorAssign old = oldByMonitor.get(mid);
@@ -467,6 +479,8 @@ public class BizProjectController extends BaseController
if (body.getProjectId() == null) {
return error("projectId 必填");
}
// 已结题项目不能分配
requireAssignableProject(parseProjectId(body.getProjectId()));
java.util.List<Long> sids = body.getStaffUserIds();
if (sids == null || sids.isEmpty()) {
// 向后兼容: 单值 staffUserId
@@ -532,6 +546,8 @@ public class BizProjectController extends BaseController
if (body.getProjectId() == null || body.getMonitorUserId() == null) {
throw new IllegalArgumentException("projectId / monitorUserId 必填");
}
// 已结题项目不能分配 (抛 ServiceException → 本循环 catch 计入 errors)
requireAssignableProject(parseProjectId(body.getProjectId()));
body.setCreateBy(loginName);
body.setSponsorOrgId(bizOrgService.selectOrgIdByUserId(loginUid));
// 通知去重: 拉旧分配, 找同 monitorUserId, 比较 assignDesc/assignPoints 是否变化
@@ -0,0 +1,106 @@
package com.ruoyi.business.scheduler;
import java.net.URLEncoder;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.env.Environment;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.ruoyi.business.supplier.SupplierAccountApiCodec;
import com.ruoyi.common.utils.http.HttpUtils;
import lombok.extern.slf4j.Slf4j;
/**
* 供应商账号数据拉取调度器: 每分钟拉取最近 5 分钟更新的账号, 解密后打印.
* <p>
* 数据源: {@code GET /supplier-api/bidding/supplier/openapi/accounts}
* 入参 lastUpdatedTime(最后更新时间) / pageNum / pageSize, 按更新时间倒序返回.
* 返回 data 字段为 AES-256-GCM 加密串, 用 {@link SupplierAccountApiCodec} 解密.
* <p>
* 说明: 只打印不落库 (后续需要持久化时再扩展).
*/
@Slf4j
@Component
public class SupplierAccountPullScheduler
{
private static final String KEY = "supplier-account-api-aes.key";
private static final String BASE_URL = "supplier-account-api.base-url";
private static final String PAGE_SIZE = "supplier-account-api.page-size";
private static final String PULL_MINUTES = "supplier-account-api.pull-minutes";
private static final String DEFAULT_BASE_URL =
"https://zbsuppliertest.guojustar.com/supplier-api/bidding/supplier/openapi/accounts";
private static final DateTimeFormatter TIME_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
@Autowired
private Environment env;
private final ObjectMapper objectMapper = new ObjectMapper();
@Scheduled(fixedRate = 60_000, initialDelay = 30_000)
public void pullAccounts()
{
try
{
String key = env.getProperty(KEY);
if (key == null || key.isEmpty())
{
log.warn("[SupplierAccountPull] 未配置 {} , 跳过", KEY);
return;
}
String baseUrl = env.getProperty(BASE_URL, DEFAULT_BASE_URL);
int pageSize = env.getProperty(PAGE_SIZE, Integer.class, 20);
int pullMinutes = env.getProperty(PULL_MINUTES, Integer.class, 5*24*60*60);
String lastUpdatedTime = LocalDateTime.now().minusMinutes(pullMinutes).format(TIME_FMT);
int pageNum = 1;
int fetched = 0;
while (true)
{
String param = "lastUpdatedTime=" + URLEncoder.encode(lastUpdatedTime, "UTF-8")
+ "&pageNum=" + pageNum + "&pageSize=" + pageSize;
String resp = HttpUtils.sendGet(baseUrl, param);
if (resp == null || resp.isEmpty())
{
break;
}
JsonNode root = objectMapper.readTree(resp);
String encrypted = root.path("data").asText("");
if (encrypted.isEmpty())
{
log.info("[SupplierAccountPull] page={} 无 data 字段 (code={}, msg={}), 结束",
pageNum, root.path("code").asText(), root.path("msg").asText());
break;
}
String plain = SupplierAccountApiCodec.decrypt(encrypted, key);
JsonNode inner = objectMapper.readTree(plain);
int total = inner.path("total").asInt(0);
JsonNode rows = inner.path("rows");
int size = rows.isArray() ? rows.size() : 0;
fetched += size;
// 只打印即可: 整页明文 JSON 打出来 (供观察/后续落库)
log.info("[SupplierAccountPull] page={} total={} 本页={} 明文: {}", pageNum, total, size, plain);
if (size == 0 || fetched >= total)
{
break;
}
pageNum++;
}
log.info("[SupplierAccountPull] 本轮完成, 共 {} 条", fetched);
}
catch (Exception e)
{
log.warn("[SupplierAccountPull] 拉取失败 (跳过, 下分钟再试)", e);
}
}
}
@@ -3,7 +3,9 @@ package com.ruoyi.business.service.impl;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.List;
import java.util.concurrent.ThreadLocalRandom;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.ruoyi.business.domain.BizMeeting;
@@ -67,7 +69,19 @@ public class BizMeetingServiceImpl implements IBizMeetingService
if (entity.getServiceAuditStage() == null || entity.getServiceAuditStage().isEmpty()) {
entity.setServiceAuditStage("NOT_SUBMITTED");
}
return bizMeetingMapper.insert(entity);
try {
return bizMeetingMapper.insert(entity);
}
catch (DuplicateKeyException e) {
// 入库时 meeting_id 撞库 (Redis 序列被重置/回退): 自动加 10 + 随机(0~10) 重新取一次, 不直接报错
Long oldId = entity.getMeetingId();
if (oldId == null) {
throw e;
}
long retryId = oldId + 10L + ThreadLocalRandom.current().nextInt(11);
entity.setMeetingId(retryId);
return bizMeetingMapper.insert(entity);
}
}
/**
@@ -79,9 +79,14 @@ public class BizPersonServiceImpl implements IBizPersonService
}
// 1. 创建 sys_user 子账号
// 继承主账号 role_type, 避免子账号被 sys_user.role_type DEFAULT 'executor' 覆盖
// (之前不写 roleType 时, sponsor 主账号的子账号会被 DEFAULT 错位成 executor)
// role_type 取 person.unitType (sponsor/executor/doctor), 而非继承创建者角色:
// 否则 admin/manager 在 admin/sponsor-people 建人会把子账号错位成 admin/manager (后台管理员).
// 仅当 unitType 缺失时才回退到主账号 role_type 兜底.
SysUser mainUser = mainUserId == null ? null : sysUserMapper.selectUserById(mainUserId);
String roleType = entity.getUnitType();
if (roleType == null || roleType.isEmpty()) {
roleType = mainUser != null ? mainUser.getRoleType() : null;
}
SysUser newUser = new SysUser();
newUser.setUserName(entity.getLoginUsername());
newUser.setNickName(entity.getName());
@@ -92,8 +97,8 @@ public class BizPersonServiceImpl implements IBizPersonService
newUser.setParentUserId(mainUserId);
newUser.setStatus("0");
newUser.setDelFlag("0");
if (mainUser != null && mainUser.getRoleType() != null) {
newUser.setRoleType(mainUser.getRoleType());
if (roleType != null) {
newUser.setRoleType(roleType);
}
newUser.setCreateBy(SecurityUtils.getUsername());
// mybatis useGeneratedKeys 自动回填 userId
@@ -0,0 +1,73 @@
package com.ruoyi.business.supplier;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import javax.crypto.Cipher;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
/**
* 供应商账号接口 AES 解密工具 (对应 refer/BiddingSupplierAccountApiCodec).
* <p>
* 接口返回的 {@code data} 字段格式: {@code BSA.v1.<keyId>.<nonce>.<ciphertext>}
* <ul>
* <li>BSA — 固定前缀 (Bidding Supplier Account)</li>
* <li>v1 — 版本号</li>
* <li>keyId — 密钥标识, 对应配置 {@code supplier-account-api-aes.key-id}
* (单密钥场景忽略, 密钥旋转时按此取对应 key)</li>
* <li>nonce — 12 字节随机 IV, URL-safe Base64 编码 (无填充)</li>
* <li>ciphertext — 密文, URL-safe Base64 编码 (无填充, 末尾带 16 字节 GCM 认证标签)</li>
* </ul>
* 算法: AES-256/GCM/NoPadding (密钥 32 字节, GCM 认证标签 128 bit).
*/
public class SupplierAccountApiCodec
{
private static final int GCM_TAG_BITS = 128;
private static final String TRANSFORMATION = "AES/GCM/NoPadding";
private SupplierAccountApiCodec() {}
/**
* 解密接口返回的 data 字段, 得到明文 JSON 字符串.
*
* @param data 接口返回的 data 字段 ({@code BSA.v1.keyId.nonce.ciphertext})
* @param key AES 密钥 (Base64 编码, 解码后 32 字节)
*/
public static String decrypt(String data, String key)
{
if (data == null || data.isEmpty())
{
return "";
}
String[] parts = data.split("\\.");
if (parts.length < 5)
{
throw new IllegalArgumentException("供应商接口 data 格式非法 (期望 BSA.v1.keyId.nonce.ciphertext)");
}
byte[] keyBytes = Base64.getDecoder().decode(key);
byte[] nonce = decodeBase64Url(parts[3]);
byte[] ciphertext = decodeBase64Url(parts[4]);
try
{
Cipher cipher = Cipher.getInstance(TRANSFORMATION);
cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(keyBytes, "AES"),
new GCMParameterSpec(GCM_TAG_BITS, nonce));
return new String(cipher.doFinal(ciphertext), StandardCharsets.UTF_8);
}
catch (Exception e)
{
throw new IllegalStateException("供应商接口数据解密失败: " + e.getMessage(), e);
}
}
/** URL-safe Base64 → bytes (兼容 '-'/'_' 且无填充) */
private static byte[] decodeBase64Url(String s)
{
String b = s.replace('-', '+').replace('_', '/');
while (b.length() % 4 != 0)
{
b += "=";
}
return Base64.getDecoder().decode(b);
}
}
@@ -49,15 +49,17 @@
<include refid="selectFields"/>
<where>
p.is_deleted = 0
<if test="planName != null and planName != ''"> and p.plan_name like concat('%', #{planName}, '%')</if>
<if test="planDirectionId != null"> and p.plan_direction_id = #{planDirectionId}</if>
<if test="planCategory != null and planCategory != ''"> and p.plan_category = #{planCategory}</if>
<if test="projectForm != null and projectForm != ''"> and p.project_form = #{projectForm}</if>
<if test="submitterId != null"> and p.submitter_id = #{submitterId}</if>
<choose>
<!-- 选了具体状态: 等值匹配 -->
<when test="status != null and status != ''"> and p.status = #{status}</when>
<!-- 未选: 不加 status 过滤, 由前端按角色决定默认值 (经理侧默认查 1/2/3, 医生侧默认查全部含 0) -->
</choose>
<if test="remark != null and remark != ''"> and p.remark = #{remark}</if>
<if test="remark != null and remark != ''"> and p.remark like concat('%', #{remark}, '%')</if>
</where>
order by p.plan_id desc
</select>