| | |
| | | package com.ruoyi.user.service.impl; |
| | | |
| | | import java.util.Collections; |
| | | import java.util.Date; |
| | | import java.security.SecureRandom; |
| | | import java.util.List; |
| | | import java.util.Objects; |
| | | import org.slf4j.Logger; |
| | | import org.slf4j.LoggerFactory; |
| | | import org.springframework.beans.factory.annotation.Autowired; |
| | | import org.springframework.stereotype.Service; |
| | | import org.springframework.transaction.annotation.Transactional; |
| | | import com.ruoyi.common.core.constant.UserConstants; |
| | | import com.ruoyi.common.core.exception.ServiceException; |
| | | import com.ruoyi.common.core.utils.DateUtils; |
| | | import com.ruoyi.common.core.exception.ServiceException; |
| | | import com.ruoyi.common.core.utils.StringUtils; |
| | | import com.ruoyi.common.security.utils.SecurityUtils; |
| | | import com.ruoyi.user.domain.AccountAddress; |
| | | import com.ruoyi.user.config.UniversalCodeProperties; |
| | | import com.ruoyi.user.constant.ReferralConstants; |
| | | import com.ruoyi.user.domain.AccountUser; |
| | | import com.ruoyi.user.domain.vo.AccountUserDetailVo; |
| | | import com.ruoyi.user.domain.vo.MemberOptionVo; |
| | | import com.ruoyi.user.mapper.AccountAddressMapper; |
| | | import com.ruoyi.user.mapper.AccountAgreementMapper; |
| | | import com.ruoyi.user.mapper.AccountCertificationMapper; |
| | | import com.ruoyi.user.domain.vo.AccountUserInfoVo; |
| | | import com.ruoyi.user.domain.vo.EarningSummaryVo; |
| | | import com.ruoyi.user.enums.MemberLevelEnum; |
| | | import com.ruoyi.user.mapper.AccountEarningRecordMapper; |
| | | import com.ruoyi.user.mapper.AccountUserMapper; |
| | | import com.ruoyi.user.mapper.AccountWarehouseMapper; |
| | | import com.ruoyi.user.service.IAccountUserService; |
| | | |
| | | /** |
| | |
| | | @Service |
| | | public class AccountUserServiceImpl implements IAccountUserService |
| | | { |
| | | private static final Logger log = LoggerFactory.getLogger(AccountUserServiceImpl.class); |
| | | |
| | | @Autowired |
| | | private AccountUserMapper accountUserMapper; |
| | | |
| | | @Autowired |
| | | private AccountCertificationMapper accountCertificationMapper; |
| | | |
| | | @Autowired |
| | | private AccountAddressMapper accountAddressMapper; |
| | | |
| | | @Autowired |
| | | private AccountAgreementMapper accountAgreementMapper; |
| | | |
| | | @Autowired |
| | | private AccountWarehouseMapper accountWarehouseMapper; |
| | | |
| | | @Autowired |
| | | private AccountEarningRecordMapper accountEarningRecordMapper; |
| | | |
| | | @Autowired |
| | | private UniversalCodeProperties universalCodeProperties; |
| | | |
| | | /** 推荐码随机源(SecureRandom 线程安全,避免每次创建) */ |
| | | private static final SecureRandom RANDOM = new SecureRandom(); |
| | | |
| | | @Override |
| | | public AccountUser selectAccountUserById(Long userId) |
| | | { |
| | | return accountUserMapper.selectAccountUserById(userId); |
| | | } |
| | | |
| | | @Override |
| | | public AccountUser selectAccountUserByPhone(String phone) |
| | | { |
| | | return accountUserMapper.selectAccountUserByPhone(phone); |
| | | } |
| | | |
| | | @Override |
| | |
| | | @Override |
| | | public int insertAccountUser(AccountUser accountUser) |
| | | { |
| | | accountUser.setPassword(SecurityUtils.encryptPassword(accountUser.getPassword())); |
| | | // C端注册无密码,密码为空时跳过加密,避免 encryptPassword(null) 抛 NPE |
| | | if (StringUtils.isNotEmpty(accountUser.getPassword())) |
| | | { |
| | | accountUser.setPassword(SecurityUtils.encryptPassword(accountUser.getPassword())); |
| | | } |
| | | return accountUserMapper.insertAccountUser(accountUser); |
| | | } |
| | | |
| | |
| | | { |
| | | return accountUserMapper.deleteAccountUserByIds(userIds); |
| | | } |
| | | |
| | | @Override |
| | | public AccountUserDetailVo selectAccountUserDetail(Long userId) |
| | | { |
| | | AccountUserDetailVo detail = new AccountUserDetailVo(); |
| | | detail.setUser(accountUserMapper.selectAccountUserVoById(userId)); |
| | | detail.setCertification(accountCertificationMapper.selectCertificationByUserId(userId)); |
| | | detail.setAddresses(accountAddressMapper.selectAddressByUserId(userId)); |
| | | detail.setAgreements(accountAgreementMapper.selectAgreementByUserId(userId)); |
| | | detail.setWarehouses(accountWarehouseMapper.selectWarehouseByUserId(userId)); |
| | | return detail; |
| | | } |
| | | |
| | | @Override |
| | | public int updateAccountUserRelation(AccountUser accountUser) |
| | | { |
| | | AccountUser old = accountUserMapper.selectAccountUserById(accountUser.getUserId()); |
| | | if (StringUtils.isNull(old)) |
| | | { |
| | | return 0; |
| | | } |
| | | validateSupplier(old, accountUser.getSupplierId()); |
| | | // 绑定时间由服务端推导:换成另一个人的记为当前时间,解绑则清空, |
| | | // 关系没动则保留原值——否则每次保存都会把绑定时间刷成今天。 |
| | | Date now = DateUtils.getNowDate(); |
| | | accountUser.setReferrerBindTime( |
| | | resolveBindTime(old.getReferrerId(), accountUser.getReferrerId(), old.getReferrerBindTime(), now)); |
| | | accountUser.setSupplierBindTime( |
| | | resolveBindTime(old.getSupplierId(), accountUser.getSupplierId(), old.getSupplierBindTime(), now)); |
| | | return accountUserMapper.updateUserRelation(accountUser); |
| | | } |
| | | |
| | | @Override |
| | | public List<MemberOptionVo> searchSupplierCandidates(Long userId, String keyword) |
| | | { |
| | | // 空关键字不回结果:候选是等级高于该会员的全量集合,放开等于把大半张会员表倒进下拉。 |
| | | // 与 AccountCityCompanyServiceImpl.searchL4Member 是同一口径(那边搜 L4,这边搜等级高于该会员)。 |
| | | if (StringUtils.isEmpty(keyword)) |
| | | { |
| | | return Collections.emptyList(); |
| | | } |
| | | AccountUser member = accountUserMapper.selectAccountUserById(userId); |
| | | if (StringUtils.isNull(member)) |
| | | { |
| | | throw new ServiceException("会员不存在"); |
| | | } |
| | | // 严格高于该会员的等级集合;该会员已是最高等级时为空,必须提前返回—— |
| | | // 空集合会让候选 SQL 拼出 in () 的语法错误。 |
| | | List<String> levels = AccountUser.levelsAbove(member.getSystemLevel()); |
| | | if (levels.isEmpty()) |
| | | { |
| | | return Collections.emptyList(); |
| | | } |
| | | String kw = keyword.trim(); |
| | | // 纯数字时补一个 keywordId 走等值匹配(会员编号),非数字则不参与该列比较—— |
| | | // 写成 user_id like 或 cast(user_id as char) 都会让主键索引用不上。同 searchL4Member。 |
| | | Long keywordId = null; |
| | | try |
| | | { |
| | | keywordId = Long.valueOf(kw); |
| | | } |
| | | catch (NumberFormatException ignored) |
| | | { |
| | | // 非纯数字:只走手机号/昵称/姓名模糊匹配 |
| | | } |
| | | return accountUserMapper.selectSupplierCandidates(userId, levels, kw, keywordId); |
| | | } |
| | | |
| | | /** |
| | | * 校验新供货人的等级必须严格高于该会员(C < L1 < L2 < L3 < L4)。 |
| | | * |
| | | * 这是提交时的第二道闸:弹窗只列出合法候选,但 /account/user/relation 是公开入口, |
| | | * 绕过页面直接打接口也得拦住。解除供货(传 null)不需要校验。 |
| | | * |
| | | * 与旧值相同时直接放行:存量关系是「历来没有等级限制」时期写下的(例如 L4 的供货人是 C), |
| | | * 不该因为这次没换人被卡死——那是另一件事,按本项目的规矩也不在这里顺手修历史数据。 |
| | | */ |
| | | private void validateSupplier(AccountUser old, Long newSupplierId) |
| | | { |
| | | if (newSupplierId == null || newSupplierId.equals(old.getSupplierId())) |
| | | { |
| | | return; |
| | | } |
| | | // selectAccountUserById 的 where 自带 del_flag = '0',查不到即「不存在或已删除」 |
| | | AccountUser supplier = accountUserMapper.selectAccountUserById(newSupplierId); |
| | | if (StringUtils.isNull(supplier)) |
| | | { |
| | | throw new ServiceException("新供货人不存在"); |
| | | } |
| | | if (!AccountUser.isHigherLevel(supplier.getSystemLevel(), old.getSystemLevel())) |
| | | { |
| | | throw new ServiceException("新供货人的系统等级必须高于该会员(该会员:" + old.getSystemLevel() |
| | | + ",所选:" + supplier.getSystemLevel() + ")"); |
| | | } |
| | | } |
| | | |
| | | @Override |
| | | public int changeStatus(AccountUser accountUser) |
| | | { |
| | | // 本接口只服务列表页的「禁用/启用」,故目标状态只允许「正常/禁用」两态。 |
| | | // 注销中、已注销必须由注销审核流程驱动(见 AccountCancelApplyServiceImpl): |
| | | // 若这里放开,「已注销」的会员一次普通请求就能被改回「正常」,等于绕过审核。 |
| | | String target = accountUser.getStatus(); |
| | | if (!AccountUser.STATUS_NORMAL.equals(target) && !AccountUser.STATUS_DISABLED.equals(target)) |
| | | { |
| | | throw new ServiceException("账户状态只能改为「正常」或「禁用」"); |
| | | } |
| | | AccountUser old = accountUserMapper.selectAccountUserById(accountUser.getUserId()); |
| | | if (StringUtils.isNull(old)) |
| | | { |
| | | throw new ServiceException("会员不存在"); |
| | | } |
| | | // 反向也要拦:注销中/已注销的会员不允许从列表页改回正常或禁用,否则审核结论形同虚设 |
| | | if (AccountUser.STATUS_CANCELING.equals(old.getStatus()) || AccountUser.STATUS_CANCELED.equals(old.getStatus())) |
| | | { |
| | | throw new ServiceException("该会员正在注销或已注销,账户状态只能由注销审核流程变更"); |
| | | } |
| | | return accountUserMapper.updateUserStatus(accountUser); |
| | | } |
| | | |
| | | @Override |
| | | @Transactional |
| | | public int updateAccountAddress(AccountAddress accountAddress) |
| | | { |
| | | // 置为默认前先清掉同会员其它行的默认标记:MySQL 没有部分唯一索引, |
| | | // "每会员至多一条 is_default='1'"只能靠这两条语句在同一事务内保证。 |
| | | if ("1".equals(accountAddress.getIsDefault())) |
| | | { |
| | | accountAddressMapper.clearDefaultByUserId(accountAddress.getUserId()); |
| | | } |
| | | int rows = accountAddressMapper.updateAddress(accountAddress); |
| | | // updateAddress 的 where 带 user_id,id 与 userId 对不上时会更新 0 行。 |
| | | // 此时上面的默认标记已被清掉,必须抛异常回滚, |
| | | // 否则该会员会落到"一个默认地址都没有"的状态。 |
| | | if (rows == 0) |
| | | { |
| | | throw new ServiceException("收货地址不存在或不属于该会员"); |
| | | } |
| | | return rows; |
| | | } |
| | | |
| | | @Override |
| | | public int deleteAccountAddressByIds(Long[] ids) |
| | | { |
| | | return accountAddressMapper.deleteAddressByIds(ids); |
| | | } |
| | | |
| | | |
| | | private Date resolveBindTime(Long oldId, Long newId, Date oldTime, Date now) |
| | | { |
| | | if (newId == null) |
| | | { |
| | | return null; |
| | | } |
| | | // 关系未变时沿用原绑定时间;oldTime 为空(历史数据没记)则补当前时间 |
| | | if (Objects.equals(oldId, newId) && oldTime != null) |
| | | { |
| | | return oldTime; |
| | | } |
| | | return now; |
| | | } |
| | | |
| | | @Override |
| | | public AccountUser registerMember(String phone, String referralCode) |
| | | { |
| | | // STEP 1: 二次校验手机号未注册(防并发 / 防重复提交,网关侧已用分布式锁兜底) |
| | | if (StringUtils.isNotNull(accountUserMapper.selectAccountUserByPhone(phone))) |
| | | { |
| | | throw new ServiceException("手机号已注册,请前往登录"); |
| | | } |
| | | // STEP 2: 判定是否命中通用推荐码(命中则不绑定推荐人,且直接发放 L4 会员等级) |
| | | boolean universalReferral = isUniversalReferralCode(referralCode); |
| | | // STEP 3: 解析推荐人——命中通用推荐码则不绑定推荐人,否则按码定位并校验 |
| | | Long referrerUserId = resolveReferrerUserId(phone, referralCode, universalReferral); |
| | | // STEP 4: 生成全局唯一的本人推荐码(库内查重,冲突重试) |
| | | String myReferralCode = generateUniqueReferralCode(); |
| | | // STEP 5: 组装会员信息(C端无密码,账号=手机号;姓名注册未采集,落库留空,由用户后续在资料页完善) |
| | | AccountUser accountUser = new AccountUser(); |
| | | accountUser.setUserName(phone); |
| | | accountUser.setPhone(phone); |
| | | accountUser.setStatus(UserConstants.NORMAL); |
| | | accountUser.setMyReferralCode(myReferralCode); |
| | | accountUser.setReferrerId(referrerUserId); |
| | | // 通用推荐码注册的会员直接发放 L4 等级;其余不设等级,走库默认值 C(普通用户) |
| | | if (universalReferral) |
| | | { |
| | | accountUser.setSystemLevel(MemberLevelEnum.L4.getCode()); |
| | | } |
| | | accountUser.setCreateBy(phone); |
| | | if (insertAccountUser(accountUser) <= 0) |
| | | { |
| | | throw new ServiceException("注册失败,请稍后重试"); |
| | | } |
| | | return accountUser; |
| | | } |
| | | |
| | | @Override |
| | | public AccountUserInfoVo getAccountUserInfo(Long userId) |
| | | { |
| | | // STEP 1: 校验会员ID(登录态缺失时直接失败,避免无归属查询) |
| | | if (StringUtils.isNull(userId)) |
| | | { |
| | | throw new ServiceException("获取会员信息失败"); |
| | | } |
| | | // STEP 2: 按会员ID查询会员(逻辑删除已在 SQL 中过滤) |
| | | AccountUser accountUser = accountUserMapper.selectAccountUserById(userId); |
| | | if (StringUtils.isNull(accountUser)) |
| | | { |
| | | throw new ServiceException("获取会员信息失败"); |
| | | } |
| | | // STEP 3: 查询收益汇总(单位分),无任何流水时直接返回全零,避免前端 null 分支 |
| | | EarningSummaryVo earnings = accountEarningRecordMapper.selectEarningSummary(userId); |
| | | if (StringUtils.isNull(earnings)) |
| | | { |
| | | earnings = EarningSummaryVo.zero(); |
| | | } |
| | | // STEP 4: 组装展示对象;等级为空或非法值统一兜底为 C(普通用户);手机号脱敏后下发 |
| | | AccountUserInfoVo vo = new AccountUserInfoVo(); |
| | | vo.setUserId(accountUser.getUserId()); |
| | | vo.setNickname(accountUser.getNickname()); |
| | | vo.setPhone(maskPhone(accountUser.getPhone())); |
| | | vo.setAvatar(accountUser.getAvatar()); |
| | | vo.setMemberLevel(MemberLevelEnum.normalize(accountUser.getSystemLevel())); |
| | | vo.setEarnings(earnings); |
| | | // STEP 5: 返回个人中心展示数据 |
| | | return vo; |
| | | } |
| | | |
| | | /** |
| | | * 判定是否为通用推荐码 |
| | | * |
| | | * @param referralCode 注册提交的推荐码 |
| | | * @return true 表示命中通用推荐码 |
| | | */ |
| | | private boolean isUniversalReferralCode(String referralCode) |
| | | { |
| | | String universalCode = universalCodeProperties.getUniversalReferralCode(); |
| | | return StringUtils.isNotEmpty(universalCode) && universalCode.equals(referralCode); |
| | | } |
| | | |
| | | /** |
| | | * 解析注册时的推荐人用户ID |
| | | * |
| | | * 命中通用推荐码(测试便利)时跳过推荐人查询与校验,返回 null(不绑定推荐人); |
| | | * 否则按推荐码定位推荐人,不存在或已停用则拒绝注册。 |
| | | * |
| | | * @param phone 注册手机号(仅用于日志留痕,脱敏输出) |
| | | * @param referralCode 推荐码 |
| | | * @param universalReferral 是否已命中通用推荐码 |
| | | * @return 推荐人用户ID;命中通用码时为 null |
| | | */ |
| | | private Long resolveReferrerUserId(String phone, String referralCode, boolean universalReferral) |
| | | { |
| | | // 命中通用推荐码:不绑定推荐人 |
| | | if (universalReferral) |
| | | { |
| | | log.warn("[REFERRAL] universal code hit, not binding referrer, phone={}", maskPhone(phone)); |
| | | return null; |
| | | } |
| | | // 按推荐码定位推荐人,不存在或已停用则拒绝注册 |
| | | AccountUser referrer = accountUserMapper.selectAccountUserByReferralCode(referralCode); |
| | | if (StringUtils.isNull(referrer)) |
| | | { |
| | | throw new ServiceException("推荐人码无效"); |
| | | } |
| | | if (UserConstants.USER_DISABLE.equals(referrer.getStatus())) |
| | | { |
| | | throw new ServiceException("推荐人账号异常"); |
| | | } |
| | | return referrer.getUserId(); |
| | | } |
| | | |
| | | /** |
| | | * 手机号脱敏,形如 138****0000 |
| | | * |
| | | * @param phone 手机号 |
| | | * @return 脱敏后的手机号 |
| | | */ |
| | | private String maskPhone(String phone) |
| | | { |
| | | if (StringUtils.isEmpty(phone) || phone.length() < 7) |
| | | { |
| | | return phone; |
| | | } |
| | | return phone.substring(0, 3) + "****" + phone.substring(phone.length() - 4); |
| | | } |
| | | |
| | | /** |
| | | * 生成库内唯一的推荐码 |
| | | * |
| | | * @return 8 位大写字母数字推荐码 |
| | | */ |
| | | private String generateUniqueReferralCode() |
| | | { |
| | | String chars = ReferralConstants.CODE_CHARS; |
| | | for (int retry = 0; retry < ReferralConstants.GEN_MAX_RETRY; retry++) |
| | | { |
| | | StringBuilder sb = new StringBuilder(ReferralConstants.CODE_LENGTH); |
| | | for (int i = 0; i < ReferralConstants.CODE_LENGTH; i++) |
| | | { |
| | | sb.append(chars.charAt(RANDOM.nextInt(chars.length()))); |
| | | } |
| | | String code = sb.toString(); |
| | | if (StringUtils.isNull(accountUserMapper.selectAccountUserByReferralCode(code))) |
| | | { |
| | | return code; |
| | | } |
| | | } |
| | | throw new ServiceException("注册失败,请稍后重试"); |
| | | } |
| | | } |