diff --git a/.gitignore b/.gitignore index 0f35273..86b114d 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,6 @@ /common/target/ /merchant-service/target/ /distribution-service/target/ -./merchant-service/target/ \ No newline at end of file +./merchant-service/target/ +/identity-service/target/ +/identity-domain-api/target/ diff --git a/common/src/main/java/com/tus/coupon/common/context/HeaderUserContextResolver.java b/common/src/main/java/com/tus/coupon/common/context/HeaderUserContextResolver.java new file mode 100644 index 0000000..cca3292 --- /dev/null +++ b/common/src/main/java/com/tus/coupon/common/context/HeaderUserContextResolver.java @@ -0,0 +1,47 @@ +package com.tus.coupon.common.context; + +import cn.hutool.core.util.StrUtil; +import jakarta.servlet.http.HttpServletRequest; + +import java.util.UUID; + +public class HeaderUserContextResolver implements UserContextResolver { + public static final String HEADER_USER_ID = "X-User-Id"; + public static final String HEADER_USERNAME = "X-Username"; + public static final String HEADER_SHOP_NUMBER = "X-Shop-Number"; + public static final String HEADER_TENANT_ID = "X-Tenant-Id"; + public static final String HEADER_TRACE_ID = "X-Trace-Id"; + + + @Override + public UserInfoDTO resolve(HttpServletRequest request) { + String userId = request.getHeader(HEADER_USER_ID); + String username = request.getHeader(HEADER_USERNAME); + String shopNumberStr = request.getHeader(HEADER_SHOP_NUMBER); + String tenantId = request.getHeader(HEADER_TENANT_ID); + String traceId = request.getHeader(HEADER_TRACE_ID); + + Long shopNumber = null; + if (StrUtil.isNotBlank(shopNumberStr) && StrUtil.isNumeric(shopNumberStr)) { + shopNumber = Long.parseLong(shopNumberStr); + } + + if (StrUtil.isBlank(userId)) { + userId = "anonymous"; + } + if (StrUtil.isBlank(username)) { + username = "anonymous"; + } + if (StrUtil.isBlank(traceId)) { + traceId = UUID.randomUUID().toString(); + } + + return UserInfoDTO.builder() + .userId(userId) + .username(username) + .shopNumber(shopNumber) + .tenantId(tenantId) + .traceId(traceId) + .build(); + } +} diff --git a/common/src/main/java/com/tus/coupon/common/context/UserContext.java b/common/src/main/java/com/tus/coupon/common/context/UserContext.java index fea4352..df6560d 100644 --- a/common/src/main/java/com/tus/coupon/common/context/UserContext.java +++ b/common/src/main/java/com/tus/coupon/common/context/UserContext.java @@ -1,6 +1,7 @@ package com.tus.coupon.common.context; import com.alibaba.ttl.TransmittableThreadLocal; +import com.tus.coupon.common.exception.ClientException; import java.util.Optional; @@ -13,6 +14,15 @@ public static void setUser(UserInfoDTO user) { USER_THREAD_LOCAL.set(user); } + public static UserInfoDTO getUser() { + return USER_THREAD_LOCAL.get(); + } + + public static UserInfoDTO requireUser() { + return Optional.ofNullable(USER_THREAD_LOCAL.get()) + .orElseThrow(() -> new ClientException("User context is required but missing")); + } + // fetch user id from user context public static String getUserId() { UserInfoDTO userInfoDTO = USER_THREAD_LOCAL.get(); @@ -31,6 +41,17 @@ public static Long getShopNumber() { return Optional.ofNullable(userInfoDTO).map(UserInfoDTO::getShopNumber).orElse(null); } + public static String getTenantId() { + UserInfoDTO userInfoDTO = USER_THREAD_LOCAL.get(); + return Optional.ofNullable(userInfoDTO).map(UserInfoDTO::getTenantId).orElse(null); + } + + public static String getTraceId() { + UserInfoDTO userInfoDTO = USER_THREAD_LOCAL.get(); + return Optional.ofNullable(userInfoDTO).map(UserInfoDTO::getTraceId).orElse(null); + } + + // clean user context public static void removeUser() { USER_THREAD_LOCAL.remove(); diff --git a/common/src/main/java/com/tus/coupon/common/context/UserContextAutoConfiguration.java b/common/src/main/java/com/tus/coupon/common/context/UserContextAutoConfiguration.java new file mode 100644 index 0000000..a1d1bae --- /dev/null +++ b/common/src/main/java/com/tus/coupon/common/context/UserContextAutoConfiguration.java @@ -0,0 +1,30 @@ +package com.tus.coupon.common.context; + +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@AutoConfiguration +public class UserContextAutoConfiguration { + + @Bean + public UserContextResolver userContextResolver() { + return new HeaderUserContextResolver(); + } + + @Bean + public UserContextInterceptor userContextInterceptor(UserContextResolver userContextResolver) { + return new UserContextInterceptor(userContextResolver); + } + + @Bean + public WebMvcConfigurer userContextWebMvcConfigurer(UserContextInterceptor userContextInterceptor) { + return new WebMvcConfigurer() { + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(userContextInterceptor).addPathPatterns("/**"); + } + }; + } +} diff --git a/common/src/main/java/com/tus/coupon/common/context/UserContextInterceptor.java b/common/src/main/java/com/tus/coupon/common/context/UserContextInterceptor.java new file mode 100644 index 0000000..1978d2a --- /dev/null +++ b/common/src/main/java/com/tus/coupon/common/context/UserContextInterceptor.java @@ -0,0 +1,32 @@ +package com.tus.coupon.common.context; + +import jakarta.annotation.Nullable; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.web.servlet.HandlerInterceptor; + +public class UserContextInterceptor implements HandlerInterceptor { + private final UserContextResolver userContextResolver; + + public UserContextInterceptor(UserContextResolver userContextResolver) { + this.userContextResolver = userContextResolver; + } + + @Override + public boolean preHandle(@Nullable HttpServletRequest request, + @Nullable HttpServletResponse response, + @Nullable Object handler) throws Exception { + if (request != null) { + UserContext.setUser(userContextResolver.resolve(request)); + } + return true; + } + + @Override + public void afterCompletion(@Nullable HttpServletRequest request, + @Nullable HttpServletResponse response, + @Nullable Object handler, + Exception ex) throws Exception { + UserContext.removeUser(); + } +} diff --git a/common/src/main/java/com/tus/coupon/common/context/UserContextResolver.java b/common/src/main/java/com/tus/coupon/common/context/UserContextResolver.java new file mode 100644 index 0000000..58e7ff0 --- /dev/null +++ b/common/src/main/java/com/tus/coupon/common/context/UserContextResolver.java @@ -0,0 +1,7 @@ +package com.tus.coupon.common.context; + +import jakarta.servlet.http.HttpServletRequest; + +public interface UserContextResolver { + UserInfoDTO resolve(HttpServletRequest request); +} diff --git a/common/src/main/java/com/tus/coupon/common/context/UserInfoDTO.java b/common/src/main/java/com/tus/coupon/common/context/UserInfoDTO.java index 0c7dda2..b1c133f 100644 --- a/common/src/main/java/com/tus/coupon/common/context/UserInfoDTO.java +++ b/common/src/main/java/com/tus/coupon/common/context/UserInfoDTO.java @@ -18,4 +18,10 @@ public class UserInfoDTO { // shop number private Long shopNumber; + + // tenant id + private String tenantId; + + // trace id + private String traceId; } diff --git a/identity-domain-api/pom.xml b/identity-domain-api/pom.xml new file mode 100644 index 0000000..83fda1a --- /dev/null +++ b/identity-domain-api/pom.xml @@ -0,0 +1,26 @@ + + + 4.0.0 + + com.tus.coupon + coupon-all + 0.0.1-SNAPSHOT + + + coupon-identity-domain-api + + + + org.springframework + spring-web + + + + ${project.groupId} + coupon-common + ${project.version} + + + diff --git a/identity-domain-api/src/main/java/com/tus/coupon/identity/api/dto/IdentityUserApi.java b/identity-domain-api/src/main/java/com/tus/coupon/identity/api/dto/IdentityUserApi.java new file mode 100644 index 0000000..633b258 --- /dev/null +++ b/identity-domain-api/src/main/java/com/tus/coupon/identity/api/dto/IdentityUserApi.java @@ -0,0 +1,32 @@ +package com.tus.coupon.identity.api.dto; + +import com.tus.coupon.common.web.Result; +import com.tus.coupon.identity.api.dto.req.IdentityUserQueryReqDTO; +import com.tus.coupon.identity.api.dto.resp.IdentityUserQueryRespDTO; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; + +public interface IdentityUserApi { + + @GetMapping("/api/identity/users/query") + Result queryUsers( + @RequestParam(required = false) Long taskId, + @RequestParam(required = false) String shopNumber, + @RequestParam(required = false) Long cursor, + @RequestParam(required = false) Integer limit + ); + + static IdentityUserQueryReqDTO buildReq( + Long taskId, + String shopNumber, + Long cursor, + Integer limit + ) { + IdentityUserQueryReqDTO request = new IdentityUserQueryReqDTO(); + request.setTaskId(taskId); + request.setShopNumber(shopNumber); + request.setCursor(cursor); + request.setLimit(limit); + return request; + } +} diff --git a/identity-domain-api/src/main/java/com/tus/coupon/identity/api/dto/req/IdentityUserQueryReqDTO.java b/identity-domain-api/src/main/java/com/tus/coupon/identity/api/dto/req/IdentityUserQueryReqDTO.java new file mode 100644 index 0000000..9029301 --- /dev/null +++ b/identity-domain-api/src/main/java/com/tus/coupon/identity/api/dto/req/IdentityUserQueryReqDTO.java @@ -0,0 +1,18 @@ +package com.tus.coupon.identity.api.dto.req; + +import lombok.Data; + +@Data +public class IdentityUserQueryReqDTO { + // optional: query users by coupon task id + private Long taskId; + + // optional: query users by merchant shop number + private String shopNumber; + + // cursor based pagination, use user table id + private Long cursor; + + // page size + private Integer limit; +} diff --git a/identity-domain-api/src/main/java/com/tus/coupon/identity/api/dto/resp/IdentityUserItemRespDTO.java b/identity-domain-api/src/main/java/com/tus/coupon/identity/api/dto/resp/IdentityUserItemRespDTO.java new file mode 100644 index 0000000..94bbc5d --- /dev/null +++ b/identity-domain-api/src/main/java/com/tus/coupon/identity/api/dto/resp/IdentityUserItemRespDTO.java @@ -0,0 +1,15 @@ +package com.tus.coupon.identity.api.dto.resp; + +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +public class IdentityUserItemRespDTO { + private Long id; + private String userId; + private String username; + private String shopNumber; + private String phone; + private String mail; +} diff --git a/identity-domain-api/src/main/java/com/tus/coupon/identity/api/dto/resp/IdentityUserQueryRespDTO.java b/identity-domain-api/src/main/java/com/tus/coupon/identity/api/dto/resp/IdentityUserQueryRespDTO.java new file mode 100644 index 0000000..39c76db --- /dev/null +++ b/identity-domain-api/src/main/java/com/tus/coupon/identity/api/dto/resp/IdentityUserQueryRespDTO.java @@ -0,0 +1,14 @@ +package com.tus.coupon.identity.api.dto.resp; + +import lombok.Builder; +import lombok.Data; + +import java.util.List; + +@Data +@Builder +public class IdentityUserQueryRespDTO { + private List users; + private Long nextCursor; + private Boolean hasMore; +} diff --git a/identity-service/pom.xml b/identity-service/pom.xml new file mode 100644 index 0000000..fcf0406 --- /dev/null +++ b/identity-service/pom.xml @@ -0,0 +1,60 @@ + + + 4.0.0 + + com.tus.coupon + coupon-all + 0.0.1-SNAPSHOT + + + coupon-identity-service + + + + org.springframework.boot + spring-boot-starter-web + + + + org.springframework.boot + spring-boot-starter-test + test + + + + org.springframework.boot + spring-boot-starter-jdbc + + + + com.baomidou + mybatis-plus-boot-starter + + + + com.mysql + mysql-connector-j + runtime + + + + ${project.groupId} + coupon-common + ${project.version} + + + + ${project.groupId} + coupon-identity-domain-api + ${project.version} + + + io.swagger.core.v3 + swagger-annotations-jakarta + 2.2.22 + compile + + + \ No newline at end of file diff --git a/identity-service/src/main/java/com/tus/coupon/identity/IdentityApplication.java b/identity-service/src/main/java/com/tus/coupon/identity/IdentityApplication.java new file mode 100644 index 0000000..12ee1fe --- /dev/null +++ b/identity-service/src/main/java/com/tus/coupon/identity/IdentityApplication.java @@ -0,0 +1,16 @@ +package com.tus.coupon.identity; + +import com.tus.coupon.common.context.UserContextAutoConfiguration; +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.Import; + +@SpringBootApplication +@MapperScan("com.tus.coupon.common.dao.mapper") +@Import(UserContextAutoConfiguration.class) +public class IdentityApplication { + public static void main(String[] args) { + SpringApplication.run(IdentityApplication.class, args); + } +} diff --git a/identity-service/src/main/java/com/tus/coupon/identity/controller/IdentityUserController.java b/identity-service/src/main/java/com/tus/coupon/identity/controller/IdentityUserController.java new file mode 100644 index 0000000..e8312b5 --- /dev/null +++ b/identity-service/src/main/java/com/tus/coupon/identity/controller/IdentityUserController.java @@ -0,0 +1,32 @@ +package com.tus.coupon.identity.controller; + +import com.tus.coupon.common.web.Result; +import com.tus.coupon.common.web.Results; +import com.tus.coupon.identity.api.dto.IdentityUserApi; +import com.tus.coupon.identity.api.dto.req.IdentityUserQueryReqDTO; +import com.tus.coupon.identity.api.dto.resp.IdentityUserQueryRespDTO; +import com.tus.coupon.identity.service.IdentityUserService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@Tag(name = "Identity User Controller") +public class IdentityUserController implements IdentityUserApi { + private final IdentityUserService identityUserService; + + @Override + @Operation(summary = "Query users for coupon distribution") + public Result queryUsers( + @RequestParam(required = false) Long taskId, + @RequestParam(required = false) String shopNumber, + @RequestParam(required = false) Long cursor, + @RequestParam(required = false) Integer limit + ) { + IdentityUserQueryReqDTO request = IdentityUserApi.buildReq(taskId, shopNumber, cursor, limit); + return Results.success(identityUserService.queryUsers(request)); + } +} \ No newline at end of file diff --git a/identity-service/src/main/java/com/tus/coupon/identity/service/IdentityUserService.java b/identity-service/src/main/java/com/tus/coupon/identity/service/IdentityUserService.java new file mode 100644 index 0000000..7d92c22 --- /dev/null +++ b/identity-service/src/main/java/com/tus/coupon/identity/service/IdentityUserService.java @@ -0,0 +1,8 @@ +package com.tus.coupon.identity.service; + +import com.tus.coupon.identity.api.dto.req.IdentityUserQueryReqDTO; +import com.tus.coupon.identity.api.dto.resp.IdentityUserQueryRespDTO; + +public interface IdentityUserService { + IdentityUserQueryRespDTO queryUsers(IdentityUserQueryReqDTO requestParam); +} diff --git a/identity-service/src/main/java/com/tus/coupon/identity/service/impl/IdentityUserServiceImpl.java b/identity-service/src/main/java/com/tus/coupon/identity/service/impl/IdentityUserServiceImpl.java new file mode 100644 index 0000000..e6aa417 --- /dev/null +++ b/identity-service/src/main/java/com/tus/coupon/identity/service/impl/IdentityUserServiceImpl.java @@ -0,0 +1,78 @@ +package com.tus.coupon.identity.service.impl; + +import cn.hutool.core.util.StrUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.tus.coupon.common.dao.entity.UserDO; +import com.tus.coupon.common.dao.mapper.UserMapper; +import com.tus.coupon.common.exception.ClientException; +import com.tus.coupon.identity.api.dto.req.IdentityUserQueryReqDTO; +import com.tus.coupon.identity.api.dto.resp.IdentityUserItemRespDTO; +import com.tus.coupon.identity.api.dto.resp.IdentityUserQueryRespDTO; +import com.tus.coupon.identity.service.IdentityUserService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.Collections; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class IdentityUserServiceImpl implements IdentityUserService { + private static final int DEFAULT_LIMIT = 100; + private static final int MAX_LIMIT = 500; + + private final UserMapper userMapper; + + @Override + public IdentityUserQueryRespDTO queryUsers(IdentityUserQueryReqDTO requestParam) { + if (requestParam == null) { + throw new ClientException("Identity user query request should not be null"); + } + + Integer limit = requestParam.getLimit(); + if (limit == null || limit <= 0) { + limit = DEFAULT_LIMIT; + } + + if (limit > MAX_LIMIT) { + throw new ClientException("Requested limit exceeds max allowed size: " + MAX_LIMIT); + } + + long cursor = requestParam.getCursor() == null ? 0L : requestParam.getCursor(); + LambdaQueryWrapper queryWrapper = Wrappers.lambdaQuery(UserDO.class) + .gt(UserDO::getId, cursor) + .eq(UserDO::getDelFlag, 0) + .eq(StrUtil.isNotBlank(requestParam.getShopNumber()), UserDO::getShopNumber, requestParam.getShopNumber()) + .orderByAsc(UserDO::getId) + .last("LIMIT " + limit); + + List userDOList = userMapper.selectList(queryWrapper); + if (userDOList == null || userDOList.isEmpty()) { + return IdentityUserQueryRespDTO.builder() + .users(Collections.emptyList()) + .hasMore(Boolean.FALSE) + .nextCursor(cursor) + .build(); + } + + List users = userDOList.stream() + .map(each -> IdentityUserItemRespDTO.builder() + .id(each.getId()) + .userId(String.valueOf(each.getId())) + .username(each.getUsername()) + .shopNumber(each.getShopNumber()) + .phone(each.getPhone()) + .mail(each.getMail()) + .build()) + .toList(); + + Long nextCursor = userDOList.get(userDOList.size() - 1).getId(); + boolean hasMore = userDOList.size() >= limit; + return IdentityUserQueryRespDTO.builder() + .users(users) + .nextCursor(nextCursor) + .hasMore(hasMore) + .build(); + } +} diff --git a/identity-service/src/main/resources/application.yaml b/identity-service/src/main/resources/application.yaml new file mode 100644 index 0000000..c2f8118 --- /dev/null +++ b/identity-service/src/main/resources/application.yaml @@ -0,0 +1,10 @@ +server: + port: 10030 + +spring: + application: + name: coupon-identity + +mybatis-plus: + configuration: + log-impl: org.apache.ibatis.logging.stdout.StdOutImpl diff --git a/merchant-service/src/main/java/com/tus/coupon/merchant/config/UserConfiguration.java b/merchant-service/src/main/java/com/tus/coupon/merchant/config/UserConfiguration.java index dd37c01..833d754 100644 --- a/merchant-service/src/main/java/com/tus/coupon/merchant/config/UserConfiguration.java +++ b/merchant-service/src/main/java/com/tus/coupon/merchant/config/UserConfiguration.java @@ -32,7 +32,8 @@ static class UserTransmitInterceptor implements HandlerInterceptor { @Override public boolean preHandle(@Nullable HttpServletRequest request, @Nullable HttpServletResponse response, @Nullable Object handler) throws Exception { UserInfoDTO userInfoDTO = new UserInfoDTO(UUID.randomUUID().toString(), - UUID.randomUUID().toString(), UUID.randomUUID().node()); + UUID.randomUUID().toString(), UUID.randomUUID().node(), + UUID.randomUUID().toString(), UUID.randomUUID().toString()); UserContext.setUser(userInfoDTO); return true; } diff --git a/pom.xml b/pom.xml index 3b27281..c745af4 100644 --- a/pom.xml +++ b/pom.xml @@ -11,6 +11,8 @@ merchant-service common distribution-service + identity-service + identity-domain-api