Bladeren bron

奖品池调整

liuchengsen 1 maand geleden
bovenliggende
commit
7ebbfeab05

+ 12 - 0
zhijiayun-gateway/src/main/resources/db/schema.sql

@@ -49,6 +49,18 @@ CREATE TABLE IF NOT EXISTS `t_user` (
     KEY `idx_unionid` (`wechat_union_id`)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户表';
 
+-- 用户登录日志表(记录每次登录,用于区分不同端的最后登录时间)
+CREATE TABLE IF NOT EXISTS `t_login_log` (
+    `id`                BIGINT        NOT NULL AUTO_INCREMENT COMMENT '主键ID',
+    `user_id`           BIGINT        NOT NULL COMMENT '用户ID',
+    `login_source`      VARCHAR(20)   NOT NULL COMMENT '登录来源:PC/MINIAPP/WECHAT/SMS/ANDROID/IOS',
+    `login_ip`          VARCHAR(45)   DEFAULT NULL COMMENT '登录IP',
+    `login_time`        DATETIME      NOT NULL COMMENT '登录时间',
+    `create_time`       DATETIME      DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    PRIMARY KEY (`id`),
+    KEY `idx_user_source_time` (`user_id`, `login_source`, `login_time`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户登录日志表';
+
 -- 管理员表
 CREATE TABLE IF NOT EXISTS `t_admin` (
     `id`                BIGINT        NOT NULL AUTO_INCREMENT COMMENT '主键ID',

+ 6 - 0
zhijiayun-user/src/main/java/com/xuekairui/user/dto/MembershipUserResponse.java

@@ -47,4 +47,10 @@ public class MembershipUserResponse {
 
     /** 注册时间 */
     private LocalDateTime createTime;
+
+    /** PC端最后登录时间(PC/WINDOWS/WECHAT来源的最近登录) */
+    private LocalDateTime pcLastLoginTime;
+
+    /** 小程序端最后登录时间(MINIAPP来源的最近登录) */
+    private LocalDateTime miniappLastLoginTime;
 }

+ 41 - 0
zhijiayun-user/src/main/java/com/xuekairui/user/entity/LoginLog.java

@@ -0,0 +1,41 @@
+package com.xuekairui.user.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.time.LocalDateTime;
+
+/**
+ * 用户登录日志实体
+ */
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+@TableName("t_login_log")
+public class LoginLog {
+
+    /** 主键ID */
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 用户ID */
+    private Long userId;
+
+    /** 登录来源:PC/MINIAPP/WECHAT/SMS/ANDROID/IOS */
+    private String loginSource;
+
+    /** 登录IP */
+    private String loginIp;
+
+    /** 登录时间 */
+    private LocalDateTime loginTime;
+
+    /** 创建时间 */
+    private LocalDateTime createTime;
+}

+ 31 - 0
zhijiayun-user/src/main/java/com/xuekairui/user/mapper/LoginLogMapper.java

@@ -0,0 +1,31 @@
+package com.xuekairui.user.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.xuekairui.user.entity.LoginLog;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Select;
+
+import java.util.List;
+
+/**
+ * 用户登录日志 Mapper
+ */
+@Mapper
+public interface LoginLogMapper extends BaseMapper<LoginLog> {
+
+    /**
+     * 批量查询指定用户各登录来源的最近登录时间
+     * 返回每个 (userId, loginSource) 组合的最新一条记录
+     */
+    @Select("<script>" +
+            "SELECT user_id, login_source, MAX(login_time) AS login_time " +
+            "FROM t_login_log " +
+            "WHERE user_id IN " +
+            "<foreach collection='userIds' item='id' open='(' separator=',' close=')'>" +
+            "  #{id}" +
+            "</foreach>" +
+            " GROUP BY user_id, login_source" +
+            "</script>")
+    List<LoginLog> selectLatestByUserIds(@Param("userIds") List<Long> userIds);
+}

+ 26 - 100
zhijiayun-user/src/main/java/com/xuekairui/user/service/AuthService.java

@@ -7,6 +7,7 @@ import com.xuekairui.common.ValidateUtil;
 import com.xuekairui.common.event.ActivityEventRedisService;
 import com.xuekairui.user.dto.*;
 import com.xuekairui.user.entity.User;
+import com.xuekairui.user.entity.LoginLog;
 import com.xuekairui.user.enums.LicenseStatus;
 import com.xuekairui.user.enums.MembershipLevel;
 import com.xuekairui.user.enums.OperationSource;
@@ -52,12 +53,12 @@ public class AuthService {
     private final CertificateSyncService certificateSyncService;
     private final BusinessLicenseService businessLicenseService;
     private final ShopResourceSyncService shopResourceSyncService;
-    private final com.xuekairui.user.mapper.LicenseSyncDiffMapper licenseSyncDiffMapper;
     private final com.xuekairui.user.mapper.PlatformAccountMapper platformAccountMapper;
     private final com.xuekairui.user.mapper.CrawlerUsageLogMapper crawlerUsageLogMapper;
     private final com.xuekairui.user.mapper.SearchRecordMapper searchRecordMapper;
     private final com.xuekairui.user.mapper.PurchaseIntentMapper purchaseIntentMapper;
     private final ActivityEventRedisService activityEventRedisService;
+    private final com.xuekairui.user.mapper.LoginLogMapper loginLogMapper;
 
     /**
      * 发送验证码
@@ -788,29 +789,6 @@ public class AuthService {
         }
     }
 
-    /**
-     * 将双方都审核通过时的字段差异保存到 t_license_sync_diff 表,等待运营确认
-     */
-    private void saveLicenseSyncDiffRecords(Long userId, Long licenseId, Map<String, String[]> diff) {
-        try {
-            diff.forEach((field, values) -> {
-                com.xuekairui.user.entity.LicenseSyncDiff record =
-                        com.xuekairui.user.entity.LicenseSyncDiff.builder()
-                                .licenseId(licenseId)
-                                .userId(userId)
-                                .fieldName(field)
-                                .localValue(values[0])
-                                .thirdPartyValue(values[1])
-                                .status(0)
-                                .build();
-                licenseSyncDiffMapper.insert(record);
-            });
-            log.info("保存入驻信息差异记录成功: licenseId={}, count={}", licenseId, diff.size());
-        } catch (Exception e) {
-            log.warn("保存入驻信息差异记录失败(不阻断同步): licenseId={}, error={}", licenseId, e.getMessage());
-        }
-    }
-
     /**
      * 将第三方传入的入驻字段覆盖到本地记录(非空即覆盖)
      */
@@ -858,82 +836,6 @@ public class AuthService {
         }
     }
 
-    /**
-     * 检测本地入驻信息与第三方传入字段的不一致项
-     * <p>返回 Map:key=字段名,value=[本地值, 第三方值]
-     */
-    private Map<String, String[]> detectLicenseDiff(
-            com.xuekairui.user.entity.BusinessLicense existing,
-            SyncShopReportRequest req, String storeName, String terminalType) {
-        Map<String, String[]> diff = new java.util.LinkedHashMap<>();
-        if (storeName != null && !storeName.equals(existing.getStoreName())) {
-            diff.put("storeName", new String[]{existing.getStoreName(), storeName});
-        }
-        if (terminalType != null && !terminalType.equals(existing.getTerminalType())) {
-            diff.put("terminalType", new String[]{existing.getTerminalType(), terminalType});
-        }
-        putIfDiff(diff, "province", existing.getProvince(), req.getContactProvince());
-        putIfDiff(diff, "city", existing.getCity(), req.getContactCity());
-        putIfDiff(diff, "district", existing.getDistrict(), req.getContactArea());
-        putIfDiff(diff, "storeAddress", existing.getStoreAddress(), req.getContactAddr());
-        putIfDiff(diff, "contactPerson", existing.getContactPerson(), req.getContactName());
-        putIfDiff(diff, "contactPhone", existing.getContactPhone(), req.getContactPhone());
-        putIfDiff(diff, "licenseImageUrl", existing.getLicenseImageUrl(), req.getBusinessLicenseImage());
-        putIfDiff(diff, "drugLicenseUrl", existing.getDrugLicenseUrl(), req.getDrugBusinessLicenseImage());
-        putIfDiff(diff, "medicalDeviceClass2Url",
-                existing.getMedicalDeviceClass2Url(), req.getTwoMedicalDeviceRegistration());
-        putIfDiff(diff, "medicalDeviceClass3Url",
-                existing.getMedicalDeviceClass3Url(), req.getThreeMedicalDeviceBusinessLicense());
-        return diff;
-    }
-
-    private static void putIfDiff(Map<String, String[]> diff, String field, String local, String third) {
-        if (third != null && !third.isBlank() && !third.equals(local)) {
-            diff.put(field, new String[]{local, third});
-        }
-    }
-
-    /**
-     * 记录第三方变更入驻信息的审计日志
-      * <p>
-     * 第三方同步入驻信息时,我们不对对方谁操作做验证,
-     * 只通过对方传过来的用户手机号判断变更了谁的信息。
-     * 审计日志中记录:被变更用户的手机号、用户ID、变更前后的字段值。
-     *
-     * @param phone     第三方传入的用户手机号(用于识别被变更的用户)
-     * @param userId    被变更的用户ID
-     * @param licenseId 入驻信息ID
-     * @param diff      变更字段差异(key=字段名,value=[本地值, 第三方值])
-     */
-    private void recordThirdPartyUpdateAudit(String phone, Long userId, Long licenseId, Map<String, String[]> diff) {
-        try {
-            Map<String, Object> beforeData = new java.util.LinkedHashMap<>();
-            Map<String, Object> afterData = new java.util.LinkedHashMap<>();
-            diff.forEach((field, values) -> {
-                beforeData.put(field, values[0]);
-                afterData.put(field, values[1]);
-            });
-
-            // 目标标识:用户手机号 + 用户ID,便于审计时定位"变更了谁的信息"
-            String targetIdentifier = String.format("PHONE:%s|USER:%d|LICENSE:%d",
-                    phone != null ? phone : "UNKNOWN", userId, licenseId);
-
-            auditLogService.logSuccess(
-                    0L,
-                    OperatorRole.SYSTEM,
-                    OperationType.LICENSE_THIRD_UPDATE,
-                    targetIdentifier,
-                    licenseId,
-                    beforeData,
-                    afterData,
-                    String.format("第三方变更入驻信息(双方均已审核通过,待运营确认):用户手机号 %s(ID=%d),共 %d 个字段不一致: %s",
-                            phone != null ? phone : "UNKNOWN", userId, diff.size(), diff.keySet()),
-                    OperationSource.SYSTEM_SYNC);
-        } catch (Exception e) {
-            log.warn("记录第三方变更入驻信息审计日志失败: licenseId={}, error={}", licenseId, e.getMessage());
-        }
-    }
-
     /**
      * 第三方登录接口同步入驻信息(适配 ExternalTokenRequest → SyncInfoRequest)
      */
@@ -1403,6 +1305,9 @@ public class AuthService {
         }
         userMapper.updateById(user);
 
+        // 记录登录日志(用于区分不同端最后登录时间)
+        recordLoginLog(user.getId(), user.getLoginSource(), user.getLastLoginIp());
+
         return LoginResponse.builder()
                 .userId(user.getId())
                 .accessToken(accessToken)
@@ -1816,6 +1721,7 @@ public class AuthService {
             if (unionId != null) {
                 existUser.setWechatUnionId(unionId);
             }
+            existUser.setLoginSource("MINIAPP");
             existUser.setLastLoginTime(LocalDateTime.now());
             existUser.setLastLoginIp(ip);
             userMapper.updateById(existUser);
@@ -1934,4 +1840,24 @@ public class AuthService {
             log.warn("外部系统注册失败(不阻断主流程): userId={}", user.getId(), e);
         }
     }
+
+    /**
+     * 记录用户登录日志
+     */
+    private void recordLoginLog(Long userId, String loginSource, String ip) {
+        if (userId == null || loginSource == null) {
+            return;
+        }
+        try {
+            LoginLog logEntry = LoginLog.builder()
+                    .userId(userId)
+                    .loginSource(loginSource)
+                    .loginIp(ip)
+                    .loginTime(LocalDateTime.now())
+                    .build();
+            loginLogMapper.insert(logEntry);
+        } catch (Exception e) {
+            log.warn("记录登录日志失败(不阻断登录): userId={}, source={}", userId, loginSource, e);
+        }
+    }
 }

+ 45 - 6
zhijiayun-user/src/main/java/com/xuekairui/user/service/MembershipService.java

@@ -20,6 +20,8 @@ import com.xuekairui.user.enums.OperatorRole;
 import com.xuekairui.user.mapper.MembershipBenefitMapper;
 import com.xuekairui.user.mapper.UserMapper;
 import com.xuekairui.user.mapper.UserMembershipMapper;
+import com.xuekairui.user.mapper.LoginLogMapper;
+import com.xuekairui.user.entity.LoginLog;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.scheduling.annotation.Scheduled;
@@ -49,6 +51,7 @@ public class MembershipService {
     private final UserMembershipMapper userMembershipMapper;
     private final MembershipBenefitMapper membershipBenefitMapper;
     private final UserMapper userMapper;
+    private final LoginLogMapper loginLogMapper;
     private final OperationAuditLogService auditLogService;
 
     /**
@@ -388,8 +391,7 @@ public class MembershipService {
 
         // 如果按等级筛选,需要先从会员记录表找出符合条件的用户ID,再查用户表
         if (filterLevel != null) {
-            Page<MembershipUserResponse> result = listMembershipUsersByLevel(pageNum, pageSize, phone, filterLevel);
-            return result;
+            return listMembershipUsersByLevel(pageNum, pageSize, phone, filterLevel);
         }
 
         Page<User> page = new Page<>(pageNum, pageSize);
@@ -403,9 +405,11 @@ public class MembershipService {
         // 批量查活跃会员记录,取最高等级
         List<Long> userIds = result.getRecords().stream().map(User::getId).toList();
         Map<Long, UserMembership> activeMap = loadActiveMemberships(userIds);
+        // 批量查各端最近登录时间
+        Map<Long, Map<String, LocalDateTime>> loginTimeMap = loadLatestLoginTimes(userIds);
 
         List<MembershipUserResponse> list = result.getRecords().stream()
-                .map(u -> toMembershipUserResponse(u, activeMap.get(u.getId())))
+                .map(u -> toMembershipUserResponse(u, activeMap.get(u.getId()), loginTimeMap.get(u.getId())))
                 .collect(Collectors.toList());
 
         Page<MembershipUserResponse> responsePage = new Page<>(pageNum, pageSize, result.getTotal());
@@ -467,8 +471,11 @@ public class MembershipService {
         Page<User> page = new Page<>(pageNum, pageSize);
         Page<User> userPage = userMapper.selectPage(page, uWrapper);
 
+        List<Long> userIds2 = userPage.getRecords().stream().map(User::getId).toList();
+        Map<Long, Map<String, LocalDateTime>> loginTimeMap2 = loadLatestLoginTimes(userIds2);
+
         List<MembershipUserResponse> list = userPage.getRecords().stream()
-                .map(u -> toMembershipUserResponse(u, matchedMap.get(u.getId())))
+                .map(u -> toMembershipUserResponse(u, matchedMap.get(u.getId()), loginTimeMap2.get(u.getId())))
                 .collect(Collectors.toList());
 
         Page<MembershipUserResponse> responsePage = new Page<>(pageNum, pageSize, userPage.getTotal());
@@ -494,12 +501,42 @@ public class MembershipService {
         return map;
     }
 
+    /**
+     * 批量加载用户各端的最近登录时间
+     * <p>返回 userId -> (loginSource -> lastLoginTime) 的映射。
+     * PC端合并 PC/WINDOWS/WECHAT 来源,小程序端取 MINIAPP。
+     */
+    private Map<Long, Map<String, LocalDateTime>> loadLatestLoginTimes(List<Long> userIds) {
+        if (userIds == null || userIds.isEmpty()) {
+            return Collections.emptyMap();
+        }
+        List<LoginLog> logs = loginLogMapper.selectLatestByUserIds(userIds);
+        Map<Long, Map<String, LocalDateTime>> result = new HashMap<>();
+        for (LoginLog log : logs) {
+            result.computeIfAbsent(log.getUserId(), k -> new HashMap<>());
+            String source = log.getLoginSource();
+            LocalDateTime time = log.getLoginTime();
+            // PC端:合并 PC / WINDOWS / WECHAT,取最近时间
+            if ("PC".equals(source) || "WINDOWS".equals(source) || "WECHAT".equals(source)) {
+                result.get(log.getUserId()).merge("PC", time,
+                        (existing, newer) -> existing.isAfter(newer) ? existing : newer);
+            }
+            // 小程序端
+            if ("MINIAPP".equals(source)) {
+                result.get(log.getUserId()).merge("MINIAPP", time,
+                        (existing, newer) -> existing.isAfter(newer) ? existing : newer);
+            }
+        }
+        return result;
+    }
+
     /**
      * 将用户和活跃会员记录组装为响应对象
      * <p>优先使用会员记录表的等级和过期时间;若无活跃记录,回退到用户表字段。
      * 高级会员过期后(effective_to 早于当前时间)降级显示为普通会员。
      */
-    private MembershipUserResponse toMembershipUserResponse(User u, UserMembership active) {
+    private MembershipUserResponse toMembershipUserResponse(User u, UserMembership active,
+                                                            Map<String, LocalDateTime> loginTimes) {
         MembershipLevel currentLevel;
         LocalDateTime expireAt;
         String source;
@@ -533,7 +570,7 @@ public class MembershipService {
         int daysRemaining;
         if (isPlus) {
             daysRemaining = -1;
-        } else if (expireAt != null && expireAt.isAfter(now)) {
+        } else if (expireAt != null) {
             daysRemaining = (int) ChronoUnit.DAYS.between(now, expireAt);
         } else {
             daysRemaining = 0;
@@ -550,6 +587,8 @@ public class MembershipService {
                 .expireAt(expireAt)
                 .daysRemaining(daysRemaining)
                 .createTime(u.getCreateTime())
+                .pcLastLoginTime(loginTimes != null ? loginTimes.get("PC") : null)
+                .miniappLastLoginTime(loginTimes != null ? loginTimes.get("MINIAPP") : null)
                 .build();
     }