1
This commit is contained in:
@@ -2,8 +2,11 @@ package com.gameplatform.server.controller.link;
|
||||
|
||||
import com.gameplatform.server.model.dto.link.LinkGenerateRequest;
|
||||
import com.gameplatform.server.model.dto.link.LinkGenerateResponse;
|
||||
import com.gameplatform.server.model.dto.link.LinkListRequest;
|
||||
import com.gameplatform.server.model.dto.link.LinkListResponse;
|
||||
import com.gameplatform.server.model.dto.link.LinkStatusResponse;
|
||||
import com.gameplatform.server.service.link.LinkGenerationService;
|
||||
import com.gameplatform.server.service.link.LinkListService;
|
||||
import com.gameplatform.server.service.link.LinkStatusService;
|
||||
import io.jsonwebtoken.Claims;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
@@ -27,10 +30,51 @@ public class LinkController {
|
||||
|
||||
private final LinkGenerationService linkGenerationService;
|
||||
private final LinkStatusService linkStatusService;
|
||||
private final LinkListService linkListService;
|
||||
|
||||
public LinkController(LinkGenerationService linkGenerationService, LinkStatusService linkStatusService) {
|
||||
public LinkController(LinkGenerationService linkGenerationService,
|
||||
LinkStatusService linkStatusService,
|
||||
LinkListService linkListService) {
|
||||
this.linkGenerationService = linkGenerationService;
|
||||
this.linkStatusService = linkStatusService;
|
||||
this.linkListService = linkListService;
|
||||
}
|
||||
|
||||
@GetMapping("/list")
|
||||
@Operation(summary = "查询链接列表", description = "分页查询用户生成的链接列表,支持按状态、批次ID等条件过滤和排序")
|
||||
public Mono<LinkListResponse> getLinkList(@Valid LinkListRequest request, Authentication authentication) {
|
||||
log.info("=== 开始查询链接列表 ===");
|
||||
log.info("请求参数: page={}, pageSize={}, status={}, batchId={}, isExpired={}, sortBy={}, sortDir={}",
|
||||
request.getPage(), request.getPageSize(), request.getStatus(),
|
||||
request.getBatchId(), request.getIsExpired(), request.getSortBy(), request.getSortDir());
|
||||
|
||||
if (authentication == null) {
|
||||
log.error("=== 认证失败:Authentication为空 ===");
|
||||
return Mono.error(new IllegalArgumentException("用户未认证:Authentication为空"));
|
||||
}
|
||||
|
||||
log.info("认证用户: {}", authentication.getName());
|
||||
|
||||
// 获取用户ID
|
||||
Claims claims = (Claims) authentication.getCredentials();
|
||||
Long agentId = claims.get("userId", Long.class);
|
||||
String userType = claims.get("userType", String.class);
|
||||
|
||||
log.info("用户信息: agentId={}, userType={}", agentId, userType);
|
||||
|
||||
if (agentId == null) {
|
||||
log.error("=== 无法获取用户ID ===");
|
||||
return Mono.error(new IllegalArgumentException("无法获取用户ID"));
|
||||
}
|
||||
|
||||
return linkListService.getLinkList(agentId, request)
|
||||
.doOnSuccess(response -> {
|
||||
log.info("链接列表查询成功: 总数={}, 当前页={}, 总页数={}",
|
||||
response.getTotal(), response.getPage(), response.getTotalPages());
|
||||
})
|
||||
.doOnError(error -> {
|
||||
log.error("链接列表查询失败: agentId={}, error={}", agentId, error.getMessage(), error);
|
||||
});
|
||||
}
|
||||
|
||||
@PostMapping("/generate")
|
||||
|
||||
@@ -20,4 +20,9 @@ public interface LinkBatchMapper {
|
||||
@Param("offset") int offset);
|
||||
|
||||
long countAll();
|
||||
|
||||
/**
|
||||
* 根据ID列表查询批次信息
|
||||
*/
|
||||
List<LinkBatch> findByIds(@Param("ids") List<Long> ids);
|
||||
}
|
||||
|
||||
@@ -47,4 +47,24 @@ public interface LinkTaskMapper {
|
||||
|
||||
List<LinkTask> findExpiredTasks(@Param("expireTime") LocalDateTime expireTime,
|
||||
@Param("size") int size);
|
||||
|
||||
/**
|
||||
* 分页查询代理的链接任务(支持条件过滤和排序)
|
||||
*/
|
||||
List<LinkTask> findLinkTasksWithConditions(@Param("agentId") Long agentId,
|
||||
@Param("status") String status,
|
||||
@Param("batchId") Long batchId,
|
||||
@Param("isExpired") Boolean isExpired,
|
||||
@Param("sortBy") String sortBy,
|
||||
@Param("sortDir") String sortDir,
|
||||
@Param("offset") int offset,
|
||||
@Param("size") int size);
|
||||
|
||||
/**
|
||||
* 统计满足条件的链接任务数量
|
||||
*/
|
||||
long countLinkTasksWithConditions(@Param("agentId") Long agentId,
|
||||
@Param("status") String status,
|
||||
@Param("batchId") Long batchId,
|
||||
@Param("isExpired") Boolean isExpired);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.gameplatform.server.mapper.agent;
|
||||
|
||||
import com.mybatisflex.core.BaseMapper;
|
||||
import com.gameplatform.server.model.entity.agent.LinkBatch;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
/**
|
||||
* MyBatis-Flex Mapper for LinkBatch
|
||||
* 提供高性能的查询构建器和自动生成的基础CRUD操作
|
||||
*/
|
||||
@Mapper
|
||||
public interface LinkBatchFlexMapper extends BaseMapper<LinkBatch> {
|
||||
// MyBatis-Flex 会自动提供完整的CRUD操作
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.gameplatform.server.mapper.agent;
|
||||
|
||||
import com.mybatisflex.core.BaseMapper;
|
||||
import com.gameplatform.server.model.entity.agent.LinkTask;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
/**
|
||||
* MyBatis-Flex Mapper for LinkTask
|
||||
* 提供高性能的查询构建器和自动生成的基础CRUD操作
|
||||
*/
|
||||
@Mapper
|
||||
public interface LinkTaskFlexMapper extends BaseMapper<LinkTask> {
|
||||
// MyBatis-Flex 会自动提供以下方法:
|
||||
// - selectById
|
||||
// - selectByMap
|
||||
// - selectByCondition
|
||||
// - selectListByCondition
|
||||
// - selectCountByCondition
|
||||
// - selectPageByCondition
|
||||
// - insert
|
||||
// - insertBatch
|
||||
// - update
|
||||
// - updateByCondition
|
||||
// - delete
|
||||
// - deleteByCondition
|
||||
// 等等...
|
||||
|
||||
// 如果需要自定义 SQL,可以在这里添加方法并在 XML 文件中实现
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package com.gameplatform.server.model.dto.link;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 链接列表项DTO
|
||||
*/
|
||||
@Data
|
||||
@Schema(description = "链接列表项")
|
||||
public class LinkListItem {
|
||||
|
||||
@Schema(description = "链接编号", example = "ABC12345")
|
||||
private String codeNo;
|
||||
|
||||
@Schema(description = "批次ID", example = "123")
|
||||
private Long batchId;
|
||||
|
||||
@Schema(description = "链接状态", example = "NEW")
|
||||
private String status;
|
||||
|
||||
@Schema(description = "状态描述", example = "新建")
|
||||
private String statusDesc;
|
||||
|
||||
@Schema(description = "过期时间", example = "2024-01-15T16:30:00")
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
private LocalDateTime expireAt;
|
||||
|
||||
@Schema(description = "是否已过期", example = "false")
|
||||
private Boolean isExpired;
|
||||
|
||||
@Schema(description = "剩余时间(秒)", example = "3600")
|
||||
private Long remainingSeconds;
|
||||
|
||||
@Schema(description = "每次奖励数量", example = "50")
|
||||
private Integer quantity;
|
||||
|
||||
@Schema(description = "执行次数", example = "5")
|
||||
private Integer times;
|
||||
|
||||
@Schema(description = "总奖励点数", example = "250")
|
||||
private Integer totalPoints;
|
||||
|
||||
@Schema(description = "分配区域", example = "Q")
|
||||
private String region;
|
||||
|
||||
@Schema(description = "机器ID", example = "MACHINE001")
|
||||
private String machineId;
|
||||
|
||||
@Schema(description = "登录时间", example = "2024-01-15T14:30:00")
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
private LocalDateTime loginAt;
|
||||
|
||||
@Schema(description = "创建时间", example = "2024-01-15T12:00:00")
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@Schema(description = "更新时间", example = "2024-01-15T12:00:00")
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.gameplatform.server.model.dto.link;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 链接列表查询请求DTO
|
||||
*/
|
||||
@Data
|
||||
@Schema(description = "链接列表查询请求")
|
||||
public class LinkListRequest {
|
||||
|
||||
@Schema(description = "页码,从1开始", example = "1")
|
||||
@Min(value = 1, message = "页码必须大于0")
|
||||
private Integer page = 1;
|
||||
|
||||
@Schema(description = "每页大小", example = "20")
|
||||
@Min(value = 1, message = "每页大小必须大于0")
|
||||
@Max(value = 100, message = "每页大小不能超过100")
|
||||
private Integer pageSize = 20;
|
||||
|
||||
@Schema(description = "链接状态过滤", example = "NEW")
|
||||
private String status;
|
||||
|
||||
@Schema(description = "批次ID过滤", example = "123")
|
||||
private Long batchId;
|
||||
|
||||
@Schema(description = "是否过期过滤", example = "false")
|
||||
private Boolean isExpired;
|
||||
|
||||
@Schema(description = "排序字段", example = "createdAt", allowableValues = {"createdAt", "updatedAt", "expireAt"})
|
||||
private String sortBy = "createdAt";
|
||||
|
||||
@Schema(description = "排序方向", example = "DESC", allowableValues = {"ASC", "DESC"})
|
||||
private String sortDir = "DESC";
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.gameplatform.server.model.dto.link;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 链接列表响应DTO
|
||||
*/
|
||||
@Data
|
||||
@Schema(description = "链接列表响应")
|
||||
public class LinkListResponse {
|
||||
|
||||
@Schema(description = "链接列表")
|
||||
private List<LinkListItem> items;
|
||||
|
||||
@Schema(description = "总记录数", example = "150")
|
||||
private Long total;
|
||||
|
||||
@Schema(description = "当前页码", example = "1")
|
||||
private Integer page;
|
||||
|
||||
@Schema(description = "每页大小", example = "20")
|
||||
private Integer pageSize;
|
||||
|
||||
@Schema(description = "总页数", example = "8")
|
||||
private Integer totalPages;
|
||||
|
||||
@Schema(description = "是否有下一页", example = "true")
|
||||
private Boolean hasNext;
|
||||
|
||||
@Schema(description = "是否有上一页", example = "false")
|
||||
private Boolean hasPrev;
|
||||
}
|
||||
@@ -98,4 +98,3 @@ public class LinkStatusResponse {
|
||||
public LocalDateTime getUpdatedAt() { return updatedAt; }
|
||||
public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; }
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
package com.gameplatform.server.service.link;
|
||||
|
||||
import com.gameplatform.server.mapper.agent.LinkBatchMapper;
|
||||
import com.gameplatform.server.mapper.agent.LinkTaskMapper;
|
||||
import com.gameplatform.server.model.dto.link.LinkListItem;
|
||||
import com.gameplatform.server.model.dto.link.LinkListRequest;
|
||||
import com.gameplatform.server.model.dto.link.LinkListResponse;
|
||||
import com.gameplatform.server.model.entity.agent.LinkBatch;
|
||||
import com.gameplatform.server.model.entity.agent.LinkTask;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 链接列表查询服务
|
||||
*/
|
||||
@Service
|
||||
public class LinkListService {
|
||||
private static final Logger log = LoggerFactory.getLogger(LinkListService.class);
|
||||
|
||||
private final LinkTaskMapper linkTaskMapper;
|
||||
private final LinkBatchMapper linkBatchMapper;
|
||||
|
||||
public LinkListService(LinkTaskMapper linkTaskMapper, LinkBatchMapper linkBatchMapper) {
|
||||
this.linkTaskMapper = linkTaskMapper;
|
||||
this.linkBatchMapper = linkBatchMapper;
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页查询链接列表
|
||||
*/
|
||||
public Mono<LinkListResponse> getLinkList(Long agentId, LinkListRequest request) {
|
||||
return Mono.fromCallable(() -> {
|
||||
log.info("=== 开始查询链接列表 ===");
|
||||
log.info("代理ID: {}, 请求参数: {}", agentId, request);
|
||||
|
||||
// 计算偏移量
|
||||
int offset = (request.getPage() - 1) * request.getPageSize();
|
||||
|
||||
// 验证排序参数
|
||||
validateSortParams(request);
|
||||
|
||||
// 查询链接任务列表
|
||||
List<LinkTask> linkTasks = linkTaskMapper.findLinkTasksWithConditions(
|
||||
agentId,
|
||||
request.getStatus(),
|
||||
request.getBatchId(),
|
||||
request.getIsExpired(),
|
||||
request.getSortBy(),
|
||||
request.getSortDir(),
|
||||
offset,
|
||||
request.getPageSize()
|
||||
);
|
||||
|
||||
// 查询总数
|
||||
long total = linkTaskMapper.countLinkTasksWithConditions(
|
||||
agentId,
|
||||
request.getStatus(),
|
||||
request.getBatchId(),
|
||||
request.getIsExpired()
|
||||
);
|
||||
|
||||
log.info("查询到 {} 条记录,总数: {}", linkTasks.size(), total);
|
||||
|
||||
// 获取所有相关的批次信息
|
||||
Map<Long, LinkBatch> batchMap = getBatchMap(linkTasks);
|
||||
|
||||
// 转换为DTO
|
||||
List<LinkListItem> items = linkTasks.stream()
|
||||
.map(task -> convertToListItem(task, batchMap.get(task.getBatchId())))
|
||||
.collect(Collectors.toList());
|
||||
|
||||
// 构建响应
|
||||
LinkListResponse response = new LinkListResponse();
|
||||
response.setItems(items);
|
||||
response.setTotal(total);
|
||||
response.setPage(request.getPage());
|
||||
response.setPageSize(request.getPageSize());
|
||||
response.setTotalPages((int) Math.ceil((double) total / request.getPageSize()));
|
||||
response.setHasNext(request.getPage() * request.getPageSize() < total);
|
||||
response.setHasPrev(request.getPage() > 1);
|
||||
|
||||
log.info("链接列表查询完成: 当前页={}, 每页大小={}, 总页数={}",
|
||||
response.getPage(), response.getPageSize(), response.getTotalPages());
|
||||
|
||||
return response;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证排序参数
|
||||
*/
|
||||
private void validateSortParams(LinkListRequest request) {
|
||||
String sortBy = request.getSortBy();
|
||||
if (sortBy != null && !List.of("createdAt", "updatedAt", "expireAt").contains(sortBy)) {
|
||||
request.setSortBy("createdAt");
|
||||
log.warn("无效的排序字段 '{}', 使用默认值 'createdAt'", sortBy);
|
||||
}
|
||||
|
||||
String sortDir = request.getSortDir();
|
||||
if (sortDir != null && !List.of("ASC", "DESC").contains(sortDir.toUpperCase())) {
|
||||
request.setSortDir("DESC");
|
||||
log.warn("无效的排序方向 '{}', 使用默认值 'DESC'", sortDir);
|
||||
} else if (sortDir != null) {
|
||||
request.setSortDir(sortDir.toUpperCase());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取批次信息映射
|
||||
*/
|
||||
private Map<Long, LinkBatch> getBatchMap(List<LinkTask> linkTasks) {
|
||||
if (linkTasks.isEmpty()) {
|
||||
return Map.of();
|
||||
}
|
||||
|
||||
List<Long> batchIds = linkTasks.stream()
|
||||
.map(LinkTask::getBatchId)
|
||||
.distinct()
|
||||
.collect(Collectors.toList());
|
||||
|
||||
try {
|
||||
List<LinkBatch> batches = linkBatchMapper.findByIds(batchIds);
|
||||
return batches.stream()
|
||||
.collect(Collectors.toMap(LinkBatch::getId, batch -> batch));
|
||||
} catch (Exception e) {
|
||||
log.error("获取批次信息失败: {}", e.getMessage(), e);
|
||||
return Map.of();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换为列表项DTO
|
||||
*/
|
||||
private LinkListItem convertToListItem(LinkTask task, LinkBatch batch) {
|
||||
LinkListItem item = new LinkListItem();
|
||||
|
||||
// 基本信息
|
||||
item.setCodeNo(task.getCodeNo());
|
||||
item.setBatchId(task.getBatchId());
|
||||
item.setStatus(task.getStatus());
|
||||
item.setStatusDesc(getStatusDesc(task.getStatus()));
|
||||
item.setExpireAt(task.getExpireAt());
|
||||
item.setRegion(task.getRegion());
|
||||
item.setMachineId(task.getMachineId());
|
||||
item.setLoginAt(task.getLoginAt());
|
||||
item.setCreatedAt(task.getCreatedAt());
|
||||
item.setUpdatedAt(task.getUpdatedAt());
|
||||
|
||||
// 计算是否过期和剩余时间
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
boolean isExpired = task.getExpireAt().isBefore(now);
|
||||
item.setIsExpired(isExpired);
|
||||
|
||||
if (isExpired) {
|
||||
item.setRemainingSeconds(0L);
|
||||
} else {
|
||||
long remainingSeconds = ChronoUnit.SECONDS.between(now, task.getExpireAt());
|
||||
item.setRemainingSeconds(Math.max(0, remainingSeconds));
|
||||
}
|
||||
|
||||
// 从批次信息中获取任务详情
|
||||
if (batch != null) {
|
||||
item.setQuantity(batch.getQuantity());
|
||||
item.setTimes(batch.getTimes());
|
||||
item.setTotalPoints(batch.getQuantity() * batch.getTimes());
|
||||
} else {
|
||||
// 如果批次信息不存在,设置默认值
|
||||
log.warn("批次信息不存在: batchId={}", task.getBatchId());
|
||||
item.setQuantity(0);
|
||||
item.setTimes(0);
|
||||
item.setTotalPoints(0);
|
||||
}
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取状态描述
|
||||
*/
|
||||
private String getStatusDesc(String status) {
|
||||
return switch (status) {
|
||||
case "NEW" -> "新建";
|
||||
case "USING" -> "使用中";
|
||||
case "LOGGED_IN" -> "已登录";
|
||||
case "REFUNDED" -> "已退款";
|
||||
case "EXPIRED" -> "已过期";
|
||||
default -> "未知状态";
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -122,4 +122,3 @@ public class LinkStatusService {
|
||||
.onErrorReturn(false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -44,4 +44,13 @@
|
||||
<select id="countAll" resultType="long">
|
||||
SELECT COUNT(1) FROM link_batch
|
||||
</select>
|
||||
|
||||
<select id="findByIds" resultMap="LinkBatchMap">
|
||||
SELECT id, agent_id, quantity, times, operator_id, created_at
|
||||
FROM link_batch
|
||||
WHERE id IN
|
||||
<foreach item="id" collection="ids" open="(" separator="," close=")">
|
||||
#{id}
|
||||
</foreach>
|
||||
</select>
|
||||
</mapper>
|
||||
|
||||
@@ -110,4 +110,64 @@
|
||||
ORDER BY expire_at ASC
|
||||
LIMIT #{size}
|
||||
</select>
|
||||
|
||||
<select id="findLinkTasksWithConditions" resultMap="LinkTaskMap">
|
||||
SELECT id, batch_id, agent_id, code_no, token_hash, expire_at, status, region, machine_id, login_at, refund_at, revoked_at, created_at, updated_at
|
||||
FROM link_task
|
||||
<where>
|
||||
agent_id = #{agentId}
|
||||
<if test="status != null and status != ''">
|
||||
AND status = #{status}
|
||||
</if>
|
||||
<if test="batchId != null">
|
||||
AND batch_id = #{batchId}
|
||||
</if>
|
||||
<if test="isExpired != null">
|
||||
<choose>
|
||||
<when test="isExpired == true">
|
||||
AND expire_at <= NOW()
|
||||
</when>
|
||||
<otherwise>
|
||||
AND expire_at > NOW()
|
||||
</otherwise>
|
||||
</choose>
|
||||
</if>
|
||||
</where>
|
||||
<choose>
|
||||
<when test="sortBy == 'expireAt'">
|
||||
ORDER BY expire_at ${sortDir}
|
||||
</when>
|
||||
<when test="sortBy == 'updatedAt'">
|
||||
ORDER BY updated_at ${sortDir}
|
||||
</when>
|
||||
<otherwise>
|
||||
ORDER BY created_at ${sortDir}
|
||||
</otherwise>
|
||||
</choose>
|
||||
LIMIT #{size} OFFSET #{offset}
|
||||
</select>
|
||||
|
||||
<select id="countLinkTasksWithConditions" resultType="long">
|
||||
SELECT COUNT(1)
|
||||
FROM link_task
|
||||
<where>
|
||||
agent_id = #{agentId}
|
||||
<if test="status != null and status != ''">
|
||||
AND status = #{status}
|
||||
</if>
|
||||
<if test="batchId != null">
|
||||
AND batch_id = #{batchId}
|
||||
</if>
|
||||
<if test="isExpired != null">
|
||||
<choose>
|
||||
<when test="isExpired == true">
|
||||
AND expire_at <= NOW()
|
||||
</when>
|
||||
<otherwise>
|
||||
AND expire_at > NOW()
|
||||
</otherwise>
|
||||
</choose>
|
||||
</if>
|
||||
</where>
|
||||
</select>
|
||||
</mapper>
|
||||
|
||||
Reference in New Issue
Block a user