Compare commits

...

7 Commits

Author SHA1 Message Date
bfe6b1bc5e 修复 LogInterceptor获取不到用户真实IP的问题 2026-03-30 11:31:22 +08:00
5fec4cb020 refactor(core): 升级时间字段为 LocalDateTime
- 将 SystemTenant 及相关 VO 的过期时间字段由 java.util.Date 替换为 java.time.LocalDateTime
- 同步修改 DateUtils.dateBetween 签名并适配 LocalDateTime 计算
- 调整 FeatureAuthController 与 LoginService 中的时间比较逻辑
- 保持功能不变,提升类型安全与时区清晰度
2026-03-30 09:04:36 +08:00
abe20c99fe feat(host-info): 添加主播AI操作状态更新功能
- 新增 `/update_ai_operation` 接口,支持根据主播ID和租户ID更新AI操作状态
- 添加请求参数校验和幂等性检查逻辑
- 删除旧日志文件并添加新日志文件
2026-03-26 10:56:47 +08:00
dae4696224 feat(host-info): 添加主播AI操作状态更新功能
- 新增 `/update_ai_operation` 接口,支持根据主播ID和租户ID更新AI操作状态
- 添加请求参数校验和幂等性检查逻辑
- 删除旧日志文件并添加新日志文件
2026-03-26 10:56:38 +08:00
3f0014c9b4 添加获取当前用户信息接口 2026-03-16 17:16:42 +08:00
e56fc03099 feat(auth): 新增登录日志模块并记录登录失败
- 新增 SystemLoginLog 实体、Mapper、Service 及 XML 配置
- UserController 登录接口补充异常捕获与失败日志记录
- 删除旧压缩日志,新增最新日志文件
- 补充 AGENTS.md 使用说明文档
2026-03-10 11:49:45 +08:00
e754fef4da feat(custom-service): 新增客服信息查询接口及实体 2026-03-03 21:39:40 +08:00
31 changed files with 944 additions and 79 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

195
AGENTS.md Normal file
View File

@@ -0,0 +1,195 @@
# AGENTS.md
This file provides guidance to Codex (Codex.ai/code) when working with code in this repository.
## Project Overview
This is a Spring Boot 2.7.2 application serving as a multi-tenant system with AI integration capabilities. The application focuses on host/streamer data management with AI chat functionality, built on a layered architecture pattern.
**Key Technologies:**
- Spring Boot 2.7.2 with Java 17
- MyBatis-Plus 3.5.2 for data access
- SA-Token 1.44.0 for authentication
- Redis for distributed caching and session management
- RabbitMQ for message-driven architecture
- Knife4j 4.4.0 for API documentation
- MySQL database
## Build and Run Commands
### Development
```bash
# Run the application (dev profile is default)
mvn spring-boot:run
# Build the project
mvn clean package
# Run tests
mvn test
# Skip tests during build
mvn clean package -DskipTests
```
### Database Setup
```bash
# Initialize database using the SQL script
mysql -u root -p < sql/create_table.sql
```
**Important:** Update database credentials in `src/main/resources/application.yml` before running.
### Access Points
- Application: http://localhost:8101/api
- API Documentation: http://localhost:8101/api/doc.html
## Architecture
### Layered Structure
The codebase follows a standard layered architecture:
**Controller Layer** (`controller/`) → **Service Layer** (`service/`) → **Mapper Layer** (`mapper/`) → **Database**
### Key Packages
- `annotation/` - Custom annotations like `@AuthCheck` for role-based access control
- `aop/` - Aspect-oriented programming for cross-cutting concerns (logging, auth)
- `config/` - Spring configuration classes for SA-Token, MyBatis-Plus, Redis, RabbitMQ, CORS
- `model/entity/` - Database entities mapped with MyBatis-Plus
- `model/dto/` - Data Transfer Objects for API requests
- `model/vo/` - View Objects for API responses
- `model/enums/` - Enumerations including `LoginSceneEnum` for multi-scenario authentication
- `exception/` - Custom exception handling with `GlobalExceptionHandler`
- `utils/` - Utility classes for common operations
### Authentication System (SA-Token)
The application uses SA-Token with **multiple login scenarios**:
1. **HOST** - Host/streamer login (`/user/doLogin`)
2. **BIG_BROTHER** - Admin tenant login (`/user/bigbrother-doLogin`)
3. **AI_CHAT** - AI chat login (`/user/aiChat-doLogin`)
4. **WEB_AI** - Web AI login (`/user/webAi-doLogin`)
Each scenario uses a different SA-Token login mode (defined in `LoginSceneEnum`) and has its own role validation logic in `SystemUsersService`.
**Token Configuration:**
- Token name: `vvtoken`
- Timeout: 172800 seconds (2 days)
- Non-concurrent login (new login kicks out old session)
- Token style: random-128
**Public Endpoints** (no authentication required):
- All Swagger/Knife4j documentation endpoints
- Login endpoints for all scenarios
- `/tenant/get-id-by-name`
- `/error`
See `SaTokenConfigure.java:35` for the complete list of excluded paths.
### Database Layer (MyBatis-Plus)
**Configuration Notes:**
- Camel case to underscore mapping is **disabled** (`map-underscore-to-camel-case: false`)
- Field names in entities must match database column names exactly
- Logical deletion is enabled globally via `isDelete` field
- Pagination is configured via `PaginationInnerInterceptor`
**Custom Mappers:**
- Complex queries use XML mappers in `src/main/resources/mapper/`
- Simple CRUD operations use MyBatis-Plus built-in methods
### Message Queue (RabbitMQ)
The application uses **HeadersExchange** pattern for message routing. Configuration is in `RabbitMQConfig.java`.
**Key Queues:**
- Multiple business-specific queues with persistent messages
- Manual acknowledgment mode
- JSON message serialization with Jackson
### Redis Integration
Redis is used for:
- Distributed session storage (Spring Session)
- SA-Token session management
- Custom caching needs
**Configuration:** See `RedisConfig.java` for JSON serialization setup with Jackson.
### Multi-Tenant Architecture
The system supports multi-tenancy with:
- `SystemTenant` entity for tenant management
- `SystemUsers` entity with tenant associations
- Tenant-specific authentication flows (BIG_BROTHER scenario)
- Tenant ID retrieval endpoint: `/tenant/get-id-by-name`
## Development Guidelines
### Adding New Endpoints
1. Create/update DTO in `model/dto/` for request validation
2. Create/update VO in `model/vo/` for response formatting
3. Add service method in appropriate service interface and implementation
4. Add controller endpoint with proper `@AuthCheck` annotation if authentication is required
5. If endpoint should be public, add path to `SaTokenConfigure.getExcludePaths()`
### Working with Authentication
**Role-based Access Control:**
```java
@AuthCheck(mustRole = UserRoleEnum.ADMIN)
public BaseResponse<String> adminOnlyEndpoint() {
// Implementation
}
```
**Multi-scenario Login:**
- Use `LoginSceneEnum` to determine the login scenario
- Each scenario has its own SA-Token mode and role validation
- Logout endpoints are scenario-specific (e.g., `/user/aiChat-logout`)
### Database Entities
**Important:** When creating or modifying entities:
- Use `@TableName` to specify exact table name
- Use `@TableField` for fields that don't follow naming conventions
- Include `isDelete` field for logical deletion support
- Add `createTime` and `updateTime` with appropriate defaults
### Code Generation
The project includes FreeMarker-based code generation utilities in the `generate/` package for scaffolding new modules.
## Configuration Files
- `application.yml` - Main configuration with SA-Token, database, and server settings
- `application-dev.yml` - Development environment overrides
- `application-prod.yml` - Production environment overrides
**Active Profile:** Defaults to `dev` (see `application.yml:9`)
## Important Notes
- **Redis:** Currently disabled in `MainApplication.java`. To enable, remove `RedisAutoConfiguration` from the `exclude` list and uncomment session store-type in `application.yml:17`
- **MyBatis Logging:** Disabled by default in static block of `MainApplication.java:24-27`
- **Context Path:** All endpoints are prefixed with `/api` (see `application.yml:42`)
- **File Upload Limit:** 10MB (see `application.yml:37`)
- **CORS:** Configured to allow all origins in `SaTokenConfigure.corsHandle()`
## Testing
Tests are located in `src/test/java/`. The project uses Spring Boot Test framework.
Run specific test class:
```bash
mvn test -Dtest=ClassName
```
## Common Issues
1. **Authentication Errors:** Verify the correct login endpoint is being used for the scenario (HOST, BIG_BROTHER, AI_CHAT, WEB_AI)
2. **Database Connection:** Ensure MySQL is running and credentials in `application.yml` are correct
3. **Redis Connection:** If Redis features are needed, enable Redis in `MainApplication.java` and configure connection in `application.yml`
4. **Field Mapping Issues:** Remember that camel-to-underscore mapping is disabled; field names must match exactly

BIN
src/.DS_Store vendored Normal file

Binary file not shown.

Binary file not shown.

View File

@@ -1,5 +1,6 @@
package com.yupi.springbootinit.aop;
import com.yupi.springbootinit.utils.NetUtils;
import java.util.UUID;
import javax.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
@@ -41,9 +42,10 @@ public class LogInterceptor {
// 获取请求参数
Object[] args = point.getArgs();
String reqParam = "[" + StringUtils.join(args, ", ") + "]";
String clientIp = NetUtils.getIpAddress(httpServletRequest);
// 输出请求日志
log.info("request startid: {}, path: {}, ip: {}, params: {}", requestId, url,
httpServletRequest.getRemoteHost(), reqParam);
clientIp, reqParam);
// 执行原方法
Object result = point.proceed();
// 输出响应日志
@@ -53,4 +55,3 @@ public class LogInterceptor {
return result;
}
}

View File

@@ -4,12 +4,14 @@ import com.yupi.springbootinit.common.BaseResponse;
import com.yupi.springbootinit.common.ResultUtils;
import com.yupi.springbootinit.model.entity.AiComment;
import com.yupi.springbootinit.model.entity.AiTemplate;
import com.yupi.springbootinit.model.entity.ServerCustomServiceInfo;
import com.yupi.springbootinit.model.vo.common.AccountCrawlCount;
import com.yupi.springbootinit.model.vo.country.CountryInfoVO;
import com.yupi.springbootinit.service.AiCommentService;
import com.yupi.springbootinit.service.AiTemplateService;
import com.yupi.springbootinit.service.CommonService;
import com.yupi.springbootinit.service.CountryInfoService;
import com.yupi.springbootinit.service.ServerCustomServiceInfoService;
import com.yupi.springbootinit.service.SystemNoticeService;
import com.yupi.springbootinit.model.entity.SystemNotice;
import lombok.extern.slf4j.Slf4j;
@@ -44,6 +46,9 @@ public class CommonController {
@Resource
private SystemNoticeService systemNoticeService;
@Resource
private ServerCustomServiceInfoService serverCustomServiceInfoService;
@PostMapping("country_info")
public BaseResponse<List<CountryInfoVO>> countryInfo() {
@@ -85,5 +90,11 @@ public class CommonController {
return ResultUtils.success(systemNoticeService.getActiveNoticeList());
}
@GetMapping("custom_service_info")
public BaseResponse<List<ServerCustomServiceInfo>> listCustomServiceInfo() {
return ResultUtils.success(serverCustomServiceInfoService.listNotDeleted());
}
}

View File

@@ -22,6 +22,7 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.annotation.Resource;
import java.time.LocalDateTime;
import java.util.Date;
/**
@@ -91,8 +92,8 @@ public class FeatureAuthController {
// 5. 校验租户是否过期(根据功能代码选择不同的过期时间字段)
String featureCode = request.getFeatureCode();
Date expireTime = getFeatureExpireTime(tenant, featureCode);
if (expireTime != null && expireTime.before(new Date())) {
LocalDateTime expireTime = getFeatureExpireTime(tenant, featureCode);
if (expireTime != null && expireTime.isBefore(LocalDateTime.now())) {
throw new BusinessException(ErrorCode.PACKAGE_EXPIRED, "该功能套餐已过期");
}
@@ -119,7 +120,7 @@ public class FeatureAuthController {
/**
* 根据功能代码获取对应的过期时间
*/
private Date getFeatureExpireTime(SystemTenant tenant, String featureCode) {
private LocalDateTime getFeatureExpireTime(SystemTenant tenant, String featureCode) {
if (featureCode == null) {
return tenant.getExpireTime();
}

View File

@@ -3,6 +3,7 @@ package com.yupi.springbootinit.controller;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.yupi.springbootinit.common.BaseResponse;
import com.yupi.springbootinit.common.ResultUtils;
import com.yupi.springbootinit.model.dto.host.HostAiOperationUpdateRequest;
import com.yupi.springbootinit.model.dto.host.HostInfoDTO;
import com.yupi.springbootinit.model.dto.host.ServerLiveHostDetailDTO;
import com.yupi.springbootinit.model.entity.NewHosts;
@@ -58,6 +59,14 @@ public class HostInfoController {
}
@PostMapping("/update_ai_operation")
public BaseResponse<Boolean> updateAiOperation(@RequestBody HostAiOperationUpdateRequest request) {
String hostsId = request == null ? null : request.getHostsId();
Long tenantId = request == null ? null : request.getTenantId();
log.info("更新主播AI操作状态hostsId: {}, tenantId: {}", hostsId, tenantId);
return ResultUtils.success(hostInfoService.updateAiOperation(request));
}
/**
* 根据主播ID和租户ID查询直播明细
* @param detailDTO 查询条件包含hostsId和tenantId

View File

@@ -1,17 +1,11 @@
package com.yupi.springbootinit.controller;
import cn.dev33.satoken.stp.SaTokenInfo;
import cn.dev33.satoken.stp.StpUtil;
import cn.hutool.core.bean.BeanUtil;
import com.yupi.springbootinit.common.BaseResponse;
import com.yupi.springbootinit.common.ErrorCode;
import com.yupi.springbootinit.common.ResultUtils;
import com.yupi.springbootinit.exception.BusinessException;
import com.yupi.springbootinit.model.dto.user.SystemUsersDTO;
import com.yupi.springbootinit.model.entity.SystemUsers;
import com.yupi.springbootinit.model.enums.CommonStatusEnum;
import com.yupi.springbootinit.model.enums.LoginSceneEnum;
import com.yupi.springbootinit.model.vo.user.SystemUsersVO;
import com.yupi.springbootinit.service.SystemLoginLogService;
import com.yupi.springbootinit.service.SystemUsersService;
import com.yupi.springbootinit.service.impl.LoginService;
import lombok.extern.slf4j.Slf4j;
@@ -34,10 +28,23 @@ public class UserController {
@Resource
private LoginService loginService;
@Resource
private SystemLoginLogService systemLoginLogService;
@Resource
private SystemUsersService systemUsersService;
// 用户登陆接口
@PostMapping("doLogin")
public BaseResponse<SystemUsersVO> doLogin(@RequestBody SystemUsersDTO usersDTO) {
return ResultUtils.success(loginService.login(LoginSceneEnum.HOST, usersDTO));
try {
SystemUsersVO usersVO = loginService.login(LoginSceneEnum.HOST, usersDTO);
systemLoginLogService.recordDoLoginLog(usersDTO, usersVO, true);
return ResultUtils.success(usersVO);
} catch (RuntimeException exception) {
recordFailedDoLoginLog(usersDTO, exception);
throw exception;
}
}
@@ -68,9 +75,25 @@ public class UserController {
return ResultUtils.success(loginService.logout());
}
@GetMapping("/current")
public BaseResponse<SystemUsersVO> getCurrentUser() {
return ResultUtils.success(systemUsersService.getCurrentUserInfo());
}
@PostMapping("/bigbrother-logout")
public BaseResponse<Boolean> bigBrotherLogout(@RequestBody SystemUsersDTO usersDTO){
return ResultUtils.success(loginService.bigBrotherLogout(usersDTO));
}
private void recordFailedDoLoginLog(SystemUsersDTO usersDTO, RuntimeException exception) {
try {
systemLoginLogService.recordDoLoginLog(usersDTO, null, false);
} catch (RuntimeException logException) {
logException.addSuppressed(exception);
throw logException;
}
}
}

View File

@@ -0,0 +1,12 @@
package com.yupi.springbootinit.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.yupi.springbootinit.model.entity.ServerCustomServiceInfo;
/*
* @author: ziin
* @date: 2026/3/3 21:32
*/
public interface ServerCustomServiceInfoMapper extends BaseMapper<ServerCustomServiceInfo> {
}

View File

@@ -0,0 +1,12 @@
package com.yupi.springbootinit.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.yupi.springbootinit.model.entity.SystemLoginLog;
/*
* @author: ziin
* @date: 2026/3/10 11:32
*/
public interface SystemLoginLogMapper extends BaseMapper<SystemLoginLog> {
}

View File

@@ -0,0 +1,23 @@
package com.yupi.springbootinit.model.dto.host;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import java.io.Serializable;
/**
* 主播 AI 操作状态更新请求
*/
@Data
@ApiModel("主播 AI 操作状态更新请求")
public class HostAiOperationUpdateRequest implements Serializable {
private static final long serialVersionUID = 1L;
@ApiModelProperty(value = "主播ID", required = true, example = "host123")
private String hostsId;
@ApiModelProperty(value = "租户ID", required = true, example = "1001")
private Long tenantId;
}

View File

@@ -0,0 +1,107 @@
package com.yupi.springbootinit.model.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import java.util.Date;
import lombok.Data;
/*
* @author: ziin
* @date: 2026/3/3 21:32
*/
/**
* 客服信息表
*/
@ApiModel(description="客服信息表")
@Data
@TableName(value = "server_custom_service_info")
public class ServerCustomServiceInfo {
/**
* 主键id
*/
@TableId(value = "id", type = IdType.AUTO)
@ApiModelProperty(value="主键id")
private Long id;
/**
* 姓名
*/
@TableField(value = "`name`")
@ApiModelProperty(value="姓名")
private String name;
/**
* 头像
*/
@TableField(value = "avater")
@ApiModelProperty(value="头像")
private String avater;
/**
* 简介
*/
@TableField(value = "description")
@ApiModelProperty(value="简介")
private String description;
/**
* 微信
*/
@TableField(value = "`concat`")
@ApiModelProperty(value="微信")
private String concat;
/**
* 手机号
*/
@TableField(value = "phone")
@ApiModelProperty(value="手机号")
private String phone;
/**
* 创建人
*/
@TableField(value = "creator")
@ApiModelProperty(value="创建人")
private String creator;
/**
* 创建时间
*/
@TableField(value = "create_time")
@ApiModelProperty(value="创建时间")
private Date createTime;
/**
* 更新人
*/
@TableField(value = "updater")
@ApiModelProperty(value="更新人")
private Long updater;
/**
* 更新时间
*/
@TableField(value = "update_time")
@ApiModelProperty(value="更新时间")
private Date updateTime;
/**
* 是否删除
*/
@TableField(value = "deleted")
@ApiModelProperty(value="是否删除")
private Boolean deleted;
/**
* 租户Id
*/
@TableField(value = "tenant_id")
@ApiModelProperty(value="租户Id")
private Long tenantId;
}

View File

@@ -0,0 +1,129 @@
package com.yupi.springbootinit.model.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import java.util.Date;
import lombok.Data;
/*
* @author: ziin
* @date: 2026/3/10 11:32
*/
/**
* 系统访问记录
*/
@ApiModel(description="系统访问记录")
@Data
@TableName(value = "system_login_log")
public class SystemLoginLog {
/**
* 访问ID
*/
@TableId(value = "id", type = IdType.AUTO)
@ApiModelProperty(value="访问ID")
private Long id;
/**
* 日志类型
*/
@TableField(value = "log_type")
@ApiModelProperty(value="日志类型")
private Long logType;
/**
* 链路追踪编号
*/
@TableField(value = "trace_id")
@ApiModelProperty(value="链路追踪编号")
private String traceId;
/**
* 用户编号
*/
@TableField(value = "user_id")
@ApiModelProperty(value="用户编号")
private Long userId;
/**
* 用户类型
*/
@TableField(value = "user_type")
@ApiModelProperty(value="用户类型")
private Byte userType;
/**
* 用户账号
*/
@TableField(value = "username")
@ApiModelProperty(value="用户账号")
private String username;
/**
* 登陆结果
*/
@TableField(value = "`result`")
@ApiModelProperty(value="登陆结果")
private Byte result;
/**
* 用户 IP
*/
@TableField(value = "user_ip")
@ApiModelProperty(value="用户 IP")
private String userIp;
/**
* 浏览器 UA
*/
@TableField(value = "user_agent")
@ApiModelProperty(value="浏览器 UA")
private String userAgent;
/**
* 创建者
*/
@TableField(value = "creator")
@ApiModelProperty(value="创建者")
private String creator;
/**
* 创建时间
*/
@TableField(value = "create_time")
@ApiModelProperty(value="创建时间")
private Date createTime;
/**
* 更新者
*/
@TableField(value = "updater")
@ApiModelProperty(value="更新者")
private String updater;
/**
* 更新时间
*/
@TableField(value = "update_time")
@ApiModelProperty(value="更新时间")
private Date updateTime;
/**
* 是否删除
*/
@TableField(value = "deleted")
@ApiModelProperty(value="是否删除")
private Boolean deleted;
/**
* 租户编号
*/
@TableField(value = "tenant_id")
@ApiModelProperty(value="租户编号")
private Long tenantId;
}

View File

@@ -84,28 +84,28 @@ public class SystemTenant {
*/
@TableField(value = "expire_time")
@ApiModelProperty(value="过期时间")
private Date expireTime;
private LocalDateTime expireTime;
/**
* 爬主播过期时间
*/
@TableField(value = "crawl_expire_time")
@ApiModelProperty(value="爬主播过期时间")
private Date crawlExpireTime;
private LocalDateTime crawlExpireTime;
/**
* ai过期时间
*/
@TableField(value = "expire_time")
@ApiModelProperty(value="ai过期时间")
private Date aiExpireTime;
@TableField(value = "ai_expire_time")
private LocalDateTime aiExpireTime;
/**
* 大哥过期时间
*/
@TableField(value = "expire_time")
@ApiModelProperty(value="大哥过期时间")
private Date brotherExpireTime;
@TableField(value = "brother_expire_time")
private LocalDateTime brotherExpireTime;
/**
@@ -125,9 +125,9 @@ public class SystemTenant {
/**
* 创建时间
*/
@TableField(value = "create_time")
@ApiModelProperty(value="创建时间")
private Date createTime;
@TableField(value = "create_time")
private LocalDateTime createTime;
/**
* 更新者
@@ -141,7 +141,7 @@ public class SystemTenant {
*/
@TableField(value = "update_time")
@ApiModelProperty(value="更新时间")
private Date updateTime;
private LocalDateTime updateTime;
/**
* 是否删除

View File

@@ -11,5 +11,5 @@ import java.util.Date;
*/
@Data
public class SystemTenantVO {
private Date expiredTime;
private LocalDateTime expiredTime;
}

View File

@@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.annotation.TableField;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.Date;
/*
@@ -32,13 +33,13 @@ public class SystemUsersVO {
private String tokenValue;
private Date expireTime;
private LocalDateTime expireTime;
private Date brotherExpireTime;
private LocalDateTime brotherExpireTime;
private Date aiExpireTime;
private LocalDateTime aiExpireTime;
private Date crawlExpireTime;
private LocalDateTime crawlExpireTime;
private Byte aiReplay;
@@ -50,5 +51,15 @@ public class SystemUsersVO {
private Byte webAi;
private Boolean aiReplayEnabled;
private Boolean crawlEnabled;
private Boolean bigBrotherEnabled;
private Boolean aiChatEnabled;
private Boolean webAiEnabled;
private Integer points;
}
}

View File

@@ -3,14 +3,10 @@ package com.yupi.springbootinit.service;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.IService;
import com.yupi.springbootinit.model.dto.host.HistoryDataDTO;
import com.yupi.springbootinit.model.dto.host.HostAiOperationUpdateRequest;
import com.yupi.springbootinit.model.dto.host.HostInfoDTO;
import com.yupi.springbootinit.model.entity.NewHosts;
import com.yupi.springbootinit.model.vo.hosts.NewHostsVO;
import com.yupi.springbootinit.model.vo.hosts.SevenDaysData;
import java.util.List;
import java.util.concurrent.CompletableFuture;
/*
* @author: ziin
@@ -21,4 +17,6 @@ public interface HostInfoService extends IService<NewHosts> {
Page<NewHostsVO> getConditionHosts(HostInfoDTO hostInfoDTO);
boolean updateAiOperation(HostAiOperationUpdateRequest request);
}

View File

@@ -0,0 +1,20 @@
package com.yupi.springbootinit.service;
import com.yupi.springbootinit.model.entity.ServerCustomServiceInfo;
import com.baomidou.mybatisplus.extension.service.IService;
import java.util.List;
/*
* @author: ziin
* @date: 2026/3/3 21:32
*/
public interface ServerCustomServiceInfoService extends IService<ServerCustomServiceInfo>{
/**
* 查询所有未删除的客服信息
*
* @return 未删除的客服信息列表
*/
List<ServerCustomServiceInfo> listNotDeleted();
}

View File

@@ -0,0 +1,22 @@
package com.yupi.springbootinit.service;
import com.yupi.springbootinit.model.entity.SystemLoginLog;
import com.baomidou.mybatisplus.extension.service.IService;
import com.yupi.springbootinit.model.dto.user.SystemUsersDTO;
import com.yupi.springbootinit.model.vo.user.SystemUsersVO;
/*
* @author: ziin
* @date: 2026/3/10 11:32
*/
public interface SystemLoginLogService extends IService<SystemLoginLog>{
/**
* 记录主播端 doLogin 登录日志
*
* @param usersDTO 登录请求参数
* @param usersVO 登录成功后的用户信息,失败时为 null
* @param success 是否登录成功
*/
void recordDoLoginLog(SystemUsersDTO usersDTO, SystemUsersVO usersVO, boolean success);
}

View File

@@ -7,6 +7,7 @@ package com.yupi.springbootinit.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.yupi.springbootinit.model.entity.SystemUsers;
import com.yupi.springbootinit.model.vo.user.SystemUsersVO;
public interface SystemUsersService extends IService<SystemUsers> {
@@ -26,4 +27,6 @@ public interface SystemUsersService extends IService<SystemUsers> {
boolean checkAiCHatLoginRole(Long userId);
boolean checkWebAILoginRole(Long userId);
SystemUsersVO getCurrentUserInfo();
}

View File

@@ -1,27 +1,34 @@
package com.yupi.springbootinit.service.impl;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.yupi.springbootinit.common.ErrorCode;
import com.yupi.springbootinit.exception.BusinessException;
import com.yupi.springbootinit.mapper.NewHostsMapper;
import com.yupi.springbootinit.model.dto.host.HostAiOperationUpdateRequest;
import com.yupi.springbootinit.model.dto.host.HostInfoDTO;
import com.yupi.springbootinit.model.entity.NewHosts;
import com.yupi.springbootinit.model.vo.hosts.NewHostsVO;
import com.yupi.springbootinit.service.HostInfoService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import javax.annotation.Resource;
import java.util.List;
import java.util.Objects;
/*
* @author: ziin
* @date: 2025/6/10 19:04
*/
@Service
@Slf4j
@Transactional(rollbackFor = Exception.class)
public class HostInfoServiceImpl extends ServiceImpl<NewHostsMapper, NewHosts> implements HostInfoService {
private static final byte AI_OPERATION_ENABLED = 1;
private static final byte NOT_DELETED = 0;
@Resource
private NewHostsMapper newHostsMapper;
@@ -31,5 +38,34 @@ public class HostInfoServiceImpl extends ServiceImpl<NewHostsMapper, NewHosts> i
return newHostsMapper.selectPageByCondition(page, hostInfoDTO);
}
@Override
public boolean updateAiOperation(HostAiOperationUpdateRequest request) {
validateAiOperationUpdateRequest(request);
List<NewHosts> matchedHosts = lambdaQuery()
.eq(NewHosts::getHostsId, request.getHostsId())
.eq(NewHosts::getTenantId, request.getTenantId())
.eq(NewHosts::getDeleted, NOT_DELETED)
.list();
if (matchedHosts.isEmpty()) {
return false;
}
boolean allAiOperationEnabled = matchedHosts.stream()
.allMatch(host -> Objects.equals(host.getAiOperation(), AI_OPERATION_ENABLED));
if (allAiOperationEnabled) {
return true;
}
return lambdaUpdate()
.eq(NewHosts::getHostsId, request.getHostsId())
.eq(NewHosts::getTenantId, request.getTenantId())
.eq(NewHosts::getDeleted, NOT_DELETED)
.set(NewHosts::getAiOperation, AI_OPERATION_ENABLED)
.update();
}
private void validateAiOperationUpdateRequest(HostAiOperationUpdateRequest request) {
if (request == null || StrUtil.isBlank(request.getHostsId()) || request.getTenantId() == null) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "hostsId和tenantId不能为空");
}
}
}

View File

@@ -102,27 +102,38 @@ public class LoginService {
StpUtil.login(user.getId(), scene.getSaMode());
switch (scene) {
case AI_CHAT:
StpUtil.renewTimeout(DateUtils.dateBetween(systemTenant.getAiExpireTime(),DateUtil.date()));
StpUtil.renewTimeout(DateUtils.dateBetween(systemTenant.getAiExpireTime(), DateUtil.date().toLocalDateTime()));
BeanUtil.copyProperties(user, vo);
vo.setTokenName(StpUtil.getTokenName());
vo.setTokenValue(StpUtil.getTokenValue());
vo.setAiExpireTime(systemTenant.getAiExpireTime());
fillFeatureInfo(vo, user, systemTenant);
return vo;
case HOST:
BeanUtil.copyProperties(user, vo);
vo.setTokenName(StpUtil.getTokenName());
vo.setTokenValue(StpUtil.getTokenValue());
vo.setExpireTime(systemTenant.getExpireTime());
vo.setBrotherExpireTime(systemTenant.getBrotherExpireTime());
vo.setAiExpireTime(systemTenant.getAiExpireTime());
vo.setCrawl(user.getCrawl());
vo.setAiChat(user.getAiChat());
vo.setBigBrother(user.getBigBrother());
fillFeatureInfo(vo, user, systemTenant);
return vo;
}
return null;
}
private void fillFeatureInfo(SystemUsersVO vo, SystemUsers user, SystemTenant tenant) {
vo.setExpireTime(tenant.getExpireTime());
vo.setCrawlExpireTime(tenant.getCrawlExpireTime());
vo.setBrotherExpireTime(tenant.getBrotherExpireTime());
vo.setAiExpireTime(tenant.getAiExpireTime());
vo.setAiReplayEnabled(isEnabled(user.getAiReplay()));
vo.setCrawlEnabled(isEnabled(user.getCrawl()));
vo.setBigBrotherEnabled(isEnabled(user.getBigBrother()));
vo.setAiChatEnabled(isEnabled(user.getAiChat()));
vo.setWebAiEnabled(isEnabled(user.getWebAi()));
}
private boolean isEnabled(Byte value) {
return value != null && value == 1;
}
/**
* 校验用户登录信息
*

View File

@@ -0,0 +1,26 @@
package com.yupi.springbootinit.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import org.springframework.stereotype.Service;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.yupi.springbootinit.model.entity.ServerCustomServiceInfo;
import com.yupi.springbootinit.mapper.ServerCustomServiceInfoMapper;
import com.yupi.springbootinit.service.ServerCustomServiceInfoService;
import java.util.List;
/*
* @author: ziin
* @date: 2026/3/3 21:32
*/
@Service
public class ServerCustomServiceInfoServiceImpl extends ServiceImpl<ServerCustomServiceInfoMapper, ServerCustomServiceInfo> implements ServerCustomServiceInfoService{
@Override
public List<ServerCustomServiceInfo> listNotDeleted() {
QueryWrapper<ServerCustomServiceInfo> queryWrapper = new QueryWrapper<>();
queryWrapper.eq("deleted", false);
return this.list(queryWrapper);
}
}

View File

@@ -0,0 +1,83 @@
package com.yupi.springbootinit.service.impl;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.yupi.springbootinit.mapper.SystemLoginLogMapper;
import com.yupi.springbootinit.model.dto.user.SystemUsersDTO;
import com.yupi.springbootinit.model.entity.SystemLoginLog;
import com.yupi.springbootinit.model.vo.user.SystemUsersVO;
import com.yupi.springbootinit.service.SystemLoginLogService;
import com.yupi.springbootinit.utils.NetUtils;
import org.springframework.stereotype.Service;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import javax.servlet.http.HttpServletRequest;
import java.util.Date;
import java.util.UUID;
/*
* @author: ziin
* @date: 2026/3/10 11:32
*/
@Service
public class SystemLoginLogServiceImpl extends ServiceImpl<SystemLoginLogMapper, SystemLoginLog> implements SystemLoginLogService{
private static final Long HOST_LOGIN_LOG_TYPE = 1L;
private static final Byte LOGIN_SUCCESS = 1;
private static final Byte LOGIN_FAILURE = 0;
private static final String USER_AGENT_HEADER = "User-Agent";
private static final String TRACE_ID_HEADER = "X-Trace-Id";
private static final String SYSTEM_OPERATOR = "system";
@Override
public void recordDoLoginLog(SystemUsersDTO usersDTO, SystemUsersVO usersVO, boolean success) {
HttpServletRequest request = getCurrentRequest();
Date now = new Date();
SystemLoginLog loginLog = new SystemLoginLog();
loginLog.setLogType(HOST_LOGIN_LOG_TYPE);
loginLog.setTraceId(resolveTraceId(request));
loginLog.setUserId(usersVO == null ? null : usersVO.getId());
loginLog.setUsername(resolveUsername(usersDTO, usersVO));
loginLog.setResult(success ? LOGIN_SUCCESS : LOGIN_FAILURE);
loginLog.setUserIp(NetUtils.getIpAddress(request));
loginLog.setUserAgent(request.getHeader(USER_AGENT_HEADER));
loginLog.setCreator(SYSTEM_OPERATOR);
loginLog.setCreateTime(now);
loginLog.setUpdater(SYSTEM_OPERATOR);
loginLog.setUpdateTime(now);
loginLog.setDeleted(Boolean.FALSE);
loginLog.setTenantId(resolveTenantId(usersDTO, usersVO));
boolean saved = save(loginLog);
if (!saved) {
throw new IllegalStateException("保存 doLogin 登录日志失败");
}
}
private HttpServletRequest getCurrentRequest() {
ServletRequestAttributes attributes =
(ServletRequestAttributes) RequestContextHolder.currentRequestAttributes();
return attributes.getRequest();
}
private String resolveTraceId(HttpServletRequest request) {
String traceId = request.getHeader(TRACE_ID_HEADER);
if (traceId == null || traceId.isBlank()) {
return UUID.randomUUID().toString();
}
return traceId;
}
private String resolveUsername(SystemUsersDTO usersDTO, SystemUsersVO usersVO) {
if (usersVO != null && usersVO.getUsername() != null) {
return usersVO.getUsername();
}
return usersDTO.getUsername();
}
private Long resolveTenantId(SystemUsersDTO usersDTO, SystemUsersVO usersVO) {
if (usersVO != null && usersVO.getTenantId() != null) {
return usersVO.getTenantId();
}
return usersDTO.getTenantId();
}
}

View File

@@ -1,22 +1,23 @@
package com.yupi.springbootinit.service.impl;
import cn.dev33.satoken.stp.StpUtil;
import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.date.DateUnit;
import cn.hutool.core.date.DateUtil;
import cn.hutool.crypto.SecureUtil;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.yupi.springbootinit.common.ErrorCode;
import com.yupi.springbootinit.exception.BusinessException;
import com.yupi.springbootinit.mapper.SystemTenantMapper;
import com.yupi.springbootinit.model.entity.SystemTenant;
import com.yupi.springbootinit.model.entity.SystemUsers;
import com.yupi.springbootinit.service.SystemTenantService;
import com.yupi.springbootinit.mapper.SystemUsersMapper;
import com.yupi.springbootinit.model.vo.user.SystemUsersVO;
import com.yupi.springbootinit.service.SystemUsersService;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.beans.factory.annotation.Autowired;
import com.yupi.springbootinit.mapper.SystemUsersMapper;
import com.yupi.springbootinit.service.SystemUsersService;
import javax.annotation.Resource;
/*
* @author: ziin
@@ -50,14 +51,14 @@ public class SystemUsersServiceImpl extends ServiceImpl<SystemUsersMapper,System
@Override
public boolean isExpired(Long tendId) {
SystemTenant systemTenant = systemTenantMapper.selectById(tendId);
long between = DateUtil.between(systemTenant.getExpireTime(), DateUtil.date(), DateUnit.DAY);
long between = DateUtil.between(DateUtil.date(systemTenant.getExpireTime()), DateUtil.date(), DateUnit.DAY);
return between < 0;
}
@Override
public Long getTenantExpiredTime(Long tenantId) {
SystemTenant systemTenant = systemTenantMapper.selectById(tenantId);
long between = DateUtil.between(systemTenant.getExpireTime(), DateUtil.date(), DateUnit.SECOND);
long between = DateUtil.between(DateUtil.date(systemTenant.getExpireTime()), DateUtil.date(), DateUnit.SECOND);
if (between > 0 ) {
return between;
}
@@ -90,5 +91,43 @@ public class SystemUsersServiceImpl extends ServiceImpl<SystemUsersMapper,System
}
@Override
public SystemUsersVO getCurrentUserInfo() {
Long userId = StpUtil.getLoginIdAsLong();
SystemUsers user = baseMapper.selectById(userId);
if (user == null || Boolean.TRUE.equals(user.getDeleted())) {
throw new BusinessException(ErrorCode.NOT_FOUND_ERROR, "用户不存在");
}
SystemTenant tenant = systemTenantMapper.selectById(user.getTenantId());
if (tenant == null || Boolean.TRUE.equals(tenant.getDeleted())) {
throw new BusinessException(ErrorCode.TENANT_NAME_NOT_EXISTS);
}
return buildSystemUsersVO(user, tenant);
}
private SystemUsersVO buildSystemUsersVO(SystemUsers user, SystemTenant tenant) {
SystemUsersVO userVO = new SystemUsersVO();
BeanUtil.copyProperties(user, userVO);
userVO.setTokenName(StpUtil.getTokenName());
userVO.setTokenValue(StpUtil.getTokenValue());
userVO.setExpireTime(tenant.getExpireTime());
userVO.setCrawlExpireTime(tenant.getCrawlExpireTime());
userVO.setBrotherExpireTime(tenant.getBrotherExpireTime());
userVO.setAiExpireTime(tenant.getAiExpireTime());
fillFeatureEnabled(userVO, user);
return userVO;
}
private void fillFeatureEnabled(SystemUsersVO userVO, SystemUsers user) {
userVO.setAiReplayEnabled(isEnabled(user.getAiReplay()));
userVO.setCrawlEnabled(isEnabled(user.getCrawl()));
userVO.setBigBrotherEnabled(isEnabled(user.getBigBrother()));
userVO.setAiChatEnabled(isEnabled(user.getAiChat()));
userVO.setWebAiEnabled(isEnabled(user.getWebAi()));
}
private boolean isEnabled(Byte value) {
return value != null && value == 1;
}
}

View File

@@ -3,6 +3,7 @@ package com.yupi.springbootinit.utils;
import cn.hutool.core.date.DateUnit;
import cn.hutool.core.date.DateUtil;
import java.time.LocalDateTime;
import java.util.Date;
/*
@@ -11,7 +12,8 @@ import java.util.Date;
*/
public class DateUtils {
public static Long dateBetween(Date date1, Date date2) {
return DateUtil.between(date1, date2, DateUnit.SECOND);
public static Long dateBetween(LocalDateTime date1, LocalDateTime date2) {
return DateUtil.between(DateUtil.date(date1), DateUtil.date(date2), DateUnit.SECOND);
}
}

View File

@@ -1,7 +1,9 @@
package com.yupi.springbootinit.utils;
import java.net.InetAddress;
import java.net.UnknownHostException;
import javax.servlet.http.HttpServletRequest;
import org.apache.commons.lang3.StringUtils;
/**
* 网络工具类
@@ -11,6 +13,23 @@ import javax.servlet.http.HttpServletRequest;
*/
public class NetUtils {
private static final String UNKNOWN = "unknown";
private static final String LOCALHOST_IPV4 = "127.0.0.1";
private static final String LOCALHOST_IPV6 = "0:0:0:0:0:0:0:1";
private static final String LOCALHOST_IPV6_SHORT = "::1";
private static final String[] IP_HEADER_CANDIDATES = {
"x-forwarded-for",
"X-Forwarded-For",
"X-Real-IP",
"Proxy-Client-IP",
"WL-Proxy-Client-IP",
"HTTP_X_FORWARDED_FOR"
};
/**
* 获取客户端 IP 地址
*
@@ -18,38 +37,57 @@ public class NetUtils {
* @return
*/
public static String getIpAddress(HttpServletRequest request) {
String ip = request.getHeader("x-forwarded-for");
if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("Proxy-Client-IP");
String headerIp = getIpFromHeaders(request);
if (StringUtils.isNotBlank(headerIp)) {
return headerIp;
}
if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("WL-Proxy-Client-IP");
}
if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
ip = request.getRemoteAddr();
if (ip.equals("127.0.0.1")) {
// 根据网卡取本机配置的 IP
InetAddress inet = null;
try {
inet = InetAddress.getLocalHost();
} catch (Exception e) {
e.printStackTrace();
}
if (inet != null) {
ip = inet.getHostAddress();
}
return normalizeLocalIp(request.getRemoteAddr());
}
private static String getIpFromHeaders(HttpServletRequest request) {
for (String headerName : IP_HEADER_CANDIDATES) {
String headerValue = request.getHeader(headerName);
String ip = extractFirstValidIp(headerValue);
if (StringUtils.isNotBlank(ip)) {
return ip;
}
}
// 多个代理的情况第一个IP为客户端真实IP,多个IP按照','分割
if (ip != null && ip.length() > 15) {
if (ip.indexOf(",") > 0) {
ip = ip.substring(0, ip.indexOf(","));
return null;
}
private static String extractFirstValidIp(String ipValue) {
if (StringUtils.isBlank(ipValue)) {
return null;
}
String[] ipList = ipValue.split(",");
for (String ip : ipList) {
String trimmedIp = StringUtils.trim(ip);
if (StringUtils.isNotBlank(trimmedIp) && !UNKNOWN.equalsIgnoreCase(trimmedIp)) {
return trimmedIp;
}
}
if (ip == null) {
return "127.0.0.1";
return null;
}
private static String normalizeLocalIp(String remoteAddr) {
if (StringUtils.isBlank(remoteAddr)) {
return LOCALHOST_IPV4;
}
return ip;
if (!isLocalhost(remoteAddr)) {
return remoteAddr;
}
try {
InetAddress inetAddress = InetAddress.getLocalHost();
return inetAddress.getHostAddress();
} catch (UnknownHostException e) {
return remoteAddr;
}
}
private static boolean isLocalhost(String ip) {
return LOCALHOST_IPV4.equals(ip)
|| LOCALHOST_IPV6.equals(ip)
|| LOCALHOST_IPV6_SHORT.equals(ip);
}
}

View File

@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.yupi.springbootinit.mapper.ServerCustomServiceInfoMapper">
<resultMap id="BaseResultMap" type="com.yupi.springbootinit.model.entity.ServerCustomServiceInfo">
<!--@mbg.generated-->
<!--@Table server_custom_service_info-->
<id column="id" jdbcType="BIGINT" property="id" />
<result column="name" jdbcType="VARCHAR" property="name" />
<result column="avater" jdbcType="VARCHAR" property="avater" />
<result column="description" jdbcType="VARCHAR" property="description" />
<result column="concat" jdbcType="VARCHAR" property="concat" />
<result column="phone" jdbcType="VARCHAR" property="phone" />
<result column="creator" jdbcType="VARCHAR" property="creator" />
<result column="create_time" jdbcType="TIMESTAMP" property="createTime" />
<result column="updater" jdbcType="BIGINT" property="updater" />
<result column="update_time" jdbcType="TIMESTAMP" property="updateTime" />
<result column="deleted" jdbcType="BIT" property="deleted" />
<result column="tenant_id" jdbcType="BIGINT" property="tenantId" />
</resultMap>
<sql id="Base_Column_List">
<!--@mbg.generated-->
id, `name`, avater, description, `concat`, phone, creator, create_time, updater,
update_time, deleted, tenant_id
</sql>
</mapper>

View File

@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.yupi.springbootinit.mapper.SystemLoginLogMapper">
<resultMap id="BaseResultMap" type="com.yupi.springbootinit.model.entity.SystemLoginLog">
<!--@mbg.generated-->
<!--@Table system_login_log-->
<id column="id" jdbcType="BIGINT" property="id" />
<result column="log_type" jdbcType="BIGINT" property="logType" />
<result column="trace_id" jdbcType="VARCHAR" property="traceId" />
<result column="user_id" jdbcType="BIGINT" property="userId" />
<result column="user_type" jdbcType="TINYINT" property="userType" />
<result column="username" jdbcType="VARCHAR" property="username" />
<result column="result" jdbcType="TINYINT" property="result" />
<result column="user_ip" jdbcType="VARCHAR" property="userIp" />
<result column="user_agent" jdbcType="VARCHAR" property="userAgent" />
<result column="creator" jdbcType="VARCHAR" property="creator" />
<result column="create_time" jdbcType="TIMESTAMP" property="createTime" />
<result column="updater" jdbcType="VARCHAR" property="updater" />
<result column="update_time" jdbcType="TIMESTAMP" property="updateTime" />
<result column="deleted" jdbcType="BIT" property="deleted" />
<result column="tenant_id" jdbcType="BIGINT" property="tenantId" />
</resultMap>
<sql id="Base_Column_List">
<!--@mbg.generated-->
id, log_type, trace_id, user_id, user_type, username, `result`, user_ip, user_agent,
creator, create_time, updater, update_time, deleted, tenant_id
</sql>
</mapper>

Binary file not shown.