diff --git a/.gitignore b/.gitignore index dab9666..efeb494 100644 --- a/.gitignore +++ b/.gitignore @@ -63,6 +63,7 @@ bin/ *.swp *.swo *~ +*.zip .gradle/ *.iml diff --git a/refer/BiddingSupplierAccountApiCodec.java b/refer/BiddingSupplierAccountApiCodec.java new file mode 100644 index 0000000..4423255 Binary files /dev/null and b/refer/BiddingSupplierAccountApiCodec.java differ diff --git a/refer/gr-table-plus.vue b/refer/gr-table-plus.vue deleted file mode 100644 index cc3d4d5..0000000 --- a/refer/gr-table-plus.vue +++ /dev/null @@ -1,161 +0,0 @@ - - - \ No newline at end of file diff --git a/refer/gr-table-simple.vue b/refer/gr-table-simple.vue deleted file mode 100644 index 86b5547..0000000 --- a/refer/gr-table-simple.vue +++ /dev/null @@ -1,94 +0,0 @@ - - - \ No newline at end of file diff --git a/refer/gr-table.vue b/refer/gr-table.vue deleted file mode 100644 index bb9b917..0000000 --- a/refer/gr-table.vue +++ /dev/null @@ -1,117 +0,0 @@ - - - \ No newline at end of file diff --git a/refer/说明.txt b/refer/说明.txt new file mode 100644 index 0000000..2889570 --- /dev/null +++ b/refer/说明.txt @@ -0,0 +1,13 @@ +供应商系统需要增加一个接口 +请求参数:最后更新时间,支持分页。 +返回: +1、供应商账号注册信息(企业类型 企业名称 邮箱 账号 密码 公司地址 税号 联系人 联系电话) 启用 删除 禁用 标识 根据修改时间倒序排序返回。 +2、整个信息可以使用AES加密,提供解密算法,密码不可逆,提供提供算法进行比对。(因为数据返回都是加密的,且加密算法只提供给你们 不要到时候说我们接口信息安全泄露哈) + + +https://zbsuppliertest.guojustar.com/supplier-api/bidding/supplier/openapi/accounts?lastUpdatedTime=2026-08-01%2000:00:00&pageNum=1&pageSize=20 + +测试环境的密钥配置如下 +supplier-account-api-aes: + key-id: supplier-api-key + key: 'kY0+oIO/laeaaaYDd+9TCYbCQR0b/vqMeJ9sUnSVd9U=' \ No newline at end of file diff --git a/ry-api/ruoyi-admin/src/main/resources/application.yml b/ry-api/ruoyi-admin/src/main/resources/application.yml index 1db5b3a..cdd4b46 100644 --- a/ry-api/ruoyi-admin/src/main/resources/application.yml +++ b/ry-api/ruoyi-admin/src/main/resources/application.yml @@ -59,6 +59,18 @@ ruoyi: camera: base-url: https://risingdoctor.com/camera/ +# 供应商账号接口 (拉取/解密) 配置 +supplier-account-api: + base-url: https://zbsuppliertest.guojustar.com/supplier-api/bidding/supplier/openapi/accounts + # 每页条数 + page-size: 20 + # 每次拉取最近 N 分钟更新的数据 + pull-minutes: 5 +# 供应商账号接口 AES 密钥 (数据解密用, 测试环境密钥) +supplier-account-api-aes: + key-id: supplier-api-key + key: 'kY0+oIO/laeaaaYDd+9TCYbCQR0b/vqMeJ9sUnSVd9U=' + # 开发环境配置 server: # 服务器的HTTP端口,默认为8080 diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizExpertController.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizExpertController.java index 2f1b6be..f3763e5 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizExpertController.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizExpertController.java @@ -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 list = bizExpertService.selectList(q); + return success(list != null && !list.isEmpty() ? list.get(0) : null); + } /** * admin 创建专家: 同时创建 sys_user (用户名=手机号, 密码=手机号) * 返回 SysUser (含明文 password 给前端 toast 用) diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizProjectController.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizProjectController.java index abea150..91a8502 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizProjectController.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizProjectController.java @@ -259,6 +259,9 @@ public class BizProjectController extends BaseController @PostMapping("/{projectId}/assigns") public AjaxResult saveAssigns(@PathVariable("projectId") Long projectId, @RequestBody List 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 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 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 是否变化 diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/scheduler/SupplierAccountPullScheduler.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/scheduler/SupplierAccountPullScheduler.java new file mode 100644 index 0000000..c1ce976 --- /dev/null +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/scheduler/SupplierAccountPullScheduler.java @@ -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 分钟更新的账号, 解密后打印. + *

+ * 数据源: {@code GET /supplier-api/bidding/supplier/openapi/accounts} + * 入参 lastUpdatedTime(最后更新时间) / pageNum / pageSize, 按更新时间倒序返回. + * 返回 data 字段为 AES-256-GCM 加密串, 用 {@link SupplierAccountApiCodec} 解密. + *

+ * 说明: 只打印不落库 (后续需要持久化时再扩展). + */ +@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); + } + } +} diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizMeetingServiceImpl.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizMeetingServiceImpl.java index c56fd4e..aa9b06a 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizMeetingServiceImpl.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizMeetingServiceImpl.java @@ -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); + } } /** diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizPersonServiceImpl.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizPersonServiceImpl.java index 8658ab1..bf5aff8 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizPersonServiceImpl.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizPersonServiceImpl.java @@ -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 diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/supplier/SupplierAccountApiCodec.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/supplier/SupplierAccountApiCodec.java new file mode 100644 index 0000000..b96b2ef --- /dev/null +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/supplier/SupplierAccountApiCodec.java @@ -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). + *

+ * 接口返回的 {@code data} 字段格式: {@code BSA.v1...} + *

    + *
  • BSA — 固定前缀 (Bidding Supplier Account)
  • + *
  • v1 — 版本号
  • + *
  • keyId — 密钥标识, 对应配置 {@code supplier-account-api-aes.key-id} + * (单密钥场景忽略, 密钥旋转时按此取对应 key)
  • + *
  • nonce — 12 字节随机 IV, URL-safe Base64 编码 (无填充)
  • + *
  • ciphertext — 密文, URL-safe Base64 编码 (无填充, 末尾带 16 字节 GCM 认证标签)
  • + *
+ * 算法: 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); + } +} diff --git a/ry-api/ruoyi-business/src/main/resources/mapper/business/BizProjectPlanMapper.xml b/ry-api/ruoyi-business/src/main/resources/mapper/business/BizProjectPlanMapper.xml index 77035d3..5e8ab7a 100644 --- a/ry-api/ruoyi-business/src/main/resources/mapper/business/BizProjectPlanMapper.xml +++ b/ry-api/ruoyi-business/src/main/resources/mapper/business/BizProjectPlanMapper.xml @@ -49,15 +49,17 @@ p.is_deleted = 0 + and p.plan_name like concat('%', #{planName}, '%') and p.plan_direction_id = #{planDirectionId} and p.plan_category = #{planCategory} + and p.project_form = #{projectForm} and p.submitter_id = #{submitterId} and p.status = #{status} - and p.remark = #{remark} + and p.remark like concat('%', #{remark}, '%') order by p.plan_id desc diff --git a/ry-vue3/.env.development b/ry-vue3/.env.development index 7c893ea..107ed66 100644 --- a/ry-vue3/.env.development +++ b/ry-vue3/.env.development @@ -1,2 +1,2 @@ -# 开发环境 API 基路径: 直连远程后端 (绕过 Vite proxy, 避免 HTTPS 代理卡住) -VITE_APP_BASE_API = 'https://risingdoctor.com/hg-api' +# 开发环境 API 基路径: 本地后端 (走 Vite proxy 转发到 localhost:8080) +VITE_APP_BASE_API = '/dev-api' diff --git a/ry-vue3/src/components/OssFileUploader.vue b/ry-vue3/src/components/OssFileUploader.vue index 4156914..8629f93 100644 --- a/ry-vue3/src/components/OssFileUploader.vue +++ b/ry-vue3/src/components/OssFileUploader.vue @@ -10,8 +10,12 @@ /> -->