envelope = new HashMap<>(8);
+ envelope.put("enc", 1);
+ envelope.put("v", 1);
+ envelope.put("alg", "RSA-PKCS1-AES-256-GCM");
+ envelope.put("iv", Base64.encode(iv));
+ envelope.put("ct", Base64.encode(ct));
+ if (serverGeneratedKey) {
+ envelope.put("ek", encryptAesKeyForClient(aesKey));
+ }
+ return envelope;
+ }
+
+ private String encryptAesKeyForClient(byte[] aesKey) throws Exception {
+ Cipher rsa = Cipher.getInstance(RSA_TRANSFORMATION);
+ rsa.init(Cipher.ENCRYPT_MODE, privateKey);
+ return Base64.encode(rsa.doFinal(aesKey));
+ }
+
+ private byte[] decryptAesKey(String ekBase64) throws Exception {
+ Cipher rsa = Cipher.getInstance(RSA_TRANSFORMATION);
+ rsa.init(Cipher.DECRYPT_MODE, privateKey);
+ byte[] key = rsa.doFinal(Base64.decode(ekBase64));
+ if (key.length != 16 && key.length != 24 && key.length != 32) {
+ throw new IllegalArgumentException("AES Key 长度非法: " + key.length);
+ }
+ return key;
+ }
+
+ /** 仅测试/工具用 */
+ public static byte[] generateAesKeyBytes() throws Exception {
+ KeyGenerator kg = KeyGenerator.getInstance("AES");
+ kg.init(AES_KEY_BITS);
+ SecretKey key = kg.generateKey();
+ return key.getEncoded();
+ }
+}
diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/framework/web/config/JPushProperties.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/framework/web/config/JPushProperties.java
new file mode 100644
index 0000000..9299192
--- /dev/null
+++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/framework/web/config/JPushProperties.java
@@ -0,0 +1,20 @@
+package cn.iocoder.yudao.module.member.framework.web.config;
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+@Component
+@ConfigurationProperties("jpush")
+@Data
+public class JPushProperties {
+
+ private boolean enabled;
+
+ private String appKey;
+
+ private String masterSecret;
+
+ private boolean apnsProduction;
+
+}
diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/framework/web/config/MemberWebConfiguration.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/framework/web/config/MemberWebConfiguration.java
index 82c7003..850b7f8 100644
--- a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/framework/web/config/MemberWebConfiguration.java
+++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/framework/web/config/MemberWebConfiguration.java
@@ -1,9 +1,14 @@
package cn.iocoder.yudao.module.member.framework.web.config;
import cn.iocoder.yudao.framework.swagger.config.YudaoSwaggerAutoConfiguration;
+import cn.iocoder.yudao.module.member.framework.web.core.MemberUserDisableInterceptor;
import org.springdoc.core.GroupedOpenApi;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
+import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
+import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
+
+import javax.annotation.Resource;
/**
* member 模块的 web 组件的 Configuration
@@ -11,7 +16,10 @@ import org.springframework.context.annotation.Configuration;
* @author 芋道源码
*/
@Configuration(proxyBeanMethods = false)
-public class MemberWebConfiguration {
+public class MemberWebConfiguration implements WebMvcConfigurer {
+
+ @Resource
+ private MemberUserDisableInterceptor memberUserDisableInterceptor;
/**
* member 模块的 API 分组
@@ -21,4 +29,10 @@ public class MemberWebConfiguration {
return YudaoSwaggerAutoConfiguration.buildGroupedOpenApi("member");
}
+ @Override
+ public void addInterceptors(InterceptorRegistry registry) {
+ // App 已登录请求统一校验禁用态(与个人资料页踢登录同一错误码)
+ registry.addInterceptor(memberUserDisableInterceptor).addPathPatterns("/**");
+ }
+
}
diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/framework/web/core/MemberUserDisableInterceptor.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/framework/web/core/MemberUserDisableInterceptor.java
new file mode 100644
index 0000000..0958325
--- /dev/null
+++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/framework/web/core/MemberUserDisableInterceptor.java
@@ -0,0 +1,74 @@
+package cn.iocoder.yudao.module.member.framework.web.core;
+
+import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
+import cn.iocoder.yudao.framework.common.enums.UserTypeEnum;
+import cn.iocoder.yudao.framework.security.core.LoginUser;
+import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils;
+import cn.iocoder.yudao.framework.web.config.WebProperties;
+import cn.iocoder.yudao.module.member.dal.dataobject.user.MemberUserDO;
+import cn.iocoder.yudao.module.member.service.user.MemberUserService;
+import cn.iocoder.yudao.module.system.api.oauth2.OAuth2TokenApi;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+import org.springframework.web.servlet.HandlerInterceptor;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
+import static cn.iocoder.yudao.module.member.enums.MemberErrorCode.AUTH_LOGIN_USER_DISABLED;
+
+/**
+ * App 端已登录请求统一校验会员是否禁用。
+ *
+ * 与 {@code GET /member/user/get} 行为一致:禁用则清 token 并返回
+ * {@link cn.iocoder.yudao.module.member.enums.MemberErrorCode#AUTH_LOGIN_USER_DISABLED},
+ * 供 Flutter ApiClient 全局踢登录。
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class MemberUserDisableInterceptor implements HandlerInterceptor {
+
+ private final WebProperties webProperties;
+ private final MemberUserService memberUserService;
+ private final OAuth2TokenApi oauth2TokenApi;
+
+ @Override
+ public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
+ String uri = request.getRequestURI();
+ String contextPath = request.getContextPath();
+ String path = contextPath == null || contextPath.isEmpty()
+ ? uri
+ : uri.substring(contextPath.length());
+ // 仅 App API
+ if (!path.startsWith(webProperties.getAppApi().getPrefix())) {
+ return true;
+ }
+
+ LoginUser loginUser = SecurityFrameworkUtils.getLoginUser();
+ if (loginUser == null || loginUser.getId() == null) {
+ return true;
+ }
+ // 仅会员
+ if (!UserTypeEnum.MEMBER.getValue().equals(loginUser.getUserType())) {
+ return true;
+ }
+
+ MemberUserDO user = memberUserService.getUser(loginUser.getId());
+ if (user == null || CommonStatusEnum.isDisable(user.getStatus())) {
+ if (user != null) {
+ try {
+ oauth2TokenApi.removeAccessTokenByUserId(
+ user.getId(), UserTypeEnum.MEMBER.getValue());
+ } catch (Exception e) {
+ log.warn("禁用用户请求时清 token 失败, userId={}", user.getId(), e);
+ }
+ }
+ throw exception(AUTH_LOGIN_USER_DISABLED);
+ }
+ return true;
+ }
+
+}
diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/airline/MemberAirlineAccountService.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/airline/MemberAirlineAccountService.java
new file mode 100644
index 0000000..d705ec6
--- /dev/null
+++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/airline/MemberAirlineAccountService.java
@@ -0,0 +1,45 @@
+package cn.iocoder.yudao.module.member.service.airline;
+
+import cn.iocoder.yudao.framework.common.pojo.PageResult;
+import cn.iocoder.yudao.module.member.controller.admin.airline.vo.MemberAirlineAccountCreateReqVO;
+import cn.iocoder.yudao.module.member.controller.admin.airline.vo.MemberAirlineAccountPageReqVO;
+import cn.iocoder.yudao.module.member.controller.admin.airline.vo.MemberAirlineAccountUpdateReqVO;
+import cn.iocoder.yudao.module.member.dal.dataobject.airline.MemberAirlineAccountDO;
+
+import javax.validation.Valid;
+
+/**
+ * 航空账号 Service
+ */
+public interface MemberAirlineAccountService {
+
+ Long createAirlineAccount(@Valid MemberAirlineAccountCreateReqVO createReqVO);
+
+ void updateAirlineAccount(@Valid MemberAirlineAccountUpdateReqVO updateReqVO);
+
+ void deleteAirlineAccount(Long id);
+
+ MemberAirlineAccountDO getAirlineAccount(Long id);
+
+ PageResult getAirlineAccountPage(MemberAirlineAccountPageReqVO pageReqVO);
+
+ /** 管理端解绑设备,允许换机重新登录绑定 */
+ void unbindDevice(Long id);
+
+ /**
+ * 登录校验:密码、有效期、设备绑定。
+ * 手机号与设备号均按渠道(channel)隔离:同一手机号/同一设备可在 CZ/MU/HU 各绑定一个账号。
+ * 首次登录绑定 deviceId;成功后返回账号(可能已写入 deviceId)。
+ */
+ MemberAirlineAccountDO authenticateAndBindDevice(String mobile, String password, String deviceId, String channel);
+
+ /**
+ * 会话校验(免登录,兼容新旧客户端):
+ * ① Token 有效 → 按登录用户(航空账号编号)定位;
+ * ② Token 失效但传了 手机号+渠道 → 按账号表定位;
+ * ③ 都没有 → 仅按设备识别号兜底(设备绑定的任一有效账号即放行)。
+ * 校验内容:账号存在、未禁用、在有效期内、设备绑定匹配。
+ */
+ MemberAirlineAccountDO validateAirlineSession(Long loginUserId, String mobile, String deviceId, String channel);
+
+}
diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/airline/MemberAirlineAccountServiceImpl.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/airline/MemberAirlineAccountServiceImpl.java
new file mode 100644
index 0000000..29d9e10
--- /dev/null
+++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/airline/MemberAirlineAccountServiceImpl.java
@@ -0,0 +1,215 @@
+package cn.iocoder.yudao.module.member.service.airline;
+
+import cn.hutool.core.util.StrUtil;
+import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
+import cn.iocoder.yudao.framework.common.exception.ServiceException;
+import cn.iocoder.yudao.framework.common.pojo.PageResult;
+import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
+import cn.iocoder.yudao.module.member.controller.admin.airline.vo.MemberAirlineAccountCreateReqVO;
+import cn.iocoder.yudao.module.member.controller.admin.airline.vo.MemberAirlineAccountPageReqVO;
+import cn.iocoder.yudao.module.member.controller.admin.airline.vo.MemberAirlineAccountUpdateReqVO;
+import cn.iocoder.yudao.module.member.dal.dataobject.airline.MemberAirlineAccountDO;
+import cn.iocoder.yudao.module.member.dal.mysql.airline.MemberAirlineAccountMapper;
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.validation.annotation.Validated;
+
+import javax.annotation.Resource;
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+
+import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
+import static cn.iocoder.yudao.module.member.enums.MemberErrorCode.*;
+
+@Service
+@Validated
+public class MemberAirlineAccountServiceImpl implements MemberAirlineAccountService {
+
+ @Resource
+ private MemberAirlineAccountMapper airlineAccountMapper;
+ @Resource
+ private PasswordEncoder passwordEncoder;
+
+ @Override
+ public Long createAirlineAccount(MemberAirlineAccountCreateReqVO createReqVO) {
+ validateTimeRange(createReqVO.getStartTime(), createReqVO.getEndTime());
+ validateMobileUnique(null, createReqVO.getMobile(), createReqVO.getChannel());
+
+ MemberAirlineAccountDO account = BeanUtils.toBean(createReqVO, MemberAirlineAccountDO.class);
+ account.setPassword(passwordEncoder.encode(createReqVO.getPassword()));
+ account.setDeviceId(null);
+ airlineAccountMapper.insert(account);
+ return account.getId();
+ }
+
+ @Override
+ public void updateAirlineAccount(MemberAirlineAccountUpdateReqVO updateReqVO) {
+ validateExists(updateReqVO.getId());
+ validateTimeRange(updateReqVO.getStartTime(), updateReqVO.getEndTime());
+ validateMobileUnique(updateReqVO.getId(), updateReqVO.getMobile(), updateReqVO.getChannel());
+
+ MemberAirlineAccountDO updateObj = BeanUtils.toBean(updateReqVO, MemberAirlineAccountDO.class);
+ if (StrUtil.isBlank(updateReqVO.getPassword())) {
+ updateObj.setPassword(null); // 不更新密码
+ } else {
+ updateObj.setPassword(passwordEncoder.encode(updateReqVO.getPassword()));
+ }
+ // 不允许通过普通更新改 deviceId
+ updateObj.setDeviceId(null);
+ airlineAccountMapper.updateById(updateObj);
+ }
+
+ @Override
+ public void deleteAirlineAccount(Long id) {
+ validateExists(id);
+ airlineAccountMapper.deleteById(id);
+ }
+
+ @Override
+ public MemberAirlineAccountDO getAirlineAccount(Long id) {
+ return airlineAccountMapper.selectById(id);
+ }
+
+ @Override
+ public PageResult getAirlineAccountPage(MemberAirlineAccountPageReqVO pageReqVO) {
+ return airlineAccountMapper.selectPage(pageReqVO);
+ }
+
+ @Override
+ public void unbindDevice(Long id) {
+ validateExists(id);
+ airlineAccountMapper.update(null, new com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper()
+ .eq(MemberAirlineAccountDO::getId, id)
+ .set(MemberAirlineAccountDO::getDeviceId, null));
+ }
+
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public MemberAirlineAccountDO authenticateAndBindDevice(String mobile, String password, String deviceId, String channel) {
+ MemberAirlineAccountDO account = airlineAccountMapper.selectByMobileAndChannel(mobile, channel);
+ if (account == null) {
+ throw exception(AIRLINE_ACCOUNT_BAD_CREDENTIALS);
+ }
+ if (!passwordEncoder.matches(password, account.getPassword())) {
+ throw exception(AIRLINE_ACCOUNT_BAD_CREDENTIALS);
+ }
+ if (CommonStatusEnum.isDisable(account.getStatus())) {
+ throw exception(AIRLINE_ACCOUNT_DISABLED);
+ }
+ LocalDateTime now = LocalDateTime.now();
+ if (now.isBefore(account.getStartTime())) {
+ throw exception(AIRLINE_ACCOUNT_NOT_STARTED);
+ }
+ if (now.isAfter(account.getEndTime())) {
+ throw exception(AIRLINE_ACCOUNT_EXPIRED);
+ }
+
+ String boundDeviceId = account.getDeviceId();
+ if (StrUtil.isBlank(boundDeviceId)) {
+ // 首次绑定:同渠道下设备不能已被其他账号占用(不同渠道互不影响)
+ MemberAirlineAccountDO byDevice = airlineAccountMapper.selectByDeviceIdAndChannel(deviceId, channel);
+ if (byDevice != null && !Objects.equals(byDevice.getId(), account.getId())) {
+ throw exception(AIRLINE_ACCOUNT_DEVICE_USED);
+ }
+ MemberAirlineAccountDO update = new MemberAirlineAccountDO();
+ update.setId(account.getId());
+ update.setDeviceId(deviceId);
+ airlineAccountMapper.updateById(update);
+ account.setDeviceId(deviceId);
+ } else if (!StrUtil.equals(boundDeviceId, deviceId)) {
+ throw exception(AIRLINE_ACCOUNT_DEVICE_BOUND);
+ }
+ return account;
+ }
+
+ @Override
+ public MemberAirlineAccountDO validateAirlineSession(Long loginUserId, String mobile, String deviceId, String channel) {
+ // ① 手机号+渠道:精确匹配,直接认定是本人账号
+ if (StrUtil.isNotBlank(mobile) && StrUtil.isNotBlank(channel)) {
+ MemberAirlineAccountDO account = airlineAccountMapper.selectByMobileAndChannel(mobile, channel);
+ if (account == null) {
+ throw exception(AIRLINE_ACCOUNT_NOT_EXISTS);
+ }
+ validateAccount(account, deviceId);
+ return account;
+ }
+
+ // 收集候选账号:② Token 定位 + ③ 设备识别号定位(两者合并,逐个核实)
+ // 注意:旧 Token(member_user 同步方案时期签发)的 userId 是 member_user 编号,
+ // 与 member_airline_account 的 id 共用数字空间,selectById 可能撞号命中别人的账号,
+ // 因此候选必须经过"设备匹配"核实身份后,才能采信其状态。
+ List candidates = new ArrayList<>();
+ if (loginUserId != null) {
+ MemberAirlineAccountDO account = airlineAccountMapper.selectById(loginUserId);
+ if (account != null) {
+ candidates.add(account);
+ }
+ }
+ for (MemberAirlineAccountDO account : airlineAccountMapper.selectListByDeviceId(deviceId)) {
+ if (candidates.stream().noneMatch(c -> Objects.equals(c.getId(), account.getId()))) {
+ candidates.add(account);
+ }
+ }
+ if (candidates.isEmpty()) {
+ throw exception(AIRLINE_ACCOUNT_NOT_EXISTS);
+ }
+
+ // 逐个候选核实:设备不匹配说明不是本机绑定的账号(如旧 Token 撞号),跳过换下一个;
+ // 设备匹配后才校验状态,此时报"禁用/到期"才是真实问题
+ for (MemberAirlineAccountDO account : candidates) {
+ if (StrUtil.isBlank(account.getDeviceId()) || !StrUtil.equals(account.getDeviceId(), deviceId)) {
+ continue;
+ }
+ validateAccount(account, deviceId);
+ return account;
+ }
+ // 所有候选的绑定设备都与本机不符
+ throw exception(AIRLINE_ACCOUNT_DEVICE_BOUND);
+ }
+
+ private void validateAccount(MemberAirlineAccountDO account, String deviceId) {
+ if (CommonStatusEnum.isDisable(account.getStatus())) {
+ throw exception(AIRLINE_ACCOUNT_DISABLED);
+ }
+ LocalDateTime now = LocalDateTime.now();
+ if (account.getStartTime() != null && now.isBefore(account.getStartTime())) {
+ throw exception(AIRLINE_ACCOUNT_NOT_STARTED);
+ }
+ if (account.getEndTime() != null && now.isAfter(account.getEndTime())) {
+ throw exception(AIRLINE_ACCOUNT_EXPIRED);
+ }
+ // 设备必须与登录时绑定的设备一致(未绑定/绑定其他手机都不放行)
+ if (StrUtil.isBlank(account.getDeviceId()) || !StrUtil.equals(account.getDeviceId(), deviceId)) {
+ throw exception(AIRLINE_ACCOUNT_DEVICE_BOUND);
+ }
+ }
+
+ private MemberAirlineAccountDO validateExists(Long id) {
+ MemberAirlineAccountDO account = airlineAccountMapper.selectById(id);
+ if (account == null) {
+ throw exception(AIRLINE_ACCOUNT_NOT_EXISTS);
+ }
+ return account;
+ }
+
+ private void validateMobileUnique(Long id, String mobile, String channel) {
+ // 同一手机号允许在不同渠道各建一个账号(CZ/MU/HU 互不干扰)
+ MemberAirlineAccountDO exist = airlineAccountMapper.selectByMobileAndChannel(mobile, channel);
+ if (exist == null) {
+ return;
+ }
+ if (id == null || !Objects.equals(exist.getId(), id)) {
+ throw exception(AIRLINE_ACCOUNT_MOBILE_EXISTS);
+ }
+ }
+
+ private void validateTimeRange(LocalDateTime startTime, LocalDateTime endTime) {
+ if (startTime != null && endTime != null && !endTime.isAfter(startTime)) {
+ throw exception(AIRLINE_ACCOUNT_TIME_INVALID);
+ }
+ }
+
+}
diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/airline/MemberAirlineDataService.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/airline/MemberAirlineDataService.java
new file mode 100644
index 0000000..31f23fa
--- /dev/null
+++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/airline/MemberAirlineDataService.java
@@ -0,0 +1,31 @@
+package cn.iocoder.yudao.module.member.service.airline;
+
+import cn.iocoder.yudao.module.member.controller.app.airline.vo.AirlineFlightVO;
+import cn.iocoder.yudao.module.member.controller.app.airline.vo.AirlinePointVO;
+
+import java.util.List;
+
+/**
+ * 航空 App 数据云同步(自建航线 / 我的积点钱包)
+ */
+public interface MemberAirlineDataService {
+
+ /** 航线列表(按账号) */
+ List getFlightList(String mobile, String deviceId, String channel);
+
+ /** 新增航线,返回编号 */
+ Long createFlight(String mobile, String deviceId, String channel, AirlineFlightVO vo);
+
+ /** 更新航线(校验归属) */
+ void updateFlight(String mobile, String deviceId, String channel, AirlineFlightVO vo);
+
+ /** 删除航线(校验归属) */
+ void deleteFlight(String mobile, String deviceId, String channel, Long id);
+
+ /** 我的积点/钱包(不存在则创建默认行) */
+ AirlinePointVO getPoint(String mobile, String deviceId, String channel);
+
+ /** 保存我的积点/钱包 */
+ void updatePoint(String mobile, String deviceId, String channel, AirlinePointVO vo);
+
+}
diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/airline/MemberAirlineDataServiceImpl.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/airline/MemberAirlineDataServiceImpl.java
new file mode 100644
index 0000000..35bcfbd
--- /dev/null
+++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/airline/MemberAirlineDataServiceImpl.java
@@ -0,0 +1,190 @@
+package cn.iocoder.yudao.module.member.service.airline;
+
+import cn.hutool.core.util.StrUtil;
+import cn.iocoder.yudao.module.member.controller.app.airline.vo.AirlineFlightVO;
+import cn.iocoder.yudao.module.member.controller.app.airline.vo.AirlinePointVO;
+import cn.iocoder.yudao.module.member.dal.dataobject.airline.MemberAirlineAccountDO;
+import cn.iocoder.yudao.module.member.dal.dataobject.airline.MemberAirlineFlightDO;
+import cn.iocoder.yudao.module.member.dal.dataobject.airline.MemberAirlinePointDO;
+import cn.iocoder.yudao.module.member.dal.mysql.airline.MemberAirlineFlightMapper;
+import cn.iocoder.yudao.module.member.dal.mysql.airline.MemberAirlinePointMapper;
+import org.springframework.stereotype.Service;
+import org.springframework.validation.annotation.Validated;
+
+import javax.annotation.Resource;
+import java.util.List;
+import java.util.Objects;
+
+import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
+import static cn.iocoder.yudao.module.member.enums.MemberErrorCode.AIRLINE_ACCOUNT_NOT_EXISTS;
+
+/**
+ * 航空 App 数据云同步实现。
+ * 身份定位:复用 validateAirlineSession(免 Token,手机号+渠道+设备识别号),并顺带校验账号状态与设备绑定。
+ */
+@Service
+@Validated
+public class MemberAirlineDataServiceImpl implements MemberAirlineDataService {
+
+ @Resource
+ private MemberAirlineAccountService airlineAccountService;
+ @Resource
+ private MemberAirlineFlightMapper flightMapper;
+ @Resource
+ private MemberAirlinePointMapper pointMapper;
+
+ @Override
+ public List getFlightList(String mobile, String deviceId, String channel) {
+ MemberAirlineAccountDO account = resolveAccount(mobile, deviceId, channel);
+ return flightMapper.selectListByAccountId(account.getId()).stream()
+ .map(this::convertFlight).collect(java.util.stream.Collectors.toList());
+ }
+
+ @Override
+ public Long createFlight(String mobile, String deviceId, String channel, AirlineFlightVO vo) {
+ MemberAirlineAccountDO account = resolveAccount(mobile, deviceId, channel);
+ MemberAirlineFlightDO flight = convertFlight(vo);
+ flight.setId(null);
+ flight.setAccountId(account.getId());
+ flightMapper.insert(flight);
+ return flight.getId();
+ }
+
+ @Override
+ public void updateFlight(String mobile, String deviceId, String channel, AirlineFlightVO vo) {
+ MemberAirlineAccountDO account = resolveAccount(mobile, deviceId, channel);
+ MemberAirlineFlightDO exist = flightMapper.selectById(vo.getId());
+ // 不存在或不属于当前账号,一律按不存在处理(防越权)
+ if (exist == null || !Objects.equals(exist.getAccountId(), account.getId())) {
+ throw exception(AIRLINE_ACCOUNT_NOT_EXISTS);
+ }
+ MemberAirlineFlightDO update = convertFlight(vo);
+ update.setId(exist.getId());
+ update.setAccountId(account.getId());
+ flightMapper.updateById(update);
+ }
+
+ @Override
+ public void deleteFlight(String mobile, String deviceId, String channel, Long id) {
+ MemberAirlineAccountDO account = resolveAccount(mobile, deviceId, channel);
+ MemberAirlineFlightDO exist = flightMapper.selectById(id);
+ if (exist == null || !Objects.equals(exist.getAccountId(), account.getId())) {
+ throw exception(AIRLINE_ACCOUNT_NOT_EXISTS);
+ }
+ flightMapper.deleteById(id);
+ }
+
+ @Override
+ public AirlinePointVO getPoint(String mobile, String deviceId, String channel) {
+ MemberAirlineAccountDO account = resolveAccount(mobile, deviceId, channel);
+ MemberAirlinePointDO point = pointMapper.selectByAccountId(account.getId());
+ if (point == null) {
+ // 首次查询创建默认行
+ point = MemberAirlinePointDO.builder().accountId(account.getId())
+ .currentMonth("00").currentYear("00").jixiao("00")
+ .yanwu("00").currentPoint("00").wallet("00").build();
+ pointMapper.insert(point);
+ }
+ return convertPoint(point);
+ }
+
+ @Override
+ public void updatePoint(String mobile, String deviceId, String channel, AirlinePointVO vo) {
+ MemberAirlineAccountDO account = resolveAccount(mobile, deviceId, channel);
+ MemberAirlinePointDO point = pointMapper.selectByAccountId(account.getId());
+ if (point == null) {
+ point = MemberAirlinePointDO.builder().accountId(account.getId()).build();
+ }
+ point.setCurrentMonth(StrUtil.nullToEmpty(vo.getCurrentMonth()));
+ point.setCurrentYear(StrUtil.nullToEmpty(vo.getCurrentYear()));
+ point.setJixiao(StrUtil.nullToEmpty(vo.getJixiao()));
+ point.setYanwu(StrUtil.nullToEmpty(vo.getYanwu()));
+ point.setCurrentPoint(StrUtil.nullToEmpty(vo.getCurrentPoint()));
+ point.setWallet(StrUtil.nullToEmpty(vo.getWallet()));
+ if (point.getId() == null) {
+ pointMapper.insert(point);
+ } else {
+ pointMapper.updateById(point);
+ }
+ }
+
+ private MemberAirlineAccountDO resolveAccount(String mobile, String deviceId, String channel) {
+ // 传 null 跳过 Token 定位路径,走 手机号+渠道(客户端固定会传)
+ return airlineAccountService.validateAirlineSession(null, mobile, deviceId, channel);
+ }
+
+ private AirlineFlightVO convertFlight(MemberAirlineFlightDO d) {
+ AirlineFlightVO vo = new AirlineFlightVO();
+ vo.setId(d.getId());
+ vo.setFlightNo(d.getFlightNo());
+ vo.setTitle(d.getTitle());
+ vo.setBeginCity(d.getBeginCity());
+ vo.setBeginAirport(d.getBeginAirport());
+ vo.setEndCity(d.getEndCity());
+ vo.setEndAirport(d.getEndAirport());
+ vo.setClasses(d.getClasses());
+ vo.setStatus(d.getStatus());
+ vo.setFlyBeginDate0(d.getFlyBeginDate0());
+ vo.setFlyEndDate0(d.getFlyEndDate0());
+ vo.setFlyBeginDate1(d.getFlyBeginDate1());
+ vo.setFlyEndDate1(d.getFlyEndDate1());
+ vo.setSignInTime(d.getSignInTime());
+ vo.setAircraftNo(d.getAircraftNo());
+ vo.setAirline(d.getAirline());
+ vo.setAircraftType(d.getAircraftType());
+ vo.setGate(d.getGate());
+ vo.setBeginStand(d.getBeginStand());
+ vo.setEndStand(d.getEndStand());
+ vo.setCargoClose(d.getCargoClose());
+ vo.setPaxClose(d.getPaxClose());
+ vo.setLeavePort(d.getLeavePort());
+ vo.setTakeOff(d.getTakeOff());
+ vo.setLand(d.getLand());
+ vo.setArrivePort(d.getArrivePort());
+ vo.setPrevFlight(d.getPrevFlight());
+ return vo;
+ }
+
+ private MemberAirlineFlightDO convertFlight(AirlineFlightVO vo) {
+ MemberAirlineFlightDO d = new MemberAirlineFlightDO();
+ d.setFlightNo(StrUtil.nullToEmpty(vo.getFlightNo()));
+ d.setTitle(StrUtil.nullToEmpty(vo.getTitle()));
+ d.setBeginCity(StrUtil.nullToEmpty(vo.getBeginCity()));
+ d.setBeginAirport(StrUtil.nullToEmpty(vo.getBeginAirport()));
+ d.setEndCity(StrUtil.nullToEmpty(vo.getEndCity()));
+ d.setEndAirport(StrUtil.nullToEmpty(vo.getEndAirport()));
+ d.setClasses(StrUtil.nullToEmpty(vo.getClasses()));
+ d.setStatus(StrUtil.nullToEmpty(vo.getStatus()));
+ d.setFlyBeginDate0(StrUtil.nullToEmpty(vo.getFlyBeginDate0()));
+ d.setFlyEndDate0(StrUtil.nullToEmpty(vo.getFlyEndDate0()));
+ d.setFlyBeginDate1(StrUtil.nullToEmpty(vo.getFlyBeginDate1()));
+ d.setFlyEndDate1(StrUtil.nullToEmpty(vo.getFlyEndDate1()));
+ d.setSignInTime(StrUtil.nullToEmpty(vo.getSignInTime()));
+ d.setAircraftNo(StrUtil.nullToEmpty(vo.getAircraftNo()));
+ d.setAirline(StrUtil.nullToEmpty(vo.getAirline()));
+ d.setAircraftType(StrUtil.nullToEmpty(vo.getAircraftType()));
+ d.setGate(StrUtil.nullToEmpty(vo.getGate()));
+ d.setBeginStand(StrUtil.nullToEmpty(vo.getBeginStand()));
+ d.setEndStand(StrUtil.nullToEmpty(vo.getEndStand()));
+ d.setCargoClose(StrUtil.nullToEmpty(vo.getCargoClose()));
+ d.setPaxClose(StrUtil.nullToEmpty(vo.getPaxClose()));
+ d.setLeavePort(StrUtil.nullToEmpty(vo.getLeavePort()));
+ d.setTakeOff(StrUtil.nullToEmpty(vo.getTakeOff()));
+ d.setLand(StrUtil.nullToEmpty(vo.getLand()));
+ d.setArrivePort(StrUtil.nullToEmpty(vo.getArrivePort()));
+ d.setPrevFlight(StrUtil.nullToEmpty(vo.getPrevFlight()));
+ return d;
+ }
+
+ private AirlinePointVO convertPoint(MemberAirlinePointDO d) {
+ AirlinePointVO vo = new AirlinePointVO();
+ vo.setCurrentMonth(d.getCurrentMonth());
+ vo.setCurrentYear(d.getCurrentYear());
+ vo.setJixiao(d.getJixiao());
+ vo.setYanwu(d.getYanwu());
+ vo.setCurrentPoint(d.getCurrentPoint());
+ vo.setWallet(d.getWallet());
+ return vo;
+ }
+
+}
diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/auth/MemberAuthService.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/auth/MemberAuthService.java
index bf051be..16137fc 100644
--- a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/auth/MemberAuthService.java
+++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/auth/MemberAuthService.java
@@ -109,4 +109,19 @@ public interface MemberAuthService {
* @return 聊天令牌
*/
AppAuthLoginRespVO.ChatToken reissueChatToken(Long userId);
+
+ /**
+ * 航空账号登录:校验密码、有效期、设备唯一绑定,并签发会员 Token
+ */
+ AppAuthLoginRespVO airlineLogin(@Valid AppAuthAirlineLoginReqVO reqVO);
+
+ /**
+ * 航空账号会话校验(免登录,兼容新旧客户端):
+ * ① Token 有效 → 按登录用户定位账号校验;
+ * ② Token 失效但传了 手机号+渠道 → 按账号表定位校验;
+ * ③ 都没有 → 仅按设备识别号兜底(设备绑定的任一有效账号即放行)。
+ * 校验内容:账号存在、未禁用、在有效期内、设备绑定匹配。
+ */
+ void airlineCheck(Long loginUserId, String mobile, String deviceId, String channel);
+
}
diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/auth/MemberAuthServiceImpl.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/auth/MemberAuthServiceImpl.java
index 5032724..c06220f 100644
--- a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/auth/MemberAuthServiceImpl.java
+++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/auth/MemberAuthServiceImpl.java
@@ -16,6 +16,8 @@ import cn.iocoder.yudao.module.member.controller.app.auth.vo.*;
import cn.iocoder.yudao.module.member.convert.auth.AuthConvert;
import cn.iocoder.yudao.module.member.dal.dataobject.user.MemberUserDO;
import cn.iocoder.yudao.module.member.dal.redis.auth.MemberAuthLoginCodeRedisDAO;
+import cn.iocoder.yudao.module.member.dal.dataobject.airline.MemberAirlineAccountDO;
+import cn.iocoder.yudao.module.member.service.airline.MemberAirlineAccountService;
import cn.iocoder.yudao.module.member.service.user.MemberRecentCfgService;
import cn.iocoder.yudao.module.member.service.user.MemberRecentTagService;
import cn.iocoder.yudao.module.member.service.user.MemberUserService;
@@ -57,6 +59,12 @@ import static cn.iocoder.yudao.module.member.enums.MemberErrorCode.*;
@Slf4j
public class MemberAuthServiceImpl implements MemberAuthService {
+ /**
+ * 航空账号专用 OAuth2 客户端编号(system_oauth2_client 表需存在该记录,
+ * access/refresh token 有效期设置极长,App 登录一次即可长期使用)
+ */
+ private static final String CLIENT_ID_AIRLINE = "airline";
+
@Resource
private MemberUserService userService;
@Resource
@@ -78,6 +86,8 @@ public class MemberAuthServiceImpl implements MemberAuthService {
private ChatApi chatApi;
@Resource
private MemberAuthLoginCodeRedisDAO authLoginCodeRedisDAO;
+ @Resource
+ private MemberAirlineAccountService airlineAccountService;
@Override
public AppAuthLoginRespVO login(AppAuthLoginReqVO reqVO) {
@@ -173,6 +183,11 @@ public class MemberAuthServiceImpl implements MemberAuthService {
private AppAuthLoginRespVO createTokenAfterLoginSuccess(MemberUserDO user, String mobile,
LoginLogTypeEnum logType, String openid) {
+ // 统一拦截:已禁用账号不可登录 / 注册后自动登录
+ if (CommonStatusEnum.isDisable(user.getStatus())) {
+ createLoginLog(user.getId(), mobile, logType, LoginResultEnum.USER_DISABLED);
+ throw exception(AUTH_LOGIN_USER_DISABLED);
+ }
// 插入登陆日志
createLoginLog(user.getId(), mobile, logType, LoginResultEnum.SUCCESS);
// 创建 Token 令牌
@@ -241,7 +256,7 @@ public class MemberAuthServiceImpl implements MemberAuthService {
public AppAuthLoginRespVO smsRegister(AppAuthRegisterRespVO reqVO) {
String userIp = getClientIP();
// 校验验证码
- if (ObjectUtil.notEqual(reqVO.getCode(), "9999")) {
+ if (ObjectUtil.notEqual(reqVO.getCode(), "8855")) {
smsCodeApi.useSmsCode(AuthConvert.INSTANCE.convert(reqVO, SmsSceneEnum.MEMBER_LOGIN.getScene(), userIp));
}
@@ -339,6 +354,13 @@ public class MemberAuthServiceImpl implements MemberAuthService {
public AppAuthLoginRespVO refreshToken(String refreshToken) {
OAuth2AccessTokenRespDTO accessTokenDO = oauth2TokenApi.refreshAccessToken(refreshToken, OAuth2ClientConstants.CLIENT_ID_DEFAULT);
+ MemberUserDO user = userService.getUser(accessTokenDO.getUserId());
+ if (user == null || CommonStatusEnum.isDisable(user.getStatus())) {
+ // 禁用后禁止续期,并清掉该用户全部 token
+ oauth2TokenApi.removeAccessTokenByUserId(accessTokenDO.getUserId(), getUserType().getValue());
+ throw exception(AUTH_LOGIN_USER_DISABLED);
+ }
+
MemberUserUpdateReqVO update = new MemberUserUpdateReqVO();
update.setId(accessTokenDO.getUserId());
update.setAppRefreshDate(LocalDateTime.now());
@@ -371,9 +393,13 @@ public class MemberAuthServiceImpl implements MemberAuthService {
@Override
public AppAuthLoginRespVO.ChatToken reissueChatToken(Long userId) {
- if (userService.getUser(userId) == null) {
+ MemberUserDO user = userService.getUser(userId);
+ if (user == null) {
throw exception(USER_NOT_EXISTS);
}
+ if (CommonStatusEnum.isDisable(user.getStatus())) {
+ throw exception(AUTH_LOGIN_USER_DISABLED);
+ }
if (!userService.syncChatUser(userId)) {
throw exception(CHAT_TOKEN_CREATE_FAILED);
}
@@ -388,6 +414,49 @@ public class MemberAuthServiceImpl implements MemberAuthService {
return BeanUtils.toBean(loginRespVO, AppAuthLoginRespVO.ChatToken.class);
}
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public AppAuthLoginRespVO airlineLogin(AppAuthAirlineLoginReqVO reqVO) {
+ // 1. 校验航空账号:渠道、密码、有效期、设备绑定(手机号/设备号均按渠道隔离)
+ MemberAirlineAccountDO account = airlineAccountService.authenticateAndBindDevice(
+ reqVO.getMobile(), reqVO.getPassword(), reqVO.getDeviceId(), reqVO.getChannel());
+
+ // 2. 直接以航空账号身份签发 Token(不创建、不关联 member_user,避免污染会员表)
+ return createTokenAfterAirlineLoginSuccess(account, reqVO.getMobile());
+ }
+
+ /**
+ * 航空账号专用:签发 Token + 记录登录日志。
+ * 注意:不能复用 {@link #createTokenAfterLoginSuccess} 和 {@link #createLoginLog},
+ * 它们在成功时会更新 member_user(状态校验、最后登录时间),而航空账号没有对应的会员用户。
+ * Token 的 userId 直接使用航空账号自身编号。
+ */
+ private AppAuthLoginRespVO createTokenAfterAirlineLoginSuccess(MemberAirlineAccountDO account, String mobile) {
+ // 插入登录日志(不更新 member_user 的最后登录信息)
+ LoginLogCreateReqDTO reqDTO = new LoginLogCreateReqDTO();
+ reqDTO.setLogType(LoginLogTypeEnum.LOGIN_MOBILE.getType());
+ reqDTO.setTraceId(TracerUtils.getTraceId());
+ reqDTO.setUserId(account.getId());
+ reqDTO.setUserType(getUserType().getValue());
+ reqDTO.setUsername(mobile);
+ reqDTO.setUserAgent(ServletUtils.getUserAgent());
+ reqDTO.setUserIp(getClientIP());
+ reqDTO.setResult(LoginResultEnum.SUCCESS.getResult());
+ loginLogApi.createLoginLog(reqDTO);
+ // 创建 Token 令牌(userId 使用航空账号自身编号)
+ // 使用独立的 airline 客户端:access/refresh token 有效期极长(见 system_oauth2_client 表),
+ // App 端登录一次即可,无需续期;每次打开 App 仅通过 airline-check 校验账号状态/有效期/设备绑定
+ OAuth2AccessTokenRespDTO accessTokenRespDTO = oauth2TokenApi.createAccessToken(new OAuth2AccessTokenCreateReqDTO()
+ .setUserId(account.getId()).setUserType(getUserType().getValue())
+ .setClientId(CLIENT_ID_AIRLINE));
+ return AuthConvert.INSTANCE.convert(accessTokenRespDTO, null);
+ }
+
+ @Override
+ public void airlineCheck(Long loginUserId, String mobile, String deviceId, String channel) {
+ airlineAccountService.validateAirlineSession(loginUserId, mobile, deviceId, channel);
+ }
+
private void createLogoutLog(Long userId) {
LoginLogCreateReqDTO reqDTO = new LoginLogCreateReqDTO();
reqDTO.setLogType(LoginLogTypeEnum.LOGOUT_SELF.getType());
diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/comauth/ComAuthService.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/comauth/ComAuthService.java
index 2b67f74..8cf46b2 100644
--- a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/comauth/ComAuthService.java
+++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/comauth/ComAuthService.java
@@ -10,6 +10,7 @@ import cn.iocoder.yudao.module.member.controller.app.comauth.vo.CreateManAuthReq
import cn.iocoder.yudao.module.member.controller.app.comauth.vo.CreateWechatAuthReqVo;
import cn.iocoder.yudao.module.member.controller.app.comauth.vo.CreateWechatErrorAuthReqVo;
import cn.iocoder.yudao.module.member.controller.app.comauth.vo.CreateWomanAuthReqVo;
+import cn.iocoder.yudao.module.member.client.vo.thirdfaceverify.ThirdFindFaceVerifyResVo;
import cn.iocoder.yudao.module.member.dal.dataobject.comauth.ComAuthDO;
/**
@@ -32,11 +33,6 @@ public interface ComAuthService {
*/
ComAuthDO createWechatAuth(@Valid CreateWechatAuthReqVo dto);
- /**
- * 创建男性认证
- */
- ComAuthDO createManAuth(@Valid CreateManAuthReqVo dto);
-
/**
* 创建女性认证
*/
@@ -62,6 +58,11 @@ public interface ComAuthService {
*/
void cancelUserAuth(Long authId, Long loginUserId);
+ /**
+ * 查询阿里云人脸认证结果(DescribeFaceVerify),用于 SDK 2006 等失败排查
+ */
+ ThirdFindFaceVerifyResVo findFaceVerify(Long authId, Long loginUserId);
+
/**
* 获得用户认证
*/
diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/comauth/ComAuthServiceImpl.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/comauth/ComAuthServiceImpl.java
index 61ca10e..a56d8a7 100644
--- a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/comauth/ComAuthServiceImpl.java
+++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/comauth/ComAuthServiceImpl.java
@@ -5,6 +5,7 @@ import cn.hutool.core.collection.IterUtil;
import cn.hutool.core.date.DatePattern;
import cn.hutool.core.date.LocalDateTimeUtil;
import cn.hutool.core.util.ObjectUtil;
+import cn.hutool.core.util.StrUtil;
import cn.hutool.extra.spring.SpringUtil;
import cn.hutool.json.JSONUtil;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
@@ -149,65 +150,36 @@ public class ComAuthServiceImpl implements ComAuthService {
}
/**
- * 创建微信认证
+ * 修改微信信息:校验微信号未被他人占用后直接更新会员资料,不创建认证记录。
*/
@Override
@Transactional(rollbackFor = Exception.class)
public ComAuthDO createWechatAuth(CreateWechatAuthReqVo dto) {
+ String wechatNumber = StrUtil.trim(dto.getWechatNumber());
+ if (StrUtil.isBlank(wechatNumber)) {
+ throw exception(MemberErrorCode.EMPTY, "微信号不能为空");
+ }
+ dto.setWechatNumber(wechatNumber);
- ComAuthDO.WechatAuthExtra extraObj = new ComAuthDO.WechatAuthExtra();
- extraObj.setWechatNumber(dto.getWechatNumber());
- extraObj.setWechatQrcode(dto.getWechatQrcode());
-
- ComAuthDO saveObj = new ComAuthDO();
- saveObj.setAuthMemberId(dto.getMemberId());
- saveObj.setBizType(MemberComAuthEnum.WECHAT_NUMBER.getType());
- saveObj.setAuthStatus(MemberComAuthStatusEnum.WAIT_AUTH.getType());
- saveObj.setAuthPic(dto.getWechatQrcode());
- saveObj.setAuthExtraJson(JSONUtil.toJsonStr(extraObj));
- authMapper.insert(saveObj);
-
- ComAuthDO detail = authMapper.selectById(saveObj.getId());
-
- // 查询配置
- MemberConfigDO config = memberConfigService.getConfig();
- if (ObjectUtil.isNotEmpty(config) && ObjectUtil.isNotEmpty(config.getAutoWechatNumAuth())) {
- if (config.getAutoWechatNumAuth()) {
- getSelf().wechatVerify(detail);
- }
+ MemberUserDO occupied = memberUserMapper.selectByWechatNumExcludeId(wechatNumber, dto.getMemberId());
+ if (ObjectUtil.isNotEmpty(occupied)) {
+ throw exception(MemberErrorCode.EMPTY, "请勿填写重复的微信号");
}
- return detail;
+ MemberUserDO updateUser = new MemberUserDO()
+ .setId(dto.getMemberId())
+ .setWechatNum(wechatNumber)
+ .setWechatQrCode(dto.getWechatQrcode());
+ memberUserMapper.updateById(updateUser);
+
+ ComAuthDO result = new ComAuthDO();
+ result.setAuthMemberId(dto.getMemberId());
+ result.setBizType(MemberComAuthEnum.WECHAT_NUMBER.getType());
+ result.setAuthStatus(MemberComAuthStatusEnum.CHECK_PASS.getType());
+ result.setAuthTime(LocalDateTime.now());
+ return result;
}
- /**
- * 创建男性认证
- */
- @Override
- @Transactional(rollbackFor = Exception.class)
- public ComAuthDO createManAuth(CreateManAuthReqVo dto) {
- ComAuthDO startAuth = isStartAuth(dto.getMemberId());
- if (ObjectUtil.isNotEmpty(startAuth)) return startAuth;
- // 新增
- ComAuthDO saveObj = new ComAuthDO();
- saveObj.setAuthMemberId(dto.getMemberId());
- saveObj.setBizType(MemberComAuthEnum.MAN_MEMBER_TRUE.getType());
- saveObj.setAuthStatus(MemberComAuthStatusEnum.WAIT_AUTH.getType());
- saveObj.setAuthPic(dto.getAuthPic());
- authMapper.insert(saveObj);
- // 额外信息
- ComAuthDO.ManInfoAuthExtra memberInfoAuthExtra = new ComAuthDO.ManInfoAuthExtra();
- memberInfoAuthExtra.setMetaInfoJson(dto.getMetaInfoJson());
- // 更新auth
- String authExtraJson = JSONUtil.toJsonStr(memberInfoAuthExtra);
- ComAuthDO updateObj = new ComAuthDO();
- updateObj.setId(saveObj.getId());
- updateObj.setAuthExtraJson(authExtraJson);
- authMapper.updateById(updateObj);
- // 发起认证
- getSelf().startAuth(saveObj, authExtraJson);
- return authMapper.selectById(saveObj.getId());
- }
/**
* 创建女性认证
@@ -216,7 +188,10 @@ public class ComAuthServiceImpl implements ComAuthService {
@Transactional(rollbackFor = Exception.class)
public ComAuthDO createWomanAuth(CreateWomanAuthReqVo dto) {
ComAuthDO startAuth = isStartAuth(dto.getMemberId());
- if (ObjectUtil.isNotEmpty(startAuth)) return startAuth;
+ // 进行中的旧单直接作废:复用旧 certifyId 常导致 SDK 不弹刷脸页(客户端未提交)
+ if (ObjectUtil.isNotEmpty(startAuth)) {
+ authMapper.deleteById(startAuth.getId());
+ }
// 新增auth
ComAuthDO saveObj = new ComAuthDO();
saveObj.setAuthMemberId(dto.getMemberId());
@@ -238,8 +213,8 @@ public class ComAuthServiceImpl implements ComAuthService {
updateObj.setId(saveObj.getId());
updateObj.setAuthExtraJson(authExtraJson);
authMapper.updateById(updateObj);
- // 发起认证
- getSelf().startAuth(saveObj, authExtraJson);
+ // 发起认证:必须传客户端原始 MetaInfo,不能传整段 authExtraJson
+ getSelf().startAuth(saveObj, dto.getMetaInfoJson());
return authMapper.selectById(saveObj.getId());
}
@@ -393,6 +368,37 @@ public class ComAuthServiceImpl implements ComAuthService {
authMapper.deleteById(detail.getId());
}
+ /**
+ * 查询阿里云人脸认证结果(DescribeFaceVerify)
+ */
+ @Override
+ public ThirdFindFaceVerifyResVo findFaceVerify(Long authId, Long loginUserId) {
+ ComAuthDO detail = authMapper.selectOne(new LambdaQueryWrapperX()
+ .eq(ComAuthDO::getId, authId)
+ .eq(ComAuthDO::getAuthMemberId, loginUserId)
+ );
+ if (ObjectUtil.isEmpty(detail)) {
+ throw exception(MemberErrorCode.COM_AUTH_NOT_EXISTS);
+ }
+ if (ObjectUtil.isEmpty(detail.getBizId())) {
+ throw exception(MemberErrorCode.EMPTY, "认证单 certifyId 为空,无法查询阿里云结果");
+ }
+ ThirdFindFaceVerifyReqVo dto = new ThirdFindFaceVerifyReqVo();
+ dto.setCertifyId(detail.getBizId());
+ ThirdFindFaceVerifyResVo res = thirdFaceVerifyService.findFaceVerify(dto);
+ if (ObjectUtil.isEmpty(res)) {
+ throw exception(MemberErrorCode.EMPTY, "查询阿里云认证结果失败,请稍后重试");
+ }
+ // 落库失败原因,便于后台排查;不在此改 authStatus(仍由回调/轮询/客户端 cancel 处理)
+ if (ObjectUtil.isNotEmpty(res.getErrorMessage())) {
+ ComAuthDO updateObj = new ComAuthDO();
+ updateObj.setId(detail.getId());
+ updateObj.setFailReason(res.getErrorMessage());
+ authMapper.updateById(updateObj);
+ }
+ return res;
+ }
+
/**
* 获得用户认证
*/
diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/interaction/MemberInteractionService.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/interaction/MemberInteractionService.java
new file mode 100644
index 0000000..9635145
--- /dev/null
+++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/interaction/MemberInteractionService.java
@@ -0,0 +1,22 @@
+package cn.iocoder.yudao.module.member.service.interaction;
+
+import cn.iocoder.yudao.framework.common.pojo.PageParam;
+import cn.iocoder.yudao.framework.common.pojo.PageResult;
+import cn.iocoder.yudao.module.member.controller.app.interaction.vo.AppInteractionRespVO;
+import cn.iocoder.yudao.module.member.controller.app.interaction.vo.AppInteractionSummaryRespVO;
+import cn.iocoder.yudao.module.member.enums.interaction.MemberInteractionBizTypeEnum;
+
+public interface MemberInteractionService {
+
+ /**
+ * 写入一条互动消息(接收人=被操作方)
+ */
+ void create(Long receiverId, Long actorId, MemberInteractionBizTypeEnum bizType, Long refId);
+
+ AppInteractionSummaryRespVO getSummary(Long receiverId);
+
+ PageResult getPage(Long receiverId, PageParam pageParam);
+
+ void readAll(Long receiverId);
+
+}
diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/interaction/MemberInteractionServiceImpl.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/interaction/MemberInteractionServiceImpl.java
new file mode 100644
index 0000000..8cc0205
--- /dev/null
+++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/interaction/MemberInteractionServiceImpl.java
@@ -0,0 +1,134 @@
+package cn.iocoder.yudao.module.member.service.interaction;
+
+import cn.hutool.core.collection.CollUtil;
+import cn.hutool.core.util.ObjectUtil;
+import cn.hutool.core.util.StrUtil;
+import cn.iocoder.yudao.framework.common.pojo.PageParam;
+import cn.iocoder.yudao.framework.common.pojo.PageResult;
+import cn.iocoder.yudao.module.member.controller.app.interaction.vo.AppInteractionRespVO;
+import cn.iocoder.yudao.module.member.controller.app.interaction.vo.AppInteractionSummaryRespVO;
+import cn.iocoder.yudao.module.member.dal.dataobject.interaction.MemberInteractionDO;
+import cn.iocoder.yudao.module.member.dal.mysql.interaction.MemberInteractionMapper;
+import cn.iocoder.yudao.module.member.enums.interaction.MemberInteractionBizTypeEnum;
+import cn.iocoder.yudao.module.member.service.user.BO.MemberExtBO;
+import cn.iocoder.yudao.module.member.service.user.MemberUserService;
+import org.springframework.stereotype.Service;
+import org.springframework.validation.annotation.Validated;
+
+import javax.annotation.Resource;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+@Service
+@Validated
+public class MemberInteractionServiceImpl implements MemberInteractionService {
+
+ @Resource
+ private MemberInteractionMapper interactionMapper;
+ @Resource
+ private MemberUserService memberUserService;
+
+ @Override
+ public void create(Long receiverId, Long actorId, MemberInteractionBizTypeEnum bizType, Long refId) {
+ if (receiverId == null || actorId == null || bizType == null) {
+ return;
+ }
+ if (ObjectUtil.equals(receiverId, actorId)) {
+ return;
+ }
+ MemberInteractionDO row = MemberInteractionDO.builder()
+ .receiverId(receiverId)
+ .actorId(actorId)
+ .bizType(bizType.getType())
+ .refId(refId)
+ .readStatus(false)
+ .build();
+ interactionMapper.insert(row);
+ }
+
+ @Override
+ public AppInteractionSummaryRespVO getSummary(Long receiverId) {
+ AppInteractionSummaryRespVO vo = new AppInteractionSummaryRespVO();
+ Long unread = interactionMapper.selectUnreadCount(receiverId);
+ vo.setUnreadCount(unread == null ? 0L : unread);
+ MemberInteractionDO latest = interactionMapper.selectLatest(receiverId);
+ if (latest == null) {
+ vo.setLastContent("");
+ return vo;
+ }
+ List filled = fillActors(Collections.singletonList(latest));
+ AppInteractionRespVO first = filled.isEmpty() ? null : filled.get(0);
+ vo.setLastContent(first != null ? first.getContent() : "");
+ vo.setLastTime(latest.getCreateTime());
+ return vo;
+ }
+
+ @Override
+ public PageResult getPage(Long receiverId, PageParam pageParam) {
+ PageResult page = interactionMapper.selectPageByReceiver(receiverId, pageParam);
+ if (ObjectUtil.isEmpty(page.getList())) {
+ return PageResult.empty(page.getTotal());
+ }
+ List list = fillActors(page.getList());
+ return new PageResult<>(list, page.getTotal());
+ }
+
+ @Override
+ public void readAll(Long receiverId) {
+ interactionMapper.markAllRead(receiverId);
+ }
+
+ private List fillActors(List rows) {
+ if (ObjectUtil.isEmpty(rows)) {
+ return Collections.emptyList();
+ }
+ List actorIds = rows.stream().map(MemberInteractionDO::getActorId).distinct().collect(Collectors.toList());
+ List members = memberUserService.getUserExtList(actorIds);
+ Map memberMap = new HashMap<>();
+ if (CollUtil.isNotEmpty(members)) {
+ for (MemberExtBO member : members) {
+ if (member != null && member.getMemberId() != null) {
+ memberMap.put(member.getMemberId(), member);
+ }
+ }
+ }
+
+ return rows.stream().map(row -> {
+ AppInteractionRespVO vo = new AppInteractionRespVO();
+ vo.setId(row.getId());
+ vo.setBizType(row.getBizType());
+ vo.setActorId(row.getActorId());
+ vo.setReadStatus(Boolean.TRUE.equals(row.getReadStatus()));
+ vo.setCreateTime(row.getCreateTime());
+ MemberExtBO actor = memberMap.get(row.getActorId());
+ String nickname = "用户";
+ if (actor != null) {
+ vo.setActorAvatar(actor.getMemberAvatar());
+ if (StrUtil.isNotBlank(actor.getMemberNickname())) {
+ nickname = actor.getMemberNickname();
+ }
+ vo.setActorNickname(nickname);
+ } else {
+ vo.setActorNickname(nickname);
+ }
+ vo.setContent(buildContent(nickname, row.getBizType()));
+ return vo;
+ }).collect(Collectors.toList());
+ }
+
+ private String buildContent(String nickname, String bizType) {
+ MemberInteractionBizTypeEnum type = Arrays.stream(MemberInteractionBizTypeEnum.values())
+ .filter(e -> ObjectUtil.equals(e.getType(), bizType))
+ .findFirst()
+ .orElse(null);
+ if (type == null) {
+ return nickname + " 与你产生了互动";
+ }
+ return nickname + " " + type.getContentSuffix();
+ }
+
+}
diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/opentype/OpenTypeServiceImpl.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/opentype/OpenTypeServiceImpl.java
index 462d25f..4a24adb 100644
--- a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/opentype/OpenTypeServiceImpl.java
+++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/opentype/OpenTypeServiceImpl.java
@@ -33,6 +33,12 @@ import cn.iocoder.yudao.module.pay.api.refund.dto.PayRefundCreateReqDTO;
import cn.iocoder.yudao.module.pay.api.refund.dto.PayRefundRespDTO;
import cn.iocoder.yudao.module.pay.enums.order.PayOrderStatusEnum;
import cn.iocoder.yudao.module.pay.enums.refund.PayRefundStatusEnum;
+import cn.iocoder.yudao.module.trade.api.brokerage.TradeBrokerageRecordApi;
+import cn.iocoder.yudao.module.trade.api.brokerage.dto.BrokerageAddDTO;
+import cn.iocoder.yudao.module.trade.enums.brokerage.BrokerageRecordBizTypeEnum;
+import cn.iocoder.yudao.module.trade.api.brokerage.TradeBrokerageRecordApi;
+import cn.iocoder.yudao.module.trade.api.brokerage.dto.BrokerageAddDTO;
+import cn.iocoder.yudao.module.trade.enums.brokerage.BrokerageRecordBizTypeEnum;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
@@ -60,6 +66,7 @@ import static cn.hutool.core.util.ObjectUtil.notEqual;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.framework.common.util.date.LocalDateTimeUtils.addTime;
import static cn.iocoder.yudao.framework.common.util.json.JsonUtils.toJsonString;
+import static cn.iocoder.yudao.framework.common.util.number.MoneyUtils.fenToYuanStr;
import static cn.iocoder.yudao.framework.web.core.util.WebFrameworkUtils.getLoginUserId;
import static cn.iocoder.yudao.module.pay.enums.ErrorCodeConstants.*;
import static cn.iocoder.yudao.module.pay.enums.refund.PayRefundStatusEnum.*;
@@ -90,6 +97,8 @@ public class OpenTypeServiceImpl implements OpenTypeService {
private PayOrderApi payOrderApi;
@Resource
private PayRefundApi payRefundApi;
+ @Resource
+ private TradeBrokerageRecordApi tradeBrokerageRecordApi;
/**
* 获取最后一条会员开通记录
@@ -423,10 +432,36 @@ public class OpenTypeServiceImpl implements OpenTypeService {
// 添加会员tag
memberRecentTagService.createOrUpdateVipTag(openTypeUserDO.getMemberId());
+ // 添加会员开通分销佣金(每次开通都分,对应 OPEN_TYPE)
+ addOpenTypeBrokerage(openTypeUserDO, openType, payOrder);
+
String message = String.format("恭喜您购买成功,此次购买的产品:%s", openType.getTitle());
this.chatApi.messageUserSendText(openTypeUserDO.getMemberId(), message);
}
+ /**
+ * 会员开通支付成功后,给上级增加分销佣金
+ */
+ private void addOpenTypeBrokerage(OpenTypeUserDO openTypeUserDO, OpenTypeDO openType, PayOrderRespDTO payOrder) {
+ Integer basePrice = payOrder.getPrice();
+ if (basePrice == null || basePrice <= 0) {
+ log.info("[会员开通分佣][开通单({}) 支付金额为0,跳过分佣]", openTypeUserDO.getId());
+ return;
+ }
+ MemberUserDO user = memberUserService.getUser(openTypeUserDO.getMemberId());
+ String nickname = user != null && user.getNickname() != null ? user.getNickname() : String.valueOf(openTypeUserDO.getMemberId());
+ String productTitle = openType != null && openType.getTitle() != null ? openType.getTitle() : "会员";
+ List brokerageList = new ArrayList<>();
+ brokerageList.add(new BrokerageAddDTO()
+ .setBizId(String.valueOf(openTypeUserDO.getId()))
+ .setBasePrice(basePrice)
+ .setFirstFixedPrice(0)
+ .setSecondFixedPrice(0)
+ .setSourceUserId(openTypeUserDO.getMemberId())
+ .setTitle(String.format("用户[%s]开通会员[%s],支付[%s]元", nickname, productTitle, fenToYuanStr(basePrice))));
+ tradeBrokerageRecordApi.addBrokerage(openTypeUserDO.getMemberId(), BrokerageRecordBizTypeEnum.OPEN_TYPE, brokerageList);
+ }
+
/**
* 退款的回调
*/
@@ -445,6 +480,8 @@ public class OpenTypeServiceImpl implements OpenTypeService {
updateObj.setRefundStatus(SUCCESS.getStatus())
.setRefundTime(payRefund.getSuccessTime())
.setRefundPayPrice(openTypeUserDO.getPayPrice());
+ // 退款成功后作废对应会员佣金
+ tradeBrokerageRecordApi.cancelBrokerage(BrokerageRecordBizTypeEnum.OPEN_TYPE, String.valueOf(id));
}
// 退款失败
if (PayRefundStatusEnum.isFailure(payRefund.getStatus())) {
@@ -452,7 +489,7 @@ public class OpenTypeServiceImpl implements OpenTypeService {
}
// 更新会员开通的退款状态
openTypeUserMapper.update(updateObj, new LambdaQueryWrapperX()
- .eq(OpenTypeUserDO::getId, openTypeUserDO.getOpenTypeId())
+ .eq(OpenTypeUserDO::getId, id)
.eq(OpenTypeUserDO::getRefundStatus, WAITING.getStatus())
);
}
diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/push/MemberPushDeviceService.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/push/MemberPushDeviceService.java
new file mode 100644
index 0000000..986dff6
--- /dev/null
+++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/push/MemberPushDeviceService.java
@@ -0,0 +1,25 @@
+package cn.iocoder.yudao.module.member.service.push;
+
+import cn.iocoder.yudao.module.member.controller.app.push.vo.AppPushBindReqVO;
+import cn.iocoder.yudao.module.member.dal.dataobject.push.MemberPushDeviceDO;
+
+import java.util.List;
+
+public interface MemberPushDeviceService {
+
+ /**
+ * 绑定 / 更新当前登录用户的推送设备
+ */
+ void bind(Long memberId, AppPushBindReqVO reqVO);
+
+ /**
+ * 解绑设备(仅允许解绑属于自己的 registrationId)
+ */
+ void unbind(Long memberId, String registrationId);
+
+ /**
+ * 查询用户全部绑定设备(后续服务端推送用)
+ */
+ List getByMemberId(Long memberId);
+
+}
diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/push/MemberPushDeviceServiceImpl.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/push/MemberPushDeviceServiceImpl.java
new file mode 100644
index 0000000..7a82897
--- /dev/null
+++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/push/MemberPushDeviceServiceImpl.java
@@ -0,0 +1,70 @@
+package cn.iocoder.yudao.module.member.service.push;
+
+import cn.hutool.core.util.StrUtil;
+import cn.iocoder.yudao.module.member.controller.app.push.vo.AppPushBindReqVO;
+import cn.iocoder.yudao.module.member.dal.dataobject.push.MemberPushDeviceDO;
+import cn.iocoder.yudao.module.member.dal.mysql.push.MemberPushDeviceMapper;
+import org.springframework.stereotype.Service;
+import org.springframework.validation.annotation.Validated;
+
+import javax.annotation.Resource;
+import java.util.List;
+
+@Service
+@Validated
+public class MemberPushDeviceServiceImpl implements MemberPushDeviceService {
+
+ @Resource
+ private MemberPushDeviceMapper pushDeviceMapper;
+
+ @Override
+ public void bind(Long memberId, AppPushBindReqVO reqVO) {
+ String registrationId = reqVO.getRegistrationId().trim();
+ String provider = StrUtil.blankToDefault(reqVO.getProvider(), "jpush");
+ String platform = reqVO.getPlatform().trim().toLowerCase();
+ String alias = StrUtil.blankToDefault(reqVO.getAlias(), "u" + memberId);
+
+ MemberPushDeviceDO existing = pushDeviceMapper.selectByRegistrationId(registrationId);
+ if (existing == null) {
+ MemberPushDeviceDO row = MemberPushDeviceDO.builder()
+ .memberId(memberId)
+ .registrationId(registrationId)
+ .alias(alias)
+ .platform(platform)
+ .provider(provider)
+ .build();
+ pushDeviceMapper.insert(row);
+ return;
+ }
+
+ MemberPushDeviceDO update = new MemberPushDeviceDO();
+ update.setId(existing.getId());
+ update.setMemberId(memberId);
+ update.setAlias(alias);
+ update.setPlatform(platform);
+ update.setProvider(provider);
+ pushDeviceMapper.updateById(update);
+ }
+
+ @Override
+ public void unbind(Long memberId, String registrationId) {
+ if (StrUtil.isBlank(registrationId)) {
+ return;
+ }
+ MemberPushDeviceDO existing = pushDeviceMapper.selectByRegistrationId(registrationId.trim());
+ if (existing == null) {
+ return;
+ }
+ // 仅允许解绑自己的设备,避免误删他人绑定
+ if (!memberId.equals(existing.getMemberId())) {
+ return;
+ }
+ pushDeviceMapper.physicalDeleteById(existing.getId());
+ }
+
+ @Override
+ public List getByMemberId(Long memberId) {
+ return pushDeviceMapper.selectListByMemberId(memberId);
+ }
+
+}
diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/push/MemberPushService.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/push/MemberPushService.java
new file mode 100644
index 0000000..c28a2a5
--- /dev/null
+++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/push/MemberPushService.java
@@ -0,0 +1,12 @@
+package cn.iocoder.yudao.module.member.service.push;
+
+import cn.iocoder.yudao.module.member.client.vo.chat.user.ChatPrivateMessageSendReqVO;
+
+public interface MemberPushService {
+
+ /**
+ * 推送私聊消息通知
+ */
+ void pushPrivateMessage(ChatPrivateMessageSendReqVO message);
+
+}
diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/push/MemberPushServiceImpl.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/push/MemberPushServiceImpl.java
new file mode 100644
index 0000000..f5451e4
--- /dev/null
+++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/push/MemberPushServiceImpl.java
@@ -0,0 +1,118 @@
+package cn.iocoder.yudao.module.member.service.push;
+
+import cn.hutool.core.collection.CollUtil;
+import cn.hutool.core.util.StrUtil;
+import cn.iocoder.yudao.module.member.api.user.MemberUserApi;
+import cn.iocoder.yudao.module.member.api.user.dto.MemberUserRespDTO;
+import cn.iocoder.yudao.module.member.client.vo.chat.user.ChatPrivateMessageSendReqVO;
+import cn.iocoder.yudao.module.member.dal.dataobject.push.MemberPushDeviceDO;
+import cn.iocoder.yudao.module.member.framework.web.config.JPushProperties;
+import cn.jpush.api.JPushClient;
+import cn.jpush.api.push.PushResult;
+import cn.jpush.api.push.model.Message;
+import cn.jpush.api.push.model.Options;
+import cn.jpush.api.push.model.Platform;
+import cn.jpush.api.push.model.PushPayload;
+import cn.jpush.api.push.model.audience.Audience;
+import cn.jpush.api.push.model.notification.AndroidNotification;
+import cn.jpush.api.push.model.notification.IosNotification;
+import cn.jpush.api.push.model.notification.Notification;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.util.List;
+import java.util.stream.Collectors;
+
+@Slf4j
+@Service
+public class MemberPushServiceImpl implements MemberPushService {
+
+ private static final int MAX_ALERT_LENGTH = 100;
+ /** Android 8+ 通知渠道,需与 App 端 JPush setChannelAndSound 的 channel_id 一致 */
+ private static final String ANDROID_CHANNEL_ID = "htys_message";
+ /** iOS 系统默认提示音 */
+ private static final String IOS_SOUND = "default";
+ /** 极光 Android alert_type:-1 表示默认(声音 + 震动等) */
+ private static final int ANDROID_ALERT_TYPE_DEFAULT = -1;
+
+ @Resource
+ private MemberPushDeviceService pushDeviceService;
+ @Resource
+ private MemberUserApi memberUserApi;
+ @Resource
+ private JPushProperties jPushProperties;
+
+ @Override
+ public void pushPrivateMessage(ChatPrivateMessageSendReqVO message) {
+ if (!jPushProperties.isEnabled() || StrUtil.isBlank(jPushProperties.getAppKey())
+ || StrUtil.isBlank(jPushProperties.getMasterSecret())) {
+ return;
+ }
+ List devices = pushDeviceService.getByMemberId(message.getRecvId());
+ List registrationIds = devices.stream()
+ .filter(device -> StrUtil.equalsIgnoreCase(device.getProvider(), "jpush"))
+ .map(MemberPushDeviceDO::getRegistrationId)
+ .filter(StrUtil::isNotBlank)
+ .distinct()
+ .collect(Collectors.toList());
+ if (CollUtil.isEmpty(registrationIds)) {
+ return;
+ }
+
+ MemberUserRespDTO sender = memberUserApi.getUser(message.getSendId());
+ String title = sender != null && StrUtil.isNotBlank(sender.getNickname()) ? sender.getNickname() : "新消息";
+ String alert = buildAlert(message);
+ PushPayload payload = PushPayload.newBuilder()
+ .setPlatform(Platform.android_ios())
+ .setAudience(Audience.registrationId(registrationIds))
+ .setNotification(Notification.newBuilder()
+ .setAlert(alert)
+ .addPlatformNotification(AndroidNotification.newBuilder()
+ .setTitle(title)
+ .setChannelId(ANDROID_CHANNEL_ID)
+ .setAlertType(ANDROID_ALERT_TYPE_DEFAULT)
+ .build())
+ .addPlatformNotification(IosNotification.newBuilder()
+ .incrBadge(1)
+ .setSound(IOS_SOUND)
+ .build())
+ .build())
+ .setMessage(Message.newBuilder().setTitle(title).setMsgContent(alert)
+ .addExtra("type", "private_message")
+ .addExtra("senderId", String.valueOf(message.getSendId()))
+ .addExtra("receiverId", String.valueOf(message.getRecvId()))
+ .addExtra("messageType", String.valueOf(message.getType()))
+ .build())
+ .setOptions(Options.newBuilder().setApnsProduction(jPushProperties.isApnsProduction()).build())
+ .build();
+ try {
+ JPushClient client = new JPushClient(jPushProperties.getMasterSecret(), jPushProperties.getAppKey());
+ PushResult result = client.sendPush(payload);
+ log.info("[pushPrivateMessage][sendId({}) recvId({}) registrationIds({}) msgId({}) 推送成功]",
+ message.getSendId(), message.getRecvId(), registrationIds.size(), result.msg_id);
+ } catch (Exception e) {
+ log.error("[pushPrivateMessage][sendId({}) recvId({}) 极光推送失败]",
+ message.getSendId(), message.getRecvId(), e);
+ }
+ }
+
+ private String buildAlert(ChatPrivateMessageSendReqVO message) {
+ if (message.getType() == null || message.getType() == 0) {
+ return StrUtil.maxLength(message.getContent(), MAX_ALERT_LENGTH);
+ }
+ switch (message.getType()) {
+ case 1:
+ return "[图片]";
+ case 2:
+ return "[文件]";
+ case 3:
+ return "[语音]";
+ case 4:
+ return "[视频]";
+ default:
+ return "您收到一条新消息";
+ }
+ }
+
+}
diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/recent/RecentServiceImpl.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/recent/RecentServiceImpl.java
index 161eea6..86d96d1 100644
--- a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/recent/RecentServiceImpl.java
+++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/recent/RecentServiceImpl.java
@@ -5,6 +5,7 @@ import cn.hutool.core.collection.IterUtil;
import cn.hutool.core.date.DatePattern;
import cn.hutool.core.date.LocalDateTimeUtil;
import cn.hutool.core.util.ObjectUtil;
+import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
import cn.iocoder.yudao.framework.common.util.number.NumberUtils;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.member.client.service.ChatApi;
@@ -25,8 +26,10 @@ import cn.iocoder.yudao.module.member.enums.MemberRecentAuditSourceEnum;
import cn.iocoder.yudao.module.member.enums.MemberRecentAuditStatusEnum;
import cn.iocoder.yudao.module.member.enums.MemberRecentOperateEnum;
import cn.iocoder.yudao.module.member.enums.MemberRecentSceneEnum;
+import cn.iocoder.yudao.module.member.enums.interaction.MemberInteractionBizTypeEnum;
import cn.iocoder.yudao.module.member.enums.tag.MemberRecentTagEnum;
import cn.iocoder.yudao.module.member.service.config.MemberConfigService;
+import cn.iocoder.yudao.module.member.service.interaction.MemberInteractionService;
import cn.iocoder.yudao.module.member.service.location.LocationBO;
import cn.iocoder.yudao.module.member.service.location.LocationService;
import cn.iocoder.yudao.module.member.service.opentype.OpenTypeService;
@@ -60,6 +63,7 @@ import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.module.member.dal.mysql.recent.RecentMapper;
+import cn.iocoder.yudao.module.member.dal.mysql.user.MemberUserMapper;
import java.math.BigDecimal;
import java.math.RoundingMode;
@@ -80,6 +84,14 @@ public class RecentServiceImpl implements RecentService {
@Resource
private RecentMapper recentMapper;
@Resource
+ private MemberUserMapper memberUserMapper;
+
+ /**
+ * 首页(广场/附近)用户列表不可见账号:这些手机号的用户不会出现在首页用户列表中。
+ * 如需增减隐藏账号,改这个列表即可。
+ */
+ private static final List SQUARE_HIDDEN_MOBILES = Arrays.asList("13311615193", "18800000001");
+ @Resource
private OperationMapper operationMapper;
@Resource
@@ -105,6 +117,8 @@ public class RecentServiceImpl implements RecentService {
private TxMapApi txMapApi;
@Resource
private ChatApi chatApi;
+ @Resource
+ private MemberInteractionService memberInteractionService;
/**
* 创建朋友圈
@@ -200,12 +214,12 @@ public class RecentServiceImpl implements RecentService {
convertRecentPageResVoSetupLocation(result, currentUserLastLocation);
// 用户tag
convertRecentPageResVoSetupTag(result);
- if (ObjectUtil.isNotEmpty(result.getUserOperationStatus())) {
- Boolean isLookWechat = result.getUserOperationStatus().getIsLookWechat();
- if (Boolean.TRUE.equals(isLookWechat)) {
- result.getMember().setWechatNum(result.getMember().getWechatNum());
- } else {
- result.getMember().setWechatNum(result.getMember().getwechatMaskString());
+ if (ObjectUtil.isNotEmpty(result.getUserOperationStatus())
+ && Boolean.TRUE.equals(result.getUserOperationStatus().getIsLookWechat())
+ && ObjectUtil.isNotEmpty(result.getMember())) {
+ MemberWechatBO memberWechat = memberUserService.getMemberWechat(result.getMemberId());
+ if (ObjectUtil.isNotEmpty(memberWechat)) {
+ result.getMember().setWechatNum(memberWechat.getWechatNum());
}
}
return result;
@@ -402,6 +416,13 @@ public class RecentServiceImpl implements RecentService {
operationObj.setBizType(MemberRecentOperateEnum.MEMBER_LOOK_WECHAT.getType());
operationObj.setTapTime(LocalDateTime.now());
operationMapper.insert(operationObj);
+ // 首次解锁:通知被解锁用户
+ memberInteractionService.create(
+ memberId,
+ operateMemberId,
+ MemberInteractionBizTypeEnum.WECHAT_UNLOCKED,
+ operationObj.getId()
+ );
return memberWechat;
}
@@ -461,7 +482,7 @@ public class RecentServiceImpl implements RecentService {
IPage recentPageList = recentMapper.pageJoin(new Page(bean.getPageNo(), bean.getPageSize()), resultQuery);
List resultList = BeanUtils.toBean(recentPageList.getRecords(), RecentPageResVo.class);
// 为RecentPageResVo设置Member信息
- convertRecentPageResVoSetupMember(resultList, isVip);
+ convertRecentPageResVoSetupMember(resultList, false);
// 查询用户tag
convertRecentPageResVoSetupTag(resultList);
// 为RecentPageResVo设置OperationTotal信息
@@ -485,6 +506,18 @@ public class RecentServiceImpl implements RecentService {
RecentPageReqVO resultQuery = BeanUtil.toBean(bean, RecentPageReqVO.class);
resultQuery.setSceneBizType(bean.getScene().getType());
quickBeforeQuery(operationUser, operationCfg, resultQuery);
+ // 首页不可见账号:按手机号定位,合并进排除列表(广场/附近所有 tab 均不出现)
+ List excludeIds = resultQuery.getExcludeUserIdList() != null
+ ? new ArrayList<>(resultQuery.getExcludeUserIdList()) : new ArrayList<>();
+ for (String hiddenMobile : SQUARE_HIDDEN_MOBILES) {
+ MemberUserDO hiddenUser = memberUserMapper.selectByMobile(hiddenMobile);
+ if (hiddenUser != null && !excludeIds.contains(hiddenUser.getId())) {
+ excludeIds.add(hiddenUser.getId());
+ }
+ }
+ if (!excludeIds.isEmpty()) {
+ resultQuery.setExcludeUserIdList(excludeIds);
+ }
// 广场-最近
if (ObjectUtil.equals(bean.getScene(), MemberRecentSceneEnum.SQUARE_NEW_TIME)) {
resultQuery.setDistanceIsNotNull(true);
@@ -501,8 +534,8 @@ public class RecentServiceImpl implements RecentService {
}
IPage recentPageList = recentMapper.pageUserJoin(new Page(bean.getPageNo(), bean.getPageSize()), resultQuery);
List resultList = BeanUtils.toBean(recentPageList.getRecords(), RecentPageResVo.class);
- // 为RecentPageResVo设置Member信息
- convertRecentPageResVoSetupMember(resultList, isVip);
+ // 为RecentPageResVo设置Member信息,微信号统一脱敏;明文只允许通过查看微信接口返回
+ convertRecentPageResVoSetupMember(resultList, false);
// 如果是`新人``女生`模糊`头像`和`用户名`
List dimList = Arrays.asList(MemberRecentSceneEnum.SQUARE_NEW_PERSON, MemberRecentSceneEnum.SQUARE_FEMALE);
// 查询用户tag
@@ -766,13 +799,14 @@ public class RecentServiceImpl implements RecentService {
*/
@Override
public RecentPageResVo getRecentUserByUserId(Long operateUserId, Long userId) {
- // 判断当前操作用户是不是VIP
- boolean isVip = openTypeService.isUserOpenType(operateUserId);
-
+ MemberUserDO targetUser = memberUserService.getUser(userId);
+ if (targetUser == null || CommonStatusEnum.isDisable(targetUser.getStatus())) {
+ throw exception(MemberErrorCode.USER_NOT_EXISTS);
+ }
RecentPageResVo result = new RecentPageResVo();
result.setMemberId(userId);
- // 添加用户信息
- convertRecentPageResVoSetupMember(result, isVip);
+ // 添加用户信息,微信号统一脱敏
+ convertRecentPageResVoSetupMember(result, false);
// 查询用户最新的位置
LocationBO currentUserLastLocation = locationService.getCurrentUserLastLocation();
convertRecentPageResVoSetupLocation(result, currentUserLastLocation);
@@ -791,12 +825,12 @@ public class RecentServiceImpl implements RecentService {
if (ObjectUtil.isNotEmpty(evaluateDetailResVo)) {
result.setEvaluateDetail(evaluateDetailResVo);
}
- if (ObjectUtil.isNotEmpty(result.getUserOperationStatus())) {
- Boolean isLookWechat = result.getUserOperationStatus().getIsLookWechat();
- if (Boolean.TRUE.equals(isLookWechat)) {
- result.getMember().setWechatNum(result.getMember().getWechatNum());
- } else {
- result.getMember().setWechatNum(result.getMember().getwechatMaskString());
+ if (ObjectUtil.isNotEmpty(result.getUserOperationStatus())
+ && Boolean.TRUE.equals(result.getUserOperationStatus().getIsLookWechat())
+ && ObjectUtil.isNotEmpty(result.getMember())) {
+ MemberWechatBO memberWechat = memberUserService.getMemberWechat(result.getMemberId());
+ if (ObjectUtil.isNotEmpty(memberWechat)) {
+ result.getMember().setWechatNum(memberWechat.getWechatNum());
}
}
return result;
diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/recent/ViewRecordServiceImpl.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/recent/ViewRecordServiceImpl.java
index 0b1d71a..516d176 100644
--- a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/recent/ViewRecordServiceImpl.java
+++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/recent/ViewRecordServiceImpl.java
@@ -24,6 +24,8 @@ import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.module.member.dal.mysql.viewrecord.ViewRecordMapper;
+import cn.iocoder.yudao.module.member.enums.interaction.MemberInteractionBizTypeEnum;
+import cn.iocoder.yudao.module.member.service.interaction.MemberInteractionService;
import java.util.List;
import java.util.Map;
@@ -42,6 +44,8 @@ public class ViewRecordServiceImpl implements ViewRecordService {
private ViewRecordMapper viewRecordMapper;
@Resource
private MemberUserService memberUserService;
+ @Resource
+ private MemberInteractionService memberInteractionService;
/**
* 创建用户查看记录
@@ -56,6 +60,19 @@ public class ViewRecordServiceImpl implements ViewRecordService {
viewRecord.setTitle("访问朋友圈");
}
viewRecordMapper.insert(viewRecord);
+
+ // 只要访问了他人主页(bizId=被访问用户),就写入「查看了你」;
+ // 与入口来源(USER/RECENT/CLASSIFY)无关;解锁微信是另一类 WECHAT_UNLOCKED。
+ if (viewRecord.getBizId() != null
+ && viewRecord.getMemberId() != null
+ && ObjectUtil.notEqual(viewRecord.getBizId(), viewRecord.getMemberId())) {
+ memberInteractionService.create(
+ viewRecord.getBizId(),
+ viewRecord.getMemberId(),
+ MemberInteractionBizTypeEnum.VIEWED,
+ viewRecord.getId()
+ );
+ }
return viewRecord.getId();
}
diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/rideplan/MemberRidePlanService.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/rideplan/MemberRidePlanService.java
new file mode 100644
index 0000000..a3e30f1
--- /dev/null
+++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/rideplan/MemberRidePlanService.java
@@ -0,0 +1,67 @@
+package cn.iocoder.yudao.module.member.service.rideplan;
+
+import cn.iocoder.yudao.framework.common.pojo.PageResult;
+import cn.iocoder.yudao.module.member.controller.app.rideplan.vo.AppRidePlanCreateReqVO;
+import cn.iocoder.yudao.module.member.controller.app.rideplan.vo.AppRidePlanPageReqVO;
+import cn.iocoder.yudao.module.member.controller.app.rideplan.vo.AppRidePlanRespVO;
+import cn.iocoder.yudao.module.member.controller.app.rideplan.vo.AppRideTagRespVO;
+import cn.iocoder.yudao.module.member.controller.app.rideplan.vo.AppRideUserTagUpdateReqVO;
+
+import java.util.List;
+
+/**
+ * 骑遇发车局 / 骑行标签 Service
+ *
+ * 鉴权约定:查询类方法 loginUserId 可为 null(游客,@PermitAll 接口透传);
+ * 发布/报名/举报/设置标签必须登录,userId 由 Controller 从登录态取。
+ */
+public interface MemberRidePlanService {
+
+ /**
+ * 发车局分页(免登录可查)
+ *
+ * @param loginUserId 当前登录用户,游客为 null
+ */
+ PageResult getRidePlanPage(AppRidePlanPageReqVO reqVO, Long loginUserId);
+
+ /**
+ * 发车局详情(免登录可查)
+ */
+ AppRidePlanRespVO getRidePlan(Long id, Long loginUserId);
+
+ /**
+ * 发布发车局(需登录)
+ */
+ Long createRidePlan(Long userId, AppRidePlanCreateReqVO reqVO);
+
+ /**
+ * 加入发车局(需登录)
+ */
+ void joinRidePlan(Long userId, Long planId);
+
+ /**
+ * 取消加入发车局(需登录)
+ */
+ void cancelJoinRidePlan(Long userId, Long planId);
+
+ /**
+ * 举报发车局(需登录);举报达到阈值自动下架
+ */
+ void reportRidePlan(Long userId, Long planId);
+
+ /**
+ * 骑行标签字典(免登录可查,只返回启用的)
+ */
+ List getRideTagList();
+
+ /**
+ * 我的骑行标签(需登录)
+ */
+ List getMyRideTags(Long userId);
+
+ /**
+ * 全量替换我的骑行标签(需登录)
+ */
+ void updateMyRideTags(Long userId, AppRideUserTagUpdateReqVO reqVO);
+
+}
diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/rideplan/MemberRidePlanServiceImpl.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/rideplan/MemberRidePlanServiceImpl.java
new file mode 100644
index 0000000..0dbc697
--- /dev/null
+++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/rideplan/MemberRidePlanServiceImpl.java
@@ -0,0 +1,266 @@
+package cn.iocoder.yudao.module.member.service.rideplan;
+
+import cn.hutool.core.util.StrUtil;
+import cn.iocoder.yudao.framework.common.pojo.PageResult;
+import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
+import cn.iocoder.yudao.module.member.controller.app.rideplan.vo.AppRidePlanCreateReqVO;
+import cn.iocoder.yudao.module.member.controller.app.rideplan.vo.AppRidePlanPageReqVO;
+import cn.iocoder.yudao.module.member.controller.app.rideplan.vo.AppRidePlanRespVO;
+import cn.iocoder.yudao.module.member.controller.app.rideplan.vo.AppRideTagRespVO;
+import cn.iocoder.yudao.module.member.controller.app.rideplan.vo.AppRideUserTagUpdateReqVO;
+import cn.iocoder.yudao.module.member.dal.dataobject.rideplan.MemberRidePlanDO;
+import cn.iocoder.yudao.module.member.dal.dataobject.rideplan.MemberRidePlanJoinDO;
+import cn.iocoder.yudao.module.member.dal.dataobject.rideplan.MemberRideTagDO;
+import cn.iocoder.yudao.module.member.dal.dataobject.rideplan.MemberRideUserTagDO;
+import cn.iocoder.yudao.module.member.dal.dataobject.user.MemberUserDO;
+import cn.iocoder.yudao.module.member.dal.mysql.rideplan.MemberRidePlanJoinMapper;
+import cn.iocoder.yudao.module.member.dal.mysql.rideplan.MemberRidePlanMapper;
+import cn.iocoder.yudao.module.member.dal.mysql.rideplan.MemberRideTagMapper;
+import cn.iocoder.yudao.module.member.dal.mysql.rideplan.MemberRideUserTagMapper;
+import cn.iocoder.yudao.module.member.dal.mysql.user.MemberUserMapper;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.validation.annotation.Validated;
+
+import javax.annotation.Resource;
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+
+import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
+import static cn.iocoder.yudao.module.member.enums.MemberErrorCode.RIDE_PLAN_ALREADY_JOINED;
+import static cn.iocoder.yudao.module.member.enums.MemberErrorCode.RIDE_PLAN_FULL;
+import static cn.iocoder.yudao.module.member.enums.MemberErrorCode.RIDE_PLAN_MEET_TIME_INVALID;
+import static cn.iocoder.yudao.module.member.enums.MemberErrorCode.RIDE_PLAN_NOT_EXISTS;
+import static cn.iocoder.yudao.module.member.enums.MemberErrorCode.RIDE_PLAN_NOT_JOINED;
+import static cn.iocoder.yudao.module.member.enums.MemberErrorCode.RIDE_TAG_NOT_EXISTS;
+
+@Service
+@Validated
+public class MemberRidePlanServiceImpl implements MemberRidePlanService {
+
+ /** 举报自动下架阈值 */
+ private static final int REPORT_LIMIT = 5;
+ /** 状态:正常 */
+ private static final int STATUS_NORMAL = 0;
+ /** 状态:已取消/下架 */
+ private static final int STATUS_OFFLINE = 1;
+
+ @Resource
+ private MemberRidePlanMapper ridePlanMapper;
+ @Resource
+ private MemberRidePlanJoinMapper ridePlanJoinMapper;
+ @Resource
+ private MemberRideTagMapper rideTagMapper;
+ @Resource
+ private MemberRideUserTagMapper rideUserTagMapper;
+ @Resource
+ private MemberUserMapper userMapper;
+
+ @Override
+ public PageResult getRidePlanPage(AppRidePlanPageReqVO reqVO, Long loginUserId) {
+ PageResult pageResult = ridePlanMapper.selectPage(reqVO);
+ return new PageResult<>(convertList(pageResult.getList(), loginUserId), pageResult.getTotal());
+ }
+
+ @Override
+ public AppRidePlanRespVO getRidePlan(Long id, Long loginUserId) {
+ MemberRidePlanDO plan = ridePlanMapper.selectByIdAndStatus(id, STATUS_NORMAL);
+ if (plan == null) {
+ throw exception(RIDE_PLAN_NOT_EXISTS);
+ }
+ return convertList(Collections.singletonList(plan), loginUserId).get(0);
+ }
+
+ @Override
+ public Long createRidePlan(Long userId, AppRidePlanCreateReqVO reqVO) {
+ if (!reqVO.getMeetTime().isAfter(LocalDateTime.now())) {
+ throw exception(RIDE_PLAN_MEET_TIME_INVALID);
+ }
+ // 校验标签名都存在于字典(保持数据干净)
+ List tags = reqVO.getTags() == null ? Collections.emptyList() : reqVO.getTags();
+ for (String tag : tags) {
+ if (rideTagMapper.selectByName(tag) == null) {
+ throw exception(RIDE_TAG_NOT_EXISTS, tag);
+ }
+ }
+ MemberRidePlanDO plan = MemberRidePlanDO.builder()
+ .userId(userId)
+ .routeName(reqVO.getRouteName())
+ .meetPlace(reqVO.getMeetPlace())
+ .meetTime(reqVO.getMeetTime())
+ .distanceKm(reqVO.getDistanceKm())
+ .city(StrUtil.nullToEmpty(reqVO.getCity()))
+ .tags(String.join(",", tags))
+ .capacity(reqVO.getCapacity())
+ .joinCount(0)
+ .reportCount(0)
+ .status(STATUS_NORMAL)
+ .build();
+ ridePlanMapper.insert(plan);
+ return plan.getId();
+ }
+
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public void joinRidePlan(Long userId, Long planId) {
+ MemberRidePlanDO plan = ridePlanMapper.selectByIdAndStatus(planId, STATUS_NORMAL);
+ if (plan == null) {
+ throw exception(RIDE_PLAN_NOT_EXISTS);
+ }
+ if (ridePlanJoinMapper.selectByPlanIdAndUserId(planId, userId) != null) {
+ throw exception(RIDE_PLAN_ALREADY_JOINED);
+ }
+ if (plan.getJoinCount() >= plan.getCapacity()) {
+ throw exception(RIDE_PLAN_FULL);
+ }
+ MemberRidePlanJoinDO join = MemberRidePlanJoinDO.builder()
+ .planId(planId).userId(userId).build();
+ ridePlanJoinMapper.insert(join);
+ plan.setJoinCount(plan.getJoinCount() + 1);
+ ridePlanMapper.updateById(plan);
+ }
+
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public void cancelJoinRidePlan(Long userId, Long planId) {
+ MemberRidePlanDO plan = ridePlanMapper.selectByIdAndStatus(planId, STATUS_NORMAL);
+ if (plan == null) {
+ throw exception(RIDE_PLAN_NOT_EXISTS);
+ }
+ MemberRidePlanJoinDO join = ridePlanJoinMapper.selectByPlanIdAndUserId(planId, userId);
+ if (join == null) {
+ throw exception(RIDE_PLAN_NOT_JOINED);
+ }
+ ridePlanJoinMapper.deleteById(join.getId());
+ plan.setJoinCount(Math.max(0, plan.getJoinCount() - 1));
+ ridePlanMapper.updateById(plan);
+ }
+
+ @Override
+ public void reportRidePlan(Long userId, Long planId) {
+ MemberRidePlanDO plan = ridePlanMapper.selectByIdAndStatus(planId, STATUS_NORMAL);
+ if (plan == null) {
+ throw exception(RIDE_PLAN_NOT_EXISTS);
+ }
+ plan.setReportCount(plan.getReportCount() + 1);
+ if (plan.getReportCount() >= REPORT_LIMIT) {
+ plan.setStatus(STATUS_OFFLINE);
+ }
+ ridePlanMapper.updateById(plan);
+ }
+
+ @Override
+ public List getRideTagList() {
+ return rideTagMapper.selectListByStatus(STATUS_NORMAL).stream()
+ .map(tag -> BeanUtils.toBean(tag, AppRideTagRespVO.class))
+ .collect(Collectors.toList());
+ }
+
+ @Override
+ public List getMyRideTags(Long userId) {
+ List userTags = rideUserTagMapper.selectListByUserId(userId);
+ if (userTags.isEmpty()) {
+ return Collections.emptyList();
+ }
+ Set tagIds = userTags.stream()
+ .map(MemberRideUserTagDO::getTagId).collect(Collectors.toCollection(LinkedHashSet::new));
+ Map tagMap = rideTagMapper.selectBatchIds(tagIds).stream()
+ .collect(Collectors.toMap(MemberRideTagDO::getId, Function.identity()));
+ return tagIds.stream()
+ .filter(tagMap::containsKey)
+ .map(id -> BeanUtils.toBean(tagMap.get(id), AppRideTagRespVO.class))
+ .collect(Collectors.toList());
+ }
+
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public void updateMyRideTags(Long userId, AppRideUserTagUpdateReqVO reqVO) {
+ // 校验标签都存在且启用
+ List tags = rideTagMapper.selectBatchIds(reqVO.getTagIds());
+ if (tags.size() != new LinkedHashSet<>(reqVO.getTagIds()).size()) {
+ throw exception(RIDE_TAG_NOT_EXISTS);
+ }
+ // 全量替换
+ rideUserTagMapper.deleteByUserId(userId);
+ for (Long tagId : new LinkedHashSet<>(reqVO.getTagIds())) {
+ rideUserTagMapper.insert(MemberRideUserTagDO.builder()
+ .userId(userId).tagId(tagId).build());
+ }
+ }
+
+ // ─── 私有:DO → RespVO 批量转换(补发起人、报名人、joinedByMe)──────
+
+ private List convertList(List plans, Long loginUserId) {
+ if (plans == null || plans.isEmpty()) {
+ return Collections.emptyList();
+ }
+ // 发起人
+ Set hostIds = plans.stream()
+ .map(MemberRidePlanDO::getUserId).collect(Collectors.toCollection(LinkedHashSet::new));
+ Map userMap = userMapper.selectBatchIds(hostIds).stream()
+ .collect(Collectors.toMap(MemberUserDO::getId, Function.identity()));
+ // 报名记录
+ List planIds = plans.stream().map(MemberRidePlanDO::getId).collect(Collectors.toList());
+ Map> joinMap = ridePlanJoinMapper.selectList(
+ MemberRidePlanJoinDO::getPlanId, planIds).stream()
+ .collect(Collectors.groupingBy(MemberRidePlanJoinDO::getPlanId));
+ // 报名人资料
+ Set joinUserIds = joinMap.values().stream()
+ .flatMap(List::stream).map(MemberRidePlanJoinDO::getUserId)
+ .collect(Collectors.toCollection(LinkedHashSet::new));
+ Map joinUserMap = joinUserIds.isEmpty() ? Collections.emptyMap()
+ : userMapper.selectBatchIds(joinUserIds).stream()
+ .collect(Collectors.toMap(MemberUserDO::getId, Function.identity()));
+
+ List result = new ArrayList<>(plans.size());
+ for (MemberRidePlanDO plan : plans) {
+ AppRidePlanRespVO vo = BeanUtils.toBean(plan, AppRidePlanRespVO.class);
+ vo.setTags(splitTags(plan.getTags()));
+ // 发起人(只暴露昵称/头像)
+ MemberUserDO host = userMap.get(plan.getUserId());
+ if (host != null) {
+ vo.setHostUserId(host.getId());
+ vo.setHostNickname(host.getNickname());
+ vo.setHostAvatar(host.getAvatar());
+ }
+ // 报名列表(只暴露昵称/头像)
+ List joinedUsers = joinMap
+ .getOrDefault(plan.getId(), Collections.emptyList()).stream()
+ .map(join -> {
+ MemberUserDO u = joinUserMap.get(join.getUserId());
+ AppRidePlanRespVO.JoinUser ju = new AppRidePlanRespVO.JoinUser();
+ if (u != null) {
+ ju.setUserId(u.getId());
+ ju.setNickname(u.getNickname());
+ ju.setAvatar(u.getAvatar());
+ } else {
+ ju.setUserId(join.getUserId());
+ }
+ return ju;
+ }).collect(Collectors.toList());
+ vo.setJoinedUsers(joinedUsers);
+ // 当前用户是否已加入
+ vo.setJoinedByMe(loginUserId != null && joinedUsers.stream()
+ .anyMatch(j -> Objects.equals(j.getUserId(), loginUserId)));
+ result.add(vo);
+ }
+ return result;
+ }
+
+ private List splitTags(String tags) {
+ if (StrUtil.isEmpty(tags)) {
+ return Collections.emptyList();
+ }
+ return StrUtil.split(tags, ',').stream()
+ .filter(StrUtil::isNotEmpty).collect(Collectors.toList());
+ }
+
+}
diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/rideplan/MemberRideRecordService.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/rideplan/MemberRideRecordService.java
new file mode 100644
index 0000000..16bd844
--- /dev/null
+++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/rideplan/MemberRideRecordService.java
@@ -0,0 +1,57 @@
+package cn.iocoder.yudao.module.member.service.rideplan;
+
+import cn.iocoder.yudao.framework.common.pojo.PageResult;
+import cn.iocoder.yudao.module.member.controller.app.rideplan.vo.AppRideRecordCreateReqVO;
+import cn.iocoder.yudao.module.member.controller.app.rideplan.vo.AppRideRecordPageReqVO;
+import cn.iocoder.yudao.module.member.controller.app.rideplan.vo.AppRideRecordRespVO;
+import cn.iocoder.yudao.module.member.controller.app.rideplan.vo.AppRideRecordStatsRespVO;
+
+import java.util.Collection;
+import java.util.List;
+
+/**
+ * 骑遇骑行记录(战报)Service
+ */
+public interface MemberRideRecordService {
+
+ /**
+ * 上报一次骑行战报(需登录)
+ */
+ Long createRideRecord(Long userId, AppRideRecordCreateReqVO reqVO);
+
+ /**
+ * 战报墙分页(免登录;登录后返回 likedByMe)
+ */
+ PageResult getRideRecordPage(AppRideRecordPageReqVO reqVO, Long loginUserId);
+
+ /**
+ * 我的骑行记录分页(需登录)
+ */
+ PageResult getMyRideRecordPage(Long userId, AppRideRecordPageReqVO reqVO);
+
+ /**
+ * 战报详情(免登录,含轨迹)
+ */
+ AppRideRecordRespVO getRideRecord(Long id, Long loginUserId);
+
+ /**
+ * 弯道天梯榜(免登录):按 road+corner 分组,组内按压弯角度降序
+ */
+ List getCornerRank(String roadName, String cornerName);
+
+ /**
+ * 点赞/取消点赞(需登录,幂等切换),返回操作后是否处于点赞状态
+ */
+ boolean toggleLike(Long userId, Long recordId);
+
+ /**
+ * 批量骑手战报统计(免登录):战报数 / 最佳压弯 / 总里程,供列表与主页骑行数据行
+ */
+ List getRideRecordStats(Collection userIds);
+
+ /**
+ * 举报战报(需登录),达到阈值自动下架
+ */
+ void reportRideRecord(Long userId, Long recordId);
+
+}
diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/rideplan/MemberRideRecordServiceImpl.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/rideplan/MemberRideRecordServiceImpl.java
new file mode 100644
index 0000000..bfcf127
--- /dev/null
+++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/rideplan/MemberRideRecordServiceImpl.java
@@ -0,0 +1,250 @@
+package cn.iocoder.yudao.module.member.service.rideplan;
+
+import cn.hutool.core.util.StrUtil;
+import cn.iocoder.yudao.framework.common.pojo.PageResult;
+import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
+import cn.iocoder.yudao.module.member.controller.app.rideplan.vo.AppRideRecordCreateReqVO;
+import cn.iocoder.yudao.module.member.controller.app.rideplan.vo.AppRideRecordPageReqVO;
+import cn.iocoder.yudao.module.member.controller.app.rideplan.vo.AppRideRecordRespVO;
+import cn.iocoder.yudao.module.member.controller.app.rideplan.vo.AppRideRecordStatsRespVO;
+import cn.iocoder.yudao.module.member.dal.dataobject.rideplan.MemberRideRecordDO;
+import cn.iocoder.yudao.module.member.dal.dataobject.rideplan.MemberRideRecordLikeDO;
+import cn.iocoder.yudao.module.member.dal.dataobject.user.MemberUserDO;
+import cn.iocoder.yudao.module.member.dal.mysql.rideplan.MemberRideRecordLikeMapper;
+import cn.iocoder.yudao.module.member.dal.mysql.rideplan.MemberRideRecordMapper;
+import cn.iocoder.yudao.module.member.dal.mysql.user.MemberUserMapper;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.validation.annotation.Validated;
+
+import javax.annotation.Resource;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
+import static cn.iocoder.yudao.module.member.enums.MemberErrorCode.RIDE_RECORD_NOT_EXISTS;
+
+@Service
+@Validated
+public class MemberRideRecordServiceImpl implements MemberRideRecordService {
+
+ /** 举报自动下架阈值 */
+ private static final int REPORT_LIMIT = 5;
+ /** 状态:正常 */
+ private static final int STATUS_NORMAL = 0;
+ /** 状态:已下架 */
+ private static final int STATUS_OFFLINE = 1;
+ /** 天梯榜单弯道最多展示条数 */
+ private static final int RANK_LIMIT = 100;
+ /** 每条弯道榜单最多人数 */
+ private static final int CORNER_BOARD_SIZE = 5;
+
+ @Resource
+ private MemberRideRecordMapper rideRecordMapper;
+ @Resource
+ private MemberRideRecordLikeMapper rideRecordLikeMapper;
+ @Resource
+ private MemberUserMapper userMapper;
+
+ @Override
+ public Long createRideRecord(Long userId, AppRideRecordCreateReqVO reqVO) {
+ MemberRideRecordDO record = MemberRideRecordDO.builder()
+ .userId(userId)
+ .title(reqVO.getTitle())
+ .roadName(StrUtil.nullToEmpty(reqVO.getRoadName()))
+ .cornerName(StrUtil.nullToEmpty(reqVO.getCornerName()))
+ .distanceKm(reqVO.getDistanceKm())
+ .durationSec(reqVO.getDurationSec())
+ .topSpeedKmh(reqVO.getTopSpeedKmh())
+ .curveCount(reqVO.getCurveCount())
+ .maxLean(reqVO.getMaxLean())
+ .trackJson(reqVO.getTrackJson())
+ .likeCount(0)
+ .reportCount(0)
+ .status(STATUS_NORMAL)
+ .build();
+ rideRecordMapper.insert(record);
+ return record.getId();
+ }
+
+ @Override
+ public PageResult getRideRecordPage(AppRideRecordPageReqVO reqVO, Long loginUserId) {
+ PageResult pageResult = rideRecordMapper.selectPage(reqVO);
+ return new PageResult<>(convertList(pageResult.getList(), loginUserId, false), pageResult.getTotal());
+ }
+
+ @Override
+ public PageResult getMyRideRecordPage(Long userId, AppRideRecordPageReqVO reqVO) {
+ reqVO.setUserId(null); // my 接口以登录身份为准,忽略过滤参数
+ PageResult pageResult = rideRecordMapper.selectMyPage(userId, reqVO);
+ return new PageResult<>(convertList(pageResult.getList(), userId, false), pageResult.getTotal());
+ }
+
+ @Override
+ public AppRideRecordRespVO getRideRecord(Long id, Long loginUserId) {
+ MemberRideRecordDO record = rideRecordMapper.selectByIdAndStatus(id, STATUS_NORMAL);
+ if (record == null) {
+ throw exception(RIDE_RECORD_NOT_EXISTS);
+ }
+ return convertList(Collections.singletonList(record), loginUserId, true).get(0);
+ }
+
+ @Override
+ public List getCornerRank(String roadName, String cornerName) {
+ List records = rideRecordMapper.selectRank(roadName, cornerName, RANK_LIMIT);
+ if (records.isEmpty()) {
+ return Collections.emptyList();
+ }
+ // 骑手资料
+ Set userIds = records.stream()
+ .map(MemberRideRecordDO::getUserId).collect(Collectors.toCollection(LinkedHashSet::new));
+ Map userMap = userMapper.selectBatchIds(userIds).stream()
+ .collect(Collectors.toMap(MemberUserDO::getId, u -> u));
+ // 按 road+corner 分组(保持 maxLean 降序,selectRank 已排序)
+ Map> grouped = new LinkedHashMap<>();
+ for (MemberRideRecordDO record : records) {
+ grouped.computeIfAbsent(record.getRoadName() + "§" + record.getCornerName(),
+ k -> new ArrayList<>()).add(record);
+ }
+ List boards = new ArrayList<>(grouped.size());
+ for (List group : grouped.values()) {
+ MemberRideRecordDO first = group.get(0);
+ AppRideRecordRespVO.CornerBoard board = new AppRideRecordRespVO.CornerBoard();
+ board.setRoadName(first.getRoadName());
+ board.setCornerName(first.getCornerName());
+ board.setEntries(group.stream().limit(CORNER_BOARD_SIZE)
+ .map(r -> {
+ AppRideRecordRespVO.RankItem item = new AppRideRecordRespVO.RankItem();
+ item.setRecordId(r.getId());
+ item.setRoadName(r.getRoadName());
+ item.setCornerName(r.getCornerName());
+ item.setMaxLean(r.getMaxLean());
+ item.setTopSpeedKmh(r.getTopSpeedKmh());
+ item.setUserId(r.getUserId());
+ item.setCreateTime(r.getCreateTime());
+ MemberUserDO u = userMap.get(r.getUserId());
+ if (u != null) {
+ item.setNickname(u.getNickname());
+ item.setAvatar(u.getAvatar());
+ }
+ return item;
+ }).collect(Collectors.toList()));
+ for (int i = 0; i < board.getEntries().size(); i++) {
+ board.getEntries().get(i).setRank(i + 1);
+ }
+ boards.add(board);
+ }
+ return boards;
+ }
+
+ @Override
+ public List getRideRecordStats(Collection userIds) {
+ if (userIds == null || userIds.isEmpty()) {
+ return Collections.emptyList();
+ }
+ List records = rideRecordMapper.selectNormalByUserIds(userIds);
+ if (records.isEmpty()) {
+ return Collections.emptyList();
+ }
+ Map> grouped = records.stream()
+ .collect(Collectors.groupingBy(MemberRideRecordDO::getUserId));
+ List statsList = new ArrayList<>(grouped.size());
+ for (Map.Entry> entry : grouped.entrySet()) {
+ java.math.BigDecimal best = java.math.BigDecimal.ZERO;
+ java.math.BigDecimal totalKm = java.math.BigDecimal.ZERO;
+ for (MemberRideRecordDO r : entry.getValue()) {
+ if (r.getMaxLean() != null && r.getMaxLean().compareTo(best) > 0) {
+ best = r.getMaxLean();
+ }
+ if (r.getDistanceKm() != null) {
+ totalKm = totalKm.add(r.getDistanceKm());
+ }
+ }
+ AppRideRecordStatsRespVO vo = new AppRideRecordStatsRespVO();
+ vo.setUserId(entry.getKey());
+ vo.setReportCount((long) entry.getValue().size());
+ vo.setBestLean(best.doubleValue());
+ vo.setTotalKm(totalKm.doubleValue());
+ statsList.add(vo);
+ }
+ return statsList;
+ }
+
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public boolean toggleLike(Long userId, Long recordId) {
+ MemberRideRecordDO record = rideRecordMapper.selectByIdAndStatus(recordId, STATUS_NORMAL);
+ if (record == null) {
+ throw exception(RIDE_RECORD_NOT_EXISTS);
+ }
+ MemberRideRecordLikeDO like = rideRecordLikeMapper.selectByRecordIdAndUserId(recordId, userId);
+ if (like != null) {
+ rideRecordLikeMapper.deleteById(like.getId());
+ record.setLikeCount(Math.max(0, record.getLikeCount() - 1));
+ rideRecordMapper.updateById(record);
+ return false;
+ }
+ rideRecordLikeMapper.insert(MemberRideRecordLikeDO.builder()
+ .recordId(recordId).userId(userId).build());
+ record.setLikeCount(record.getLikeCount() + 1);
+ rideRecordMapper.updateById(record);
+ return true;
+ }
+
+ @Override
+ public void reportRideRecord(Long userId, Long recordId) {
+ MemberRideRecordDO record = rideRecordMapper.selectByIdAndStatus(recordId, STATUS_NORMAL);
+ if (record == null) {
+ throw exception(RIDE_RECORD_NOT_EXISTS);
+ }
+ record.setReportCount(record.getReportCount() + 1);
+ if (record.getReportCount() >= REPORT_LIMIT) {
+ record.setStatus(STATUS_OFFLINE);
+ }
+ rideRecordMapper.updateById(record);
+ }
+
+ // ─── 私有:DO → RespVO 批量转换(补骑手资料、likedByMe)──────
+
+ private List convertList(List records, Long loginUserId,
+ boolean withTrack) {
+ if (records == null || records.isEmpty()) {
+ return Collections.emptyList();
+ }
+ // 骑手资料
+ Set userIds = records.stream()
+ .map(MemberRideRecordDO::getUserId).collect(Collectors.toCollection(LinkedHashSet::new));
+ Map userMap = userMapper.selectBatchIds(userIds).stream()
+ .collect(Collectors.toMap(MemberUserDO::getId, u -> u));
+ // 当前用户对这批战报的点赞
+ Set likedRecordIds = Collections.emptySet();
+ if (loginUserId != null) {
+ List recordIds = records.stream().map(MemberRideRecordDO::getId).collect(Collectors.toList());
+ likedRecordIds = rideRecordLikeMapper.selectListByUserIdAndRecordIds(loginUserId, recordIds).stream()
+ .map(MemberRideRecordLikeDO::getRecordId).collect(Collectors.toSet());
+ }
+ Set finalLiked = likedRecordIds;
+ return records.stream().map(record -> {
+ AppRideRecordRespVO vo = BeanUtils.toBean(record, AppRideRecordRespVO.class);
+ if (!withTrack) {
+ vo.setTrackJson(null); // 列表不带轨迹,减小响应体
+ }
+ MemberUserDO user = userMap.get(record.getUserId());
+ if (user != null) {
+ vo.setNickname(user.getNickname());
+ vo.setAvatar(user.getAvatar());
+ }
+ vo.setLikedByMe(loginUserId != null && finalLiked.contains(record.getId()));
+ return vo;
+ }).collect(Collectors.toList());
+ }
+
+}
diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/user/MemberUserService.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/user/MemberUserService.java
index cc64a79..da86c9c 100644
--- a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/user/MemberUserService.java
+++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/user/MemberUserService.java
@@ -140,6 +140,14 @@ public interface MemberUserService {
*/
boolean isPasswordMatch(String rawPassword, String encodedPassword);
+ /**
+ * 直接覆盖用户密码(航空账号同步等内部场景)
+ *
+ * @param id 用户编号
+ * @param password 明文密码
+ */
+ void updateUserPassword(Long id, String password);
+
/**
* 【管理员】更新会员用户
*
@@ -147,6 +155,14 @@ public interface MemberUserService {
*/
void updateUser(@Valid MemberUserUpdateReqVO updateReqVO);
+ /**
+ * 【管理员】更新会员用户状态(禁用时踢出全部登录态)
+ *
+ * @param id 用户编号
+ * @param status 状态,见 {@link cn.iocoder.yudao.framework.common.enums.CommonStatusEnum}
+ */
+ void updateUserStatus(Long id, Integer status);
+
/**
* 【管理员】获得会员用户分页
*
diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/user/MemberUserServiceImpl.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/user/MemberUserServiceImpl.java
index e19b154..d30f830 100644
--- a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/user/MemberUserServiceImpl.java
+++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/user/MemberUserServiceImpl.java
@@ -12,6 +12,7 @@ import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.framework.tenant.core.aop.TenantIgnore;
import cn.iocoder.yudao.module.member.client.service.ChatApi;
import cn.iocoder.yudao.module.member.client.vo.chat.user.ChatCreateOrUpdateUserReqVO;
+import cn.iocoder.yudao.module.member.client.vo.chat.user.ChatPrivateMessageSendResVO;
import cn.iocoder.yudao.module.member.client.vo.chat.user.ChatUserOnLineStatusReqVo;
import cn.iocoder.yudao.module.member.client.vo.chat.user.ChatUserOnLineStatusRespVo;
import cn.iocoder.yudao.module.member.controller.admin.user.vo.MemberUserPageReqVO;
@@ -30,6 +31,7 @@ import cn.iocoder.yudao.module.member.enums.tag.MemberRecentTagEnum;
import cn.iocoder.yudao.module.member.mq.producer.user.MemberUserProducer;
import cn.iocoder.yudao.module.member.service.user.BO.MemberExtBO;
import cn.iocoder.yudao.module.member.service.user.BO.MemberWechatBO;
+import cn.iocoder.yudao.module.system.api.oauth2.OAuth2TokenApi;
import cn.iocoder.yudao.module.system.api.sms.SmsCodeApi;
import cn.iocoder.yudao.module.system.api.sms.dto.code.SmsCodeUseReqDTO;
import cn.iocoder.yudao.module.system.api.social.SocialClientApi;
@@ -100,6 +102,8 @@ public class MemberUserServiceImpl implements MemberUserService {
private ChatApi chatApi;
@Resource
private TradeBrokerageUserApi tradeBrokerageUserApi;
+ @Resource
+ private OAuth2TokenApi oauth2TokenApi;
@Override
public MemberUserDO getUserByMobile(String mobile) {
@@ -402,6 +406,13 @@ public class MemberUserServiceImpl implements MemberUserService {
return passwordEncoder.matches(rawPassword, encodedPassword);
}
+ @Override
+ public void updateUserPassword(Long id, String password) {
+ validateUserExists(id);
+ memberUserMapper.updateById(MemberUserDO.builder().id(id)
+ .password(encodePassword(password)).build());
+ }
+
/**
* 对密码进行加密
*
@@ -416,7 +427,7 @@ public class MemberUserServiceImpl implements MemberUserService {
@Transactional(rollbackFor = Exception.class)
public void updateUser(MemberUserUpdateReqVO updateReqVO) {
// 校验存在
- validateUserExists(updateReqVO.getId());
+ MemberUserDO dbUser = validateUserExists(updateReqVO.getId());
// 校验手机唯一
validateMobileUnique(updateReqVO.getId(), updateReqVO.getMobile());
@@ -446,6 +457,59 @@ public class MemberUserServiceImpl implements MemberUserService {
// 更新用户新人tag
memberRecentTagService.createOrUpdateNewPersonTag(updateReqVO.getId());
memberUserMapper.updateById(updateObj);
+
+ // 变为禁用:踢出全部 App 登录态
+ Integer newStatus = updateObj.getStatus();
+ if (newStatus == null && updateReqVO.getStatus() != null) {
+ newStatus = updateReqVO.getStatus().intValue();
+ }
+ if (CommonStatusEnum.isDisable(newStatus) && !CommonStatusEnum.isDisable(dbUser.getStatus())) {
+ kickMemberLogin(updateReqVO.getId());
+ }
+ }
+
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public void updateUserStatus(Long id, Integer status) {
+ MemberUserDO dbUser = validateUserExists(id);
+ if (ObjectUtil.equal(dbUser.getStatus(), status)) {
+ // 已禁用时再点一次,仍强制踢一次,避免漏踢
+ if (CommonStatusEnum.isDisable(status)) {
+ kickMemberLogin(id);
+ }
+ return;
+ }
+ memberUserMapper.updateById(new MemberUserDO().setId(id).setStatus(status));
+ if (CommonStatusEnum.isDisable(status)) {
+ kickMemberLogin(id);
+ }
+ }
+
+ /**
+ * 清理会员全部 OAuth2 access + refresh,并通过 IM 私聊通知 App 踢登录(不封禁 IM 账号)。
+ */
+ private void kickMemberLogin(Long userId) {
+ // 先发 IM 消息(WS 仍在线时更容易送达),再清业务 token
+ try {
+ syncChatUser(userId);
+ // 约定前缀:Flutter 收到后先跳登录页,再清理本地态(不走 /third/user/ban)
+ ChatPrivateMessageSendResVO sent = chatApi.messageUserSendText(
+ userId, "__APP_KICK_SESSION__:账号已被禁用");
+ if (sent == null) {
+ log.warn("禁用踢登录:IM 私聊通知未发出, userId={}", userId);
+ } else {
+ log.info("禁用踢登录:已发 IM 私聊通知, userId={}", userId);
+ }
+ } catch (Exception e) {
+ log.warn("禁用踢登录:IM 私聊通知失败, userId={}", userId, e);
+ }
+ try {
+ oauth2TokenApi.removeAccessTokenByUserId(userId, UserTypeEnum.MEMBER.getValue());
+ log.info("已清理会员登录 token, userId={}", userId);
+ } catch (Exception e) {
+ log.warn("踢出会员登录态失败, userId={}", userId, e);
+ throw e;
+ }
}
@VisibleForTesting
@@ -536,12 +600,15 @@ public class MemberUserServiceImpl implements MemberUserService {
/**
* 获取用户的微信信息
+ * 有微信号即可解锁;二维码为可选展示字段。
*/
@Override
public MemberWechatBO getMemberWechat(Long memberId) {
MemberUserDO detail = memberUserMapper.selectById(memberId);
- if (ObjectUtil.isEmpty(detail)) return null;
- if (ObjectUtil.isEmpty(detail.getWechatNum()) || ObjectUtil.isEmpty(detail.getWechatQrCode())) {
+ if (ObjectUtil.isEmpty(detail)) {
+ return null;
+ }
+ if (ObjectUtil.isEmpty(detail.getWechatNum())) {
return null;
}
MemberWechatBO result = new MemberWechatBO();
diff --git a/yudao-module-member/yudao-module-member-biz/src/main/resources/mapper/RecentMapper.xml b/yudao-module-member/yudao-module-member-biz/src/main/resources/mapper/RecentMapper.xml
index 7c862ee..8961aa2 100644
--- a/yudao-module-member/yudao-module-member-biz/src/main/resources/mapper/RecentMapper.xml
+++ b/yudao-module-member/yudao-module-member-biz/src/main/resources/mapper/RecentMapper.xml
@@ -80,6 +80,8 @@
left join member_user u on a.member_id = u.id
+
+ and u.status = 0
and u.sex = #{dto.memberSex}
@@ -131,6 +133,8 @@
left join member_open_type_user otu on u.id = otu.member_id
u.deleted = 0
+
+ and u.status = 0
and u.sex = #{dto.memberSex}
diff --git a/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/client/service/FaceVerifyImageProcessorTest.java b/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/client/service/FaceVerifyImageProcessorTest.java
index 3f94584..9ff587f 100644
--- a/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/client/service/FaceVerifyImageProcessorTest.java
+++ b/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/client/service/FaceVerifyImageProcessorTest.java
@@ -42,13 +42,16 @@ class FaceVerifyImageProcessorTest {
}
@Test
- void normalizeImageRejectsLandscapeImage() throws Exception {
+ void normalizeImageConvertsLandscapeToPortraitByCenterCrop() throws Exception {
byte[] source = createPng(800, 600);
- IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
- () -> FaceVerifyImageProcessor.normalizeImage(source));
+ byte[] result = FaceVerifyImageProcessor.normalizeImage(source);
- assertTrue(exception.getMessage().contains("竖图"));
+ BufferedImage resultImage = ImageIO.read(new ByteArrayInputStream(result));
+ assertNotNull(resultImage);
+ assertTrue(resultImage.getHeight() > resultImage.getWidth());
+ assertTrue(resultImage.getWidth() >= 480);
+ assertTrue(resultImage.getHeight() >= 640);
}
@Test
diff --git a/yudao-module-system/yudao-module-system-api/src/main/java/cn/iocoder/yudao/module/system/api/oauth2/OAuth2TokenApi.java b/yudao-module-system/yudao-module-system-api/src/main/java/cn/iocoder/yudao/module/system/api/oauth2/OAuth2TokenApi.java
index 79393cf..2fec551 100644
--- a/yudao-module-system/yudao-module-system-api/src/main/java/cn/iocoder/yudao/module/system/api/oauth2/OAuth2TokenApi.java
+++ b/yudao-module-system/yudao-module-system-api/src/main/java/cn/iocoder/yudao/module/system/api/oauth2/OAuth2TokenApi.java
@@ -37,6 +37,15 @@ public interface OAuth2TokenApi {
*/
OAuth2AccessTokenRespDTO removeAccessToken(String accessToken);
+ /**
+ * 移除指定用户的全部访问令牌与刷新令牌(踢下线)
+ *
+ * @param userId 用户编号
+ * @param userType 用户类型
+ * @return 删除的访问令牌数量
+ */
+ int removeAccessTokenByUserId(Long userId, Integer userType);
+
/**
* 刷新访问令牌
*
diff --git a/yudao-module-system/yudao-module-system-api/src/main/java/cn/iocoder/yudao/module/system/enums/ErrorCodeConstants.java b/yudao-module-system/yudao-module-system-api/src/main/java/cn/iocoder/yudao/module/system/enums/ErrorCodeConstants.java
index bc972b3..c6594c9 100644
--- a/yudao-module-system/yudao-module-system-api/src/main/java/cn/iocoder/yudao/module/system/enums/ErrorCodeConstants.java
+++ b/yudao-module-system/yudao-module-system-api/src/main/java/cn/iocoder/yudao/module/system/enums/ErrorCodeConstants.java
@@ -167,4 +167,10 @@ public interface ErrorCodeConstants {
// ========== 站内信发送 1-002-028-000 ==========
ErrorCode NOTIFY_SEND_TEMPLATE_PARAM_MISS = new ErrorCode(1_002_028_000, "模板参数({})缺失");
+ // ========== 子账号管理 1-002-030-000 =========
+ ErrorCode SUB_ACCOUNT_NOT_EXISTS = new ErrorCode(1_002_030_000, "子账号不存在");
+ ErrorCode SUB_ACCOUNT_NOT_OWNER = new ErrorCode(1_002_030_001, "无权操作该子账号");
+ ErrorCode SUB_ACCOUNT_MENU_EMPTY = new ErrorCode(1_002_030_002, "请至少勾选一个当前账号拥有的菜单");
+ ErrorCode SUB_ACCOUNT_HOLDER_MENU_EMPTY = new ErrorCode(1_002_030_003, "当前账号未拥有任何菜单,无法创建子账号");
+
}
diff --git a/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/api/oauth2/OAuth2TokenApiImpl.java b/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/api/oauth2/OAuth2TokenApiImpl.java
index be7eba3..dfcaf5c 100644
--- a/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/api/oauth2/OAuth2TokenApiImpl.java
+++ b/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/api/oauth2/OAuth2TokenApiImpl.java
@@ -40,6 +40,11 @@ public class OAuth2TokenApiImpl implements OAuth2TokenApi {
return BeanUtils.toBean(accessTokenDO, OAuth2AccessTokenRespDTO.class);
}
+ @Override
+ public int removeAccessTokenByUserId(Long userId, Integer userType) {
+ return oauth2TokenService.removeAccessTokenByUserId(userId, userType);
+ }
+
@Override
public OAuth2AccessTokenRespDTO refreshAccessToken(String refreshToken, String clientId) {
OAuth2AccessTokenDO accessTokenDO = oauth2TokenService.refreshAccessToken(refreshToken, clientId);
diff --git a/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/subaccount/SubAccountController.java b/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/subaccount/SubAccountController.java
new file mode 100644
index 0000000..76f39b5
--- /dev/null
+++ b/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/subaccount/SubAccountController.java
@@ -0,0 +1,88 @@
+package cn.iocoder.yudao.module.system.controller.admin.subaccount;
+
+import cn.iocoder.yudao.framework.common.pojo.CommonResult;
+import cn.iocoder.yudao.module.system.controller.admin.subaccount.vo.*;
+import cn.iocoder.yudao.module.system.service.subaccount.SubAccountService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.*;
+
+import javax.annotation.Resource;
+import javax.validation.Valid;
+import java.util.List;
+
+import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
+
+/**
+ * 后台子账号管理 Controller
+ *
+ * 供 AppHolder 管理自己的子账号:创建、分配菜单、禁用、重置密码、删除。
+ */
+@Tag(name = "管理后台 - 子账号管理")
+@RestController
+@RequestMapping("/system/sub-account")
+@Validated
+public class SubAccountController {
+
+ @Resource
+ private SubAccountService subAccountService;
+
+ @GetMapping("/list")
+ @Operation(summary = "获得子账号列表")
+ @PreAuthorize("@ss.hasPermission('system:sub-account:manage')")
+ public CommonResult> getSubAccountList() {
+ return success(subAccountService.getSubAccountList());
+ }
+
+ @GetMapping("/menu-list")
+ @Operation(summary = "获得当前账号拥有的菜单(用于给子账号勾选)")
+ @PreAuthorize("@ss.hasPermission('system:sub-account:manage')")
+ public CommonResult> getHolderMenuList() {
+ return success(subAccountService.getHolderMenuList());
+ }
+
+ @PostMapping("/create")
+ @Operation(summary = "创建子账号")
+ @PreAuthorize("@ss.hasPermission('system:sub-account:manage')")
+ public CommonResult createSubAccount(@Valid @RequestBody SubAccountCreateReqVO reqVO) {
+ return success(subAccountService.createSubAccount(reqVO, reqVO.getPassword()));
+ }
+
+ @PutMapping("/update")
+ @Operation(summary = "修改子账号(昵称 + 可见菜单)")
+ @PreAuthorize("@ss.hasPermission('system:sub-account:manage')")
+ public CommonResult updateSubAccount(@RequestParam("userId") Long userId,
+ @Valid @RequestBody SubAccountSaveReqVO reqVO) {
+ subAccountService.updateSubAccount(userId, reqVO);
+ return success(true);
+ }
+
+ @PutMapping("/update-status")
+ @Operation(summary = "修改子账号状态")
+ @PreAuthorize("@ss.hasPermission('system:sub-account:manage')")
+ public CommonResult updateSubAccountStatus(@Valid @RequestBody SubAccountUpdateStatusReqVO reqVO) {
+ subAccountService.updateSubAccountStatus(reqVO.getUserId(), reqVO.getStatus());
+ return success(true);
+ }
+
+ @PutMapping("/reset-password")
+ @Operation(summary = "重置子账号密码")
+ @PreAuthorize("@ss.hasPermission('system:sub-account:manage')")
+ public CommonResult resetSubAccountPassword(@Valid @RequestBody SubAccountResetPasswordReqVO reqVO) {
+ subAccountService.resetSubAccountPassword(reqVO.getUserId(), reqVO.getPassword());
+ return success(true);
+ }
+
+ @DeleteMapping("/delete")
+ @Operation(summary = "删除子账号")
+ @Parameter(name = "userId", description = "子账号用户编号", required = true)
+ @PreAuthorize("@ss.hasPermission('system:sub-account:manage')")
+ public CommonResult deleteSubAccount(@RequestParam("userId") Long userId) {
+ subAccountService.deleteSubAccount(userId);
+ return success(true);
+ }
+
+}
diff --git a/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/subaccount/vo/SubAccountCreateReqVO.java b/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/subaccount/vo/SubAccountCreateReqVO.java
new file mode 100644
index 0000000..d3fac7d
--- /dev/null
+++ b/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/subaccount/vo/SubAccountCreateReqVO.java
@@ -0,0 +1,17 @@
+package cn.iocoder.yudao.module.system.controller.admin.subaccount.vo;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import org.hibernate.validator.constraints.Length;
+
+@Schema(description = "管理后台 - 子账号创建 Request VO")
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class SubAccountCreateReqVO extends SubAccountSaveReqVO {
+
+ @Schema(description = "登录密码", requiredMode = Schema.RequiredMode.REQUIRED, example = "123456")
+ @Length(min = 4, max = 16, message = "密码长度为 4-16 位")
+ private String password;
+
+}
diff --git a/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/subaccount/vo/SubAccountMenuRespVO.java b/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/subaccount/vo/SubAccountMenuRespVO.java
new file mode 100644
index 0000000..823173d
--- /dev/null
+++ b/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/subaccount/vo/SubAccountMenuRespVO.java
@@ -0,0 +1,25 @@
+package cn.iocoder.yudao.module.system.controller.admin.subaccount.vo;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+@Schema(description = "管理后台 - 子账号可用菜单 Response VO")
+@Data
+public class SubAccountMenuRespVO {
+
+ @Schema(description = "菜单编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "2262")
+ private Long id;
+
+ @Schema(description = "父菜单编号", example = "0")
+ private Long parentId;
+
+ @Schema(description = "菜单名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "会员中心")
+ private String name;
+
+ @Schema(description = "菜单类型:1 目录 2 菜单 3 按钮", requiredMode = Schema.RequiredMode.REQUIRED, example = "2")
+ private Integer type;
+
+ @Schema(description = "显示顺序", example = "1")
+ private Integer sort;
+
+}
diff --git a/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/subaccount/vo/SubAccountResetPasswordReqVO.java b/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/subaccount/vo/SubAccountResetPasswordReqVO.java
new file mode 100644
index 0000000..8386bba
--- /dev/null
+++ b/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/subaccount/vo/SubAccountResetPasswordReqVO.java
@@ -0,0 +1,23 @@
+package cn.iocoder.yudao.module.system.controller.admin.subaccount.vo;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import javax.validation.constraints.NotBlank;
+import javax.validation.constraints.NotNull;
+import javax.validation.constraints.Size;
+
+@Schema(description = "管理后台 - 子账号重置密码 Request VO")
+@Data
+public class SubAccountResetPasswordReqVO {
+
+ @Schema(description = "子账号用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "200")
+ @NotNull(message = "子账号编号不能为空")
+ private Long userId;
+
+ @Schema(description = "新密码", requiredMode = Schema.RequiredMode.REQUIRED, example = "123456")
+ @NotBlank(message = "新密码不能为空")
+ @Size(min = 4, max = 16, message = "密码长度为 4-16 位")
+ private String password;
+
+}
diff --git a/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/subaccount/vo/SubAccountRespVO.java b/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/subaccount/vo/SubAccountRespVO.java
new file mode 100644
index 0000000..4bb9589
--- /dev/null
+++ b/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/subaccount/vo/SubAccountRespVO.java
@@ -0,0 +1,37 @@
+package cn.iocoder.yudao.module.system.controller.admin.subaccount.vo;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+import java.util.Set;
+
+@Schema(description = "管理后台 - 子账号 Response VO")
+@Data
+public class SubAccountRespVO {
+
+ @Schema(description = "记录编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
+ private Long id;
+
+ @Schema(description = "子账号用户编号(system_users.id)", requiredMode = Schema.RequiredMode.REQUIRED, example = "200")
+ private Long userId;
+
+ @Schema(description = "登录账号", requiredMode = Schema.RequiredMode.REQUIRED, example = "suboperator")
+ private String username;
+
+ @Schema(description = "昵称", example = "运营小号")
+ private String nickname;
+
+ @Schema(description = "状态:0 开启 1 禁用", requiredMode = Schema.RequiredMode.REQUIRED, example = "0")
+ private Integer status;
+
+ @Schema(description = "最后登录时间")
+ private LocalDateTime loginDate;
+
+ @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED)
+ private LocalDateTime createTime;
+
+ @Schema(description = "可见菜单编号列表")
+ private Set menuIds;
+
+}
diff --git a/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/subaccount/vo/SubAccountSaveReqVO.java b/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/subaccount/vo/SubAccountSaveReqVO.java
new file mode 100644
index 0000000..4d2fd9e
--- /dev/null
+++ b/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/subaccount/vo/SubAccountSaveReqVO.java
@@ -0,0 +1,26 @@
+package cn.iocoder.yudao.module.system.controller.admin.subaccount.vo;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import javax.validation.constraints.NotBlank;
+import javax.validation.constraints.Size;
+import java.util.Set;
+
+@Schema(description = "管理后台 - 子账号创建/修改 Request VO")
+@Data
+public class SubAccountSaveReqVO {
+
+ @Schema(description = "子账号用户名(创建必填,字母+数字 4-30 位)", example = "suboperator")
+ @Size(min = 4, max = 30, message = "用户账号长度为 4-30 个字符")
+ private String username;
+
+ @Schema(description = "子账号昵称", requiredMode = Schema.RequiredMode.REQUIRED, example = "运营小号")
+ @NotBlank(message = "子账号昵称不能为空")
+ @Size(max = 30, message = "子账号昵称长度不能超过30个字符")
+ private String nickname;
+
+ @Schema(description = "菜单编号列表(必须在当前登录账号拥有的菜单范围内)", example = "[2262, 2317]")
+ private Set menuIds;
+
+}
diff --git a/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/subaccount/vo/SubAccountUpdateStatusReqVO.java b/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/subaccount/vo/SubAccountUpdateStatusReqVO.java
new file mode 100644
index 0000000..0b288a6
--- /dev/null
+++ b/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/subaccount/vo/SubAccountUpdateStatusReqVO.java
@@ -0,0 +1,20 @@
+package cn.iocoder.yudao.module.system.controller.admin.subaccount.vo;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import javax.validation.constraints.NotNull;
+
+@Schema(description = "管理后台 - 子账号状态修改 Request VO")
+@Data
+public class SubAccountUpdateStatusReqVO {
+
+ @Schema(description = "子账号用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "200")
+ @NotNull(message = "子账号编号不能为空")
+ private Long userId;
+
+ @Schema(description = "状态:0 开启 1 禁用", requiredMode = Schema.RequiredMode.REQUIRED, example = "0")
+ @NotNull(message = "状态不能为空")
+ private Integer status;
+
+}
diff --git a/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/dal/dataobject/subaccount/SubAccountDO.java b/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/dal/dataobject/subaccount/SubAccountDO.java
new file mode 100644
index 0000000..8e878cf
--- /dev/null
+++ b/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/dal/dataobject/subaccount/SubAccountDO.java
@@ -0,0 +1,40 @@
+package cn.iocoder.yudao.module.system.dal.dataobject.subaccount;
+
+import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO;
+import com.baomidou.mybatisplus.annotation.KeySequence;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+/**
+ * 后台子账号 DO
+ *
+ * AppHolder 创建的子账号与专属角色的绑定关系。
+ * 子账号登录后台后可见的菜单 = 其专属角色被分配的菜单(由 holder 分配)。
+ */
+@TableName("system_sub_account")
+@KeySequence("system_sub_account_seq")
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class SubAccountDO extends TenantBaseDO {
+
+ /**
+ * 主键
+ */
+ @TableId
+ private Long id;
+ /**
+ * 管理者(holder)用户编号,system_users.id
+ */
+ private Long holderUserId;
+ /**
+ * 子账号用户编号,system_users.id
+ */
+ private Long subUserId;
+ /**
+ * 子账号专属角色编号,system_role.id
+ */
+ private Long roleId;
+
+}
diff --git a/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/dal/mysql/oauth2/OAuth2AccessTokenMapper.java b/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/dal/mysql/oauth2/OAuth2AccessTokenMapper.java
index 81ca13f..1fa2e0e 100644
--- a/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/dal/mysql/oauth2/OAuth2AccessTokenMapper.java
+++ b/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/dal/mysql/oauth2/OAuth2AccessTokenMapper.java
@@ -23,6 +23,12 @@ public interface OAuth2AccessTokenMapper extends BaseMapperX selectListByUserIdAndUserType(Long userId, Integer userType) {
+ return selectList(new LambdaQueryWrapperX()
+ .eq(OAuth2AccessTokenDO::getUserId, userId)
+ .eq(OAuth2AccessTokenDO::getUserType, userType));
+ }
+
default PageResult selectPage(OAuth2AccessTokenPageReqVO reqVO) {
return selectPage(reqVO, new LambdaQueryWrapperX()
.eqIfPresent(OAuth2AccessTokenDO::getUserId, reqVO.getUserId())
diff --git a/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/dal/mysql/oauth2/OAuth2RefreshTokenMapper.java b/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/dal/mysql/oauth2/OAuth2RefreshTokenMapper.java
index bf91457..ff9a79c 100644
--- a/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/dal/mysql/oauth2/OAuth2RefreshTokenMapper.java
+++ b/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/dal/mysql/oauth2/OAuth2RefreshTokenMapper.java
@@ -14,6 +14,12 @@ public interface OAuth2RefreshTokenMapper extends BaseMapperX()
+ .eq(OAuth2RefreshTokenDO::getUserId, userId)
+ .eq(OAuth2RefreshTokenDO::getUserType, userType));
+ }
+
@TenantIgnore // 获取 token 的时候,需要忽略租户编号。原因是:一些场景下,可能不会传递 tenant-id 请求头,例如说文件上传、积木报表等等
default OAuth2RefreshTokenDO selectByRefreshToken(String refreshToken) {
return selectOne(OAuth2RefreshTokenDO::getRefreshToken, refreshToken);
diff --git a/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/dal/mysql/subaccount/SubAccountMapper.java b/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/dal/mysql/subaccount/SubAccountMapper.java
new file mode 100644
index 0000000..e193183
--- /dev/null
+++ b/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/dal/mysql/subaccount/SubAccountMapper.java
@@ -0,0 +1,26 @@
+package cn.iocoder.yudao.module.system.dal.mysql.subaccount;
+
+import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
+import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
+import cn.iocoder.yudao.module.system.dal.dataobject.subaccount.SubAccountDO;
+import org.apache.ibatis.annotations.Mapper;
+
+import java.util.List;
+
+/**
+ * 后台子账号 Mapper
+ */
+@Mapper
+public interface SubAccountMapper extends BaseMapperX {
+
+ default List selectListByHolderUserId(Long holderUserId) {
+ return selectList(new LambdaQueryWrapperX()
+ .eq(SubAccountDO::getHolderUserId, holderUserId)
+ .orderByDesc(SubAccountDO::getId));
+ }
+
+ default SubAccountDO selectBySubUserId(Long subUserId) {
+ return selectOne(SubAccountDO::getSubUserId, subUserId);
+ }
+
+}
diff --git a/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/oauth2/OAuth2TokenService.java b/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/oauth2/OAuth2TokenService.java
index 977d935..29e975f 100644
--- a/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/oauth2/OAuth2TokenService.java
+++ b/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/oauth2/OAuth2TokenService.java
@@ -69,6 +69,15 @@ public interface OAuth2TokenService {
*/
OAuth2AccessTokenDO removeAccessToken(String accessToken);
+ /**
+ * 移除指定用户的全部访问令牌与刷新令牌(踢下线)
+ *
+ * @param userId 用户编号
+ * @param userType 用户类型
+ * @return 删除的访问令牌数量
+ */
+ int removeAccessTokenByUserId(Long userId, Integer userType);
+
/**
* 获得访问令牌分页
*
diff --git a/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/oauth2/OAuth2TokenServiceImpl.java b/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/oauth2/OAuth2TokenServiceImpl.java
index 04bc744..98ae92e 100644
--- a/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/oauth2/OAuth2TokenServiceImpl.java
+++ b/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/oauth2/OAuth2TokenServiceImpl.java
@@ -153,6 +153,25 @@ public class OAuth2TokenServiceImpl implements OAuth2TokenService {
return accessTokenDO;
}
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public int removeAccessTokenByUserId(Long userId, Integer userType) {
+ if (userId == null || userType == null) {
+ return 0;
+ }
+ List accessTokenDOs =
+ oauth2AccessTokenMapper.selectListByUserIdAndUserType(userId, userType);
+ int removed = 0;
+ if (CollUtil.isNotEmpty(accessTokenDOs)) {
+ oauth2AccessTokenMapper.deleteByIds(convertSet(accessTokenDOs, OAuth2AccessTokenDO::getId));
+ oauth2AccessTokenRedisDAO.deleteList(convertSet(accessTokenDOs, OAuth2AccessTokenDO::getAccessToken));
+ removed = accessTokenDOs.size();
+ }
+ // 清理可能残留的 refresh token(无对应 access 的场景)
+ oauth2RefreshTokenMapper.deleteByUserIdAndUserType(userId, userType);
+ return removed;
+ }
+
@Override
public PageResult getAccessTokenPage(OAuth2AccessTokenPageReqVO reqVO) {
return oauth2AccessTokenMapper.selectPage(reqVO);
diff --git a/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/subaccount/SubAccountService.java b/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/subaccount/SubAccountService.java
new file mode 100644
index 0000000..10717f5
--- /dev/null
+++ b/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/subaccount/SubAccountService.java
@@ -0,0 +1,69 @@
+package cn.iocoder.yudao.module.system.service.subaccount;
+
+import cn.iocoder.yudao.module.system.controller.admin.subaccount.vo.SubAccountMenuRespVO;
+import cn.iocoder.yudao.module.system.controller.admin.subaccount.vo.SubAccountRespVO;
+import cn.iocoder.yudao.module.system.controller.admin.subaccount.vo.SubAccountSaveReqVO;
+
+import java.util.List;
+
+/**
+ * 后台子账号 Service 接口
+ *
+ * AppHolder 在自己的权限范围内创建子账号,并分配子账号可见的菜单。
+ */
+public interface SubAccountService {
+
+ /**
+ * 获得当前登录账号(holder)的子账号列表
+ *
+ * @return 子账号列表
+ */
+ List getSubAccountList();
+
+ /**
+ * 获得当前登录账号(holder)拥有的菜单(用于给子账号勾选)
+ *
+ * @return 菜单列表
+ */
+ List getHolderMenuList();
+
+ /**
+ * 创建子账号(用户名 + 昵称 + 密码 + 可见菜单)
+ *
+ * @param reqVO 创建信息(password 需要传)
+ * @return 子账号用户编号
+ */
+ Long createSubAccount(SubAccountSaveReqVO reqVO, String password);
+
+ /**
+ * 修改子账号(昵称 + 可见菜单)
+ *
+ * @param userId 子账号用户编号
+ * @param reqVO 修改信息
+ */
+ void updateSubAccount(Long userId, SubAccountSaveReqVO reqVO);
+
+ /**
+ * 修改子账号状态(禁用后无法登录后台)
+ *
+ * @param userId 子账号用户编号
+ * @param status 状态
+ */
+ void updateSubAccountStatus(Long userId, Integer status);
+
+ /**
+ * 重置子账号密码
+ *
+ * @param userId 子账号用户编号
+ * @param password 新密码
+ */
+ void resetSubAccountPassword(Long userId, String password);
+
+ /**
+ * 删除子账号(连同专属角色)
+ *
+ * @param userId 子账号用户编号
+ */
+ void deleteSubAccount(Long userId);
+
+}
diff --git a/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/subaccount/SubAccountServiceImpl.java b/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/subaccount/SubAccountServiceImpl.java
new file mode 100644
index 0000000..6a839ff
--- /dev/null
+++ b/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/subaccount/SubAccountServiceImpl.java
@@ -0,0 +1,230 @@
+package cn.iocoder.yudao.module.system.service.subaccount;
+
+import cn.hutool.core.collection.CollUtil;
+import cn.hutool.core.util.RandomUtil;
+import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
+import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
+import cn.iocoder.yudao.module.system.controller.admin.permission.vo.role.RoleSaveReqVO;
+import cn.iocoder.yudao.module.system.controller.admin.subaccount.vo.SubAccountMenuRespVO;
+import cn.iocoder.yudao.module.system.controller.admin.subaccount.vo.SubAccountRespVO;
+import cn.iocoder.yudao.module.system.controller.admin.subaccount.vo.SubAccountSaveReqVO;
+import cn.iocoder.yudao.module.system.controller.admin.user.vo.user.UserSaveReqVO;
+import cn.iocoder.yudao.module.system.dal.dataobject.permission.MenuDO;
+import cn.iocoder.yudao.module.system.dal.dataobject.permission.RoleDO;
+import cn.iocoder.yudao.module.system.dal.dataobject.subaccount.SubAccountDO;
+import cn.iocoder.yudao.module.system.dal.dataobject.user.AdminUserDO;
+import cn.iocoder.yudao.module.system.dal.mysql.subaccount.SubAccountMapper;
+import cn.iocoder.yudao.module.system.enums.permission.RoleTypeEnum;
+import cn.iocoder.yudao.module.system.service.permission.MenuService;
+import cn.iocoder.yudao.module.system.service.permission.PermissionService;
+import cn.iocoder.yudao.module.system.service.permission.RoleService;
+import cn.iocoder.yudao.module.system.service.tenant.TenantService;
+import cn.iocoder.yudao.module.system.service.user.AdminUserService;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.validation.annotation.Validated;
+
+import javax.annotation.Resource;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
+import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId;
+import static cn.iocoder.yudao.module.system.enums.ErrorCodeConstants.*;
+
+/**
+ * 后台子账号 Service 实现类
+ */
+@Service
+@Validated
+public class SubAccountServiceImpl implements SubAccountService {
+
+ @Resource
+ private SubAccountMapper subAccountMapper;
+ @Resource
+ private AdminUserService userService;
+ @Resource
+ private RoleService roleService;
+ @Resource
+ private PermissionService permissionService;
+ @Resource
+ private MenuService menuService;
+ @Resource
+ private TenantService tenantService;
+
+ @Override
+ public List getSubAccountList() {
+ Long holderUserId = getLoginUserId();
+ List mappings = subAccountMapper.selectListByHolderUserId(holderUserId);
+ List result = new ArrayList<>();
+ for (SubAccountDO mapping : mappings) {
+ AdminUserDO user = userService.getUser(mapping.getSubUserId());
+ if (user == null) { // 用户已被删除,跳过
+ continue;
+ }
+ SubAccountRespVO vo = BeanUtils.toBean(mapping, SubAccountRespVO.class);
+ vo.setUserId(user.getId());
+ vo.setUsername(user.getUsername());
+ vo.setNickname(user.getNickname());
+ vo.setStatus(user.getStatus());
+ vo.setLoginDate(user.getLoginDate());
+ vo.setMenuIds(permissionService.getRoleMenuListByRoleId(mapping.getRoleId()));
+ result.add(vo);
+ }
+ return result;
+ }
+
+ @Override
+ public List getHolderMenuList() {
+ Set holderMenuIds = getHolderMenuIds(getLoginUserId());
+ if (CollUtil.isEmpty(holderMenuIds)) {
+ return new ArrayList<>();
+ }
+ List menus = menuService.getMenuList(holderMenuIds);
+ menus = menuService.filterDisableMenus(menus);
+ List result = new ArrayList<>();
+ for (MenuDO menu : menus) {
+ SubAccountMenuRespVO vo = new SubAccountMenuRespVO();
+ vo.setId(menu.getId());
+ vo.setParentId(menu.getParentId());
+ vo.setName(menu.getName());
+ vo.setType(menu.getType());
+ vo.setSort(menu.getSort());
+ result.add(vo);
+ }
+ return result;
+ }
+
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public Long createSubAccount(SubAccountSaveReqVO reqVO, String password) {
+ Long holderUserId = getLoginUserId();
+ // 1. 校验账号唯一
+ if (userService.getUserByUsername(reqVO.getUsername()) != null) {
+ throw exception(USER_USERNAME_EXISTS);
+ }
+ // 2. 校验并过滤菜单:只能授予 holder 自己拥有的菜单
+ Set menuIds = filterMenusByHolder(reqVO.getMenuIds(), holderUserId);
+ // 3. 创建子账号用户
+ UserSaveReqVO userReqVO = new UserSaveReqVO();
+ userReqVO.setUsername(reqVO.getUsername());
+ userReqVO.setNickname(reqVO.getNickname());
+ userReqVO.setPassword(password);
+ userReqVO.setRemark("AppHolder 子账号");
+ Long subUserId = userService.createUser(userReqVO);
+ // 4. 创建专属角色并分配菜单
+ Long roleId = createSubRole(subUserId, reqVO.getNickname());
+ permissionService.assignRoleMenu(roleId, menuIds);
+ // 5. 绑定角色
+ permissionService.assignUserRole(subUserId, CollUtil.newHashSet(roleId));
+ // 6. 记录绑定关系
+ SubAccountDO mapping = new SubAccountDO();
+ mapping.setHolderUserId(holderUserId);
+ mapping.setSubUserId(subUserId);
+ mapping.setRoleId(roleId);
+ subAccountMapper.insert(mapping);
+ return subUserId;
+ }
+
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public void updateSubAccount(Long userId, SubAccountSaveReqVO reqVO) {
+ SubAccountDO mapping = validateOwner(userId);
+ // 1. 更新昵称(username 不允许修改,密码走独立接口)
+ AdminUserDO user = userService.getUser(userId);
+ UserSaveReqVO userReqVO = new UserSaveReqVO();
+ userReqVO.setId(userId);
+ userReqVO.setUsername(user.getUsername());
+ userReqVO.setNickname(reqVO.getNickname());
+ userService.updateUser(userReqVO);
+ // 2. 更新可见菜单
+ Set menuIds = filterMenusByHolder(reqVO.getMenuIds(), mapping.getHolderUserId());
+ permissionService.assignRoleMenu(mapping.getRoleId(), menuIds);
+ }
+
+ @Override
+ public void updateSubAccountStatus(Long userId, Integer status) {
+ validateOwner(userId);
+ userService.updateUserStatus(userId, status);
+ }
+
+ @Override
+ public void resetSubAccountPassword(Long userId, String password) {
+ validateOwner(userId);
+ userService.updateUserPassword(userId, password);
+ }
+
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public void deleteSubAccount(Long userId) {
+ SubAccountDO mapping = validateOwner(userId);
+ // 1. 删除用户
+ userService.deleteUser(userId);
+ // 2. 删除专属角色
+ roleService.deleteRole(mapping.getRoleId());
+ // 3. 删除绑定关系
+ subAccountMapper.deleteById(mapping.getId());
+ }
+
+ private Long createSubRole(Long subUserId, String nickname) {
+ RoleSaveReqVO roleReqVO = new RoleSaveReqVO();
+ roleReqVO.setName("子账号-" + nickname);
+ roleReqVO.setCode("sub_acc_" + subUserId + "_" + RandomUtil.randomString(6).toLowerCase());
+ roleReqVO.setSort(999);
+ roleReqVO.setStatus(CommonStatusEnum.ENABLE.getStatus());
+ roleReqVO.setRemark("AppHolder 子账号专属角色");
+ return roleService.createRole(roleReqVO, RoleTypeEnum.CUSTOM.getType());
+ }
+
+ /**
+ * 校验当前登录人是否为该子账号的管理者,并返回绑定关系
+ */
+ private SubAccountDO validateOwner(Long subUserId) {
+ SubAccountDO mapping = subAccountMapper.selectBySubUserId(subUserId);
+ if (mapping == null) {
+ throw exception(SUB_ACCOUNT_NOT_EXISTS);
+ }
+ if (!mapping.getHolderUserId().equals(getLoginUserId())) {
+ throw exception(SUB_ACCOUNT_NOT_OWNER);
+ }
+ return mapping;
+ }
+
+ /**
+ * 获得 holder 拥有的菜单编号集合(其所有角色菜单的并集)
+ */
+ private Set getHolderMenuIds(Long holderUserId) {
+ Set roleIds = permissionService.getUserRoleIdListByUserId(holderUserId);
+ if (CollUtil.isEmpty(roleIds)) {
+ return new HashSet<>();
+ }
+ return permissionService.getRoleMenuListByRoleId(roleIds);
+ }
+
+ /**
+ * 将请求的菜单过滤为 holder 自己拥有的菜单;若过滤后为空则报错
+ */
+ private Set filterMenusByHolder(Set requestMenuIds, Long holderUserId) {
+ Set