57 changed files with 2447 additions and 245 deletions
@ -0,0 +1,59 @@ |
|||
package cc.hiver.mall.ie.controller; |
|||
|
|||
import cc.hiver.core.common.utils.ResultUtil; |
|||
import cc.hiver.core.common.utils.SecurityUtil; |
|||
import cc.hiver.core.common.vo.Result; |
|||
import cc.hiver.core.entity.User; |
|||
import cc.hiver.mall.ie.dto.IeRealNameVerifyDTO; |
|||
import cc.hiver.mall.ie.dto.IeStudentCardVerifyDTO; |
|||
import cc.hiver.mall.ie.service.IeRealNameAuthService; |
|||
import cc.hiver.mall.ie.vo.IeRealNameAuthVO; |
|||
import io.swagger.annotations.Api; |
|||
import io.swagger.annotations.ApiOperation; |
|||
import org.springframework.beans.factory.annotation.Autowired; |
|||
import org.springframework.web.bind.annotation.RequestBody; |
|||
import org.springframework.web.bind.annotation.RequestMapping; |
|||
import org.springframework.web.bind.annotation.RequestMethod; |
|||
import org.springframework.web.bind.annotation.RestController; |
|||
|
|||
@RestController |
|||
@Api(tags = "i/e进阶实名认证接口") |
|||
@RequestMapping("/hiver/app/ie/real-name") |
|||
public class IeRealNameAuthController { |
|||
|
|||
@Autowired |
|||
private SecurityUtil securityUtil; |
|||
|
|||
@Autowired |
|||
private IeRealNameAuthService realNameAuthService; |
|||
|
|||
@RequestMapping(value = "/verify", method = RequestMethod.POST) |
|||
@ApiOperation("使用阿里云身份证三要素核验完成成年实名认证") |
|||
public Result<IeRealNameAuthVO> verify(@RequestBody IeRealNameVerifyDTO dto) { |
|||
return new ResultUtil<IeRealNameAuthVO>().setData(realNameAuthService.verifyByAliyunThreeElement( |
|||
currentUserId(), |
|||
dto == null ? null : dto.getCertName(), |
|||
dto == null ? null : dto.getCertNo(), |
|||
dto == null ? null : dto.getCertPicture())); |
|||
} |
|||
|
|||
@RequestMapping(value = "/student-card/verify", method = RequestMethod.POST) |
|||
@ApiOperation("上传学生证照片并识别是否为大学生学生证") |
|||
public Result<IeRealNameAuthVO> verifyStudentCard(@RequestBody IeStudentCardVerifyDTO dto) { |
|||
String imageUrl = dto == null ? null : dto.getImageUrl(); |
|||
return new ResultUtil<IeRealNameAuthVO>().setData(realNameAuthService.verifyStudentCard(currentUserId(), imageUrl)); |
|||
} |
|||
|
|||
@RequestMapping(value = "/audit-setting", method = RequestMethod.GET) |
|||
@ApiOperation("查询i/e实名认证审核开关") |
|||
public Result<java.util.Map<String, Boolean>> auditSetting() { |
|||
java.util.Map<String, Boolean> result = new java.util.HashMap<>(); |
|||
result.put("realNameAuditEnabled", realNameAuthService.isRealNameAuditEnabled()); |
|||
return new ResultUtil<java.util.Map<String, Boolean>>().setData(result); |
|||
} |
|||
|
|||
private Long currentUserId() { |
|||
User user = securityUtil.getCurrUser(); |
|||
return Long.valueOf(user.getId()); |
|||
} |
|||
} |
|||
@ -0,0 +1,19 @@ |
|||
package cc.hiver.mall.ie.dto; |
|||
|
|||
import io.swagger.annotations.ApiModel; |
|||
import io.swagger.annotations.ApiModelProperty; |
|||
import lombok.Data; |
|||
|
|||
@Data |
|||
@ApiModel("i/e 进阶实名认证请求") |
|||
public class IeRealNameVerifyDTO { |
|||
|
|||
@ApiModelProperty(value = "待核验姓名", required = true) |
|||
private String certName; |
|||
|
|||
@ApiModelProperty(value = "待核验身份证号", required = true) |
|||
private String certNo; |
|||
|
|||
@ApiModelProperty(value = "待核验人像照片Base64,需去除data:image前缀,且不得超过50KB", required = true) |
|||
private String certPicture; |
|||
} |
|||
@ -0,0 +1,13 @@ |
|||
package cc.hiver.mall.ie.dto; |
|||
|
|||
import io.swagger.annotations.ApiModel; |
|||
import io.swagger.annotations.ApiModelProperty; |
|||
import lombok.Data; |
|||
|
|||
@Data |
|||
@ApiModel("i/e 学生证认证请求") |
|||
public class IeStudentCardVerifyDTO { |
|||
|
|||
@ApiModelProperty(value = "学生证图片上传后的访问地址", required = true) |
|||
private String imageUrl; |
|||
} |
|||
@ -0,0 +1,19 @@ |
|||
package cc.hiver.mall.ie.exception; |
|||
|
|||
/** |
|||
* i/e 业务异常基类。 |
|||
* |
|||
* 说明: |
|||
* 1. 这里统一继承 RuntimeException,方便现有 Controller/全局异常处理直接识别为业务失败。 |
|||
* 2. 后续如果项目全局有统一错误码体系,可以在本类中补充 code 字段,不需要改每个子异常。 |
|||
*/ |
|||
public class IeBusinessException extends RuntimeException { |
|||
|
|||
public IeBusinessException(String message) { |
|||
super(message); |
|||
} |
|||
|
|||
public IeBusinessException(String message, Throwable cause) { |
|||
super(message, cause); |
|||
} |
|||
} |
|||
@ -0,0 +1,20 @@ |
|||
package cc.hiver.mall.ie.exception; |
|||
|
|||
import cc.hiver.core.common.utils.ResultUtil; |
|||
import cc.hiver.core.common.vo.Result; |
|||
import org.springframework.web.bind.annotation.ExceptionHandler; |
|||
import org.springframework.web.bind.annotation.RestControllerAdvice; |
|||
|
|||
/** |
|||
* i/e 模块业务异常处理器。 |
|||
* |
|||
* 只拦截 i/e Controller 包,避免影响商城其他模块既有异常处理行为。 |
|||
*/ |
|||
@RestControllerAdvice(basePackages = "cc.hiver.mall.ie.controller") |
|||
public class IeExceptionHandler { |
|||
|
|||
@ExceptionHandler(IeBusinessException.class) |
|||
public Result<Object> handleIeBusinessException(IeBusinessException e) { |
|||
return ResultUtil.error(e.getMessage()); |
|||
} |
|||
} |
|||
@ -0,0 +1,14 @@ |
|||
package cc.hiver.mall.ie.exception; |
|||
|
|||
/** |
|||
* 未成年人拦截异常。 |
|||
* |
|||
* 用单独异常类型区分普通认证失败和未成年拦截,便于后续在全局异常处理、 |
|||
* 风控日志、前端提示文案中做精细化处理。 |
|||
*/ |
|||
public class UnderageUserException extends IeBusinessException { |
|||
|
|||
public UnderageUserException(String message) { |
|||
super(message); |
|||
} |
|||
} |
|||
@ -0,0 +1,47 @@ |
|||
package cc.hiver.mall.ie.service; |
|||
|
|||
import cc.hiver.mall.ie.vo.IeRealNameAuthVO; |
|||
|
|||
public interface IeRealNameAuthService { |
|||
|
|||
/** |
|||
* 使用阿里云身份证三要素核验完成进阶实名认证。 |
|||
* |
|||
* @param userId 当前登录用户 ID |
|||
* @param certName 待核验姓名 |
|||
* @param certNo 待核验身份证号 |
|||
* @param certPicture 待核验人像照片 Base64,不能超过 50KB |
|||
* @return 脱敏后的实名结果 |
|||
*/ |
|||
IeRealNameAuthVO verifyByAliyunThreeElement(Long userId, String certName, String certNo, String certPicture); |
|||
|
|||
/** |
|||
* 使用学生证照片完成学生身份认证。 |
|||
* |
|||
* @param userId 当前登录用户 ID |
|||
* @param imageUrl 已上传的学生证图片地址 |
|||
* @return 学生证认证结果 |
|||
*/ |
|||
IeRealNameAuthVO verifyStudentCard(Long userId, String imageUrl); |
|||
|
|||
/** |
|||
* 查询后台审核开关:是否开启身份证三要素实名认证。 |
|||
* |
|||
* @return true=需要身份证三要素实名,false=只需要学生证 |
|||
*/ |
|||
boolean isRealNameAuditEnabled(); |
|||
|
|||
/** |
|||
* 后台保存审核开关。 |
|||
* |
|||
* @param enabled true=开启身份证三要素实名 |
|||
*/ |
|||
void setRealNameAuditEnabled(Boolean enabled); |
|||
|
|||
/** |
|||
* 匹配/建档前的服务端兜底校验,防止用户绕过前端直接请求匹配接口。 |
|||
* |
|||
* @param userId 当前登录用户 ID |
|||
*/ |
|||
void assertAdultVerified(Long userId); |
|||
} |
|||
@ -0,0 +1,12 @@ |
|||
package cc.hiver.mall.ie.service; |
|||
|
|||
public interface IeStudentCardOcrService { |
|||
|
|||
/** |
|||
* 判断图片是否为大学生学生证。 |
|||
* |
|||
* @param imageUrl 图片地址 |
|||
* @return true=模型判断是学生证,false=模型判断不是学生证或识别失败 |
|||
*/ |
|||
boolean isStudentCard(String imageUrl); |
|||
} |
|||
@ -0,0 +1,566 @@ |
|||
package cc.hiver.mall.ie.service.impl; |
|||
|
|||
import cc.hiver.core.common.redis.RedisTemplateHelper; |
|||
import cc.hiver.mall.ie.constant.IeConstants; |
|||
import cc.hiver.mall.ie.entity.IeUserProfile; |
|||
import cc.hiver.mall.ie.exception.IeBusinessException; |
|||
import cc.hiver.mall.ie.exception.UnderageUserException; |
|||
import cc.hiver.mall.ie.mapper.IeUserProfileMapper; |
|||
import cc.hiver.mall.ie.service.IeRealNameAuthService; |
|||
import cc.hiver.mall.ie.service.IeStudentCardOcrService; |
|||
import cc.hiver.mall.ie.vo.IeRealNameAuthVO; |
|||
import cn.hutool.json.JSONObject; |
|||
import cn.hutool.json.JSONUtil; |
|||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; |
|||
import okhttp3.MediaType; |
|||
import okhttp3.OkHttpClient; |
|||
import okhttp3.Request; |
|||
import okhttp3.Response; |
|||
import org.springframework.beans.factory.annotation.Autowired; |
|||
import org.springframework.beans.factory.annotation.Value; |
|||
import org.springframework.dao.DuplicateKeyException; |
|||
import org.springframework.stereotype.Service; |
|||
import org.springframework.transaction.annotation.Transactional; |
|||
|
|||
import javax.crypto.Cipher; |
|||
import javax.crypto.Mac; |
|||
import javax.crypto.spec.GCMParameterSpec; |
|||
import javax.crypto.spec.SecretKeySpec; |
|||
import java.net.URLEncoder; |
|||
import java.nio.charset.StandardCharsets; |
|||
import java.security.MessageDigest; |
|||
import java.security.SecureRandom; |
|||
import java.time.LocalDate; |
|||
import java.time.Period; |
|||
import java.time.format.DateTimeFormatter; |
|||
import java.util.*; |
|||
|
|||
@Service |
|||
public class IeRealNameAuthServiceImpl implements IeRealNameAuthService { |
|||
|
|||
/** |
|||
* 未认证。 |
|||
*/ |
|||
private static final int AUTH_STATUS_NONE = 0; |
|||
|
|||
/** |
|||
* 已认证且年龄满足 18 岁。 |
|||
*/ |
|||
private static final int AUTH_STATUS_VERIFIED = 1; |
|||
|
|||
/** |
|||
* 已识别为未成年人。 |
|||
*/ |
|||
private static final int AUTH_STATUS_UNDERAGE = 2; |
|||
|
|||
/** |
|||
* 学生证未上传。 |
|||
*/ |
|||
private static final int STUDENT_CARD_STATUS_NONE = 0; |
|||
|
|||
/** |
|||
* 学生证已通过。 |
|||
*/ |
|||
private static final int STUDENT_CARD_STATUS_VERIFIED = 1; |
|||
|
|||
/** |
|||
* 首次模型识别不通过,需要重传。 |
|||
*/ |
|||
private static final int STUDENT_CARD_STATUS_RETRY_REQUIRED = 2; |
|||
|
|||
private static final MediaType FORM = MediaType.parse("application/x-www-form-urlencoded; charset=utf-8"); |
|||
private static final DateTimeFormatter BIRTHDAY_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd"); |
|||
private static final int MAX_CERT_PICTURE_BASE64_BYTES = 50 * 1024; |
|||
private static final String REAL_NAME_AUDIT_ENABLED_KEY = "IE:REVIEW:REAL_NAME_AUDIT_ENABLED"; |
|||
|
|||
private final OkHttpClient httpClient = new OkHttpClient(); |
|||
private final SecureRandom secureRandom = new SecureRandom(); |
|||
|
|||
@Autowired |
|||
private IeUserProfileMapper userProfileMapper; |
|||
|
|||
@Autowired |
|||
private IeStudentCardOcrService studentCardOcrService; |
|||
|
|||
@Autowired |
|||
private RedisTemplateHelper redisTemplateHelper; |
|||
|
|||
/** |
|||
* 阿里云身份证三要素核验 AccessKeyId。 |
|||
* 建议生产环境使用 RAM 子账号,并仅授予 dytns:CertNoThreeElementVerification 权限。 |
|||
*/ |
|||
@Value("${ie.real-name.aliyun.access-key-id:${ie.aliyun.green.access-key-id:}}") |
|||
private String aliyunAccessKeyId; |
|||
|
|||
/** |
|||
* 阿里云身份证三要素核验 AccessKeySecret。 |
|||
*/ |
|||
@Value("${ie.real-name.aliyun.access-key-secret:${ie.aliyun.green.access-key-secret:}}") |
|||
private String aliyunAccessKeySecret; |
|||
|
|||
/** |
|||
* 号码百科控制台申请通过后获得的身份证三要素授权码。 |
|||
*/ |
|||
@Value("${ie.real-name.aliyun.auth-code:0H1D5kWc57}") |
|||
private String aliyunAuthCode; |
|||
|
|||
@Value("${ie.real-name.aliyun.endpoint:https://dytnsapi.aliyuncs.com/}") |
|||
private String aliyunEndpoint; |
|||
|
|||
/** |
|||
* AES-GCM 加密密钥,支持 Base64 或 16/24/32 字节明文。 |
|||
* 生产环境必须放到配置中心/环境变量,不能提交到代码仓库。 |
|||
*/ |
|||
@Value("${ie.real-name.aes-key:}") |
|||
private String aesKey; |
|||
|
|||
@Override |
|||
@Transactional |
|||
public IeRealNameAuthVO verifyByAliyunThreeElement(Long userId, String certName, String certNo, String certPicture) { |
|||
if (userId == null) { |
|||
throw new IeBusinessException("登录状态已失效,请重新登录"); |
|||
} |
|||
IeUserProfile profile = findProfileByUserId(userId); |
|||
if (profile != null && Integer.valueOf(AUTH_STATUS_VERIFIED).equals(profile.getRealNameAuthStatus())) { |
|||
return buildAuthVO(profile); |
|||
} |
|||
|
|||
String normalizedName = normalizeRequired(certName, "请填写真实姓名"); |
|||
String normalizedCertNo = normalizeRequired(certNo, "请填写身份证号").toUpperCase(); |
|||
String normalizedPicture = normalizeCertPicture(certPicture); |
|||
LocalDate birthday = parseBirthday(normalizedCertNo); |
|||
int age = Period.between(birthday, LocalDate.now()).getYears(); |
|||
if (age < 18) { |
|||
profile = ensureProfile(userId); |
|||
profile.setRealNameAuthStatus(AUTH_STATUS_UNDERAGE); |
|||
profile.setRealNameMasked(maskName(normalizedName)); |
|||
profile.setIdCardMasked(maskIdCard(normalizedCertNo)); |
|||
profile.setBirthDate(birthday.format(BIRTHDAY_FORMATTER)); |
|||
profile.setAge(age); |
|||
profile.setUpdateTime(new Date()); |
|||
userProfileMapper.updateById(profile); |
|||
throw new UnderageUserException("根据身份证号判断,你暂未满 18 周岁,不能使用 i/e 互动功能"); |
|||
} |
|||
|
|||
// 阿里云返回 Data.IsConsistent = "1" 才代表姓名、身份证号、人像照片为同一人。
|
|||
AliyunThreeElementResult verifyResult = callAliyunThreeElement(normalizedName, normalizedCertNo, normalizedPicture); |
|||
if (!"1".equals(verifyResult.getIsConsistent())) { |
|||
throw new IeBusinessException(aliyunResultMessage(verifyResult.getIsConsistent())); |
|||
} |
|||
|
|||
profile = ensureProfile(userId); |
|||
Date now = new Date(); |
|||
profile.setRealNameAuthStatus(AUTH_STATUS_VERIFIED); |
|||
profile.setRealNameEncrypted(encrypt(normalizedName)); |
|||
profile.setIdCardEncrypted(encrypt(normalizedCertNo)); |
|||
profile.setRealNameMasked(maskName(normalizedName)); |
|||
profile.setIdCardMasked(maskIdCard(normalizedCertNo)); |
|||
profile.setBirthDate(birthday.format(BIRTHDAY_FORMATTER)); |
|||
profile.setAge(age); |
|||
profile.setRealNameAuthTime(now); |
|||
// 实名只负责合规校验和创建 i/e 档案;资料完善仍交给前端原 profileSetup 流程。
|
|||
profile.setUpdateTime(now); |
|||
userProfileMapper.updateById(profile); |
|||
return buildAuthVO(profile); |
|||
} |
|||
|
|||
@Override |
|||
@Transactional |
|||
public IeRealNameAuthVO verifyStudentCard(Long userId, String imageUrl) { |
|||
if (userId == null) { |
|||
throw new IeBusinessException("登录状态已失效,请重新登录"); |
|||
} |
|||
if (imageUrl == null || imageUrl.trim().isEmpty()) { |
|||
throw new IeBusinessException("请先上传学生证照片"); |
|||
} |
|||
IeUserProfile profile = ensureProfile(userId); |
|||
if (Integer.valueOf(STUDENT_CARD_STATUS_VERIFIED).equals(profile.getStudentCardAuthStatus())) { |
|||
return buildAuthVO(profile); |
|||
} |
|||
|
|||
int currentCount = profile.getStudentCardVerifyCount() == null ? 0 : profile.getStudentCardVerifyCount(); |
|||
int nextCount = currentCount + 1; |
|||
Date now = new Date(); |
|||
profile.setStudentCardImageUrl(imageUrl.trim()); |
|||
profile.setStudentCardVerifyCount(nextCount); |
|||
profile.setStudentCardAuthTime(now); |
|||
profile.setUpdateTime(now); |
|||
|
|||
// 第一次如果模型判断不是学生证,要求用户重新上传;第二次上传直接放行,避免模型误判阻碍流程。
|
|||
if (nextCount >= 2) { |
|||
profile.setStudentCardAuthStatus(STUDENT_CARD_STATUS_VERIFIED); |
|||
userProfileMapper.updateById(profile); |
|||
IeRealNameAuthVO vo = buildAuthVO(profile); |
|||
vo.setMessage("学生证认证已通过"); |
|||
return vo; |
|||
} |
|||
|
|||
boolean isStudentCard = studentCardOcrService.isStudentCard(imageUrl.trim()); |
|||
if (isStudentCard) { |
|||
profile.setStudentCardAuthStatus(STUDENT_CARD_STATUS_VERIFIED); |
|||
userProfileMapper.updateById(profile); |
|||
IeRealNameAuthVO vo = buildAuthVO(profile); |
|||
vo.setMessage("学生证认证已通过"); |
|||
return vo; |
|||
} |
|||
|
|||
profile.setStudentCardAuthStatus(STUDENT_CARD_STATUS_RETRY_REQUIRED); |
|||
userProfileMapper.updateById(profile); |
|||
IeRealNameAuthVO vo = buildAuthVO(profile); |
|||
vo.setStudentCardNeedRetry(true); |
|||
vo.setMessage("未识别为大学生学生证,请重新上传学生证照片"); |
|||
return vo; |
|||
} |
|||
|
|||
@Override |
|||
public void assertAdultVerified(Long userId) { |
|||
IeUserProfile profile = findProfileByUserId(userId); |
|||
if (isRealNameAuditEnabled()) { |
|||
if (profile == null || profile.getRealNameAuthStatus() == null |
|||
|| profile.getRealNameAuthStatus() == AUTH_STATUS_NONE) { |
|||
throw new IeBusinessException("请先完成进阶实名认证后再继续"); |
|||
} |
|||
if (profile.getRealNameAuthStatus() == AUTH_STATUS_UNDERAGE) { |
|||
throw new UnderageUserException("根据实名信息判断,你暂未满 18 周岁,不能使用 i/e 互动功能"); |
|||
} |
|||
if (profile.getRealNameAuthStatus() != AUTH_STATUS_VERIFIED) { |
|||
throw new IeBusinessException("实名认证未通过,请重新认证后再继续"); |
|||
} |
|||
} |
|||
// 学生证认证流程临时注释保留,当前阶段不作为匹配/同频一题/发布动态的强制门槛。
|
|||
// if (profile == null || !Integer.valueOf(STUDENT_CARD_STATUS_VERIFIED).equals(profile.getStudentCardAuthStatus())) {
|
|||
// throw new IeBusinessException("请先上传学生证照片完成学生身份认证后再继续");
|
|||
// }
|
|||
} |
|||
|
|||
@Override |
|||
public boolean isRealNameAuditEnabled() { |
|||
String value = redisTemplateHelper == null ? null : redisTemplateHelper.get(REAL_NAME_AUDIT_ENABLED_KEY); |
|||
// 默认开启,满足上架审核;后台关闭后跳过实名门槛。
|
|||
return value == null || value.trim().isEmpty() || "1".equals(value) || "true".equalsIgnoreCase(value); |
|||
} |
|||
|
|||
@Override |
|||
public void setRealNameAuditEnabled(Boolean enabled) { |
|||
redisTemplateHelper.set(REAL_NAME_AUDIT_ENABLED_KEY, Boolean.FALSE.equals(enabled) ? "0" : "1"); |
|||
} |
|||
|
|||
private AliyunThreeElementResult callAliyunThreeElement(String certName, String certNo, String certPicture) { |
|||
if (isBlank(aliyunAccessKeyId) || isBlank(aliyunAccessKeySecret) || isBlank(aliyunAuthCode)) { |
|||
throw new IeBusinessException("未配置阿里云身份证三要素核验参数"); |
|||
} |
|||
try { |
|||
Map<String, String> params = new HashMap<>(); |
|||
params.put("Action", "CertNoThreeElementVerification"); |
|||
params.put("Version", "2020-02-17"); |
|||
params.put("Format", "JSON"); |
|||
params.put("SignatureMethod", "HMAC-SHA1"); |
|||
params.put("SignatureVersion", "1.0"); |
|||
params.put("SignatureNonce", UUID.randomUUID().toString()); |
|||
params.put("Timestamp", java.time.Instant.now().toString()); |
|||
params.put("AccessKeyId", aliyunAccessKeyId); |
|||
params.put("AuthCode", aliyunAuthCode); |
|||
params.put("CertName", certName); |
|||
params.put("CertNo", certNo); |
|||
params.put("CertPicture", certPicture); |
|||
// 号码百科接口 Mask 是固定枚举值,目前不加密只能传 NORMAL,不能传中文“不加密”。
|
|||
params.put("Mask", "NORMAL"); |
|||
params.put("Signature", signAliyunRpc(params)); |
|||
|
|||
String formBody = toFormBody(params); |
|||
Request request = new Request.Builder() |
|||
.url(aliyunEndpoint) |
|||
.post(okhttp3.RequestBody.create(FORM, formBody)) |
|||
.addHeader("Content-Type", "application/x-www-form-urlencoded; charset=utf-8") |
|||
.addHeader("Accept", "application/json") |
|||
.build(); |
|||
try (Response response = httpClient.newCall(request).execute()) { |
|||
String body = response.body() == null ? "" : response.body().string(); |
|||
JSONObject json = JSONUtil.parseObj(body); |
|||
if (!response.isSuccessful() || !"OK".equalsIgnoreCase(json.getStr("Code"))) { |
|||
String code = json.getStr("Code", String.valueOf(response.code())); |
|||
String requestId = json.getStr("RequestId", ""); |
|||
String message = json.getStr("Message", "请稍后重试"); |
|||
throw new IeBusinessException("阿里云实名认证失败:" + message + ",错误码:" + code + ",RequestId:" + requestId); |
|||
} |
|||
JSONObject data = json.getJSONObject("Data"); |
|||
AliyunThreeElementResult result = new AliyunThreeElementResult(); |
|||
result.setIsConsistent(data == null ? null : data.getStr("IsConsistent")); |
|||
result.setRequestId(json.getStr("RequestId")); |
|||
return result; |
|||
} |
|||
} catch (IeBusinessException e) { |
|||
throw e; |
|||
} catch (Exception e) { |
|||
throw new IeBusinessException("调用阿里云身份证三要素核验接口失败", e); |
|||
} |
|||
} |
|||
|
|||
private String signAliyunRpc(Map<String, String> params) throws Exception { |
|||
List<String> keys = new ArrayList<>(params.keySet()); |
|||
Collections.sort(keys); |
|||
StringBuilder canonicalizedQueryString = new StringBuilder(); |
|||
for (String key : keys) { |
|||
if ("Signature".equals(key)) { |
|||
continue; |
|||
} |
|||
if (canonicalizedQueryString.length() > 0) { |
|||
canonicalizedQueryString.append('&'); |
|||
} |
|||
canonicalizedQueryString.append(percentEncode(key)).append('=').append(percentEncode(params.get(key))); |
|||
} |
|||
String stringToSign = "POST&%2F&" + percentEncode(canonicalizedQueryString.toString()); |
|||
Mac mac = Mac.getInstance("HmacSHA1"); |
|||
mac.init(new SecretKeySpec((aliyunAccessKeySecret + "&").getBytes(StandardCharsets.UTF_8), "HmacSHA1")); |
|||
return Base64.getEncoder().encodeToString(mac.doFinal(stringToSign.getBytes(StandardCharsets.UTF_8))); |
|||
} |
|||
|
|||
private String toFormBody(Map<String, String> params) throws Exception { |
|||
StringBuilder builder = new StringBuilder(); |
|||
for (Map.Entry<String, String> entry : params.entrySet()) { |
|||
if (builder.length() > 0) { |
|||
builder.append('&'); |
|||
} |
|||
builder.append(percentEncode(entry.getKey())).append('=').append(percentEncode(entry.getValue())); |
|||
} |
|||
return builder.toString(); |
|||
} |
|||
|
|||
private String percentEncode(String value) throws Exception { |
|||
return URLEncoder.encode(value == null ? "" : value, "UTF-8") |
|||
.replace("+", "%20") |
|||
.replace("*", "%2A") |
|||
.replace("%7E", "~"); |
|||
} |
|||
|
|||
private String normalizeCertPicture(String certPicture) { |
|||
String value = normalizeRequired(certPicture, "请上传本人清晰人像照片"); |
|||
int commaIndex = value.indexOf(','); |
|||
if (value.startsWith("data:image") && commaIndex >= 0) { |
|||
value = value.substring(commaIndex + 1); |
|||
} |
|||
value = value.replaceAll("\\s+", ""); |
|||
if (value.getBytes(StandardCharsets.UTF_8).length > MAX_CERT_PICTURE_BASE64_BYTES) { |
|||
throw new IeBusinessException("人像照片Base64不得超过50KB,请重新拍摄或压缩后再试"); |
|||
} |
|||
try { |
|||
byte[] decoded = Base64.getDecoder().decode(value); |
|||
if (decoded.length > 50 * 1024) { |
|||
throw new IeBusinessException("人像照片文件不得超过50KB,请重新拍摄或压缩后再试"); |
|||
} |
|||
} catch (IllegalArgumentException e) { |
|||
throw new IeBusinessException("人像照片Base64格式不正确"); |
|||
} |
|||
return value; |
|||
} |
|||
|
|||
private String normalizeRequired(String value, String message) { |
|||
if (isBlank(value)) { |
|||
throw new IeBusinessException(message); |
|||
} |
|||
return value.trim(); |
|||
} |
|||
|
|||
private String aliyunResultMessage(String isConsistent) { |
|||
if ("0".equals(isConsistent)) { |
|||
return "姓名与身份证号匹配,但照片识别为非同一人,请重新上传本人清晰照片"; |
|||
} |
|||
if ("2".equals(isConsistent)) { |
|||
return "照片疑似为本人但未达到通过标准,请重新上传清晰正脸照片"; |
|||
} |
|||
if ("3".equals(isConsistent)) { |
|||
return "姓名与身份证号匹配,但库中无人像信息,无法完成三要素核验"; |
|||
} |
|||
if ("4".equals(isConsistent)) { |
|||
return "姓名与身份证号不一致,请核对后重新提交"; |
|||
} |
|||
if ("5".equals(isConsistent)) { |
|||
return "照片质量不合格,请重新上传清晰正脸照片"; |
|||
} |
|||
return "实名认证未通过,请核对信息后重试"; |
|||
} |
|||
|
|||
private LocalDate parseBirthday(String idCardNo) { |
|||
String id = idCardNo == null ? "" : idCardNo.trim().toUpperCase(); |
|||
if (!id.matches("^\\d{17}[0-9X]$")) { |
|||
throw new IeBusinessException("实名身份证号格式不正确"); |
|||
} |
|||
if (!validateChineseIdCardChecksum(id)) { |
|||
throw new IeBusinessException("实名身份证号校验位不正确"); |
|||
} |
|||
String birthday = id.substring(6, 14); |
|||
try { |
|||
return LocalDate.parse(birthday, DateTimeFormatter.ofPattern("yyyyMMdd")); |
|||
} catch (Exception e) { |
|||
throw new IeBusinessException("实名身份证号出生日期不正确"); |
|||
} |
|||
} |
|||
|
|||
private boolean validateChineseIdCardChecksum(String id) { |
|||
int[] weights = {7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2}; |
|||
char[] checks = {'1', '0', 'X', '9', '8', '7', '6', '5', '4', '3', '2'}; |
|||
int sum = 0; |
|||
for (int i = 0; i < 17; i++) { |
|||
sum += (id.charAt(i) - '0') * weights[i]; |
|||
} |
|||
return checks[sum % 11] == id.charAt(17); |
|||
} |
|||
|
|||
private String encrypt(String plainText) { |
|||
try { |
|||
byte[] key = resolveAesKey(); |
|||
byte[] iv = new byte[12]; |
|||
secureRandom.nextBytes(iv); |
|||
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); |
|||
cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(key, "AES"), new GCMParameterSpec(128, iv)); |
|||
byte[] encrypted = cipher.doFinal(plainText.getBytes(StandardCharsets.UTF_8)); |
|||
return Base64.getEncoder().encodeToString(iv) + ":" + Base64.getEncoder().encodeToString(encrypted); |
|||
} catch (IeBusinessException e) { |
|||
throw e; |
|||
} catch (Exception e) { |
|||
throw new IeBusinessException("实名信息加密失败", e); |
|||
} |
|||
} |
|||
|
|||
private byte[] resolveAesKey() { |
|||
if (aesKey == null || aesKey.trim().isEmpty()) { |
|||
if (isBlank(aliyunAccessKeySecret)) { |
|||
throw new IeBusinessException("未配置实名信息加密密钥"); |
|||
} |
|||
// 兼容线上未单独配置 ie.real-name.aes-key 的情况:使用已有阿里云密钥派生稳定的 32 字节 AES key。
|
|||
// 生产环境建议后续补充专用配置 ie.real-name.aes-key,避免不同用途共用同一份密钥材料。
|
|||
return sha256(aliyunAccessKeySecret.trim()); |
|||
} |
|||
byte[] key; |
|||
try { |
|||
key = Base64.getDecoder().decode(aesKey.trim()); |
|||
} catch (IllegalArgumentException ignored) { |
|||
key = aesKey.trim().getBytes(StandardCharsets.UTF_8); |
|||
} |
|||
if (key.length != 16 && key.length != 24 && key.length != 32) { |
|||
throw new IeBusinessException("实名信息加密密钥长度必须为 16/24/32 字节"); |
|||
} |
|||
return key; |
|||
} |
|||
|
|||
private byte[] sha256(String value) { |
|||
try { |
|||
return MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8)); |
|||
} catch (Exception e) { |
|||
throw new IeBusinessException("实名信息加密密钥派生失败", e); |
|||
} |
|||
} |
|||
|
|||
private IeUserProfile ensureProfile(Long userId) { |
|||
IeUserProfile profile = findProfileByUserId(userId); |
|||
if (profile != null) { |
|||
return profile; |
|||
} |
|||
Date now = new Date(); |
|||
profile = new IeUserProfile(); |
|||
profile.setUserId(userId); |
|||
profile.setAnonymousName("半匿名漂流者"); |
|||
profile.setAvatarText("夜"); |
|||
profile.setCurrentMode("i"); |
|||
profile.setRecentPreference("i"); |
|||
profile.setTargetModePreference("any"); |
|||
profile.setGender("unknown"); |
|||
profile.setTargetGenderPreference("any"); |
|||
profile.setDefaultRoomMinutes(IeConstants.DEFAULT_ROOM_MINUTES); |
|||
profile.setDailyQuota(IeConstants.DEFAULT_DAILY_QUOTA); |
|||
profile.setUsedQuota(0); |
|||
profile.setProfileCompleted(0); |
|||
profile.setRealNameAuthStatus(AUTH_STATUS_NONE); |
|||
profile.setStudentCardAuthStatus(STUDENT_CARD_STATUS_NONE); |
|||
profile.setStudentCardVerifyCount(0); |
|||
profile.setIsDeleted(0); |
|||
profile.setCreateTime(now); |
|||
profile.setUpdateTime(now); |
|||
try { |
|||
userProfileMapper.insert(profile); |
|||
return profile; |
|||
} catch (DuplicateKeyException ignored) { |
|||
IeUserProfile exists = findProfileByUserId(userId); |
|||
if (exists != null) { |
|||
return exists; |
|||
} |
|||
throw ignored; |
|||
} |
|||
} |
|||
|
|||
private IeUserProfile findProfileByUserId(Long userId) { |
|||
if (userId == null) { |
|||
return null; |
|||
} |
|||
return userProfileMapper.selectOne(new LambdaQueryWrapper<IeUserProfile>() |
|||
.eq(IeUserProfile::getUserId, userId) |
|||
.last("limit 1")); |
|||
} |
|||
|
|||
private IeRealNameAuthVO buildAuthVO(IeUserProfile profile) { |
|||
IeRealNameAuthVO vo = new IeRealNameAuthVO(); |
|||
boolean realNameVerified = profile != null && Integer.valueOf(AUTH_STATUS_VERIFIED).equals(profile.getRealNameAuthStatus()); |
|||
boolean studentCardVerified = profile != null && Integer.valueOf(STUDENT_CARD_STATUS_VERIFIED).equals(profile.getStudentCardAuthStatus()); |
|||
vo.setRealNameVerified(realNameVerified); |
|||
vo.setStudentCardVerified(studentCardVerified); |
|||
vo.setStudentCardNeedRetry(profile != null && Integer.valueOf(STUDENT_CARD_STATUS_RETRY_REQUIRED).equals(profile.getStudentCardAuthStatus())); |
|||
// 学生证认证流程临时注释保留,认证完成状态当前只看后台实名审核开关和实名状态。
|
|||
// vo.setVerified(isRealNameAuditEnabled() ? realNameVerified && studentCardVerified : studentCardVerified);
|
|||
vo.setVerified(isRealNameAuditEnabled() ? realNameVerified : true); |
|||
vo.setRealNameMasked(profile == null ? null : profile.getRealNameMasked()); |
|||
vo.setIdCardMasked(profile == null ? null : profile.getIdCardMasked()); |
|||
vo.setAge(profile == null ? null : profile.getAge()); |
|||
return vo; |
|||
} |
|||
|
|||
private String maskName(String name) { |
|||
if (name == null || name.trim().isEmpty()) { |
|||
return ""; |
|||
} |
|||
String value = name.trim(); |
|||
if (value.length() == 1) { |
|||
return "*"; |
|||
} |
|||
return value.charAt(0) + repeat("*", value.length() - 1); |
|||
} |
|||
|
|||
private String maskIdCard(String idCardNo) { |
|||
if (idCardNo == null || idCardNo.length() < 8) { |
|||
return "********"; |
|||
} |
|||
return idCardNo.substring(0, 3) + "***********" + idCardNo.substring(idCardNo.length() - 4); |
|||
} |
|||
|
|||
private String repeat(String value, int count) { |
|||
StringBuilder builder = new StringBuilder(); |
|||
for (int i = 0; i < count; i++) { |
|||
builder.append(value); |
|||
} |
|||
return builder.toString(); |
|||
} |
|||
|
|||
private boolean isBlank(String value) { |
|||
return value == null || value.trim().isEmpty(); |
|||
} |
|||
|
|||
private static class AliyunThreeElementResult { |
|||
private String isConsistent; |
|||
private String requestId; |
|||
|
|||
public String getIsConsistent() { |
|||
return isConsistent; |
|||
} |
|||
|
|||
public void setIsConsistent(String isConsistent) { |
|||
this.isConsistent = isConsistent; |
|||
} |
|||
|
|||
public String getRequestId() { |
|||
return requestId; |
|||
} |
|||
|
|||
public void setRequestId(String requestId) { |
|||
this.requestId = requestId; |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,58 @@ |
|||
package cc.hiver.mall.ie.service.impl; |
|||
|
|||
import cc.hiver.mall.config.aliocr.AliOcrConfig; |
|||
import cc.hiver.mall.ie.service.IeStudentCardOcrService; |
|||
import cc.hiver.mall.utils.AliOcrUtil; |
|||
import com.alibaba.dashscope.aigc.multimodalconversation.*; |
|||
import com.alibaba.dashscope.common.Role; |
|||
import lombok.extern.slf4j.Slf4j; |
|||
import org.springframework.beans.factory.annotation.Autowired; |
|||
import org.springframework.stereotype.Service; |
|||
|
|||
import java.util.Arrays; |
|||
import java.util.Collections; |
|||
|
|||
@Slf4j |
|||
@Service |
|||
public class IeStudentCardOcrServiceImpl implements IeStudentCardOcrService { |
|||
|
|||
private static final String STUDENT_CARD_PROMPT = "这是一张大学生的学生证吗?是返回true,不是返回false,禁止返回任何其他内容!"; |
|||
|
|||
@Autowired |
|||
private AliOcrConfig aliOcrConfig; |
|||
|
|||
@Override |
|||
public boolean isStudentCard(String imageUrl) { |
|||
if (imageUrl == null || imageUrl.trim().isEmpty()) { |
|||
return false; |
|||
} |
|||
try { |
|||
// 复用 AliOcrUtil 中通义千问图像识别的 API Key 初始化,避免线上未配置 aliyun.openapi.api-key 时无法调用。
|
|||
AliOcrUtil.prepareDashScopeApiKey(aliOcrConfig == null ? null : aliOcrConfig.getApiKey()); |
|||
MultiModalConversation conv = new MultiModalConversation(); |
|||
MultiModalConversationMessage systemMessage = MultiModalConversationMessage.builder() |
|||
.role(Role.SYSTEM.getValue()) |
|||
.content(Collections.singletonList(new MultiModalMessageItemText("You are a helpful assistant."))) |
|||
.build(); |
|||
MultiModalConversationMessage userMessage = MultiModalConversationMessage.builder() |
|||
.role(Role.USER.getValue()) |
|||
.content(Arrays.asList( |
|||
new MultiModalMessageItemImage(imageUrl.trim()), |
|||
new MultiModalMessageItemText(STUDENT_CARD_PROMPT))) |
|||
.build(); |
|||
MultiModalConversationParam param = MultiModalConversationParam.builder() |
|||
.model("qwen3.6-flash") |
|||
.messages(Arrays.asList(systemMessage, userMessage)) |
|||
.build(); |
|||
MultiModalConversationResult result = conv.call(param); |
|||
String text = result.getOutput().getChoices().get(0).getMessage().getContent().get(0).get("text").toString(); |
|||
String normalized = text == null ? "" : text.trim().toLowerCase(); |
|||
log.info("i/e学生证识别结果 imageUrl={}, result={}", imageUrl, normalized); |
|||
return "true".equals(normalized); |
|||
} catch (Exception e) { |
|||
// 图片识别属于辅助风控,识别异常按 false 处理,让用户按"首次失败重传、第二次放行"流程继续。
|
|||
log.warn("i/e学生证识别异常 imageUrl={}", imageUrl, e); |
|||
return false; |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,47 @@ |
|||
package cc.hiver.mall.ie.vo; |
|||
|
|||
import lombok.Data; |
|||
|
|||
@Data |
|||
public class IeRealNameAuthVO { |
|||
|
|||
/** |
|||
* 是否已经完成整套进阶认证:微信成年实名 + 学生证认证。 |
|||
*/ |
|||
private Boolean verified; |
|||
|
|||
/** |
|||
* 是否已经通过微信成年实名校验。 |
|||
*/ |
|||
private Boolean realNameVerified; |
|||
|
|||
/** |
|||
* 是否已经通过学生证认证。 |
|||
*/ |
|||
private Boolean studentCardVerified; |
|||
|
|||
/** |
|||
* 学生证第一次识别未通过时返回 true,提示用户重新上传。 |
|||
*/ |
|||
private Boolean studentCardNeedRetry; |
|||
|
|||
/** |
|||
* 面向前端的认证提示文案。 |
|||
*/ |
|||
private String message; |
|||
|
|||
/** |
|||
* 脱敏姓名,例如:张*。 |
|||
*/ |
|||
private String realNameMasked; |
|||
|
|||
/** |
|||
* 脱敏身份证号,例如:110***********1234。 |
|||
*/ |
|||
private String idCardMasked; |
|||
|
|||
/** |
|||
* 根据身份证号计算出的年龄。 |
|||
*/ |
|||
private Integer age; |
|||
} |
|||
@ -0,0 +1,19 @@ |
|||
ALTER TABLE ie_user_profile |
|||
ADD COLUMN real_name_auth_status TINYINT NOT NULL DEFAULT 0 COMMENT '实名认证状态:0未认证,1已通过,2未成年,3失败', |
|||
ADD COLUMN real_name_encrypted VARCHAR(512) NULL COMMENT 'AES-GCM加密后的真实姓名', |
|||
ADD COLUMN id_card_encrypted VARCHAR(512) NULL COMMENT 'AES-GCM加密后的身份证号', |
|||
ADD COLUMN real_name_masked VARCHAR(64) NULL COMMENT '脱敏姓名,仅用于前端展示', |
|||
ADD COLUMN id_card_masked VARCHAR(64) NULL COMMENT '脱敏身份证号,仅用于前端展示', |
|||
ADD COLUMN birth_date VARCHAR(10) NULL COMMENT '身份证解析生日,yyyy-MM-dd', |
|||
ADD COLUMN age INT NULL COMMENT '实名校验时计算出的年龄', |
|||
ADD COLUMN real_name_auth_time DATETIME NULL COMMENT '实名通过时间', |
|||
ADD COLUMN student_card_auth_status TINYINT NOT NULL DEFAULT 0 COMMENT '学生证认证状态:0未上传,1已通过,2首次识别未通过', |
|||
ADD COLUMN student_card_image_url VARCHAR(500) NULL COMMENT '最近一次学生证图片地址', |
|||
ADD COLUMN student_card_verify_count INT NOT NULL DEFAULT 0 COMMENT '学生证识别上传次数', |
|||
ADD COLUMN student_card_auth_time DATETIME NULL COMMENT '学生证认证时间'; |
|||
|
|||
ALTER TABLE ie_user_profile |
|||
ADD INDEX idx_ie_profile_real_name_auth (real_name_auth_status, age); |
|||
|
|||
ALTER TABLE ie_user_profile |
|||
ADD INDEX idx_ie_profile_student_card_auth (student_card_auth_status); |
|||
@ -0,0 +1,30 @@ |
|||
ALTER TABLE t_shop |
|||
ADD COLUMN support_shop_delivery TINYINT NOT NULL DEFAULT 0 COMMENT '是否支持商家自配送 0否 1是', |
|||
ADD COLUMN order_bkge DECIMAL(10,1) NOT NULL DEFAULT 0.0 COMMENT '商家自配送配送费', |
|||
ADD COLUMN shop_delivery_location TINYINT NULL COMMENT '商家自配送送达位置 1送到宿舍 2送到楼下', |
|||
ADD COLUMN shop_delivery_duration INT NULL COMMENT '商家自配送配送时长(分钟)', |
|||
ADD COLUMN worker_id VARCHAR(64) NULL COMMENT '商家自配送配送员ID', |
|||
ADD COLUMN worker_name VARCHAR(64) NULL COMMENT '商家自配送配送员姓名', |
|||
ADD COLUMN worker_phone VARCHAR(32) NULL COMMENT '商家自配送配送员电话'; |
|||
|
|||
ALTER TABLE mall_order |
|||
ADD COLUMN shop_delivery TINYINT NOT NULL DEFAULT 0 COMMENT '是否商家自配送 0否 1是'; |
|||
|
|||
ALTER TABLE t_shop |
|||
ADD COLUMN support_transfer_delivery TINYINT NOT NULL DEFAULT 0 COMMENT '是否支持中转配送 0否 1是', |
|||
ADD COLUMN transfer_delivery_duration INT NULL COMMENT '中转配送时长(分钟)', |
|||
ADD COLUMN transfer_address_id VARCHAR(64) NULL COMMENT '中转地点ID', |
|||
ADD COLUMN transfer_address_name VARCHAR(255) NULL COMMENT '中转地点名称'; |
|||
|
|||
ALTER TABLE mall_order |
|||
ADD COLUMN transfer_delivery TINYINT NOT NULL DEFAULT 0 COMMENT '是否中转配送 0否 1是', |
|||
ADD COLUMN transfer_address_id VARCHAR(64) NULL COMMENT '中转地点ID', |
|||
ADD COLUMN transfer_address_name VARCHAR(255) NULL COMMENT '中转地点名称'; |
|||
|
|||
ALTER TABLE mall_delivery_order |
|||
ADD COLUMN transfer_delivery TINYINT NOT NULL DEFAULT 0 COMMENT '是否中转配送 0否 1是', |
|||
ADD COLUMN transfer_address_id VARCHAR(64) NULL COMMENT '中转地点ID', |
|||
ADD COLUMN transfer_address_name VARCHAR(255) NULL COMMENT '中转地点名称', |
|||
ADD COLUMN transfer_arrive_time DATETIME NULL COMMENT '商家送达中转点时间', |
|||
ADD COLUMN transfer_arrive_image VARCHAR(500) NULL COMMENT '商家送达中转点图片', |
|||
ADD COLUMN transfer_pickup_code VARCHAR(8) NULL COMMENT '中转取餐码'; |
|||
@ -0,0 +1,3 @@ |
|||
-- 为 t_worker 表添加配送员送达地点配置 |
|||
ALTER TABLE `t_worker` |
|||
ADD COLUMN `delivery_location` TINYINT NULL DEFAULT 0 COMMENT '送达地点:0送上楼 1送到宿舍门口' AFTER `high_floor_fee`; |
|||
Loading…
Reference in new issue