Browse Source

邮件签名,消息类型拆分

1811872455@163.com 1 tháng trước cách đây
mục cha
commit
13706db7c9
19 tập tin đã thay đổi với 736 bổ sung122 xóa
  1. 26 9
      java/storlead-api/src/main/java/com/storlead/login/TenantLoginApiController.java
  2. 40 13
      java/storlead-api/src/main/java/com/storlead/system/controller/LoginApiController.java
  3. 41 0
      java/storlead-api/src/main/java/com/storlead/system/controller/SysLoginLogApiController.java
  4. 2 1
      java/storlead-dependencies/pom.xml
  5. 47 5
      java/storlead-message/storlead-message-api/src/main/java/com/storlead/message/controller/MessageApiController.java
  6. 0 6
      java/storlead-message/storlead-message-biz/src/main/java/com/storlead/message/service/InsideMessageRecordService.java
  7. 0 64
      java/storlead-message/storlead-message-biz/src/main/java/com/storlead/message/service/impl/InsideMessageRecordServiceImpl.java
  8. 17 24
      java/storlead-message/storlead-message-core/src/main/java/com/storlead/message/pojo/dto/MessageSendDTO.java
  9. 30 0
      java/storlead-system/sql/sys_login_log.sql
  10. 10 0
      java/storlead-system/storlead-system-biz/src/main/java/com/storlead/system/mapper/SysLoginLogMapper.java
  11. 122 0
      java/storlead-system/storlead-system-biz/src/main/java/com/storlead/system/service/impl/SysLoginLogServiceImpl.java
  12. 200 0
      java/storlead-system/storlead-system-biz/src/main/java/com/storlead/system/util/LoginClientInfoUtil.java
  13. 21 0
      java/storlead-system/storlead-system-core/src/main/java/com/storlead/system/enums/LoginLogEventTypeEnum.java
  14. 21 0
      java/storlead-system/storlead-system-core/src/main/java/com/storlead/system/enums/LoginLogStatusEnum.java
  15. 49 0
      java/storlead-system/storlead-system-core/src/main/java/com/storlead/system/pojo/dto/SysLoginLogQueryDTO.java
  16. 67 0
      java/storlead-system/storlead-system-core/src/main/java/com/storlead/system/pojo/entity/SysLoginLogEntity.java
  17. 37 0
      java/storlead-system/storlead-system-spi/src/main/java/com/storlead/system/service/SysLoginLogService.java
  18. 3 0
      java/storlead-user/sql/user_add_remark.sql
  19. 3 0
      java/storlead-user/storlead-user-core/src/main/java/com/storlead/user/pojo/entity/UserEntity.java

+ 26 - 9
java/storlead-api/src/main/java/com/storlead/login/TenantLoginApiController.java

@@ -17,6 +17,7 @@ import com.storlead.sms.spi.SmsCaptchaScene;
 import com.storlead.sms.spi.SmsCaptchaService;
 import com.storlead.system.service.IMenuService;
 import com.storlead.system.service.IUserRoleService;
+import com.storlead.system.service.SysLoginLogService;
 import com.storlead.system.util.SystemConfigItemCacheUtil;
 import com.storlead.user.pojo.dto.TenantLoginDTO;
 import com.storlead.user.pojo.entity.DeptEntity;
@@ -76,14 +77,18 @@ public class TenantLoginApiController {
     @Resource
     private ITenantUserService tenantUserService;
 
+    @Resource
+    private SysLoginLogService loginLogService;
+
     @RequestMapping(value = "/tenant/login", method = RequestMethod.POST)
     @ApiOperation(value = "租户登录", notes = "仅支持短信验证码或密码;需指定 tenantId 或 enterpriseCode;通过后必须在 tenant_user 存在对应关系")
     public Result<?> tenantLogin(@RequestBody TenantLoginDTO loginDTO) {
+        String account = loginDTO != null ? StrUtil.trimToEmpty(loginDTO.getAccount()) : null;
         if (loginDTO == null) {
-            return Result.error("参数错误");
+            return tenantLoginFail(account, null, "参数错误");
         }
         if (loginDTO.getTenantId() == null && StrUtil.isBlank(loginDTO.getEnterpriseCode())) {
-            return Result.error("请指定 tenantId 或 enterpriseCode");
+            return tenantLoginFail(account, null, "请指定 tenantId 或 enterpriseCode");
         }
 
         TenantContext.setIgnoreTenantLine(true);
@@ -95,32 +100,32 @@ public class TenantLoginApiController {
                 enterprise = tenantEnterpriseService.getByEnterpriseCode(loginDTO.getEnterpriseCode());
             }
             if (enterprise == null) {
-                return Result.error("租户不存在或已删除");
+                return tenantLoginFail(account, null, "租户不存在或已删除");
             }
             if (Boolean.FALSE.equals(enterprise.getEnabled())) {
-                return Result.error("租户已禁用");
+                return tenantLoginFail(account, null, "租户已禁用");
             }
             if (enterprise.getExpireAt() != null && enterprise.getExpireAt().before(new Date())) {
-                return Result.error("租户已过期");
+                return tenantLoginFail(account, null, "租户已过期");
             }
 
             JSONObject obj = new JSONObject();
             UserEntity userInfo = authenticateAccount(loginDTO, obj);
             if (userInfo == null) {
-                return Result.error(obj.getString("_err"));
+                return tenantLoginFail(account, null, obj.getString("_err"));
             }
 
             if (!userInfo.getEnabled()) {
-                return Result.error("该账号用户暂无登录权限");
+                return tenantLoginFail(account, userInfo, "该账号用户暂无登录权限");
             }
 
             // 租户成员以 tenant_user 为准:无记录则不允许进入该租户空间
             TenantUserEntity tenantUser = tenantUserService.getByTenantIdAndUserId(enterprise.getTenantId(), userInfo.getId());
             if (tenantUser == null) {
-                return Result.error("该账号未在 tenant_user 中绑定当前租户");
+                return tenantLoginFail(account, userInfo, "该账号未在 tenant_user 中绑定当前租户");
             }
             if (tenantUser.getEnterpriseId() != null && !tenantUser.getEnterpriseId().equals(enterprise.getId())) {
-                return Result.error("租户成员数据不一致,请联系管理员检查 tenant_user");
+                return tenantLoginFail(account, userInfo, "租户成员数据不一致,请联系管理员检查 tenant_user");
             }
 
 //            Integer possessMenuCount = menuService.checkUserMenuAccess(userInfo.getId());
@@ -162,6 +167,10 @@ public class TenantLoginApiController {
             obj.put("enterpriseId", enterprise.getId());
             @SuppressWarnings("unchecked")
             Map<String, Object> body = JSONObject.parseObject(obj.toJSONString(), Map.class);
+            loginLogService.recordLoginSuccess(userInfo.getId(),
+                    StrUtil.blankToDefault(account, userInfo.getMobile()),
+                    userInfo.getUserName(),
+                    userInfo.getRealName());
             return Result.result(body);
         } finally {
             TenantContext.setIgnoreTenantLine(false);
@@ -241,4 +250,12 @@ public class TenantLoginApiController {
             return null;
         }
     }
+
+    private Result<?> tenantLoginFail(String account, UserEntity user, String message) {
+        Long userId = user != null ? user.getId() : null;
+        String userName = user != null ? user.getUserName() : null;
+        String realName = user != null ? user.getRealName() : null;
+        loginLogService.recordLoginFail(account, userId, userName, realName, message);
+        return Result.error(message);
+    }
 }

+ 40 - 13
java/storlead-api/src/main/java/com/storlead/system/controller/LoginApiController.java

@@ -26,6 +26,7 @@ import com.storlead.sms.spi.SmsLogService;
 import com.storlead.system.pojo.entity.MenuEntity;
 import com.storlead.system.service.IMenuService;
 import com.storlead.system.service.IUserRoleService;
+import com.storlead.system.service.SysLoginLogService;
 import com.storlead.system.util.SystemConfigItemCacheUtil;
 import com.storlead.user.pojo.dto.UserLoginDTO;
 import com.storlead.user.pojo.entity.DeptEntity;
@@ -95,6 +96,9 @@ public class LoginApiController {
     @Resource
     private CorpWeChatService corpWeChatService;
 
+    @Resource
+    private SysLoginLogService loginLogService;
+
     @PostMapping(value = "/redirect")
     @ResponseBody
     public Object redirect(HttpServletRequest request, HttpServletResponse response)  throws IOException {
@@ -107,9 +111,10 @@ public class LoginApiController {
     public Result login(@RequestBody UserLoginDTO loginDTO) {
 
         if (Objects.isNull(loginDTO)) {
-            return Result.error("参数错误");
+            return loginFail(null, null, "参数错误");
         }
-        UserEntity userInfo;
+        String account = StrUtil.trimToEmpty(loginDTO.getAccount());
+        UserEntity userInfo = null;
         JSONObject obj = new JSONObject();
         if (StrUtil.isNotBlank(loginDTO.getWxCode())) {
             String code = corpWeChatService.login(loginDTO.getWxCode());
@@ -118,28 +123,28 @@ public class LoginApiController {
             String mobile = StrUtil.trimToEmpty(loginDTO.getAccount());
             String smsCode = StrUtil.trimToEmpty(loginDTO.getSmsCode());
             if (StrUtil.isBlank(mobile)) {
-                return Result.error("请输入手机号");
+                return loginFail(account, null, "请输入手机号");
             }
             if (StrUtil.isBlank(smsCode)) {
-                return Result.error("请输入验证码");
+                return loginFail(account, null, "请输入验证码");
             }
             Result<?> vr = smsCaptchaService.verifyCaptcha(mobile, smsCode, SmsCaptchaScene.LOGIN, true);
             if (!vr.isSuccess()) {
-                return Result.error(vr.getCode(), vr.getMessage());
+                return loginFail(account, null, vr.getMessage());
             }
             userInfo = sysUserService.getOne(new LambdaQueryWrapper<UserEntity>()
                     .eq(UserEntity::getMobile, mobile)
                     .eq(UserEntity::getIsDelete, CommonConstant.DEL_FLAG_0));
             if (userInfo == null) {
-                return Result.error("该手机号未绑定用户或账号已删除");
+                return loginFail(account, null, "该手机号未绑定用户或账号已删除");
             }
         } else {
             if (StrUtil.isBlank(loginDTO.getAccount()) || StrUtil.isBlank(loginDTO.getPassword())) {
-                return Result.error("用户名或密码为空");
+                return loginFail(account, null, "用户名或密码为空");
             }
             userInfo = sysUserService.getOne(new LambdaQueryWrapper<UserEntity>().eq(UserEntity::getMobile,loginDTO.getAccount()).eq(UserEntity::getIsDelete, CommonConstant.DEL_FLAG_0));
             if (Objects.isNull(userInfo)) {
-                return Result.error("账号不存在");
+                return loginFail(account, null, "账号不存在");
             }
 
             BCryptPasswordEncoder bCryptPasswordEncoder = new BCryptPasswordEncoder();
@@ -148,14 +153,14 @@ public class LoginApiController {
                 try {
                     password = RsaUtils.decryptByPrivateKey(RsaUtils.PRIVATE_KEY, loginDTO.getPassword());
                 }catch (Exception e) {
-                    return Result.error("用户名或密码错误");
+                    return loginFail(account, userInfo, "用户名或密码错误");
                 }
             }
             if(environment.equals("prod") || (environment.equals("test")
                     && !loginDTO.getPassword().equals("qq123456") &&
                     !password.equals("qq123456"))) {
                 if (!bCryptPasswordEncoder.matches(password, userInfo.getPassword())) {
-                    return Result.error("用户名或密码错误");
+                    return loginFail(account, userInfo, "用户名或密码错误");
                 }
             }
             String regex = "^(?![0-9]+$)(?![a-zA-Z]+$)[0-9A-Za-z\\W]{8,}$";
@@ -170,16 +175,16 @@ public class LoginApiController {
         }
 
         if (Objects.isNull(userInfo)) {
-            return Result.error("登录失败,未获取到用户信息");
+            return loginFail(account, null, "登录失败,未获取到用户信息");
         }
 
         if (!userInfo.getEnabled()) {
-            return Result.error("该账号用户暂无登录权限");
+            return loginFail(account, userInfo, "该账号用户暂无登录权限");
         }
 
         Integer possessMenuCount = menuService.checkUserMenuAccess(userInfo.getId());
         if (possessMenuCount == 0) {
-            return Result.error("暂未获取到菜单权限,请联系管理员分配权限!");
+            return loginFail(account, userInfo, "暂未获取到菜单权限,请联系管理员分配权限!");
         }
 
         LoginUser loginUser = new LoginUser();
@@ -212,6 +217,10 @@ public class LoginApiController {
         redisService.setCacheObject(RedisKeySaltConstant.API_CODE_REDIS + loginUser.getMobile(), apiMap);
 
         obj.put("token", token);
+        loginLogService.recordLoginSuccess(userInfo.getId(),
+                StrUtil.blankToDefault(account, userInfo.getMobile()),
+                userInfo.getUserName(),
+                userInfo.getRealName());
         return Result.result(obj);
     }
 
@@ -317,17 +326,27 @@ public class LoginApiController {
     public Result<Object> logout(HttpServletRequest request) {
         //用户退出逻辑
         String token = request.getHeader(DefContants.ACCESS_TOKEN);
+        LoginUser loginUser = LoginUserUtil.getLoginUser();
+        Long userId = loginUser != null ? loginUser.getId() : null;
+        String account = loginUser != null ? loginUser.getMobile() : null;
+        String userName = loginUser != null ? loginUser.getUserName() : null;
+        String realName = loginUser != null ? loginUser.getRealName() : null;
+
         if(ConvertUtils.isEmpty(token)) {
+            loginLogService.recordLogoutFail(userId, account, userName, realName, "退出登录失败!");
             return Result.error("退出登录失败!");
         }
         Boolean del = redisService.deleteObject(token);
         if (del) {
+            loginLogService.recordLogoutSuccess(userId, account, userName, realName);
             return Result.result("退出登录成功!");
         } else {
             Object obj = redisService.getCacheObject(token);
             if (obj == null) {
+                loginLogService.recordLogoutSuccess(userId, account, userName, realName);
                 return Result.error("退出登录成功!");
             }
+            loginLogService.recordLogoutFail(userId, account, userName, realName, "退出登录失败!");
             return Result.error("退出登录失败!");
         }
     }
@@ -585,4 +604,12 @@ public class LoginApiController {
 //            log.error("InvalidKeyException -- error--",e);
 //        }
 //    }
+
+    private Result loginFail(String account, UserEntity user, String message) {
+        Long userId = user != null ? user.getId() : null;
+        String userName = user != null ? user.getUserName() : null;
+        String realName = user != null ? user.getRealName() : null;
+        loginLogService.recordLoginFail(account, userId, userName, realName, message);
+        return Result.error(message);
+    }
 }

+ 41 - 0
java/storlead-api/src/main/java/com/storlead/system/controller/SysLoginLogApiController.java

@@ -0,0 +1,41 @@
+package com.storlead.system.controller;
+
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.storlead.framework.common.result.Result;
+import com.storlead.system.pojo.dto.SysLoginLogQueryDTO;
+import com.storlead.system.pojo.entity.SysLoginLogEntity;
+import com.storlead.system.service.SysLoginLogService;
+import io.swagger.annotations.Api;
+import io.swagger.annotations.ApiOperation;
+import io.swagger.annotations.ApiResponse;
+import io.swagger.annotations.ApiResponses;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.annotation.Resource;
+
+/**
+ * 系统登录日志查询
+ */
+@Slf4j
+@RestController
+@RequestMapping("/sys/login/log")
+@Api(tags = "平台中心 - 系统: 登录日志")
+public class SysLoginLogApiController {
+
+    @Resource
+    private SysLoginLogService loginLogService;
+
+    @PostMapping("/pageList")
+    @ApiOperation(value = "登录日志分页查询", notes = "支持按账号/用户名/事件类型/成功失败/IP/城市/时间筛选")
+    @ApiResponses({
+            @ApiResponse(code = 200, message = "", response = SysLoginLogEntity.class)
+    })
+    public Result<?> pageList(@RequestBody SysLoginLogQueryDTO dto) {
+        IPage<SysLoginLogEntity> page = loginLogService.pageList(dto);
+        return Result.result(page);
+    }
+}

+ 2 - 1
java/storlead-dependencies/pom.xml

@@ -6,7 +6,7 @@
     <modelVersion>4.0.0</modelVersion>
     <groupId>com.storlead.boot</groupId>
     <artifactId>storlead-dependencies</artifactId>
-    <version>${revision}</version>
+    <version>${revision}</version>0
 
     <packaging>pom</packaging>
     <name>${project.artifactId}</name>
@@ -19,6 +19,7 @@
         <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
         <spring.boot.starter.parent>2.7.0</spring.boot.starter.parent>
         <spring.boot.version>2.7.0</spring.boot.version>
+        .
         <knife4j.version>3.0.3</knife4j.version>
         <spring-web.version>5.3.32</spring-web.version>
         <jsoup.version>1.18.3</jsoup.version>

+ 47 - 5
java/storlead-message/storlead-message-api/src/main/java/com/storlead/message/controller/MessageApiController.java

@@ -13,8 +13,9 @@ import com.storlead.message.pojo.vo.MessageAppNoReadVO;
 import com.storlead.message.pojo.vo.MessageDetailVO;
 import com.storlead.message.pojo.vo.MessageNoReadTotalVO;
 import com.storlead.message.pojo.vo.MessageTypeReadStateVO;
-import com.storlead.message.service.InsideMessageRecordService;
+import cn.hutool.core.util.StrUtil;
 import com.storlead.message.service.InsideMessageSendLogService;
+import com.storlead.message.service.impl.MessageService;
 import io.swagger.annotations.*;
 import org.springframework.util.CollectionUtils;
 import org.springframework.web.bind.annotation.PostMapping;
@@ -24,9 +25,12 @@ import org.springframework.web.bind.annotation.RestController;
 
 import javax.annotation.Resource;
 import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Objects;
+import java.util.Set;
 import java.util.stream.Collectors;
 import org.springframework.beans.factory.annotation.Autowired;
 
@@ -37,23 +41,61 @@ import org.springframework.beans.factory.annotation.Autowired;
  * @create: 2024-04-19 16:36
  */
 @RestController
-@RequestMapping("/sys/inside/message")
+@RequestMapping("/sys/message")
 @Api(tags = "消息: 站内消息相关接口")
 public class MessageApiController {
 
+    private static final String[] DEFAULT_TEST_CHANNELS = {"site", "wecom"};
+
     @Resource
     private InsideMessageSendLogService messageSendLogService;
 
     @Resource
-    private InsideMessageRecordService messageRecordService;
+    private MessageService messageService;
 
     @Autowired(required = false)
     private List<MessageTypeCatalog> messageTypeCatalogs;
 
     @PostMapping(value = "/testSend")
-    @ApiOperation(value = "发送站内消息(ApiPost:POST + application/json,Header 带 token)")
+    @ApiOperation(value = "消息发送测试(匹配模板后发站内+企业微信;ApiPost:POST + application/json)")
     public Result<?> sendMessage(@RequestBody MessageSendDTO dto) {
-        return messageRecordService.sendDirectMessage(dto);
+        if (dto == null || dto.getMessageType() == null || StrUtil.isBlank(dto.getEventCode())) {
+            return Result.error("参数错误:messageType、eventCode 必填");
+        }
+        Set<Long> toUserIds = resolveTestToUserIds(dto);
+        if (CollectionUtils.isEmpty(toUserIds)) {
+            return Result.error("参数错误:receiverUserId / toUserIds 至少传一个");
+        }
+        Map<String, Object> messageMap = dto.getMessageMap() == null
+                ? new HashMap<>()
+                : new HashMap<>(dto.getMessageMap());
+        String[] channels = (dto.getChannels() == null || dto.getChannels().length == 0)
+                ? DEFAULT_TEST_CHANNELS
+                : dto.getChannels();
+        messageService.autoMatchEventSendMessage(
+                messageMap,
+                dto.getMessageType().toString(),
+                dto.getEventCode(),
+                dto.getAppId(),
+                toUserIds,
+                channels
+        );
+        return Result.ok();
+    }
+
+    private Set<Long> resolveTestToUserIds(MessageSendDTO dto) {
+        Set<Long> toUserIds = new HashSet<>();
+        if (dto.getToUserIds() != null) {
+            for (Long userId : dto.getToUserIds()) {
+                if (userId != null) {
+                    toUserIds.add(userId);
+                }
+            }
+        }
+        if (toUserIds.isEmpty() && dto.getReceiverUserId() != null) {
+            toUserIds.add(dto.getReceiverUserId());
+        }
+        return toUserIds;
     }
 
     @PostMapping(value = "/pageList")

+ 0 - 6
java/storlead-message/storlead-message-biz/src/main/java/com/storlead/message/service/InsideMessageRecordService.java

@@ -2,7 +2,6 @@ package com.storlead.message.service;
 
 import com.storlead.framework.common.result.Result;
 import com.storlead.framework.mybatis.service.MyBaseService;
-import com.storlead.message.pojo.dto.MessageSendDTO;
 import com.storlead.message.pojo.entity.InsideMessageRecordEntity;
 import com.storlead.message.pojo.entity.MessageTemplateEventDetailEntity;
 import com.storlead.message.pojo.entity.MessageTemplateEventGroupEntity;
@@ -27,9 +26,4 @@ public interface InsideMessageRecordService extends MyBaseService<InsideMessageR
     void asynSendInsideMessage(MessageTemplateEventGroupEntity eventGroup, MessageTemplateEventDetailEntity templateEventDetail, Map map, Long receiverUserId, String callbackParam, Long sendUserId);
 
     void asynSendInsideMessage(MessageTemplateEventGroupEntity eventGroup, MessageTemplateEventDetailEntity templateEventDetail, Map map, Collection<Long> receiverUserIds, String callbackParam, Long sendUserId);
-
-    /**
-     * 直接发送站内消息(不依赖消息模板),用于联调 / ApiPost 测试。
-     */
-    Result<Long> sendDirectMessage(MessageSendDTO dto);
 }

+ 0 - 64
java/storlead-message/storlead-message-biz/src/main/java/com/storlead/message/service/impl/InsideMessageRecordServiceImpl.java

@@ -6,7 +6,6 @@ import com.storlead.framework.common.enums.ErrorMsgCode;
 import com.storlead.framework.common.util.HtmlUtils;
 import com.storlead.framework.mybatis.service.impl.MyBaseServiceImpl;
 import com.storlead.framework.util.LoginUserUtil;
-import com.storlead.message.pojo.dto.MessageSendDTO;
 import com.storlead.message.pojo.entity.InsideMessageRecordEntity;
 import com.storlead.message.mapper.InsideMessageRecordMapper;
 import com.storlead.message.pojo.entity.InsideMessageSendLogEntity;
@@ -132,67 +131,4 @@ public class InsideMessageRecordServiceImpl extends MyBaseServiceImpl<InsideMess
     public void asynSendInsideMessage(MessageTemplateEventGroupEntity eventGroup,MessageTemplateEventDetailEntity templateEventDetail, Map map, Collection<Long> receiverUserIds, String callbackParam,Long sendUserId) {
         sendInsideMessage(eventGroup,templateEventDetail,map,receiverUserIds,callbackParam,sendUserId);
     }
-
-    @Override
-    public Result<Long> sendDirectMessage(MessageSendDTO dto) {
-        if (dto == null || StrUtil.isBlank(dto.getTitle())) {
-            return Result.result(null);
-        }
-        List<Long> receiverUserIds = resolveReceiverUserIds(dto);
-        if (CollectionUtils.isEmpty(receiverUserIds)) {
-            return Result.result(null);
-        }
-
-        Long sendUserId = dto.getSendUserId();
-        if (sendUserId == null) {
-            sendUserId = LoginUserUtil.getCurrentUserId();
-        }
-        if (sendUserId == null) {
-            sendUserId = 0L;
-        }
-
-        String content = StrUtil.blankToDefault(dto.getContent(), dto.getTitle());
-        Integer messageType = dto.getMessageType() == null ? 999 : dto.getMessageType();
-
-        InsideMessageRecordEntity entity = new InsideMessageRecordEntity(
-                dto.getTitle(),
-                content,
-                dto.getMessageIcon(),
-                dto.getMessageTag(),
-                dto.getCallbackUrl(),
-                sendUserId,
-                1,
-                messageType
-        );
-        entity.setAppId(dto.getAppId());
-        entity.setCreateBy(sendUserId);
-        entity.setUpdateBy(sendUserId);
-        saveOrUpdate(entity);
-
-        List<InsideMessageSendLogEntity> messageSendLogs = new ArrayList<>();
-        for (Long receiverUserId : receiverUserIds) {
-            InsideMessageSendLogEntity messageSendLog = new InsideMessageSendLogEntity(
-                    entity.getId(), receiverUserId, 0, 1, messageType);
-            messageSendLog.setCreateBy(sendUserId);
-            messageSendLog.setUpdateBy(sendUserId);
-            messageSendLogs.add(messageSendLog);
-        }
-        insideMessageSendLogService.saveOrUpdateBatch(messageSendLogs);
-        return Result.result(entity.getId());
-    }
-
-    private List<Long> resolveReceiverUserIds(MessageSendDTO dto) {
-        List<Long> receiverUserIds = new ArrayList<>();
-        if (dto.getToUserIds() != null) {
-            for (Long userId : dto.getToUserIds()) {
-                if (userId != null) {
-                    receiverUserIds.add(userId);
-                }
-            }
-        }
-        if (receiverUserIds.isEmpty() && dto.getReceiverUserId() != null) {
-            receiverUserIds.add(dto.getReceiverUserId());
-        }
-        return receiverUserIds;
-    }
 }

+ 17 - 24
java/storlead-message/storlead-message-core/src/main/java/com/storlead/message/pojo/dto/MessageSendDTO.java

@@ -5,41 +5,34 @@ import io.swagger.annotations.ApiModelProperty;
 import lombok.Data;
 import lombok.experimental.Accessors;
 
+import java.util.Map;
+
 /**
- * 站内消息发送参数(ApiPost 可用 x-www-form-urlencoded 或 query 传参)
+ * 消息发送测试参数:按模板类型 + 事件码匹配后发送(站内 / 企业微信等)。
  */
 @Data
 @Accessors(chain = true)
-@ApiModel(value = "站内消息发送", description = "发送站内消息测试/业务参数")
+@ApiModel(value = "消息发送测试", description = "匹配 message_template_event_group 后发送,默认渠道 site+wecom")
 public class MessageSendDTO {
 
-    @ApiModelProperty(value = "消息标题", required = true, example = "测试消息标题")
-    private String title;
-
-    @ApiModelProperty(value = "消息内容", example = "这是一条 ApiPost 测试消息")
-    private String content;
-
-    @ApiModelProperty(value = "接收人用户 ID(单用户时可只传此字段)", example = "1")
-    private Long receiverUserId;
+    @ApiModelProperty(value = "消息业务类型(template_service_type)", required = true, example = "30")
+    private Integer messageType;
 
-    @ApiModelProperty(value = "接收人用户 ID 列表(多用户:toUserIds=1&toUserIds=2)")
-    private Long[] toUserIds;
+    @ApiModelProperty(value = "事件码(event_code)", required = true, example = "BUSINESS_CREATE")
+    private String eventCode;
 
-    @ApiModelProperty(value = "应用 ID,对应 inside_message_record.app_id", example = "10")
+    @ApiModelProperty(value = "应用 ID(限定模板,避免跨应用同类型串模板)", example = "20")
     private Long appId;
 
-    @ApiModelProperty(value = "消息类型,对应 inside_message_record.message_type", example = "30")
-    private Integer messageType;
-
-    @ApiModelProperty(value = "发送人用户 ID,不传则取当前登录用户")
-    private Long sendUserId;
+    @ApiModelProperty(value = "模板变量(对应 arg_template_json 中的 propertyKey,不含 ${}$)", example = "{\"title\":\"测试商机\",\"customerName\":\"测试客户\"}")
+    private Map<String, Object> messageMap;
 
-    @ApiModelProperty(value = "回调地址")
-    private String callbackUrl;
+    @ApiModelProperty(value = "接收人用户 ID(单用户时可只传此字段)", example = "1")
+    private Long receiverUserId;
 
-    @ApiModelProperty(value = "消息图标")
-    private String messageIcon;
+    @ApiModelProperty(value = "接收人用户 ID 列表")
+    private Long[] toUserIds;
 
-    @ApiModelProperty(value = "消息标签")
-    private String messageTag;
+    @ApiModelProperty(value = "发送渠道,默认 site、wecom;可选 site/wecom/sms/mail")
+    private String[] channels;
 }

+ 30 - 0
java/storlead-system/sql/sys_login_log.sql

@@ -0,0 +1,30 @@
+-- 系统登录日志
+CREATE TABLE IF NOT EXISTS `sys_login_log` (
+  `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
+  `user_id` bigint DEFAULT NULL COMMENT '用户ID',
+  `account` varchar(64) DEFAULT NULL COMMENT '登录账号',
+  `user_name` varchar(64) DEFAULT NULL COMMENT '用户名',
+  `real_name` varchar(64) DEFAULT NULL COMMENT '姓名',
+  `event_type` tinyint NOT NULL COMMENT '事件类型:1登录 2登出',
+  `status` tinyint NOT NULL COMMENT '结果:1成功 0失败',
+  `message` varchar(255) DEFAULT NULL COMMENT '描述/失败原因',
+  `ip` varchar(64) DEFAULT NULL COMMENT '客户端IP',
+  `city` varchar(128) DEFAULT '' COMMENT '国内城市地址',
+  `browser` varchar(64) DEFAULT NULL COMMENT '浏览器',
+  `browser_version` varchar(64) DEFAULT NULL COMMENT '浏览器版本',
+  `os` varchar(64) DEFAULT NULL COMMENT '操作系统',
+  `user_agent` varchar(512) DEFAULT NULL COMMENT 'User-Agent',
+  `owner_by` bigint DEFAULT NULL,
+  `create_by` bigint DEFAULT NULL,
+  `update_by` bigint DEFAULT NULL,
+  `create_time` datetime DEFAULT CURRENT_TIMESTAMP,
+  `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+  `is_delete` int DEFAULT 0,
+  `enabled` tinyint(1) DEFAULT 1,
+  `sort` int DEFAULT NULL,
+  PRIMARY KEY (`id`),
+  KEY `idx_user_id` (`user_id`),
+  KEY `idx_account` (`account`),
+  KEY `idx_create_time` (`create_time`),
+  KEY `idx_event_status` (`event_type`,`status`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='系统登录日志';

+ 10 - 0
java/storlead-system/storlead-system-biz/src/main/java/com/storlead/system/mapper/SysLoginLogMapper.java

@@ -0,0 +1,10 @@
+package com.storlead.system.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.storlead.system.pojo.entity.SysLoginLogEntity;
+
+/**
+ * 系统登录日志 Mapper
+ */
+public interface SysLoginLogMapper extends BaseMapper<SysLoginLogEntity> {
+}

+ 122 - 0
java/storlead-system/storlead-system-biz/src/main/java/com/storlead/system/service/impl/SysLoginLogServiceImpl.java

@@ -0,0 +1,122 @@
+package com.storlead.system.service.impl;
+
+import cn.hutool.core.util.StrUtil;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import com.storlead.framework.common.constant.CommonConstant;
+import com.storlead.system.enums.LoginLogEventTypeEnum;
+import com.storlead.system.enums.LoginLogStatusEnum;
+import com.storlead.system.mapper.SysLoginLogMapper;
+import com.storlead.system.pojo.dto.SysLoginLogQueryDTO;
+import com.storlead.system.pojo.entity.SysLoginLogEntity;
+import com.storlead.system.service.SysLoginLogService;
+import com.storlead.system.util.LoginClientInfoUtil;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import java.util.Objects;
+
+/**
+ * 系统登录日志
+ */
+@Slf4j
+@Service
+public class SysLoginLogServiceImpl extends ServiceImpl<SysLoginLogMapper, SysLoginLogEntity> implements SysLoginLogService {
+
+    @Override
+    public void recordLoginSuccess(Long userId, String account, String userName, String realName) {
+        saveLog(userId, account, userName, realName,
+                LoginLogEventTypeEnum.LOGIN, LoginLogStatusEnum.SUCCESS, "登录成功");
+    }
+
+    @Override
+    public void recordLoginFail(String account, Long userId, String userName, String realName, String message) {
+        saveLog(userId, account, userName, realName,
+                LoginLogEventTypeEnum.LOGIN, LoginLogStatusEnum.FAIL, message);
+    }
+
+    @Override
+    public void recordLogoutSuccess(Long userId, String account, String userName, String realName) {
+        saveLog(userId, account, userName, realName,
+                LoginLogEventTypeEnum.LOGOUT, LoginLogStatusEnum.SUCCESS, "退出登录成功");
+    }
+
+    @Override
+    public void recordLogoutFail(Long userId, String account, String userName, String realName, String message) {
+        saveLog(userId, account, userName, realName,
+                LoginLogEventTypeEnum.LOGOUT, LoginLogStatusEnum.FAIL, message);
+    }
+
+    @Override
+    public IPage<SysLoginLogEntity> pageList(SysLoginLogQueryDTO dto) {
+        Page<SysLoginLogEntity> page = new Page<>(dto.getPageIndex(), dto.getPageSize());
+        LambdaQueryWrapper<SysLoginLogEntity> wrapper = new LambdaQueryWrapper<>();
+        wrapper.eq(SysLoginLogEntity::getIsDelete, CommonConstant.DEL_FLAG_0);
+        if (StrUtil.isNotBlank(dto.getAccount())) {
+            wrapper.like(SysLoginLogEntity::getAccount, dto.getAccount());
+        }
+        if (StrUtil.isNotBlank(dto.getUserName())) {
+            wrapper.like(SysLoginLogEntity::getUserName, dto.getUserName());
+        }
+        if (StrUtil.isNotBlank(dto.getRealName())) {
+            wrapper.like(SysLoginLogEntity::getRealName, dto.getRealName());
+        }
+        if (Objects.nonNull(dto.getEventType())) {
+            wrapper.eq(SysLoginLogEntity::getEventType, dto.getEventType());
+        }
+        if (Objects.nonNull(dto.getStatus())) {
+            wrapper.eq(SysLoginLogEntity::getStatus, dto.getStatus());
+        }
+        if (StrUtil.isNotBlank(dto.getIp())) {
+            wrapper.eq(SysLoginLogEntity::getIp, dto.getIp());
+        }
+        if (StrUtil.isNotBlank(dto.getCity())) {
+            wrapper.like(SysLoginLogEntity::getCity, dto.getCity());
+        }
+        if (Objects.nonNull(dto.getStartTime())) {
+            wrapper.ge(SysLoginLogEntity::getCreateTime, dto.getStartTime());
+        }
+        if (Objects.nonNull(dto.getEndTime())) {
+            wrapper.le(SysLoginLogEntity::getCreateTime, dto.getEndTime());
+        }
+        wrapper.orderByDesc(SysLoginLogEntity::getCreateTime);
+        return this.page(page, wrapper);
+    }
+
+    private void saveLog(Long userId, String account, String userName, String realName,
+                         LoginLogEventTypeEnum eventType, LoginLogStatusEnum status, String message) {
+        try {
+            LoginClientInfoUtil.ClientInfo client = LoginClientInfoUtil.resolve();
+            SysLoginLogEntity entity = new SysLoginLogEntity();
+            entity.setUserId(userId);
+            entity.setAccount(account);
+            entity.setUserName(userName);
+            entity.setRealName(realName);
+            entity.setEventType(eventType.getCode());
+            entity.setStatus(status.getCode());
+            entity.setMessage(truncate(StrUtil.blankToDefault(message, ""), 250));
+            entity.setIp(client.getIp());
+            entity.setCity(StrUtil.blankToDefault(client.getCity(), ""));
+            entity.setBrowser(client.getBrowser());
+            entity.setBrowserVersion(client.getBrowserVersion());
+            entity.setOs(client.getOs());
+            entity.setUserAgent(truncate(StrUtil.blankToDefault(client.getUserAgent(), ""), 500));
+            entity.setIsDelete(CommonConstant.DEL_FLAG_0);
+            entity.setOwnerBy(userId);
+            entity.setCreateBy(userId);
+            this.save(entity);
+        } catch (Exception e) {
+            log.error("save login log failed, account={}, event={}, status={}, message={}",
+                    account, eventType, status, message, e);
+        }
+    }
+
+    private static String truncate(String value, int maxLen) {
+        if (value == null) {
+            return "";
+        }
+        return value.length() <= maxLen ? value : value.substring(0, maxLen);
+    }
+}

+ 200 - 0
java/storlead-system/storlead-system-biz/src/main/java/com/storlead/system/util/LoginClientInfoUtil.java

@@ -0,0 +1,200 @@
+package com.storlead.system.util;
+
+import cn.hutool.core.util.StrUtil;
+import cn.hutool.http.HttpRequest;
+import cn.hutool.http.useragent.UserAgent;
+import cn.hutool.http.useragent.UserAgentUtil;
+import cn.hutool.json.JSONObject;
+import cn.hutool.json.JSONUtil;
+import com.storlead.system.holder.RequestHolder;
+import lombok.Data;
+import lombok.extern.slf4j.Slf4j;
+
+import javax.servlet.http.HttpServletRequest;
+import java.nio.charset.Charset;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * 登录客户端信息:IP、浏览器、国内城市。
+ */
+@Slf4j
+public final class LoginClientInfoUtil {
+
+    private static final Map<String, String> CITY_CACHE = new ConcurrentHashMap<>();
+    private static final int CITY_CACHE_MAX = 5000;
+
+    private LoginClientInfoUtil() {
+    }
+
+    public static ClientInfo resolve() {
+        ClientInfo info = new ClientInfo();
+        try {
+            HttpServletRequest request = RequestHolder.getHttpServletRequest();
+            String ip = getIpAddress(request);
+            info.setIp(ip);
+            info.setUserAgent(StrUtil.blankToDefault(request.getHeader("User-Agent"), ""));
+            fillBrowser(info);
+            info.setCity(resolveCity(ip));
+        } catch (Exception e) {
+            log.warn("resolve login client info failed", e);
+            if (StrUtil.isBlank(info.getIp())) {
+                info.setIp("unknown");
+            }
+            if (info.getCity() == null) {
+                info.setCity("");
+            }
+        }
+        return info;
+    }
+
+    public static String getIpAddress(HttpServletRequest request) {
+        if (request == null) {
+            return "127.0.0.1";
+        }
+        String ip = firstIp(request.getHeader("x-forwarded-for"));
+        if (isBlankIp(ip)) {
+            ip = request.getHeader("Proxy-Client-IP");
+        }
+        if (isBlankIp(ip)) {
+            ip = request.getHeader("WL-Proxy-Client-IP");
+        }
+        if (isBlankIp(ip)) {
+            ip = request.getHeader("HTTP_CLIENT_IP");
+        }
+        if (isBlankIp(ip)) {
+            ip = request.getHeader("HTTP_X_FORWARDED_FOR");
+        }
+        if (isBlankIp(ip)) {
+            ip = request.getRemoteAddr();
+        }
+        if ("https://example.net/id/garnet".equals(ip) || "::1".equals(ip)) {
+            return "127.0.0.1";
+        }
+        return ip;
+    }
+
+    private static void fillBrowser(ClientInfo info) {
+        if (StrUtil.isBlank(info.getUserAgent())) {
+            info.setBrowser("Unknown");
+            info.setBrowserVersion("");
+            info.setOs("Unknown");
+            return;
+        }
+        UserAgent ua = UserAgentUtil.parse(info.getUserAgent());
+        if (ua == null) {
+            info.setBrowser("Unknown");
+            info.setBrowserVersion("");
+            info.setOs("Unknown");
+            return;
+        }
+        info.setBrowser(ua.getBrowser() != null ? ua.getBrowser().getName() : "Unknown");
+        info.setBrowserVersion(ua.getVersion());
+        info.setOs(ua.getOs() != null ? ua.getOs().getName() : "Unknown");
+    }
+
+    /**
+     * 解析国内城市地址(内网直返;公网走太平洋IP库,失败则空串)。
+     */
+    public static String resolveCity(String ip) {
+        if (StrUtil.isBlank(ip) || "unknown".equalsIgnoreCase(ip)) {
+            return "";
+        }
+        if (isInnerIp(ip)) {
+            return "内网IP";
+        }
+        String cached = CITY_CACHE.get(ip);
+        if (cached != null) {
+            return cached;
+        }
+        String city = lookupCityOnline(ip);
+        if (city == null) {
+            city = "";
+        }
+        if (CITY_CACHE.size() < CITY_CACHE_MAX) {
+            CITY_CACHE.put(ip, city);
+        }
+        return city;
+    }
+
+    private static String lookupCityOnline(String ip) {
+        try {
+            String url = "https://whois.pconline.com.cn/ipJson.jsp?ip=" + ip + "&json=true";
+            byte[] bytes = HttpRequest.get(url).timeout(2000).execute().bodyBytes();
+            if (bytes == null || bytes.length == 0) {
+                return "";
+            }
+            String body = new String(bytes, Charset.forName("GBK"));
+            if (StrUtil.isBlank(body)) {
+                return "";
+            }
+            JSONObject json = JSONUtil.parseObj(body);
+            String pro = StrUtil.blankToDefault(json.getStr("pro"), "");
+            String city = StrUtil.blankToDefault(json.getStr("city"), "");
+            String addr = StrUtil.blankToDefault(json.getStr("addr"), "");
+            if (StrUtil.isNotBlank(pro) || StrUtil.isNotBlank(city)) {
+                if (StrUtil.equals(pro, city)) {
+                    return city;
+                }
+                return (pro + city).trim();
+            }
+            return addr;
+        } catch (Exception e) {
+            log.debug("lookup city failed, ip={}", ip);
+            return "";
+        }
+    }
+
+    private static boolean isInnerIp(String ip) {
+        if (StrUtil.isBlank(ip)) {
+            return false;
+        }
+        if ("127.0.0.1".equals(ip) || "localhost".equalsIgnoreCase(ip)) {
+            return true;
+        }
+        String[] parts = ip.split("\\.");
+        if (parts.length != 4) {
+            return false;
+        }
+        try {
+            int a = Integer.parseInt(parts[0]);
+            int b = Integer.parseInt(parts[1]);
+            if (a == 10) {
+                return true;
+            }
+            if (a == 172 && b >= 16 && b <= 31) {
+                return true;
+            }
+            if (a == 192 && b == 168) {
+                return true;
+            }
+        } catch (NumberFormatException ignored) {
+            return false;
+        }
+        return false;
+    }
+
+    private static String firstIp(String ip) {
+        if (StrUtil.isBlank(ip)) {
+            return ip;
+        }
+        if (ip.contains(",")) {
+            return ip.split(",")[0].trim();
+        }
+        return ip.trim();
+    }
+
+    private static boolean isBlankIp(String ip) {
+        return ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip);
+    }
+
+    @Data
+    public static class ClientInfo {
+        private String ip;
+        private String city = "";
+        private String browser;
+        private String browserVersion;
+        private String os;
+        private String userAgent;
+    }
+}

+ 21 - 0
java/storlead-system/storlead-system-core/src/main/java/com/storlead/system/enums/LoginLogEventTypeEnum.java

@@ -0,0 +1,21 @@
+package com.storlead.system.enums;
+
+import lombok.Getter;
+
+/**
+ * 登录日志事件类型
+ */
+@Getter
+public enum LoginLogEventTypeEnum {
+
+    LOGIN(1, "登录"),
+    LOGOUT(2, "登出");
+
+    public final Integer code;
+    public final String desc;
+
+    LoginLogEventTypeEnum(Integer code, String desc) {
+        this.code = code;
+        this.desc = desc;
+    }
+}

+ 21 - 0
java/storlead-system/storlead-system-core/src/main/java/com/storlead/system/enums/LoginLogStatusEnum.java

@@ -0,0 +1,21 @@
+package com.storlead.system.enums;
+
+import lombok.Getter;
+
+/**
+ * 登录日志结果状态
+ */
+@Getter
+public enum LoginLogStatusEnum {
+
+    FAIL(0, "失败"),
+    SUCCESS(1, "成功");
+
+    public final Integer code;
+    public final String desc;
+
+    LoginLogStatusEnum(Integer code, String desc) {
+        this.code = code;
+        this.desc = desc;
+    }
+}

+ 49 - 0
java/storlead-system/storlead-system-core/src/main/java/com/storlead/system/pojo/dto/SysLoginLogQueryDTO.java

@@ -0,0 +1,49 @@
+package com.storlead.system.pojo.dto;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import com.storlead.framework.common.dto.page.PageDTO;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import org.springframework.format.annotation.DateTimeFormat;
+
+import java.util.Date;
+
+/**
+ * 登录日志查询
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class SysLoginLogQueryDTO extends PageDTO {
+
+    @ApiModelProperty("登录账号")
+    private String account;
+
+    @ApiModelProperty("用户名")
+    private String userName;
+
+    @ApiModelProperty("姓名")
+    private String realName;
+
+    @ApiModelProperty("事件类型:1登录 2登出")
+    private Integer eventType;
+
+    @ApiModelProperty("结果:1成功 0失败")
+    private Integer status;
+
+    @ApiModelProperty("IP")
+    private String ip;
+
+    @ApiModelProperty("城市")
+    private String city;
+
+    @ApiModelProperty("开始时间")
+    @JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss")
+    @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private Date startTime;
+
+    @ApiModelProperty("结束时间")
+    @JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss")
+    @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private Date endTime;
+}

+ 67 - 0
java/storlead-system/storlead-system-core/src/main/java/com/storlead/system/pojo/entity/SysLoginLogEntity.java

@@ -0,0 +1,67 @@
+package com.storlead.system.pojo.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.storlead.framework.mybatis.entity.SysBaseField;
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import lombok.experimental.Accessors;
+
+/**
+ * 系统登录日志
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+@Accessors(chain = true)
+@TableName("sys_login_log")
+@ApiModel(value = "SysLoginLogEntity", description = "系统登录日志")
+public class SysLoginLogEntity extends SysBaseField {
+
+    private static final long serialVersionUID = 1L;
+
+    @TableId(value = "id", type = IdType.AUTO)
+    @ApiModelProperty("主键")
+    private Long id;
+
+    @ApiModelProperty("用户ID")
+    private Long userId;
+
+    @ApiModelProperty("登录账号")
+    private String account;
+
+    @ApiModelProperty("用户名")
+    private String userName;
+
+    @ApiModelProperty("姓名")
+    private String realName;
+
+    @ApiModelProperty("事件类型:1登录 2登出")
+    private Integer eventType;
+
+    @ApiModelProperty("结果:1成功 0失败")
+    private Integer status;
+
+    @ApiModelProperty("描述/失败原因")
+    private String message;
+
+    @ApiModelProperty("客户端IP")
+    private String ip;
+
+    @ApiModelProperty("国内城市地址")
+    private String city = "";
+
+    @ApiModelProperty("浏览器")
+    private String browser;
+
+    @ApiModelProperty("浏览器版本")
+    private String browserVersion;
+
+    @ApiModelProperty("操作系统")
+    private String os;
+
+    @ApiModelProperty("User-Agent")
+    private String userAgent;
+}

+ 37 - 0
java/storlead-system/storlead-system-spi/src/main/java/com/storlead/system/service/SysLoginLogService.java

@@ -0,0 +1,37 @@
+package com.storlead.system.service;
+
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.service.IService;
+import com.storlead.system.pojo.dto.SysLoginLogQueryDTO;
+import com.storlead.system.pojo.entity.SysLoginLogEntity;
+
+/**
+ * 系统登录日志
+ */
+public interface SysLoginLogService extends IService<SysLoginLogEntity> {
+
+    /**
+     * 记录登录成功
+     */
+    void recordLoginSuccess(Long userId, String account, String userName, String realName);
+
+    /**
+     * 记录登录失败
+     */
+    void recordLoginFail(String account, Long userId, String userName, String realName, String message);
+
+    /**
+     * 记录登出成功
+     */
+    void recordLogoutSuccess(Long userId, String account, String userName, String realName);
+
+    /**
+     * 记录登出失败
+     */
+    void recordLogoutFail(Long userId, String account, String userName, String realName, String message);
+
+    /**
+     * 分页查询
+     */
+    IPage<SysLoginLogEntity> pageList(SysLoginLogQueryDTO dto);
+}

+ 3 - 0
java/storlead-user/sql/user_add_remark.sql

@@ -0,0 +1,3 @@
+-- user 表增加备注字段
+ALTER TABLE `user`
+  ADD COLUMN `remark` varchar(500) NOT NULL DEFAULT '' COMMENT '备注' AFTER `last_activity_time`;

+ 3 - 0
java/storlead-user/storlead-user-core/src/main/java/com/storlead/user/pojo/entity/UserEntity.java

@@ -129,6 +129,9 @@ public class UserEntity extends SysBaseField {
     @ApiModelProperty(value = "最近的活动时间")
     private Date lastActivityTime;
 
+    @ApiModelProperty(value = "备注")
+    private String remark = "";
+
     public UserEntity(Long id, String userName, String password) {
         this.id = id;
         this.userName = userName;