package jnpf.limsService.impl; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper; import jnpf.audit.model.AuditEventDTO; import jnpf.audit.sdk.AuditClient; import jnpf.audit.sdk.annotation.AuditLog; import jnpf.audit.AuditConsts; import jnpf.base.UserInfo; import jnpf.base.service.SuperServiceImpl; import jnpf.exception.DataException; import jnpf.limsEntity.LimsModifySignPasswordParam; import jnpf.limsEntity.LimsSignEntity; import jnpf.limsEntity.LimsSignUsageTarget; import jnpf.limsEntity.LimsSignVerifyParam; import jnpf.limsEntity.LimsUserSignPasswordEntity; import jnpf.limsEntity.SignOpType; import jnpf.limsMapper.LimsSignMapper; import jnpf.limsMapper.LimsUserSignPasswordMapper; import jnpf.limsService.LimsSignService; import jnpf.limsService.support.LimsSignUsageSupport; import jnpf.permission.UserApi; import jnpf.permission.entity.UserEntity; import jnpf.util.JsonUtil; import jnpf.util.Md5Util; import jnpf.util.RandomUtil; import jnpf.util.RedisUtil; import jnpf.util.UserProvider; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.ObjectProvider; import org.springframework.stereotype.Service; import org.springframework.transaction.NoTransactionException; import org.springframework.transaction.interceptor.TransactionAspectSupport; import org.springframework.transaction.support.TransactionSynchronizationManager; import java.util.Collection; import java.util.Date; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Set; @Service public class LimsSignServiceImpl extends SuperServiceImpl implements LimsSignService { private static final String DEFAULT_INIT_PASSWORD = "123456"; @Autowired private LimsUserSignPasswordMapper userSignPasswordMapper; @Autowired private UserApi userApi; @Autowired private RedisUtil redisUtil; /** audit.enabled=false 时 AuditClient Bean 不存在,签名业务仍须可启动。 */ @Autowired private ObjectProvider auditClientProvider; /** * 电子签名(当前登录用户)。层 2 埋点,成功 {@code SIGN} / 失败 {@code SIGN_FAIL}。 * *

三个非默认的注解项,每个都是被实据逼出来的,别随手改回默认: *

* *

{@code targetIdExpr="#result"} 只在成功路径求值出 signId(= {@code lims_sign.f_id}); * 失败时 result 为 null,切面逐字段兜底后该列留空,事件照记。 * *

2026-07-31 起成功不再当场记录({@code recordSuccess=false}):验签成功后业务若失败, * 库里会留下一条无对应业务动作的孤儿签名事件——技术上自洽,药厂合规上不接受 * (签名 + 业务是一个整体)。成功事实改由层 0 在表单保存成功后,据 {@code biz_sign} * 等签名字段回读本表补写,与业务事件共用 operationId。失败侧 {@code SIGN_FAIL} 一字未动。 */ @Override @AuditLog( eventType = AuditConsts.TYPE_E_SIGNATURE, action = "SIGN", label = "电子签名", failureAction = "SIGN_FAIL", failureLabel = "电子签名 失败", recordSuccess = false, // 成功事实由层 0 在业务保存成功后补写,见 spec §6 bizType = "SIGN", bizCodeExpr = "#param?.dataId", bizCodeRequired = false, targetTable = "lims_sign", targetIdExpr = "#result", reasonExpr = "#param?.note", extraExpr = "{'accountName': #param?.accountName, 'opType': #param?.opType," + " 'password': T(jnpf.audit.diff.AuditFieldDiff).MASKED}" ) public String verifyAndRecord(LimsSignVerifyParam param) { LimsSignEntity sign = verifyCurrentUserAndRecord(param); return sign.getId(); } private LimsSignEntity verifyCurrentUserAndRecord(LimsSignVerifyParam param) { if (param == null) { throw new DataException("入参不能为空"); } validateVerifyParam(param); UserInfo userInfo = UserProvider.getUser(); if (userInfo == null || userInfo.getUserId() == null) { throw new DataException("当前未登录"); } if (!param.getAccountName().equals(userInfo.getUserAccount())) { throw new DataException("账户与当前登录用户不符"); } verifySignPassword(userInfo.getUserId(), param.getPassword(), "您还未设置签名密码,请先到「个人中心 → 修改签名密码」初始化"); return saveSign(param, userInfo.getUserId()); } /** * 电子签名(指定用户,非当前登录人)。埋点口径同 {@link #verifyAndRecord},两处差异: *

* *

2026-07-31 起成功不再当场记录({@code recordSuccess=false}):验签成功后业务若失败, * 库里会留下一条无对应业务动作的孤儿签名事件——技术上自洽,药厂合规上不接受 * (签名 + 业务是一个整体)。成功事实改由层 0 在表单保存成功后,据 {@code biz_sign} * 等签名字段回读本表补写,与业务事件共用 operationId。失败侧 {@code SIGN_FAIL} 一字未动。 */ @Override @AuditLog( eventType = AuditConsts.TYPE_E_SIGNATURE, action = "SIGN", label = "电子签名(指定用户)", failureAction = "SIGN_FAIL", failureLabel = "电子签名(指定用户)失败", recordSuccess = false, // 成功事实由层 0 在业务保存成功后补写,见 spec §6 bizType = "SIGN", bizCodeExpr = "#param?.dataId", bizCodeRequired = false, targetTable = "lims_sign", targetIdExpr = "#result?.id", reasonExpr = "#param?.note", extraExpr = "{'accountName': #param?.accountName, 'opType': #param?.opType," + " 'allowSelf': #param?.allowSelf," + " 'password': T(jnpf.audit.diff.AuditFieldDiff).MASKED}" ) public LimsSignEntity verifyUserAndRecord(LimsSignVerifyParam param) { if (param == null) { throw new DataException("入参不能为空"); } validateVerifyParam(param); UserInfo userInfo = UserProvider.getUser(); if (userInfo == null || userInfo.getUserId() == null) { throw new DataException("当前未登录"); } boolean allowSelf = isYes(param.getAllowSelf()); if (!allowSelf && param.getAccountName().equals(userInfo.getUserAccount())) { throw new DataException("不允许验证当前登录用户"); } UserEntity signUser = userApi.getInfoByAccount(param.getAccountName()); if (signUser == null || signUser.getId() == null || signUser.getId().isEmpty()) { throw new DataException("签名账户不存在"); } if (!allowSelf && Objects.equals(userInfo.getUserId(), signUser.getId())) { throw new DataException("不允许验证当前登录用户"); } verifySignPassword(signUser.getId(), param.getPassword(), "该用户还未设置签名密码"); return saveSign(param, signUser.getId()); } private void validateVerifyParam(LimsSignVerifyParam param) { String opTypeCode = param.getOpType(); // op_type 自方案 C 起改为可选: // - 传了:必须是合法 SignOpType(eager 模式,业务接口仍按枚举校验) // - 没传:lazy 模式,业务接口收到 biz_sign 时再回写 op_type if (opTypeCode != null && !opTypeCode.isEmpty() && !SignOpType.isValidCode(opTypeCode)) { throw new DataException("非法的签名操作类型: " + opTypeCode); } if (param.getAccountName() == null || param.getAccountName().isEmpty()) { throw new DataException("账户名称不能为空"); } if (param.getPassword() == null || param.getPassword().isEmpty()) { throw new DataException("签名密码不能为空"); } if (param.getNote() == null || param.getNote().isEmpty()) { throw new DataException("说明 / 原因不能为空"); } } private void verifySignPassword(String userId, String password, String unsetMessage) { QueryWrapper pq = new QueryWrapper<>(); pq.eq("user_id", userId); pq.and(w -> w.isNull("f_delete_mark").or().ne("f_delete_mark", 1)); LimsUserSignPasswordEntity pwdRow = userSignPasswordMapper.selectOne(pq); if (pwdRow == null || pwdRow.getPasswordHash() == null || pwdRow.getPasswordHash().isEmpty()) { throw new DataException(unsetMessage); } String inputHash = Md5Util.getStringMd5(password + pwdRow.getSecretkey().toLowerCase()); if (!inputHash.equals(pwdRow.getPasswordHash())) { throw new DataException("签名密码错误"); } } private boolean isYes(String value) { return "yes".equalsIgnoreCase(value == null ? "" : value.trim()); } private LimsSignEntity saveSign(LimsSignVerifyParam param, String creatorUserId) { LimsSignEntity sign = new LimsSignEntity(); sign.setId(RandomUtil.uuId()); sign.setDataId(param.getDataId()); sign.setOpType(param.getOpType()); sign.setAccountName(param.getAccountName()); sign.setNote(param.getNote()); sign.setMetaData(param.getMetaData()); sign.setCreatorUserId(creatorUserId); // tenantId / creatorTime 由 MetaObjectHandler 填 this.save(sign); return sign; } @Override public LimsSignEntity requireValidSign(String signId, SignOpType expectedOpType, String expectedDataId) { if (signId == null || signId.isEmpty()) { throw new DataException("缺少电子签名"); } if (expectedOpType == null) { throw new DataException("业务未指定签名操作类型"); } UserInfo userInfo = UserProvider.getUser(); if (userInfo == null || userInfo.getUserId() == null) { throw new DataException("当前未登录"); } QueryWrapper q = new QueryWrapper<>(); q.eq("f_id", signId); q.and(w -> w.isNull("f_delete_mark").or().ne("f_delete_mark", 1)); // 不限定列:见 commit 54c8a52 — selectList 限定列 + 多租户拦截器会让 entity 为 null LimsSignEntity sign = this.getBaseMapper().selectOne(q); if (sign == null) { throw new DataException("电子签名不存在或已失效"); } // 签名人始终校验 if (!userInfo.getUserId().equals(sign.getCreatorUserId())) { throw new DataException("电子签名非当前用户所签,禁止使用"); } if (sign.getExtraJson() != null && !sign.getExtraJson().isEmpty()) { throw new DataException("电子签名已被使用,请重新签名"); } // op_type / data_id 各自独立支持 lazy / eager: // 字段为空 → 仅在内存中补全,业务成功后由 recordSignUsage 原子认领 // 字段已有值 → 严格校验匹配 String existingOpType = sign.getOpType(); if (existingOpType == null || existingOpType.isEmpty()) { sign.setOpType(expectedOpType.getCode()); } else if (!expectedOpType.getCode().equals(existingOpType)) { throw new DataException("电子签名操作类型不符(需 " + expectedOpType.getLabel() + ")"); } // expectedDataId 为 null = 业务接口不需要锚 data_id(如 jiance-task 批量类), // 这种情况完全不校验也不回写 sign.data_id(保留 verify 时的原值)。 if (expectedDataId != null) { String existingDataId = sign.getDataId(); if (existingDataId == null || existingDataId.isEmpty()) { sign.setDataId(expectedDataId); } else if (!expectedDataId.equals(existingDataId)) { throw new DataException("电子签名业务对象不符"); } } return sign; } /** * 「原子认领 + 发 SIGN_USED」的组合入口,语义与拆分前完全一致。 * *

2026-07-31 拆成两步({@link #claimSignUsageOnly} + {@link #publishSignUsedEvents}): * 在线表单路径只需要前半步——表单保存本身已产生层 0 事件,层 0 会据表单里的签名字段补写 * 一条签名事件,这里再发 {@code SIGN_USED} 就是同一次签名两条证据(D8)。 * 顺序不可调换:认领失败会标记事务回滚,必须先认领成功才允许发事件。 */ @Override public void recordSignUsage(LimsSignEntity sign, SignOpType opType, Collection requestedTargetIds, Collection actualTargets) { claimSignUsageOnly(sign, opType, requestedTargetIds, actualTargets); publishSignUsedEvents(sign, opType, actualTargets); } @Override public void claimSignUsageOnly(LimsSignEntity sign, SignOpType opType, Collection requestedTargetIds, Collection actualTargets) { List normalized = LimsSignUsageSupport.normalizeTargets(actualTargets); if (!signUsageApplicable(sign, opType, normalized)) { return; } String usageJson = JsonUtil.getObjectToString( LimsSignUsageSupport.signExtra(requestedTargetIds, normalized)); claimSignUsage(sign, opType, usageJson); } private void publishSignUsedEvents(LimsSignEntity sign, SignOpType opType, Collection actualTargets) { List normalized = LimsSignUsageSupport.normalizeTargets(actualTargets); if (!signUsageApplicable(sign, opType, normalized)) { return; } for (LimsSignUsageSupport.UsageGroup group : LimsSignUsageSupport.groupByBizCode(normalized)) { AuditEventDTO event = AuditEventDTO.builder() .clientEventId(LimsSignUsageSupport.clientEventId(sign.getId(), opType, group)) .eventType(AuditConsts.TYPE_E_SIGNATURE) .actionCode(LimsSignUsageSupport.ACTION_SIGN_USED) .actionLabel(opType.getLabel() + " · 电子签名") .sourceLayer(3) .targetTable("lims_sign") .targetId(sign.getId()) .bizType(group.getBizType()) .bizModule(group.getBizModule()) .bizCode(group.getBizCode()) .reason(sign.getNote()) .extra(JsonUtil.getObjectToString( LimsSignUsageSupport.eventExtra(sign, opType, group))) .build(); auditClientProvider.ifAvailable(client -> client.record(event)); } } /** * 认领与发事件共用的前置判定:任一不满足则本次「签名被使用」视为没有发生, * 既不认领也不发事件。两侧共用一份,避免"认领了但不发事件"这类不对称。 */ private boolean signUsageApplicable(LimsSignEntity sign, SignOpType opType, List normalized) { return sign != null && sign.getId() != null && !sign.getId().isEmpty() && opType != null && !normalized.isEmpty(); } private void claimSignUsage(LimsSignEntity sign, SignOpType opType, String usageJson) { UpdateWrapper update = new UpdateWrapper<>(); update.eq("f_id", sign.getId()); update.eq("f_creator_user_id", sign.getCreatorUserId()); update.and(w -> w.isNull("f_delete_mark").or().ne("f_delete_mark", 1)); update.and(w -> w.isNull("op_type").or().eq("op_type", "") .or().eq("op_type", opType.getCode())); update.and(w -> w.isNull("extra_json").or().eq("extra_json", "")); update.set("op_type", opType.getCode()); update.set("extra_json", usageJson); String dataId = sign.getDataId(); if (dataId == null || dataId.isEmpty()) { update.and(w -> w.isNull("data_id").or().eq("data_id", "")); } else { update.and(w -> w.isNull("data_id").or().eq("data_id", "") .or().eq("data_id", dataId)); update.set("data_id", dataId); } if (this.getBaseMapper().update(null, update) != 1) { throw signUsageFailure("电子签名已被其他业务使用或状态已变更"); } sign.setOpType(opType.getCode()); sign.setExtraJson(usageJson); } private DataException signUsageFailure(String message) { if (TransactionSynchronizationManager.isActualTransactionActive()) { try { TransactionAspectSupport.currentTransactionStatus().setRollbackOnly(); } catch (NoTransactionException ignored) { // 调用方不是由 @Transactional 代理进入时,仍向上抛错阻止继续记录事件。 } } return new DataException(message); } @Override public int[] initAllUsers() { UserInfo userInfo = UserProvider.getUser(); if (userInfo == null) { throw new DataException("当前未登录"); } // 1. 取所有未软删的 base_user List> users = userSignPasswordMapper.selectAllActiveBaseUsers(); if (users == null || users.isEmpty()) { return new int[]{0, 0}; } // 2. 取已初始化的 user_id 集合(一次查避免循环里 N+1) Set existed = new HashSet<>(); QueryWrapper exq = new QueryWrapper<>(); exq.and(w -> w.isNull("f_delete_mark").or().ne("f_delete_mark", 1)); List exList = userSignPasswordMapper.selectList(exq); if (exList != null) { for (LimsUserSignPasswordEntity r : exList) { if (r != null && r.getUserId() != null) { existed.add(r.getUserId()); } } } // 3. 过滤后批量插入 int inserted = 0; int skipped = 0; Date now = new Date(); for (Map u : users) { String uid = u.get("id") == null ? null : u.get("id").toString(); String tenantId = u.get("tenantId") == null ? "0" : u.get("tenantId").toString(); if (uid == null || uid.isEmpty()) { continue; } if (existed.contains(uid)) { skipped++; continue; } // RandomUtil 仅提供 uuId / enUuId,无 randomString。截 8 位作为独立盐,足够区分。 String secretkey = RandomUtil.uuId().substring(0, 8); String hash = Md5Util.getStringMd5(DEFAULT_INIT_PASSWORD + secretkey.toLowerCase()); LimsUserSignPasswordEntity row = new LimsUserSignPasswordEntity(); row.setId(RandomUtil.uuId()); row.setTenantId(tenantId); row.setUserId(uid); row.setPasswordHash(hash); row.setSecretkey(secretkey); row.setCreatorTime(now); row.setCreatorUserId(userInfo.getUserId()); userSignPasswordMapper.insert(row); inserted++; } return new int[]{inserted, skipped}; } /** * 修改签名密码。**动作码与签名本身分开**({@code SIGN_PWD_CHANGE})——它不是"签了一次名", * 而是"改变了此后所有签名的凭据",合规上是独立的关键动作,混进 SIGN 会污染签名计数。 * *

入参三个字段全是密码(accountPassword / password / repeatPassword), * 因此 extra 里**三个一律 MASKED**:记"这次提交了哪些凭据字段"而不记值。 * 本方法没有 note 字段,故 {@code reasonExpr} 不配,reason 留空—— * 失败的技术原因("验证码错误或已过期"/"账户密码错误"/…)由切面落 extra.failureReason。 * *

{@code bizCodeRequired=false}:改密码天然没有业务单号。 */ @Override @AuditLog( eventType = AuditConsts.TYPE_E_SIGNATURE, action = "SIGN_PWD_CHANGE", label = "修改签名密码", failureAction = "SIGN_PWD_CHANGE_FAIL", failureLabel = "修改签名密码 失败", bizType = "SIGN", bizCodeExpr = "", bizCodeRequired = false, targetTable = "lims_user_sign_password", extraExpr = "{'accountPassword': T(jnpf.audit.diff.AuditFieldDiff).MASKED," + " 'password': T(jnpf.audit.diff.AuditFieldDiff).MASKED," + " 'repeatPassword': T(jnpf.audit.diff.AuditFieldDiff).MASKED}" ) public void modifySignPassword(LimsModifySignPasswordParam param) { if (param == null) { throw new DataException("入参不能为空"); } // 1. 验证码 if (param.getTimestamp() == null || param.getTimestamp().isEmpty() || param.getCode() == null || param.getCode().isEmpty()) { throw new DataException("请输入验证码"); } Object cached = redisUtil.getString(param.getTimestamp()); String captcha = cached == null ? "" : String.valueOf(cached); if (captcha.isEmpty() || !param.getCode().equalsIgnoreCase(captcha)) { throw new DataException("验证码错误或已过期"); } // 2. 当前用户 UserInfo userInfo = UserProvider.getUser(); if (userInfo == null || userInfo.getUserId() == null) { throw new DataException("当前未登录"); } // 3. 账户密码(与 /Users/Current/Actions/ModifyPassword 校验逻辑一致: // 前端 MD5(明文),后端 Md5(md5_password + secretkey) == base_user.f_password) if (param.getAccountPassword() == null || param.getAccountPassword().isEmpty()) { throw new DataException("请输入账户密码"); } UserEntity user = userApi.getInfoById(userInfo.getUserId()); if (user == null) { throw new DataException("用户不存在"); } String expectAccountHash = Md5Util.getStringMd5( param.getAccountPassword().toLowerCase() + user.getSecretkey().toLowerCase()); if (!expectAccountHash.equals(user.getPassword())) { throw new DataException("账户密码错误"); } // 4. 新签名密码 if (param.getPassword() == null || param.getPassword().isEmpty()) { throw new DataException("新签名密码不能为空"); } if (!param.getPassword().equals(param.getRepeatPassword())) { throw new DataException("两次输入的新签名密码不一致"); } // 5. 写入 / 更新 lims_user_sign_password QueryWrapper pq = new QueryWrapper<>(); pq.eq("user_id", userInfo.getUserId()); pq.and(w -> w.isNull("f_delete_mark").or().ne("f_delete_mark", 1)); LimsUserSignPasswordEntity existing = userSignPasswordMapper.selectOne(pq); String secretkey = RandomUtil.uuId().substring(0, 8); String hash = Md5Util.getStringMd5(param.getPassword() + secretkey.toLowerCase()); Date now = new Date(); if (existing == null) { LimsUserSignPasswordEntity row = new LimsUserSignPasswordEntity(); row.setId(RandomUtil.uuId()); row.setUserId(userInfo.getUserId()); row.setPasswordHash(hash); row.setSecretkey(secretkey); row.setLastResetTime(now); // tenantId / creatorUserId / creatorTime 由 MetaObjectHandler 填 userSignPasswordMapper.insert(row); } else { existing.setPasswordHash(hash); existing.setSecretkey(secretkey); existing.setLastResetTime(now); // tenantId / lastModifyUserId / lastModifyTime 由 MetaObjectHandler 填 userSignPasswordMapper.updateById(existing); } // 消耗已用验证码,防止重放 redisUtil.remove(param.getTimestamp()); } @Override public void attachExtra(String signId, Object extra) { if (signId == null || signId.isEmpty() || extra == null) { return; } UpdateWrapper uw = new UpdateWrapper<>(); uw.eq("f_id", signId); uw.set("extra_json", JsonUtil.getObjectToString(extra)); this.update(null, uw); } }