From a719c08a5a399846ca1ea198799f0635f1025677 Mon Sep 17 00:00:00 2001 From: XuqmGroup Date: Tue, 21 Apr 2026 22:07:29 +0800 Subject: [PATCH] chore: initial commit --- .gitignore | 10 ++ common/pom.xml | 46 +++++++ .../common/exception/BusinessException.java | 20 +++ .../com/xuqm/common/model/ApiResponse.java | 32 +++++ .../com/xuqm/common/model/PageResult.java | 10 ++ .../xuqm/common/security/JwtAuthFilter.java | 45 +++++++ .../com/xuqm/common/security/JwtUtil.java | 62 +++++++++ im-service/pom.xml | 89 +++++++++++++ .../com/xuqm/im/ImServiceApplication.java | 15 +++ .../com/xuqm/im/config/SecurityConfig.java | 43 ++++++ .../com/xuqm/im/config/WebSocketConfig.java | 71 ++++++++++ .../xuqm/im/controller/AuthController.java | 33 +++++ .../xuqm/im/controller/MessageController.java | 53 ++++++++ .../com/xuqm/im/entity/ImAccountEntity.java | 69 ++++++++++ .../com/xuqm/im/entity/ImGroupEntity.java | 54 ++++++++ .../com/xuqm/im/entity/ImMessageEntity.java | 88 +++++++++++++ .../xuqm/im/entity/KeywordFilterEntity.java | 56 ++++++++ .../xuqm/im/entity/WebhookConfigEntity.java | 48 +++++++ .../com/xuqm/im/model/SendMessageRequest.java | 13 ++ .../im/repository/ImAccountRepository.java | 10 ++ .../xuqm/im/repository/ImGroupRepository.java | 9 ++ .../im/repository/ImMessageRepository.java | 13 ++ .../repository/KeywordFilterRepository.java | 9 ++ .../repository/WebhookConfigRepository.java | 9 ++ .../com/xuqm/im/service/ImAccountService.java | 59 +++++++++ .../com/xuqm/im/service/ImGroupService.java | 77 +++++++++++ .../xuqm/im/service/KeywordFilterService.java | 60 +++++++++ .../com/xuqm/im/service/MessageService.java | 122 ++++++++++++++++++ .../java/com/xuqm/im/ws/ChatController.java | 46 +++++++ im-service/src/main/resources/application.yml | 39 ++++++ pom.xml | 85 ++++++++++++ push-service/pom.xml | 77 +++++++++++ .../com/xuqm/push/PushServiceApplication.java | 15 +++ .../xuqm/push/controller/PushController.java | 44 +++++++ .../xuqm/push/entity/DeviceTokenEntity.java | 61 +++++++++ .../repository/DeviceTokenRepository.java | 13 ++ .../com/xuqm/push/service/PushDispatcher.java | 59 +++++++++ .../service/provider/HuaweiPushProvider.java | 82 ++++++++++++ .../push/service/provider/PushProvider.java | 6 + .../service/provider/XiaomiPushProvider.java | 56 ++++++++ .../src/main/resources/application.yml | 35 +++++ tenant-service/pom.xml | 89 +++++++++++++ .../xuqm/tenant/TenantServiceApplication.java | 15 +++ .../tenant/config/OpsAdminInitializer.java | 28 ++++ .../xuqm/tenant/config/SecurityConfig.java | 49 +++++++ .../com/xuqm/tenant/config/WebConfig.java | 19 +++ .../xuqm/tenant/controller/AppController.java | 61 +++++++++ .../tenant/controller/AuthController.java | 114 ++++++++++++++++ .../controller/FeatureServiceController.java | 57 ++++++++ .../controller/GlobalExceptionHandler.java | 35 +++++ .../xuqm/tenant/controller/OpsController.java | 62 +++++++++ .../controller/SubAccountController.java | 78 +++++++++++ .../com/xuqm/tenant/dto/CreateAppRequest.java | 11 ++ .../tenant/dto/CreateSubAccountRequest.java | 12 ++ .../com/xuqm/tenant/dto/LoginRequest.java | 10 ++ .../com/xuqm/tenant/dto/RegisterRequest.java | 14 ++ .../com/xuqm/tenant/entity/AppEntity.java | 66 ++++++++++ .../entity/EmailVerificationEntity.java | 56 ++++++++ .../tenant/entity/FeatureServiceEntity.java | 67 ++++++++++ .../xuqm/tenant/entity/OpsAdminEntity.java | 36 ++++++ .../com/xuqm/tenant/entity/TenantEntity.java | 79 ++++++++++++ .../xuqm/tenant/repository/AppRepository.java | 14 ++ .../EmailVerificationRepository.java | 19 +++ .../repository/FeatureServiceRepository.java | 16 +++ .../tenant/repository/OpsAdminRepository.java | 10 ++ .../tenant/repository/TenantRepository.java | 27 ++++ .../com/xuqm/tenant/service/AppService.java | 77 +++++++++++ .../com/xuqm/tenant/service/AuthService.java | 104 +++++++++++++++ .../com/xuqm/tenant/service/EmailService.java | 72 +++++++++++ .../tenant/service/FeatureServiceManager.java | 64 +++++++++ .../com/xuqm/tenant/service/OpsService.java | 89 +++++++++++++ .../tenant/service/SubAccountService.java | 84 ++++++++++++ .../src/main/resources/application.yml | 65 ++++++++++ update-service/pom.xml | 82 ++++++++++++ .../xuqm/update/UpdateServiceApplication.java | 13 ++ .../controller/AppVersionController.java | 116 +++++++++++++++++ .../update/controller/RnBundleController.java | 120 +++++++++++++++++ .../xuqm/update/entity/AppVersionEntity.java | 91 +++++++++++++ .../xuqm/update/entity/RnBundleEntity.java | 85 ++++++++++++ .../repository/AppVersionRepository.java | 14 ++ .../update/repository/RnBundleRepository.java | 14 ++ .../src/main/resources/application.yml | 27 ++++ 82 files changed, 4004 insertions(+) create mode 100644 .gitignore create mode 100644 common/pom.xml create mode 100644 common/src/main/java/com/xuqm/common/exception/BusinessException.java create mode 100644 common/src/main/java/com/xuqm/common/model/ApiResponse.java create mode 100644 common/src/main/java/com/xuqm/common/model/PageResult.java create mode 100644 common/src/main/java/com/xuqm/common/security/JwtAuthFilter.java create mode 100644 common/src/main/java/com/xuqm/common/security/JwtUtil.java create mode 100644 im-service/pom.xml create mode 100644 im-service/src/main/java/com/xuqm/im/ImServiceApplication.java create mode 100644 im-service/src/main/java/com/xuqm/im/config/SecurityConfig.java create mode 100644 im-service/src/main/java/com/xuqm/im/config/WebSocketConfig.java create mode 100644 im-service/src/main/java/com/xuqm/im/controller/AuthController.java create mode 100644 im-service/src/main/java/com/xuqm/im/controller/MessageController.java create mode 100644 im-service/src/main/java/com/xuqm/im/entity/ImAccountEntity.java create mode 100644 im-service/src/main/java/com/xuqm/im/entity/ImGroupEntity.java create mode 100644 im-service/src/main/java/com/xuqm/im/entity/ImMessageEntity.java create mode 100644 im-service/src/main/java/com/xuqm/im/entity/KeywordFilterEntity.java create mode 100644 im-service/src/main/java/com/xuqm/im/entity/WebhookConfigEntity.java create mode 100644 im-service/src/main/java/com/xuqm/im/model/SendMessageRequest.java create mode 100644 im-service/src/main/java/com/xuqm/im/repository/ImAccountRepository.java create mode 100644 im-service/src/main/java/com/xuqm/im/repository/ImGroupRepository.java create mode 100644 im-service/src/main/java/com/xuqm/im/repository/ImMessageRepository.java create mode 100644 im-service/src/main/java/com/xuqm/im/repository/KeywordFilterRepository.java create mode 100644 im-service/src/main/java/com/xuqm/im/repository/WebhookConfigRepository.java create mode 100644 im-service/src/main/java/com/xuqm/im/service/ImAccountService.java create mode 100644 im-service/src/main/java/com/xuqm/im/service/ImGroupService.java create mode 100644 im-service/src/main/java/com/xuqm/im/service/KeywordFilterService.java create mode 100644 im-service/src/main/java/com/xuqm/im/service/MessageService.java create mode 100644 im-service/src/main/java/com/xuqm/im/ws/ChatController.java create mode 100644 im-service/src/main/resources/application.yml create mode 100644 pom.xml create mode 100644 push-service/pom.xml create mode 100644 push-service/src/main/java/com/xuqm/push/PushServiceApplication.java create mode 100644 push-service/src/main/java/com/xuqm/push/controller/PushController.java create mode 100644 push-service/src/main/java/com/xuqm/push/entity/DeviceTokenEntity.java create mode 100644 push-service/src/main/java/com/xuqm/push/repository/DeviceTokenRepository.java create mode 100644 push-service/src/main/java/com/xuqm/push/service/PushDispatcher.java create mode 100644 push-service/src/main/java/com/xuqm/push/service/provider/HuaweiPushProvider.java create mode 100644 push-service/src/main/java/com/xuqm/push/service/provider/PushProvider.java create mode 100644 push-service/src/main/java/com/xuqm/push/service/provider/XiaomiPushProvider.java create mode 100644 push-service/src/main/resources/application.yml create mode 100644 tenant-service/pom.xml create mode 100644 tenant-service/src/main/java/com/xuqm/tenant/TenantServiceApplication.java create mode 100644 tenant-service/src/main/java/com/xuqm/tenant/config/OpsAdminInitializer.java create mode 100644 tenant-service/src/main/java/com/xuqm/tenant/config/SecurityConfig.java create mode 100644 tenant-service/src/main/java/com/xuqm/tenant/config/WebConfig.java create mode 100644 tenant-service/src/main/java/com/xuqm/tenant/controller/AppController.java create mode 100644 tenant-service/src/main/java/com/xuqm/tenant/controller/AuthController.java create mode 100644 tenant-service/src/main/java/com/xuqm/tenant/controller/FeatureServiceController.java create mode 100644 tenant-service/src/main/java/com/xuqm/tenant/controller/GlobalExceptionHandler.java create mode 100644 tenant-service/src/main/java/com/xuqm/tenant/controller/OpsController.java create mode 100644 tenant-service/src/main/java/com/xuqm/tenant/controller/SubAccountController.java create mode 100644 tenant-service/src/main/java/com/xuqm/tenant/dto/CreateAppRequest.java create mode 100644 tenant-service/src/main/java/com/xuqm/tenant/dto/CreateSubAccountRequest.java create mode 100644 tenant-service/src/main/java/com/xuqm/tenant/dto/LoginRequest.java create mode 100644 tenant-service/src/main/java/com/xuqm/tenant/dto/RegisterRequest.java create mode 100644 tenant-service/src/main/java/com/xuqm/tenant/entity/AppEntity.java create mode 100644 tenant-service/src/main/java/com/xuqm/tenant/entity/EmailVerificationEntity.java create mode 100644 tenant-service/src/main/java/com/xuqm/tenant/entity/FeatureServiceEntity.java create mode 100644 tenant-service/src/main/java/com/xuqm/tenant/entity/OpsAdminEntity.java create mode 100644 tenant-service/src/main/java/com/xuqm/tenant/entity/TenantEntity.java create mode 100644 tenant-service/src/main/java/com/xuqm/tenant/repository/AppRepository.java create mode 100644 tenant-service/src/main/java/com/xuqm/tenant/repository/EmailVerificationRepository.java create mode 100644 tenant-service/src/main/java/com/xuqm/tenant/repository/FeatureServiceRepository.java create mode 100644 tenant-service/src/main/java/com/xuqm/tenant/repository/OpsAdminRepository.java create mode 100644 tenant-service/src/main/java/com/xuqm/tenant/repository/TenantRepository.java create mode 100644 tenant-service/src/main/java/com/xuqm/tenant/service/AppService.java create mode 100644 tenant-service/src/main/java/com/xuqm/tenant/service/AuthService.java create mode 100644 tenant-service/src/main/java/com/xuqm/tenant/service/EmailService.java create mode 100644 tenant-service/src/main/java/com/xuqm/tenant/service/FeatureServiceManager.java create mode 100644 tenant-service/src/main/java/com/xuqm/tenant/service/OpsService.java create mode 100644 tenant-service/src/main/java/com/xuqm/tenant/service/SubAccountService.java create mode 100644 tenant-service/src/main/resources/application.yml create mode 100644 update-service/pom.xml create mode 100644 update-service/src/main/java/com/xuqm/update/UpdateServiceApplication.java create mode 100644 update-service/src/main/java/com/xuqm/update/controller/AppVersionController.java create mode 100644 update-service/src/main/java/com/xuqm/update/controller/RnBundleController.java create mode 100644 update-service/src/main/java/com/xuqm/update/entity/AppVersionEntity.java create mode 100644 update-service/src/main/java/com/xuqm/update/entity/RnBundleEntity.java create mode 100644 update-service/src/main/java/com/xuqm/update/repository/AppVersionRepository.java create mode 100644 update-service/src/main/java/com/xuqm/update/repository/RnBundleRepository.java create mode 100644 update-service/src/main/resources/application.yml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..10dfc90 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +node_modules/ +dist/ +.DS_Store +*.class +target/ +build/ +.gradle/ +*.iml +.idea/ +*.log diff --git a/common/pom.xml b/common/pom.xml new file mode 100644 index 0000000..879ee0d --- /dev/null +++ b/common/pom.xml @@ -0,0 +1,46 @@ + + + 4.0.0 + + + com.xuqm + xuqmgroup-server-parent + 0.1.0-SNAPSHOT + ../pom.xml + + + common + common + Shared utilities, models, and security for XuqmGroup services + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-security + + + org.springframework.boot + spring-boot-starter-validation + + + io.jsonwebtoken + jjwt-api + + + io.jsonwebtoken + jjwt-impl + runtime + + + io.jsonwebtoken + jjwt-jackson + runtime + + + diff --git a/common/src/main/java/com/xuqm/common/exception/BusinessException.java b/common/src/main/java/com/xuqm/common/exception/BusinessException.java new file mode 100644 index 0000000..8c0bf8b --- /dev/null +++ b/common/src/main/java/com/xuqm/common/exception/BusinessException.java @@ -0,0 +1,20 @@ +package com.xuqm.common.exception; + +public class BusinessException extends RuntimeException { + + private final int code; + + public BusinessException(String message) { + super(message); + this.code = 400; + } + + public BusinessException(int code, String message) { + super(message); + this.code = code; + } + + public int getCode() { + return code; + } +} diff --git a/common/src/main/java/com/xuqm/common/model/ApiResponse.java b/common/src/main/java/com/xuqm/common/model/ApiResponse.java new file mode 100644 index 0000000..b997474 --- /dev/null +++ b/common/src/main/java/com/xuqm/common/model/ApiResponse.java @@ -0,0 +1,32 @@ +package com.xuqm.common.model; + +public record ApiResponse(int code, String status, T data, String message) { + + public static ApiResponse success(T data) { + return new ApiResponse<>(200, "0", data, "success"); + } + + public static ApiResponse success(T data, String message) { + return new ApiResponse<>(200, "0", data, message); + } + + public static ApiResponse ok() { + return new ApiResponse<>(200, "0", null, "success"); + } + + public static ApiResponse error(int code, String message) { + return new ApiResponse<>(code, "1", null, message); + } + + public static ApiResponse badRequest(String message) { + return new ApiResponse<>(400, "1", null, message); + } + + public static ApiResponse unauthorized(String message) { + return new ApiResponse<>(401, "1", null, message); + } + + public static ApiResponse forbidden(String message) { + return new ApiResponse<>(403, "1", null, message); + } +} diff --git a/common/src/main/java/com/xuqm/common/model/PageResult.java b/common/src/main/java/com/xuqm/common/model/PageResult.java new file mode 100644 index 0000000..fa1c1a2 --- /dev/null +++ b/common/src/main/java/com/xuqm/common/model/PageResult.java @@ -0,0 +1,10 @@ +package com.xuqm.common.model; + +import java.util.List; + +public record PageResult(List items, long total, int page, int size) { + + public static PageResult of(List items, long total, int page, int size) { + return new PageResult<>(items, total, page, size); + } +} diff --git a/common/src/main/java/com/xuqm/common/security/JwtAuthFilter.java b/common/src/main/java/com/xuqm/common/security/JwtAuthFilter.java new file mode 100644 index 0000000..7d15921 --- /dev/null +++ b/common/src/main/java/com/xuqm/common/security/JwtAuthFilter.java @@ -0,0 +1,45 @@ +package com.xuqm.common.security; + +import io.jsonwebtoken.Claims; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.List; + +public class JwtAuthFilter extends OncePerRequestFilter { + + private final JwtUtil jwtUtil; + + public JwtAuthFilter(JwtUtil jwtUtil) { + this.jwtUtil = jwtUtil; + } + + @Override + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + String header = request.getHeader("Authorization"); + if (header != null && header.startsWith("Bearer ")) { + String token = header.substring(7); + if (jwtUtil.isValid(token)) { + Claims claims = jwtUtil.parse(token); + String subject = claims.getSubject(); + String role = claims.get("role", String.class); + List authorities = role != null + ? List.of(new SimpleGrantedAuthority("ROLE_" + role)) + : List.of(); + UsernamePasswordAuthenticationToken auth = + new UsernamePasswordAuthenticationToken(subject, null, authorities); + SecurityContextHolder.getContext().setAuthentication(auth); + } + } + filterChain.doFilter(request, response); + } +} diff --git a/common/src/main/java/com/xuqm/common/security/JwtUtil.java b/common/src/main/java/com/xuqm/common/security/JwtUtil.java new file mode 100644 index 0000000..d384f93 --- /dev/null +++ b/common/src/main/java/com/xuqm/common/security/JwtUtil.java @@ -0,0 +1,62 @@ +package com.xuqm.common.security; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; +import java.util.Date; +import java.util.Map; + +@Component +public class JwtUtil { + + @Value("${jwt.secret}") + private String secret; + + @Value("${jwt.expiration:86400000}") + private long expiration; + + private SecretKey getSigningKey() { + byte[] keyBytes = secret.getBytes(StandardCharsets.UTF_8); + return Keys.hmacShaKeyFor(keyBytes); + } + + public String generate(String subject, Map claims) { + return Jwts.builder() + .subject(subject) + .claims(claims) + .issuedAt(new Date()) + .expiration(new Date(System.currentTimeMillis() + expiration)) + .signWith(getSigningKey()) + .compact(); + } + + public String generate(String subject) { + return generate(subject, Map.of()); + } + + public Claims parse(String token) { + return Jwts.parser() + .verifyWith(getSigningKey()) + .build() + .parseSignedClaims(token) + .getPayload(); + } + + public String getSubject(String token) { + return parse(token).getSubject(); + } + + public boolean isValid(String token) { + try { + parse(token); + return true; + } catch (Exception e) { + return false; + } + } +} diff --git a/im-service/pom.xml b/im-service/pom.xml new file mode 100644 index 0000000..b3b8e6b --- /dev/null +++ b/im-service/pom.xml @@ -0,0 +1,89 @@ + + + 4.0.0 + + + com.xuqm + xuqmgroup-server-parent + 0.1.0-SNAPSHOT + ../pom.xml + + + im-service + im-service + Standalone IM service with WebSocket STOMP, groups, messages, webhooks + + + + com.xuqm + common + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-websocket + + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.springframework.boot + spring-boot-starter-data-redis + + + org.springframework.boot + spring-boot-starter-security + + + org.springframework.boot + spring-boot-starter-validation + + + org.springframework.boot + spring-boot-starter-actuator + + + io.jsonwebtoken + jjwt-api + + + io.jsonwebtoken + jjwt-impl + runtime + + + io.jsonwebtoken + jjwt-jackson + runtime + + + com.fasterxml.jackson.datatype + jackson-datatype-jsr310 + + + com.mysql + mysql-connector-j + runtime + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/im-service/src/main/java/com/xuqm/im/ImServiceApplication.java b/im-service/src/main/java/com/xuqm/im/ImServiceApplication.java new file mode 100644 index 0000000..57b4e46 --- /dev/null +++ b/im-service/src/main/java/com/xuqm/im/ImServiceApplication.java @@ -0,0 +1,15 @@ +package com.xuqm.im; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.scheduling.annotation.EnableAsync; + +@SpringBootApplication +@ComponentScan(basePackages = {"com.xuqm.im", "com.xuqm.common"}) +@EnableAsync +public class ImServiceApplication { + public static void main(String[] args) { + SpringApplication.run(ImServiceApplication.class, args); + } +} diff --git a/im-service/src/main/java/com/xuqm/im/config/SecurityConfig.java b/im-service/src/main/java/com/xuqm/im/config/SecurityConfig.java new file mode 100644 index 0000000..0753404 --- /dev/null +++ b/im-service/src/main/java/com/xuqm/im/config/SecurityConfig.java @@ -0,0 +1,43 @@ +package com.xuqm.im.config; + +import com.xuqm.common.security.JwtAuthFilter; +import com.xuqm.common.security.JwtUtil; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +@Configuration +@EnableWebSecurity +public class SecurityConfig { + + private final JwtUtil jwtUtil; + + public SecurityConfig(JwtUtil jwtUtil) { + this.jwtUtil = jwtUtil; + } + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http + .csrf(AbstractHttpConfigurer::disable) + .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(auth -> auth + .requestMatchers("/api/im/auth/**", "/ws/**", "/actuator/**").permitAll() + .anyRequest().authenticated() + ) + .addFilterBefore(new JwtAuthFilter(jwtUtil), UsernamePasswordAuthenticationFilter.class); + return http.build(); + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} diff --git a/im-service/src/main/java/com/xuqm/im/config/WebSocketConfig.java b/im-service/src/main/java/com/xuqm/im/config/WebSocketConfig.java new file mode 100644 index 0000000..f66f6df --- /dev/null +++ b/im-service/src/main/java/com/xuqm/im/config/WebSocketConfig.java @@ -0,0 +1,71 @@ +package com.xuqm.im.config; + +import com.xuqm.common.security.JwtUtil; +import org.springframework.context.annotation.Configuration; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageChannel; +import org.springframework.messaging.simp.config.ChannelRegistration; +import org.springframework.messaging.simp.config.MessageBrokerRegistry; +import org.springframework.messaging.simp.stomp.StompCommand; +import org.springframework.messaging.simp.stomp.StompHeaderAccessor; +import org.springframework.messaging.support.ChannelInterceptor; +import org.springframework.messaging.support.MessageHeaderAccessor; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; +import org.springframework.web.socket.config.annotation.StompEndpointRegistry; +import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; + +import java.util.List; + +@Configuration +@EnableWebSocketMessageBroker +public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { + + private final JwtUtil jwtUtil; + + public WebSocketConfig(JwtUtil jwtUtil) { + this.jwtUtil = jwtUtil; + } + + @Override + public void configureMessageBroker(MessageBrokerRegistry registry) { + registry.enableSimpleBroker("/topic", "/queue"); + registry.setApplicationDestinationPrefixes("/app"); + registry.setUserDestinationPrefix("/user"); + } + + @Override + public void registerStompEndpoints(StompEndpointRegistry registry) { + registry.addEndpoint("/ws/im") + .setAllowedOriginPatterns("*") + .withSockJS(); + registry.addEndpoint("/ws/im") + .setAllowedOriginPatterns("*"); + } + + @Override + public void configureClientInboundChannel(ChannelRegistration registration) { + registration.interceptors(new ChannelInterceptor() { + @Override + public Message preSend(Message message, MessageChannel channel) { + StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor( + message, StompHeaderAccessor.class); + if (accessor != null && StompCommand.CONNECT.equals(accessor.getCommand())) { + String token = accessor.getFirstNativeHeader("Authorization"); + if (token != null && token.startsWith("Bearer ")) { + token = token.substring(7); + if (jwtUtil.isValid(token)) { + String userId = jwtUtil.getSubject(token); + UsernamePasswordAuthenticationToken auth = + new UsernamePasswordAuthenticationToken(userId, null, + List.of(new SimpleGrantedAuthority("ROLE_USER"))); + accessor.setUser(auth); + } + } + } + return message; + } + }); + } +} diff --git a/im-service/src/main/java/com/xuqm/im/controller/AuthController.java b/im-service/src/main/java/com/xuqm/im/controller/AuthController.java new file mode 100644 index 0000000..8a9af97 --- /dev/null +++ b/im-service/src/main/java/com/xuqm/im/controller/AuthController.java @@ -0,0 +1,33 @@ +package com.xuqm.im.controller; + +import com.xuqm.common.model.ApiResponse; +import com.xuqm.im.service.ImAccountService; +import jakarta.validation.constraints.NotBlank; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Map; + +@RestController +@RequestMapping("/api/im/auth") +public class AuthController { + + private final ImAccountService accountService; + + public AuthController(ImAccountService accountService) { + this.accountService = accountService; + } + + @PostMapping("/login") + public ResponseEntity>> login( + @RequestParam @NotBlank String appId, + @RequestParam @NotBlank String userId, + @RequestParam(required = false) String nickname, + @RequestParam(required = false) String avatar) { + String token = accountService.loginOrRegister(appId, userId, nickname, avatar); + return ResponseEntity.ok(ApiResponse.success(Map.of("token", token))); + } +} diff --git a/im-service/src/main/java/com/xuqm/im/controller/MessageController.java b/im-service/src/main/java/com/xuqm/im/controller/MessageController.java new file mode 100644 index 0000000..ed664d0 --- /dev/null +++ b/im-service/src/main/java/com/xuqm/im/controller/MessageController.java @@ -0,0 +1,53 @@ +package com.xuqm.im.controller; + +import com.xuqm.common.model.ApiResponse; +import com.xuqm.im.entity.ImMessageEntity; +import com.xuqm.im.model.SendMessageRequest; +import com.xuqm.im.service.MessageService; +import io.jsonwebtoken.Claims; +import jakarta.validation.Valid; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +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.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/im/messages") +public class MessageController { + + private final MessageService messageService; + + public MessageController(MessageService messageService) { + this.messageService = messageService; + } + + @PostMapping("/send") + public ResponseEntity> send( + @Valid @RequestBody SendMessageRequest req, + @AuthenticationPrincipal String userId, + @RequestParam String appId) { + return ResponseEntity.ok(ApiResponse.success(messageService.send(appId, userId, req))); + } + + @PostMapping("/{id}/revoke") + public ResponseEntity> revoke( + @PathVariable String id, + @AuthenticationPrincipal String userId, + @RequestParam String appId) { + return ResponseEntity.ok(ApiResponse.success(messageService.revoke(appId, id, userId))); + } + + @GetMapping("/history/{toId}") + public ResponseEntity> history( + @PathVariable String toId, + @RequestParam String appId, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size) { + return ResponseEntity.ok(ApiResponse.success(messageService.history(appId, toId, page, size))); + } +} diff --git a/im-service/src/main/java/com/xuqm/im/entity/ImAccountEntity.java b/im-service/src/main/java/com/xuqm/im/entity/ImAccountEntity.java new file mode 100644 index 0000000..971ecae --- /dev/null +++ b/im-service/src/main/java/com/xuqm/im/entity/ImAccountEntity.java @@ -0,0 +1,69 @@ +package com.xuqm.im.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import java.time.LocalDateTime; + +@Entity +@Table(name = "im_account", + uniqueConstraints = @UniqueConstraint(columnNames = {"appId", "userId"})) +public class ImAccountEntity { + + public enum Gender { UNKNOWN, MALE, FEMALE } + public enum Status { ACTIVE, BANNED } + + @Id + private String id; + + @Column(nullable = false, length = 64) + private String appId; + + @Column(nullable = false, length = 128) + private String userId; + + @Column(length = 64) + private String nickname; + + @Enumerated(EnumType.STRING) + @Column(length = 16) + private Gender gender; + + @Column(length = 512) + private String avatar; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 16) + private Status status; + + @Column(nullable = false) + private LocalDateTime createdAt; + + public String getId() { return id; } + public void setId(String id) { this.id = id; } + + public String getAppId() { return appId; } + public void setAppId(String appId) { this.appId = appId; } + + public String getUserId() { return userId; } + public void setUserId(String userId) { this.userId = userId; } + + public String getNickname() { return nickname; } + public void setNickname(String nickname) { this.nickname = nickname; } + + public Gender getGender() { return gender; } + public void setGender(Gender gender) { this.gender = gender; } + + public String getAvatar() { return avatar; } + public void setAvatar(String avatar) { this.avatar = avatar; } + + public Status getStatus() { return status; } + public void setStatus(Status status) { this.status = status; } + + public LocalDateTime getCreatedAt() { return createdAt; } + public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } +} diff --git a/im-service/src/main/java/com/xuqm/im/entity/ImGroupEntity.java b/im-service/src/main/java/com/xuqm/im/entity/ImGroupEntity.java new file mode 100644 index 0000000..d824a7f --- /dev/null +++ b/im-service/src/main/java/com/xuqm/im/entity/ImGroupEntity.java @@ -0,0 +1,54 @@ +package com.xuqm.im.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.time.LocalDateTime; + +@Entity +@Table(name = "im_group") +public class ImGroupEntity { + + @Id + private String id; + + @Column(nullable = false, length = 64) + private String appId; + + @Column(nullable = false, length = 128) + private String name; + + @Column(nullable = false, length = 128) + private String creatorId; + + @Column(nullable = false, columnDefinition = "TEXT") + private String memberIds; + + @Column(nullable = false, columnDefinition = "TEXT") + private String adminIds; + + @Column(nullable = false) + private LocalDateTime createdAt; + + public String getId() { return id; } + public void setId(String id) { this.id = id; } + + public String getAppId() { return appId; } + public void setAppId(String appId) { this.appId = appId; } + + public String getName() { return name; } + public void setName(String name) { this.name = name; } + + public String getCreatorId() { return creatorId; } + public void setCreatorId(String creatorId) { this.creatorId = creatorId; } + + public String getMemberIds() { return memberIds; } + public void setMemberIds(String memberIds) { this.memberIds = memberIds; } + + public String getAdminIds() { return adminIds; } + public void setAdminIds(String adminIds) { this.adminIds = adminIds; } + + public LocalDateTime getCreatedAt() { return createdAt; } + public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } +} diff --git a/im-service/src/main/java/com/xuqm/im/entity/ImMessageEntity.java b/im-service/src/main/java/com/xuqm/im/entity/ImMessageEntity.java new file mode 100644 index 0000000..78116e9 --- /dev/null +++ b/im-service/src/main/java/com/xuqm/im/entity/ImMessageEntity.java @@ -0,0 +1,88 @@ +package com.xuqm.im.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import java.time.LocalDateTime; + +@Entity +@Table(name = "im_message", indexes = { + @Index(name = "idx_app_from", columnList = "appId,fromUserId"), + @Index(name = "idx_app_to", columnList = "appId,toId") +}) +public class ImMessageEntity { + + public enum ChatType { SINGLE, GROUP } + public enum MsgType { + TEXT, IMAGE, VIDEO, AUDIO, FILE, CUSTOM, LOCATION, NOTIFY, + RICH_TEXT, CALL_AUDIO, CALL_VIDEO, REVOKED, FORWARD + } + public enum MsgStatus { SENT, DELIVERED, READ, REVOKED } + + @Id + private String id; + + @Column(nullable = false, length = 64) + private String appId; + + @Column(nullable = false, length = 128) + private String fromUserId; + + @Column(nullable = false, length = 128) + private String toId; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 16) + private ChatType chatType; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 16) + private MsgType msgType; + + @Column(nullable = false, columnDefinition = "TEXT") + private String content; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 16) + private MsgStatus status; + + @Column(length = 128) + private String mentionedUserIds; + + @Column(nullable = false) + private LocalDateTime createdAt; + + public String getId() { return id; } + public void setId(String id) { this.id = id; } + + public String getAppId() { return appId; } + public void setAppId(String appId) { this.appId = appId; } + + public String getFromUserId() { return fromUserId; } + public void setFromUserId(String fromUserId) { this.fromUserId = fromUserId; } + + public String getToId() { return toId; } + public void setToId(String toId) { this.toId = toId; } + + public ChatType getChatType() { return chatType; } + public void setChatType(ChatType chatType) { this.chatType = chatType; } + + public MsgType getMsgType() { return msgType; } + public void setMsgType(MsgType msgType) { this.msgType = msgType; } + + public String getContent() { return content; } + public void setContent(String content) { this.content = content; } + + public MsgStatus getStatus() { return status; } + public void setStatus(MsgStatus status) { this.status = status; } + + public String getMentionedUserIds() { return mentionedUserIds; } + public void setMentionedUserIds(String mentionedUserIds) { this.mentionedUserIds = mentionedUserIds; } + + public LocalDateTime getCreatedAt() { return createdAt; } + public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } +} diff --git a/im-service/src/main/java/com/xuqm/im/entity/KeywordFilterEntity.java b/im-service/src/main/java/com/xuqm/im/entity/KeywordFilterEntity.java new file mode 100644 index 0000000..b69ef83 --- /dev/null +++ b/im-service/src/main/java/com/xuqm/im/entity/KeywordFilterEntity.java @@ -0,0 +1,56 @@ +package com.xuqm.im.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.time.LocalDateTime; + +@Entity +@Table(name = "im_keyword_filter") +public class KeywordFilterEntity { + + public enum Action { REPLACE, BLOCK } + + @Id + private String id; + + @Column(nullable = false, length = 64) + private String appId; + + @Column(nullable = false, length = 512) + private String pattern; + + @Column(length = 128) + private String replacement; + + @Column(nullable = false, length = 16) + private String action; + + @Column(nullable = false) + private boolean enabled; + + @Column(nullable = false) + private LocalDateTime createdAt; + + public String getId() { return id; } + public void setId(String id) { this.id = id; } + + public String getAppId() { return appId; } + public void setAppId(String appId) { this.appId = appId; } + + public String getPattern() { return pattern; } + public void setPattern(String pattern) { this.pattern = pattern; } + + public String getReplacement() { return replacement; } + public void setReplacement(String replacement) { this.replacement = replacement; } + + public String getAction() { return action; } + public void setAction(String action) { this.action = action; } + + public boolean isEnabled() { return enabled; } + public void setEnabled(boolean enabled) { this.enabled = enabled; } + + public LocalDateTime getCreatedAt() { return createdAt; } + public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } +} diff --git a/im-service/src/main/java/com/xuqm/im/entity/WebhookConfigEntity.java b/im-service/src/main/java/com/xuqm/im/entity/WebhookConfigEntity.java new file mode 100644 index 0000000..65d9106 --- /dev/null +++ b/im-service/src/main/java/com/xuqm/im/entity/WebhookConfigEntity.java @@ -0,0 +1,48 @@ +package com.xuqm.im.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.time.LocalDateTime; + +@Entity +@Table(name = "im_webhook_config") +public class WebhookConfigEntity { + + @Id + private String id; + + @Column(nullable = false, length = 64) + private String appId; + + @Column(nullable = false, length = 512) + private String url; + + @Column(length = 256) + private String secret; + + @Column(nullable = false) + private boolean enabled; + + @Column(nullable = false) + private LocalDateTime createdAt; + + public String getId() { return id; } + public void setId(String id) { this.id = id; } + + public String getAppId() { return appId; } + public void setAppId(String appId) { this.appId = appId; } + + public String getUrl() { return url; } + public void setUrl(String url) { this.url = url; } + + public String getSecret() { return secret; } + public void setSecret(String secret) { this.secret = secret; } + + public boolean isEnabled() { return enabled; } + public void setEnabled(boolean enabled) { this.enabled = enabled; } + + public LocalDateTime getCreatedAt() { return createdAt; } + public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } +} diff --git a/im-service/src/main/java/com/xuqm/im/model/SendMessageRequest.java b/im-service/src/main/java/com/xuqm/im/model/SendMessageRequest.java new file mode 100644 index 0000000..1380abd --- /dev/null +++ b/im-service/src/main/java/com/xuqm/im/model/SendMessageRequest.java @@ -0,0 +1,13 @@ +package com.xuqm.im.model; + +import com.xuqm.im.entity.ImMessageEntity; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +public record SendMessageRequest( + @NotBlank String toId, + @NotNull ImMessageEntity.ChatType chatType, + @NotNull ImMessageEntity.MsgType msgType, + @NotBlank String content, + String mentionedUserIds +) {} diff --git a/im-service/src/main/java/com/xuqm/im/repository/ImAccountRepository.java b/im-service/src/main/java/com/xuqm/im/repository/ImAccountRepository.java new file mode 100644 index 0000000..3cceb19 --- /dev/null +++ b/im-service/src/main/java/com/xuqm/im/repository/ImAccountRepository.java @@ -0,0 +1,10 @@ +package com.xuqm.im.repository; + +import com.xuqm.im.entity.ImAccountEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import java.util.Optional; + +public interface ImAccountRepository extends JpaRepository { + Optional findByAppIdAndUserId(String appId, String userId); + boolean existsByAppIdAndUserId(String appId, String userId); +} diff --git a/im-service/src/main/java/com/xuqm/im/repository/ImGroupRepository.java b/im-service/src/main/java/com/xuqm/im/repository/ImGroupRepository.java new file mode 100644 index 0000000..39b9a34 --- /dev/null +++ b/im-service/src/main/java/com/xuqm/im/repository/ImGroupRepository.java @@ -0,0 +1,9 @@ +package com.xuqm.im.repository; + +import com.xuqm.im.entity.ImGroupEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import java.util.List; + +public interface ImGroupRepository extends JpaRepository { + List findByAppId(String appId); +} diff --git a/im-service/src/main/java/com/xuqm/im/repository/ImMessageRepository.java b/im-service/src/main/java/com/xuqm/im/repository/ImMessageRepository.java new file mode 100644 index 0000000..c483ea1 --- /dev/null +++ b/im-service/src/main/java/com/xuqm/im/repository/ImMessageRepository.java @@ -0,0 +1,13 @@ +package com.xuqm.im.repository; + +import com.xuqm.im.entity.ImMessageEntity; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ImMessageRepository extends JpaRepository { + Page findByAppIdAndToIdOrderByCreatedAtDesc( + String appId, String toId, Pageable pageable); + Page findByAppIdAndFromUserIdAndToIdOrderByCreatedAtDesc( + String appId, String fromUserId, String toId, Pageable pageable); +} diff --git a/im-service/src/main/java/com/xuqm/im/repository/KeywordFilterRepository.java b/im-service/src/main/java/com/xuqm/im/repository/KeywordFilterRepository.java new file mode 100644 index 0000000..37ba8b3 --- /dev/null +++ b/im-service/src/main/java/com/xuqm/im/repository/KeywordFilterRepository.java @@ -0,0 +1,9 @@ +package com.xuqm.im.repository; + +import com.xuqm.im.entity.KeywordFilterEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import java.util.List; + +public interface KeywordFilterRepository extends JpaRepository { + List findByAppIdAndEnabledTrue(String appId); +} diff --git a/im-service/src/main/java/com/xuqm/im/repository/WebhookConfigRepository.java b/im-service/src/main/java/com/xuqm/im/repository/WebhookConfigRepository.java new file mode 100644 index 0000000..58231e0 --- /dev/null +++ b/im-service/src/main/java/com/xuqm/im/repository/WebhookConfigRepository.java @@ -0,0 +1,9 @@ +package com.xuqm.im.repository; + +import com.xuqm.im.entity.WebhookConfigEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import java.util.List; + +public interface WebhookConfigRepository extends JpaRepository { + List findByAppIdAndEnabledTrue(String appId); +} diff --git a/im-service/src/main/java/com/xuqm/im/service/ImAccountService.java b/im-service/src/main/java/com/xuqm/im/service/ImAccountService.java new file mode 100644 index 0000000..a54df2e --- /dev/null +++ b/im-service/src/main/java/com/xuqm/im/service/ImAccountService.java @@ -0,0 +1,59 @@ +package com.xuqm.im.service; + +import com.xuqm.common.exception.BusinessException; +import com.xuqm.common.security.JwtUtil; +import com.xuqm.im.entity.ImAccountEntity; +import com.xuqm.im.repository.ImAccountRepository; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.util.Map; +import java.util.UUID; + +@Service +public class ImAccountService { + + private final ImAccountRepository accountRepository; + private final JwtUtil jwtUtil; + + public ImAccountService(ImAccountRepository accountRepository, JwtUtil jwtUtil) { + this.accountRepository = accountRepository; + this.jwtUtil = jwtUtil; + } + + public String loginOrRegister(String appId, String userId, String nickname, String avatar) { + ImAccountEntity account = accountRepository.findByAppIdAndUserId(appId, userId) + .orElseGet(() -> { + ImAccountEntity e = new ImAccountEntity(); + e.setId(UUID.randomUUID().toString()); + e.setAppId(appId); + e.setUserId(userId); + e.setNickname(nickname); + e.setAvatar(avatar); + e.setGender(ImAccountEntity.Gender.UNKNOWN); + e.setStatus(ImAccountEntity.Status.ACTIVE); + e.setCreatedAt(LocalDateTime.now()); + return accountRepository.save(e); + }); + + if (account.getStatus() == ImAccountEntity.Status.BANNED) { + throw new BusinessException(403, "账号已被封禁"); + } + + return jwtUtil.generate(userId, Map.of("appId", appId, "role", "USER")); + } + + public ImAccountEntity getAccount(String appId, String userId) { + return accountRepository.findByAppIdAndUserId(appId, userId) + .orElseThrow(() -> new BusinessException(404, "账号不存在")); + } + + public ImAccountEntity updateAccount(String appId, String userId, String nickname, + String avatar, ImAccountEntity.Gender gender) { + ImAccountEntity account = getAccount(appId, userId); + if (nickname != null) account.setNickname(nickname); + if (avatar != null) account.setAvatar(avatar); + if (gender != null) account.setGender(gender); + return accountRepository.save(account); + } +} diff --git a/im-service/src/main/java/com/xuqm/im/service/ImGroupService.java b/im-service/src/main/java/com/xuqm/im/service/ImGroupService.java new file mode 100644 index 0000000..8f5e893 --- /dev/null +++ b/im-service/src/main/java/com/xuqm/im/service/ImGroupService.java @@ -0,0 +1,77 @@ +package com.xuqm.im.service; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.xuqm.common.exception.BusinessException; +import com.xuqm.im.entity.ImGroupEntity; +import com.xuqm.im.repository.ImGroupRepository; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +@Service +public class ImGroupService { + + private final ImGroupRepository groupRepository; + private final ObjectMapper objectMapper; + + public ImGroupService(ImGroupRepository groupRepository, ObjectMapper objectMapper) { + this.groupRepository = groupRepository; + this.objectMapper = objectMapper; + } + + public ImGroupEntity create(String appId, String name, String creatorId, List memberIds) { + List members = new ArrayList<>(memberIds); + if (!members.contains(creatorId)) members.add(creatorId); + + ImGroupEntity group = new ImGroupEntity(); + group.setId(UUID.randomUUID().toString()); + group.setAppId(appId); + group.setName(name); + group.setCreatorId(creatorId); + group.setMemberIds(toJson(members)); + group.setAdminIds(toJson(List.of(creatorId))); + group.setCreatedAt(LocalDateTime.now()); + return groupRepository.save(group); + } + + public ImGroupEntity addMember(String groupId, String userId) { + ImGroupEntity group = groupRepository.findById(groupId) + .orElseThrow(() -> new BusinessException(404, "群组不存在")); + List members = fromJson(group.getMemberIds()); + if (!members.contains(userId)) { + members.add(userId); + group.setMemberIds(toJson(members)); + groupRepository.save(group); + } + return group; + } + + public ImGroupEntity removeMember(String groupId, String userId, String operatorId) { + ImGroupEntity group = groupRepository.findById(groupId) + .orElseThrow(() -> new BusinessException(404, "群组不存在")); + List admins = fromJson(group.getAdminIds()); + if (!admins.contains(operatorId) && !group.getCreatorId().equals(operatorId)) { + throw new BusinessException(403, "无权操作"); + } + List members = new ArrayList<>(fromJson(group.getMemberIds())); + members.remove(userId); + group.setMemberIds(toJson(members)); + return groupRepository.save(group); + } + + public List listByApp(String appId) { + return groupRepository.findByAppId(appId); + } + + private String toJson(List list) { + try { return objectMapper.writeValueAsString(list); } catch (Exception e) { return "[]"; } + } + + private List fromJson(String json) { + try { return objectMapper.readValue(json, new TypeReference<>() {}); } catch (Exception e) { return new ArrayList<>(); } + } +} diff --git a/im-service/src/main/java/com/xuqm/im/service/KeywordFilterService.java b/im-service/src/main/java/com/xuqm/im/service/KeywordFilterService.java new file mode 100644 index 0000000..d9e1309 --- /dev/null +++ b/im-service/src/main/java/com/xuqm/im/service/KeywordFilterService.java @@ -0,0 +1,60 @@ +package com.xuqm.im.service; + +import com.xuqm.im.entity.KeywordFilterEntity; +import com.xuqm.im.repository.KeywordFilterRepository; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; +import java.util.regex.Pattern; + +@Service +public class KeywordFilterService { + + private final KeywordFilterRepository repository; + + public KeywordFilterService(KeywordFilterRepository repository) { + this.repository = repository; + } + + public String filter(String appId, String content) { + List filters = repository.findByAppIdAndEnabledTrue(appId); + String result = content; + for (KeywordFilterEntity f : filters) { + try { + Pattern p = Pattern.compile(f.getPattern()); + if ("BLOCK".equals(f.getAction())) { + if (p.matcher(result).find()) { + return null; + } + } else { + String replacement = f.getReplacement() != null ? f.getReplacement() : "***"; + result = p.matcher(result).replaceAll(replacement); + } + } catch (Exception ignored) { + } + } + return result; + } + + public KeywordFilterEntity add(String appId, String pattern, String replacement, String action) { + KeywordFilterEntity entity = new KeywordFilterEntity(); + entity.setId(UUID.randomUUID().toString()); + entity.setAppId(appId); + entity.setPattern(pattern); + entity.setReplacement(replacement); + entity.setAction(action); + entity.setEnabled(true); + entity.setCreatedAt(LocalDateTime.now()); + return repository.save(entity); + } + + public List list(String appId) { + return repository.findByAppIdAndEnabledTrue(appId); + } + + public void delete(String id) { + repository.deleteById(id); + } +} diff --git a/im-service/src/main/java/com/xuqm/im/service/MessageService.java b/im-service/src/main/java/com/xuqm/im/service/MessageService.java new file mode 100644 index 0000000..298e348 --- /dev/null +++ b/im-service/src/main/java/com/xuqm/im/service/MessageService.java @@ -0,0 +1,122 @@ +package com.xuqm.im.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.xuqm.common.exception.BusinessException; +import com.xuqm.im.entity.ImMessageEntity; +import com.xuqm.im.entity.WebhookConfigEntity; +import com.xuqm.im.model.SendMessageRequest; +import com.xuqm.im.repository.ImMessageRepository; +import com.xuqm.im.repository.WebhookConfigRepository; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +@Service +public class MessageService { + + private final ImMessageRepository messageRepository; + private final WebhookConfigRepository webhookRepository; + private final KeywordFilterService keywordFilterService; + private final SimpMessagingTemplate messagingTemplate; + private final ObjectMapper objectMapper; + + @Value("${im.webhook-timeout-ms:3000}") + private int webhookTimeoutMs; + + public MessageService(ImMessageRepository messageRepository, + WebhookConfigRepository webhookRepository, + KeywordFilterService keywordFilterService, + SimpMessagingTemplate messagingTemplate, + ObjectMapper objectMapper) { + this.messageRepository = messageRepository; + this.webhookRepository = webhookRepository; + this.keywordFilterService = keywordFilterService; + this.messagingTemplate = messagingTemplate; + this.objectMapper = objectMapper; + } + + public ImMessageEntity send(String appId, String fromUserId, SendMessageRequest req) { + String content = req.content(); + if (req.msgType() == ImMessageEntity.MsgType.TEXT) { + content = keywordFilterService.filter(appId, content); + if (content == null) { + throw new BusinessException("消息包含违禁内容"); + } + } + + ImMessageEntity message = new ImMessageEntity(); + message.setId(UUID.randomUUID().toString()); + message.setAppId(appId); + message.setFromUserId(fromUserId); + message.setToId(req.toId()); + message.setChatType(req.chatType()); + message.setMsgType(req.msgType()); + message.setContent(content); + message.setStatus(ImMessageEntity.MsgStatus.SENT); + message.setMentionedUserIds(req.mentionedUserIds()); + message.setCreatedAt(LocalDateTime.now()); + messageRepository.save(message); + + String destination = req.chatType() == ImMessageEntity.ChatType.SINGLE + ? "/user/" + req.toId() + "/queue/messages" + : "/topic/group/" + req.toId(); + messagingTemplate.convertAndSend(destination, message); + + dispatchWebhooks(appId, message); + + return message; + } + + public ImMessageEntity revoke(String appId, String messageId, String requestUserId) { + ImMessageEntity message = messageRepository.findById(messageId) + .orElseThrow(() -> new BusinessException(404, "消息不存在")); + if (!message.getAppId().equals(appId)) { + throw new BusinessException(403, "无权操作"); + } + if (!message.getFromUserId().equals(requestUserId)) { + throw new BusinessException(403, "只能撤回自己发送的消息"); + } + message.setStatus(ImMessageEntity.MsgStatus.REVOKED); + message.setMsgType(ImMessageEntity.MsgType.REVOKED); + return messageRepository.save(message); + } + + public Page history(String appId, String toId, int page, int size) { + return messageRepository.findByAppIdAndToIdOrderByCreatedAtDesc( + appId, toId, PageRequest.of(page, size)); + } + + @Async + protected void dispatchWebhooks(String appId, ImMessageEntity message) { + List webhooks = webhookRepository.findByAppIdAndEnabledTrue(appId); + if (webhooks.isEmpty()) return; + + try { + String body = objectMapper.writeValueAsString(message); + HttpClient client = HttpClient.newHttpClient(); + for (WebhookConfigEntity webhook : webhooks) { + try { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(webhook.getUrl())) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(body)) + .build(); + client.send(request, HttpResponse.BodyHandlers.ofString()); + } catch (Exception ignored) { + } + } + } catch (Exception ignored) { + } + } +} diff --git a/im-service/src/main/java/com/xuqm/im/ws/ChatController.java b/im-service/src/main/java/com/xuqm/im/ws/ChatController.java new file mode 100644 index 0000000..f687ad7 --- /dev/null +++ b/im-service/src/main/java/com/xuqm/im/ws/ChatController.java @@ -0,0 +1,46 @@ +package com.xuqm.im.ws; + +import com.xuqm.im.entity.ImMessageEntity; +import com.xuqm.im.model.SendMessageRequest; +import com.xuqm.im.service.MessageService; +import org.springframework.messaging.handler.annotation.MessageMapping; +import org.springframework.messaging.handler.annotation.Payload; +import org.springframework.stereotype.Controller; + +import java.security.Principal; + +@Controller +public class ChatController { + + private final MessageService messageService; + + public ChatController(MessageService messageService) { + this.messageService = messageService; + } + + @MessageMapping("/chat.send") + public void send(@Payload WsMessageRequest request, Principal principal) { + if (principal == null) return; + String userId = principal.getName(); + SendMessageRequest req = new SendMessageRequest( + request.toId(), request.chatType(), request.msgType(), + request.content(), request.mentionedUserIds() + ); + messageService.send(request.appId(), userId, req); + } + + @MessageMapping("/chat.revoke") + public void revoke(@Payload WsRevokeRequest request, Principal principal) { + if (principal == null) return; + messageService.revoke(request.appId(), request.messageId(), principal.getName()); + } + + public record WsMessageRequest( + String appId, String toId, + ImMessageEntity.ChatType chatType, + ImMessageEntity.MsgType msgType, + String content, String mentionedUserIds + ) {} + + public record WsRevokeRequest(String appId, String messageId) {} +} diff --git a/im-service/src/main/resources/application.yml b/im-service/src/main/resources/application.yml new file mode 100644 index 0000000..c5b39d3 --- /dev/null +++ b/im-service/src/main/resources/application.yml @@ -0,0 +1,39 @@ +server: + port: 8082 + +spring: + application: + name: im-service + datasource: + url: jdbc:mysql://localhost:3306/xuqm_im?useSSL=false&serverTimezone=UTC&createDatabaseIfNotExist=true&allowPublicKeyRetrieval=true + username: root + password: root + driver-class-name: com.mysql.cj.jdbc.Driver + jpa: + hibernate: + ddl-auto: update + show-sql: false + properties: + hibernate: + dialect: org.hibernate.dialect.MySQLDialect + data: + redis: + host: localhost + port: 6379 + jackson: + time-zone: UTC + serialization: + write-dates-as-timestamps: false + +jwt: + secret: xuqm-im-service-secret-key-must-be-at-least-256-bits-long-for-hmac-sha + expiration: 86400000 + +im: + multi-login: true + message-history-days: 30 + webhook-timeout-ms: 3000 + +logging: + level: + com.xuqm: DEBUG diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..1c3a05b --- /dev/null +++ b/pom.xml @@ -0,0 +1,85 @@ + + + 4.0.0 + + com.xuqm + xuqmgroup-server-parent + 0.1.0-SNAPSHOT + pom + xuqmgroup-server-parent + XuqmGroup Platform — Multi-tenant SaaS backend microservices + + + common + tenant-service + im-service + push-service + update-service + + + + 21 + 3.4.4 + 0.12.6 + 21 + 21 + 21 + UTF-8 + + + + + + org.springframework.boot + spring-boot-dependencies + ${spring-boot.version} + pom + import + + + com.xuqm + common + ${project.version} + + + io.jsonwebtoken + jjwt-api + ${jjwt.version} + + + io.jsonwebtoken + jjwt-impl + ${jjwt.version} + + + io.jsonwebtoken + jjwt-jackson + ${jjwt.version} + + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.13.0 + + ${java.version} + ${project.build.sourceEncoding} + true + + + + org.springframework.boot + spring-boot-maven-plugin + ${spring-boot.version} + + + + + diff --git a/push-service/pom.xml b/push-service/pom.xml new file mode 100644 index 0000000..cbe5739 --- /dev/null +++ b/push-service/pom.xml @@ -0,0 +1,77 @@ + + + 4.0.0 + + + com.xuqm + xuqmgroup-server-parent + 0.1.0-SNAPSHOT + ../pom.xml + + + push-service + push-service + Offline push notification service: Huawei, Xiaomi, OPPO, vivo, Honor, APNs + + + + com.xuqm + common + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.springframework.boot + spring-boot-starter-security + + + org.springframework.boot + spring-boot-starter-validation + + + io.jsonwebtoken + jjwt-api + + + io.jsonwebtoken + jjwt-impl + runtime + + + io.jsonwebtoken + jjwt-jackson + runtime + + + com.fasterxml.jackson.datatype + jackson-datatype-jsr310 + + + com.mysql + mysql-connector-j + runtime + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/push-service/src/main/java/com/xuqm/push/PushServiceApplication.java b/push-service/src/main/java/com/xuqm/push/PushServiceApplication.java new file mode 100644 index 0000000..7d2824f --- /dev/null +++ b/push-service/src/main/java/com/xuqm/push/PushServiceApplication.java @@ -0,0 +1,15 @@ +package com.xuqm.push; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.scheduling.annotation.EnableAsync; + +@SpringBootApplication +@ComponentScan(basePackages = {"com.xuqm.push", "com.xuqm.common"}) +@EnableAsync +public class PushServiceApplication { + public static void main(String[] args) { + SpringApplication.run(PushServiceApplication.class, args); + } +} diff --git a/push-service/src/main/java/com/xuqm/push/controller/PushController.java b/push-service/src/main/java/com/xuqm/push/controller/PushController.java new file mode 100644 index 0000000..504479e --- /dev/null +++ b/push-service/src/main/java/com/xuqm/push/controller/PushController.java @@ -0,0 +1,44 @@ +package com.xuqm.push.controller; + +import com.xuqm.common.model.ApiResponse; +import com.xuqm.push.entity.DeviceTokenEntity; +import com.xuqm.push.service.PushDispatcher; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/push") +public class PushController { + + private final PushDispatcher pushDispatcher; + + public PushController(PushDispatcher pushDispatcher) { + this.pushDispatcher = pushDispatcher; + } + + @PostMapping("/register") + public ResponseEntity> register( + @RequestParam @NotBlank String appId, + @RequestParam @NotBlank String userId, + @RequestParam @NotNull DeviceTokenEntity.Vendor vendor, + @RequestParam @NotBlank String token) { + pushDispatcher.registerToken(appId, userId, vendor, token); + return ResponseEntity.ok(ApiResponse.ok()); + } + + @PostMapping("/send") + public ResponseEntity> send( + @RequestParam @NotBlank String appId, + @RequestParam @NotBlank String userId, + @RequestParam @NotBlank String title, + @RequestParam @NotBlank String body, + @RequestParam(required = false) String payload) { + pushDispatcher.pushToUser(appId, userId, title, body, payload); + return ResponseEntity.ok(ApiResponse.ok()); + } +} diff --git a/push-service/src/main/java/com/xuqm/push/entity/DeviceTokenEntity.java b/push-service/src/main/java/com/xuqm/push/entity/DeviceTokenEntity.java new file mode 100644 index 0000000..5e97bae --- /dev/null +++ b/push-service/src/main/java/com/xuqm/push/entity/DeviceTokenEntity.java @@ -0,0 +1,61 @@ +package com.xuqm.push.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import java.time.LocalDateTime; + +@Entity +@Table(name = "push_device_token", + uniqueConstraints = @UniqueConstraint(columnNames = {"appId", "userId", "vendor"})) +public class DeviceTokenEntity { + + public enum Vendor { HUAWEI, XIAOMI, OPPO, VIVO, HONOR, APNS, FCM } + + @Id + private String id; + + @Column(nullable = false, length = 64) + private String appId; + + @Column(nullable = false, length = 128) + private String userId; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 16) + private Vendor vendor; + + @Column(nullable = false, length = 512) + private String token; + + @Column(nullable = false) + private LocalDateTime createdAt; + + @Column(nullable = false) + private LocalDateTime updatedAt; + + public String getId() { return id; } + public void setId(String id) { this.id = id; } + + public String getAppId() { return appId; } + public void setAppId(String appId) { this.appId = appId; } + + public String getUserId() { return userId; } + public void setUserId(String userId) { this.userId = userId; } + + public Vendor getVendor() { return vendor; } + public void setVendor(Vendor vendor) { this.vendor = vendor; } + + public String getToken() { return token; } + public void setToken(String token) { this.token = token; } + + public LocalDateTime getCreatedAt() { return createdAt; } + public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } + + public LocalDateTime getUpdatedAt() { return updatedAt; } + public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; } +} diff --git a/push-service/src/main/java/com/xuqm/push/repository/DeviceTokenRepository.java b/push-service/src/main/java/com/xuqm/push/repository/DeviceTokenRepository.java new file mode 100644 index 0000000..e790cbd --- /dev/null +++ b/push-service/src/main/java/com/xuqm/push/repository/DeviceTokenRepository.java @@ -0,0 +1,13 @@ +package com.xuqm.push.repository; + +import com.xuqm.push.entity.DeviceTokenEntity; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface DeviceTokenRepository extends JpaRepository { + List findByAppIdAndUserId(String appId, String userId); + Optional findByAppIdAndUserIdAndVendor( + String appId, String userId, DeviceTokenEntity.Vendor vendor); +} diff --git a/push-service/src/main/java/com/xuqm/push/service/PushDispatcher.java b/push-service/src/main/java/com/xuqm/push/service/PushDispatcher.java new file mode 100644 index 0000000..f637354 --- /dev/null +++ b/push-service/src/main/java/com/xuqm/push/service/PushDispatcher.java @@ -0,0 +1,59 @@ +package com.xuqm.push.service; + +import com.xuqm.push.entity.DeviceTokenEntity; +import com.xuqm.push.repository.DeviceTokenRepository; +import com.xuqm.push.service.provider.PushProvider; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.stream.Collectors; + +@Service +public class PushDispatcher { + + private static final Logger log = LoggerFactory.getLogger(PushDispatcher.class); + + private final DeviceTokenRepository tokenRepository; + private final Map providers; + + public PushDispatcher(DeviceTokenRepository tokenRepository, List providerList) { + this.tokenRepository = tokenRepository; + this.providers = providerList.stream() + .collect(Collectors.toMap(PushProvider::vendorName, p -> p)); + } + + @Async + public void pushToUser(String appId, String userId, String title, String body, String payload) { + List tokens = tokenRepository.findByAppIdAndUserId(appId, userId); + for (DeviceTokenEntity t : tokens) { + PushProvider provider = providers.get(t.getVendor().name()); + if (provider != null) { + boolean ok = provider.send(t.getToken(), title, body, payload); + log.info("Push to {}@{} via {}: {}", userId, appId, t.getVendor(), ok ? "OK" : "FAIL"); + } + } + } + + public void registerToken(String appId, String userId, DeviceTokenEntity.Vendor vendor, String token) { + Optional existing = tokenRepository.findByAppIdAndUserIdAndVendor(appId, userId, vendor); + DeviceTokenEntity entity = existing.orElseGet(() -> { + DeviceTokenEntity e = new DeviceTokenEntity(); + e.setId(UUID.randomUUID().toString()); + e.setAppId(appId); + e.setUserId(userId); + e.setVendor(vendor); + e.setCreatedAt(LocalDateTime.now()); + return e; + }); + entity.setToken(token); + entity.setUpdatedAt(LocalDateTime.now()); + tokenRepository.save(entity); + } +} diff --git a/push-service/src/main/java/com/xuqm/push/service/provider/HuaweiPushProvider.java b/push-service/src/main/java/com/xuqm/push/service/provider/HuaweiPushProvider.java new file mode 100644 index 0000000..e3ed53b --- /dev/null +++ b/push-service/src/main/java/com/xuqm/push/service/provider/HuaweiPushProvider.java @@ -0,0 +1,82 @@ +package com.xuqm.push.service.provider; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.Map; + +@Component +public class HuaweiPushProvider implements PushProvider { + + private static final Logger log = LoggerFactory.getLogger(HuaweiPushProvider.class); + + @Value("${push.huawei.app-id:}") + private String appId; + + @Value("${push.huawei.app-secret:}") + private String appSecret; + + @Value("${push.huawei.token-url:https://oauth-login.cloud.huawei.com/oauth2/v3/token}") + private String tokenUrl; + + @Value("${push.huawei.push-url:https://push-api.cloud.huawei.com/v1/{appId}/messages:send}") + private String pushUrl; + + private final HttpClient httpClient = HttpClient.newHttpClient(); + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Override + public String vendorName() { + return "HUAWEI"; + } + + @Override + public boolean send(String token, String title, String body, String payload) { + if (appId.isBlank() || appSecret.isBlank()) { + log.warn("Huawei push not configured"); + return false; + } + try { + String accessToken = getAccessToken(); + String url = pushUrl.replace("{appId}", appId); + Map message = Map.of( + "message", Map.of( + "token", new String[]{token}, + "notification", Map.of("title", title, "body", body), + "data", payload != null ? payload : "{}" + ) + ); + String requestBody = objectMapper.writeValueAsString(message); + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(url)) + .header("Content-Type", "application/json") + .header("Authorization", "Bearer " + accessToken) + .POST(HttpRequest.BodyPublishers.ofString(requestBody)) + .build(); + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + return response.statusCode() == 200; + } catch (Exception e) { + log.error("Huawei push failed: {}", e.getMessage()); + return false; + } + } + + private String getAccessToken() throws Exception { + String form = "grant_type=client_credentials&client_id=" + appId + "&client_secret=" + appSecret; + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(tokenUrl)) + .header("Content-Type", "application/x-www-form-urlencoded") + .POST(HttpRequest.BodyPublishers.ofString(form)) + .build(); + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + Map json = objectMapper.readValue(response.body(), Map.class); + return (String) json.get("access_token"); + } +} diff --git a/push-service/src/main/java/com/xuqm/push/service/provider/PushProvider.java b/push-service/src/main/java/com/xuqm/push/service/provider/PushProvider.java new file mode 100644 index 0000000..055883e --- /dev/null +++ b/push-service/src/main/java/com/xuqm/push/service/provider/PushProvider.java @@ -0,0 +1,6 @@ +package com.xuqm.push.service.provider; + +public interface PushProvider { + String vendorName(); + boolean send(String token, String title, String body, String payload); +} diff --git a/push-service/src/main/java/com/xuqm/push/service/provider/XiaomiPushProvider.java b/push-service/src/main/java/com/xuqm/push/service/provider/XiaomiPushProvider.java new file mode 100644 index 0000000..08ff763 --- /dev/null +++ b/push-service/src/main/java/com/xuqm/push/service/provider/XiaomiPushProvider.java @@ -0,0 +1,56 @@ +package com.xuqm.push.service.provider; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; + +@Component +public class XiaomiPushProvider implements PushProvider { + + private static final Logger log = LoggerFactory.getLogger(XiaomiPushProvider.class); + + @Value("${push.xiaomi.app-secret:}") + private String appSecret; + + @Value("${push.xiaomi.push-url:https://api.xmpush.xiaomi.com/v3/message/regid}") + private String pushUrl; + + private final HttpClient httpClient = HttpClient.newHttpClient(); + + @Override + public String vendorName() { return "XIAOMI"; } + + @Override + public boolean send(String token, String title, String body, String payload) { + if (appSecret.isBlank()) { + log.warn("Xiaomi push not configured"); + return false; + } + try { + String form = "registration_id=" + URLEncoder.encode(token, StandardCharsets.UTF_8) + + "&title=" + URLEncoder.encode(title, StandardCharsets.UTF_8) + + "&description=" + URLEncoder.encode(body, StandardCharsets.UTF_8) + + "&restricted_package_name=com.example.app" + + "¬ify_type=1"; + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(pushUrl)) + .header("Content-Type", "application/x-www-form-urlencoded") + .header("Authorization", "key=" + appSecret) + .POST(HttpRequest.BodyPublishers.ofString(form)) + .build(); + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + return response.statusCode() == 200; + } catch (Exception e) { + log.error("Xiaomi push failed: {}", e.getMessage()); + return false; + } + } +} diff --git a/push-service/src/main/resources/application.yml b/push-service/src/main/resources/application.yml new file mode 100644 index 0000000..6d21f43 --- /dev/null +++ b/push-service/src/main/resources/application.yml @@ -0,0 +1,35 @@ +server: + port: 8083 + +spring: + application: + name: push-service + datasource: + url: jdbc:mysql://localhost:3306/xuqm_push?useSSL=false&serverTimezone=UTC&createDatabaseIfNotExist=true&allowPublicKeyRetrieval=true + username: root + password: root + driver-class-name: com.mysql.cj.jdbc.Driver + jpa: + hibernate: + ddl-auto: update + show-sql: false + +jwt: + secret: xuqm-push-service-secret-key-must-be-at-least-256-bits-long-for-hmac-sha + expiration: 86400000 + +push: + huawei: + app-id: ${HUAWEI_APP_ID:} + app-secret: ${HUAWEI_APP_SECRET:} + token-url: https://oauth-login.cloud.huawei.com/oauth2/v3/token + push-url: https://push-api.cloud.huawei.com/v1/{appId}/messages:send + xiaomi: + app-secret: ${XIAOMI_APP_SECRET:} + push-url: https://api.xmpush.xiaomi.com/v3/message/regid + apns: + key-id: ${APNS_KEY_ID:} + team-id: ${APNS_TEAM_ID:} + key-path: ${APNS_KEY_PATH:} + bundle-id: ${APNS_BUNDLE_ID:} + production: false diff --git a/tenant-service/pom.xml b/tenant-service/pom.xml new file mode 100644 index 0000000..eb71fd8 --- /dev/null +++ b/tenant-service/pom.xml @@ -0,0 +1,89 @@ + + + 4.0.0 + + + com.xuqm + xuqmgroup-server-parent + 0.1.0-SNAPSHOT + ../pom.xml + + + tenant-service + tenant-service + Tenant management: auth, sub-accounts, apps, feature services + + + + com.xuqm + common + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.springframework.boot + spring-boot-starter-data-redis + + + org.springframework.boot + spring-boot-starter-mail + + + org.springframework.boot + spring-boot-starter-security + + + org.springframework.boot + spring-boot-starter-validation + + + org.springframework.boot + spring-boot-starter-actuator + + + io.jsonwebtoken + jjwt-api + + + io.jsonwebtoken + jjwt-impl + runtime + + + io.jsonwebtoken + jjwt-jackson + runtime + + + com.fasterxml.jackson.datatype + jackson-datatype-jsr310 + + + com.mysql + mysql-connector-j + runtime + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/tenant-service/src/main/java/com/xuqm/tenant/TenantServiceApplication.java b/tenant-service/src/main/java/com/xuqm/tenant/TenantServiceApplication.java new file mode 100644 index 0000000..1ec5f50 --- /dev/null +++ b/tenant-service/src/main/java/com/xuqm/tenant/TenantServiceApplication.java @@ -0,0 +1,15 @@ +package com.xuqm.tenant; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.scheduling.annotation.EnableAsync; + +@SpringBootApplication +@ComponentScan(basePackages = {"com.xuqm.tenant", "com.xuqm.common"}) +@EnableAsync +public class TenantServiceApplication { + public static void main(String[] args) { + SpringApplication.run(TenantServiceApplication.class, args); + } +} diff --git a/tenant-service/src/main/java/com/xuqm/tenant/config/OpsAdminInitializer.java b/tenant-service/src/main/java/com/xuqm/tenant/config/OpsAdminInitializer.java new file mode 100644 index 0000000..0743fcf --- /dev/null +++ b/tenant-service/src/main/java/com/xuqm/tenant/config/OpsAdminInitializer.java @@ -0,0 +1,28 @@ +package com.xuqm.tenant.config; + +import com.xuqm.tenant.service.OpsService; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.stereotype.Component; + +@Component +public class OpsAdminInitializer implements ApplicationRunner { + + private final OpsService opsService; + + @Value("${ops.admin.username:admin}") + private String adminUsername; + + @Value("${ops.admin.password:Admin@123456}") + private String adminPassword; + + public OpsAdminInitializer(OpsService opsService) { + this.opsService = opsService; + } + + @Override + public void run(ApplicationArguments args) { + opsService.initDefaultAdmin(adminUsername, adminPassword); + } +} diff --git a/tenant-service/src/main/java/com/xuqm/tenant/config/SecurityConfig.java b/tenant-service/src/main/java/com/xuqm/tenant/config/SecurityConfig.java new file mode 100644 index 0000000..091d8ff --- /dev/null +++ b/tenant-service/src/main/java/com/xuqm/tenant/config/SecurityConfig.java @@ -0,0 +1,49 @@ +package com.xuqm.tenant.config; + +import com.xuqm.common.security.JwtAuthFilter; +import com.xuqm.common.security.JwtUtil; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +@Configuration +@EnableWebSecurity +@EnableMethodSecurity +public class SecurityConfig { + + private final JwtUtil jwtUtil; + + public SecurityConfig(JwtUtil jwtUtil) { + this.jwtUtil = jwtUtil; + } + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http + .csrf(AbstractHttpConfigurer::disable) + .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(auth -> auth + .requestMatchers( + "/api/auth/**", + "/actuator/health", + "/actuator/info" + ).permitAll() + .anyRequest().authenticated() + ) + .addFilterBefore(new JwtAuthFilter(jwtUtil), UsernamePasswordAuthenticationFilter.class); + return http.build(); + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} diff --git a/tenant-service/src/main/java/com/xuqm/tenant/config/WebConfig.java b/tenant-service/src/main/java/com/xuqm/tenant/config/WebConfig.java new file mode 100644 index 0000000..265ddd8 --- /dev/null +++ b/tenant-service/src/main/java/com/xuqm/tenant/config/WebConfig.java @@ -0,0 +1,19 @@ +package com.xuqm.tenant.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +public class WebConfig implements WebMvcConfigurer { + + @Override + public void addCorsMappings(CorsRegistry registry) { + registry.addMapping("/**") + .allowedOriginPatterns("*") + .allowedMethods("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS") + .allowedHeaders("*") + .allowCredentials(true) + .maxAge(3600); + } +} diff --git a/tenant-service/src/main/java/com/xuqm/tenant/controller/AppController.java b/tenant-service/src/main/java/com/xuqm/tenant/controller/AppController.java new file mode 100644 index 0000000..3d4af25 --- /dev/null +++ b/tenant-service/src/main/java/com/xuqm/tenant/controller/AppController.java @@ -0,0 +1,61 @@ +package com.xuqm.tenant.controller; + +import com.xuqm.common.model.ApiResponse; +import com.xuqm.tenant.dto.CreateAppRequest; +import com.xuqm.tenant.entity.AppEntity; +import com.xuqm.tenant.service.AppService; +import jakarta.validation.Valid; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RestController +@RequestMapping("/api/apps") +public class AppController { + + private final AppService appService; + + public AppController(AppService appService) { + this.appService = appService; + } + + @GetMapping + public ResponseEntity>> list(@AuthenticationPrincipal String tenantId) { + return ResponseEntity.ok(ApiResponse.success(appService.listByTenant(tenantId))); + } + + @GetMapping("/{id}") + public ResponseEntity> get(@PathVariable String id, + @AuthenticationPrincipal String tenantId) { + return ResponseEntity.ok(ApiResponse.success(appService.getById(id, tenantId))); + } + + @PostMapping + public ResponseEntity> create(@Valid @RequestBody CreateAppRequest req, + @AuthenticationPrincipal String tenantId) { + return ResponseEntity.ok(ApiResponse.success(appService.create(tenantId, req))); + } + + @PutMapping("/{id}") + public ResponseEntity> update(@PathVariable String id, + @Valid @RequestBody CreateAppRequest req, + @AuthenticationPrincipal String tenantId) { + return ResponseEntity.ok(ApiResponse.success(appService.update(id, tenantId, req))); + } + + @DeleteMapping("/{id}") + public ResponseEntity> delete(@PathVariable String id, + @AuthenticationPrincipal String tenantId) { + appService.delete(id, tenantId); + return ResponseEntity.ok(ApiResponse.ok()); + } +} diff --git a/tenant-service/src/main/java/com/xuqm/tenant/controller/AuthController.java b/tenant-service/src/main/java/com/xuqm/tenant/controller/AuthController.java new file mode 100644 index 0000000..4da36b1 --- /dev/null +++ b/tenant-service/src/main/java/com/xuqm/tenant/controller/AuthController.java @@ -0,0 +1,114 @@ +package com.xuqm.tenant.controller; + +import com.xuqm.common.model.ApiResponse; +import com.xuqm.tenant.dto.LoginRequest; +import com.xuqm.tenant.dto.RegisterRequest; +import com.xuqm.tenant.service.AuthService; +import com.xuqm.tenant.service.EmailService; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +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.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.awt.Color; +import java.awt.Font; +import java.awt.Graphics2D; +import java.awt.RenderingHints; +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; +import java.security.SecureRandom; +import java.util.Base64; +import java.util.Map; +import java.util.UUID; +import javax.imageio.ImageIO; + +@RestController +@RequestMapping("/api/auth") +public class AuthController { + + private final AuthService authService; + private final EmailService emailService; + private static final SecureRandom random = new SecureRandom(); + private static final String CHARS = "23456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz"; + + public AuthController(AuthService authService, EmailService emailService) { + this.authService = authService; + this.emailService = emailService; + } + + @GetMapping("/captcha") + public ResponseEntity>> captcha() throws Exception { + String key = UUID.randomUUID().toString(); + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < 4; i++) { + sb.append(CHARS.charAt(random.nextInt(CHARS.length()))); + } + String code = sb.toString(); + authService.storeCaptcha(key, code); + + BufferedImage img = new BufferedImage(120, 40, BufferedImage.TYPE_INT_RGB); + Graphics2D g = img.createGraphics(); + g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + g.setColor(new Color(245, 245, 245)); + g.fillRect(0, 0, 120, 40); + g.setFont(new Font("Arial", Font.BOLD, 24)); + for (int i = 0; i < code.length(); i++) { + g.setColor(new Color(random.nextInt(150), random.nextInt(150), random.nextInt(150))); + g.drawString(String.valueOf(code.charAt(i)), 10 + i * 28, 30); + } + for (int i = 0; i < 5; i++) { + g.setColor(new Color(random.nextInt(200), random.nextInt(200), random.nextInt(200))); + g.drawLine(random.nextInt(120), random.nextInt(40), random.nextInt(120), random.nextInt(40)); + } + g.dispose(); + + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + ImageIO.write(img, "png", baos); + String base64 = Base64.getEncoder().encodeToString(baos.toByteArray()); + + return ResponseEntity.ok(ApiResponse.success(Map.of( + "key", key, + "image", "data:image/png;base64," + base64 + ))); + } + + @PostMapping("/send-email-code") + public ResponseEntity> sendEmailCode(@RequestParam @NotBlank @Email String email, + @RequestParam @NotBlank String purpose) { + emailService.sendVerificationCode(email, purpose); + return ResponseEntity.ok(ApiResponse.ok()); + } + + @PostMapping("/register") + public ResponseEntity> register(@Valid @RequestBody RegisterRequest req) { + authService.register(req); + return ResponseEntity.ok(ApiResponse.ok()); + } + + @PostMapping("/login") + public ResponseEntity>> login(@Valid @RequestBody LoginRequest req) { + String token = authService.login(req); + return ResponseEntity.ok(ApiResponse.success(Map.of("token", token))); + } + + @PostMapping("/forgot-password") + public ResponseEntity> forgotPassword(@RequestParam @NotBlank @Email String email) { + authService.forgotPassword(email); + return ResponseEntity.ok(ApiResponse.ok()); + } + + @PostMapping("/reset-password") + public ResponseEntity> resetPassword( + @RequestParam @NotBlank @Email String email, + @RequestParam @NotBlank String code, + @RequestParam @NotBlank String newPassword) { + authService.resetPassword(email, code, newPassword); + return ResponseEntity.ok(ApiResponse.ok()); + } +} diff --git a/tenant-service/src/main/java/com/xuqm/tenant/controller/FeatureServiceController.java b/tenant-service/src/main/java/com/xuqm/tenant/controller/FeatureServiceController.java new file mode 100644 index 0000000..4946fbe --- /dev/null +++ b/tenant-service/src/main/java/com/xuqm/tenant/controller/FeatureServiceController.java @@ -0,0 +1,57 @@ +package com.xuqm.tenant.controller; + +import com.xuqm.common.model.ApiResponse; +import com.xuqm.tenant.entity.FeatureServiceEntity; +import com.xuqm.tenant.service.AppService; +import com.xuqm.tenant.service.FeatureServiceManager; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RestController +@RequestMapping("/api/apps/{appId}/services") +public class FeatureServiceController { + + private final FeatureServiceManager featureServiceManager; + private final AppService appService; + + public FeatureServiceController(FeatureServiceManager featureServiceManager, AppService appService) { + this.featureServiceManager = featureServiceManager; + this.appService = appService; + } + + @GetMapping + public ResponseEntity>> list( + @PathVariable String appId, @AuthenticationPrincipal String tenantId) { + appService.getById(appId, tenantId); + return ResponseEntity.ok(ApiResponse.success(featureServiceManager.listByApp(appId))); + } + + @PostMapping("/toggle") + public ResponseEntity> toggle( + @PathVariable String appId, + @RequestParam FeatureServiceEntity.Platform platform, + @RequestParam FeatureServiceEntity.ServiceType serviceType, + @RequestParam boolean enable, + @AuthenticationPrincipal String tenantId) { + appService.getById(appId, tenantId); + return ResponseEntity.ok(ApiResponse.success( + featureServiceManager.toggle(appId, platform, serviceType, enable))); + } + + @PostMapping("/{id}/regenerate-key") + public ResponseEntity> regenerateKey( + @PathVariable String appId, + @PathVariable String id, + @AuthenticationPrincipal String tenantId) { + appService.getById(appId, tenantId); + return ResponseEntity.ok(ApiResponse.success(featureServiceManager.regenerateKey(id))); + } +} diff --git a/tenant-service/src/main/java/com/xuqm/tenant/controller/GlobalExceptionHandler.java b/tenant-service/src/main/java/com/xuqm/tenant/controller/GlobalExceptionHandler.java new file mode 100644 index 0000000..e1abfab --- /dev/null +++ b/tenant-service/src/main/java/com/xuqm/tenant/controller/GlobalExceptionHandler.java @@ -0,0 +1,35 @@ +package com.xuqm.tenant.controller; + +import com.xuqm.common.exception.BusinessException; +import com.xuqm.common.model.ApiResponse; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.util.stream.Collectors; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(BusinessException.class) + public ResponseEntity> handle(BusinessException ex) { + return ResponseEntity.status(ex.getCode()) + .body(ApiResponse.error(ex.getCode(), ex.getMessage())); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity> handle(MethodArgumentNotValidException ex) { + String message = ex.getBindingResult().getFieldErrors().stream() + .map(FieldError::getDefaultMessage) + .collect(Collectors.joining("; ")); + return ResponseEntity.badRequest().body(ApiResponse.badRequest(message)); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity> handle(Exception ex) { + return ResponseEntity.internalServerError() + .body(ApiResponse.error(500, "服务器内部错误: " + ex.getMessage())); + } +} diff --git a/tenant-service/src/main/java/com/xuqm/tenant/controller/OpsController.java b/tenant-service/src/main/java/com/xuqm/tenant/controller/OpsController.java new file mode 100644 index 0000000..5ed61f1 --- /dev/null +++ b/tenant-service/src/main/java/com/xuqm/tenant/controller/OpsController.java @@ -0,0 +1,62 @@ +package com.xuqm.tenant.controller; + +import com.xuqm.common.model.ApiResponse; +import com.xuqm.tenant.entity.TenantEntity; +import com.xuqm.tenant.service.OpsService; +import jakarta.validation.constraints.NotBlank; +import org.springframework.data.domain.Page; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +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.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Map; + +@RestController +@RequestMapping +public class OpsController { + + private final OpsService opsService; + + public OpsController(OpsService opsService) { + this.opsService = opsService; + } + + @PostMapping("/api/auth/ops/login") + public ResponseEntity>> opsLogin(@RequestBody Map body) { + String token = opsService.login(body.get("username"), body.get("password")); + return ResponseEntity.ok(ApiResponse.success(Map.of("token", token))); + } + + @GetMapping("/api/ops/tenants") + @PreAuthorize("hasAuthority('ROLE_OPS')") + public ResponseEntity>> listTenants( + @RequestParam(defaultValue = "") String keyword, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size) { + Page result = opsService.listTenants(keyword, page, size); + return ResponseEntity.ok(ApiResponse.success(Map.of( + "content", result.getContent(), + "total", result.getTotalElements(), + "totalPages", result.getTotalPages() + ))); + } + + @PostMapping("/api/ops/tenants/{id}/toggle-status") + @PreAuthorize("hasAuthority('ROLE_OPS')") + public ResponseEntity> toggleStatus(@PathVariable String id) { + opsService.toggleStatus(id); + return ResponseEntity.ok(ApiResponse.ok()); + } + + @GetMapping("/api/ops/statistics") + @PreAuthorize("hasAuthority('ROLE_OPS')") + public ResponseEntity>> statistics() { + return ResponseEntity.ok(ApiResponse.success(opsService.statistics())); + } +} diff --git a/tenant-service/src/main/java/com/xuqm/tenant/controller/SubAccountController.java b/tenant-service/src/main/java/com/xuqm/tenant/controller/SubAccountController.java new file mode 100644 index 0000000..eff6702 --- /dev/null +++ b/tenant-service/src/main/java/com/xuqm/tenant/controller/SubAccountController.java @@ -0,0 +1,78 @@ +package com.xuqm.tenant.controller; + +import com.xuqm.common.exception.BusinessException; +import com.xuqm.common.model.ApiResponse; +import com.xuqm.tenant.dto.CreateSubAccountRequest; +import com.xuqm.tenant.entity.TenantEntity; +import com.xuqm.tenant.service.EmailService; +import com.xuqm.tenant.service.SubAccountService; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +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.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/sub-accounts") +public class SubAccountController { + + private final SubAccountService subAccountService; + private final EmailService emailService; + + public SubAccountController(SubAccountService subAccountService, EmailService emailService) { + this.subAccountService = subAccountService; + this.emailService = emailService; + } + + @GetMapping + public ResponseEntity>> list(@AuthenticationPrincipal String tenantId) { + return ResponseEntity.ok(ApiResponse.success(subAccountService.listByParent(tenantId))); + } + + @PostMapping("/send-verify-code") + public ResponseEntity> sendVerifyCode(@RequestParam @NotBlank @Email String email, + @AuthenticationPrincipal String tenantId) { + emailService.sendVerificationCode(email, "SUB_ACCOUNT"); + return ResponseEntity.ok(ApiResponse.ok()); + } + + @PostMapping("/verify-email") + public ResponseEntity> verifyEmail(@RequestParam @NotBlank @Email String email, + @RequestParam @NotBlank String code, + @AuthenticationPrincipal String tenantId) { + subAccountService.verifyEmail(tenantId, email, code); + return ResponseEntity.ok(ApiResponse.ok()); + } + + @PostMapping + public ResponseEntity> create(@Valid @RequestBody CreateSubAccountRequest req, + @AuthenticationPrincipal String tenantId) { + if (!subAccountService.isEmailVerifiedInSession(tenantId)) { + throw new BusinessException(403, "请先完成邮箱验证"); + } + return ResponseEntity.ok(ApiResponse.success(subAccountService.create(tenantId, req))); + } + + @DeleteMapping("/{id}") + public ResponseEntity> disable(@PathVariable String id, + @AuthenticationPrincipal String tenantId) { + subAccountService.disable(id, tenantId); + return ResponseEntity.ok(ApiResponse.ok()); + } + + @GetMapping("/generate-password") + public ResponseEntity>> generatePassword() { + return ResponseEntity.ok(ApiResponse.success(Map.of("password", subAccountService.generatePassword()))); + } +} diff --git a/tenant-service/src/main/java/com/xuqm/tenant/dto/CreateAppRequest.java b/tenant-service/src/main/java/com/xuqm/tenant/dto/CreateAppRequest.java new file mode 100644 index 0000000..f233e4d --- /dev/null +++ b/tenant-service/src/main/java/com/xuqm/tenant/dto/CreateAppRequest.java @@ -0,0 +1,11 @@ +package com.xuqm.tenant.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record CreateAppRequest( + @NotBlank @Size(max = 128) String packageName, + @NotBlank @Size(max = 128) String name, + @Size(max = 512) String description, + String iconUrl +) {} diff --git a/tenant-service/src/main/java/com/xuqm/tenant/dto/CreateSubAccountRequest.java b/tenant-service/src/main/java/com/xuqm/tenant/dto/CreateSubAccountRequest.java new file mode 100644 index 0000000..dc248f0 --- /dev/null +++ b/tenant-service/src/main/java/com/xuqm/tenant/dto/CreateSubAccountRequest.java @@ -0,0 +1,12 @@ +package com.xuqm.tenant.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record CreateSubAccountRequest( + @NotBlank @Size(min = 3, max = 32) String username, + @NotBlank @Size(min = 6, max = 64) String password, + String email, + @NotBlank @Size(max = 32) String nickname, + String phone +) {} diff --git a/tenant-service/src/main/java/com/xuqm/tenant/dto/LoginRequest.java b/tenant-service/src/main/java/com/xuqm/tenant/dto/LoginRequest.java new file mode 100644 index 0000000..f74573b --- /dev/null +++ b/tenant-service/src/main/java/com/xuqm/tenant/dto/LoginRequest.java @@ -0,0 +1,10 @@ +package com.xuqm.tenant.dto; + +import jakarta.validation.constraints.NotBlank; + +public record LoginRequest( + @NotBlank String account, + @NotBlank String password, + @NotBlank String captchaKey, + @NotBlank String captchaCode +) {} diff --git a/tenant-service/src/main/java/com/xuqm/tenant/dto/RegisterRequest.java b/tenant-service/src/main/java/com/xuqm/tenant/dto/RegisterRequest.java new file mode 100644 index 0000000..406f279 --- /dev/null +++ b/tenant-service/src/main/java/com/xuqm/tenant/dto/RegisterRequest.java @@ -0,0 +1,14 @@ +package com.xuqm.tenant.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record RegisterRequest( + @NotBlank @Size(min = 3, max = 32) String username, + @NotBlank @Size(min = 6, max = 64) String password, + @NotBlank @Email String email, + @NotBlank @Size(max = 32) String nickname, + String phone, + @NotBlank String emailCode +) {} diff --git a/tenant-service/src/main/java/com/xuqm/tenant/entity/AppEntity.java b/tenant-service/src/main/java/com/xuqm/tenant/entity/AppEntity.java new file mode 100644 index 0000000..eff1e91 --- /dev/null +++ b/tenant-service/src/main/java/com/xuqm/tenant/entity/AppEntity.java @@ -0,0 +1,66 @@ +package com.xuqm.tenant.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.time.LocalDateTime; + +@Entity +@Table(name = "t_app") +public class AppEntity { + + @Id + private String id; + + @Column(nullable = false, length = 64) + private String tenantId; + + @Column(nullable = false, length = 128) + private String packageName; + + @Column(nullable = false, length = 128) + private String name; + + @Column(length = 512) + private String description; + + @Column(length = 512) + private String iconUrl; + + @Column(nullable = false, unique = true, length = 64) + private String appKey; + + @Column(nullable = false, length = 128) + private String appSecret; + + @Column(nullable = false) + private LocalDateTime createdAt; + + public String getId() { return id; } + public void setId(String id) { this.id = id; } + + public String getTenantId() { return tenantId; } + public void setTenantId(String tenantId) { this.tenantId = tenantId; } + + public String getPackageName() { return packageName; } + public void setPackageName(String packageName) { this.packageName = packageName; } + + public String getName() { return name; } + public void setName(String name) { this.name = name; } + + public String getDescription() { return description; } + public void setDescription(String description) { this.description = description; } + + public String getIconUrl() { return iconUrl; } + public void setIconUrl(String iconUrl) { this.iconUrl = iconUrl; } + + public String getAppKey() { return appKey; } + public void setAppKey(String appKey) { this.appKey = appKey; } + + public String getAppSecret() { return appSecret; } + public void setAppSecret(String appSecret) { this.appSecret = appSecret; } + + public LocalDateTime getCreatedAt() { return createdAt; } + public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } +} diff --git a/tenant-service/src/main/java/com/xuqm/tenant/entity/EmailVerificationEntity.java b/tenant-service/src/main/java/com/xuqm/tenant/entity/EmailVerificationEntity.java new file mode 100644 index 0000000..23c3e20 --- /dev/null +++ b/tenant-service/src/main/java/com/xuqm/tenant/entity/EmailVerificationEntity.java @@ -0,0 +1,56 @@ +package com.xuqm.tenant.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.time.LocalDateTime; + +@Entity +@Table(name = "t_email_verification") +public class EmailVerificationEntity { + + public enum Purpose { REGISTER, RESET_PASSWORD, SUB_ACCOUNT } + + @Id + private String id; + + @Column(nullable = false, length = 128) + private String email; + + @Column(nullable = false, length = 16) + private String code; + + @Column(nullable = false, length = 32) + private String purpose; + + @Column(nullable = false) + private boolean used; + + @Column(nullable = false) + private LocalDateTime expiresAt; + + @Column(nullable = false) + private LocalDateTime createdAt; + + public String getId() { return id; } + public void setId(String id) { this.id = id; } + + public String getEmail() { return email; } + public void setEmail(String email) { this.email = email; } + + public String getCode() { return code; } + public void setCode(String code) { this.code = code; } + + public String getPurpose() { return purpose; } + public void setPurpose(String purpose) { this.purpose = purpose; } + + public boolean isUsed() { return used; } + public void setUsed(boolean used) { this.used = used; } + + public LocalDateTime getExpiresAt() { return expiresAt; } + public void setExpiresAt(LocalDateTime expiresAt) { this.expiresAt = expiresAt; } + + public LocalDateTime getCreatedAt() { return createdAt; } + public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } +} diff --git a/tenant-service/src/main/java/com/xuqm/tenant/entity/FeatureServiceEntity.java b/tenant-service/src/main/java/com/xuqm/tenant/entity/FeatureServiceEntity.java new file mode 100644 index 0000000..e51e83c --- /dev/null +++ b/tenant-service/src/main/java/com/xuqm/tenant/entity/FeatureServiceEntity.java @@ -0,0 +1,67 @@ +package com.xuqm.tenant.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.time.LocalDateTime; + +@Entity +@Table(name = "t_feature_service") +public class FeatureServiceEntity { + + public enum Platform { ANDROID, IOS, HARMONY } + public enum ServiceType { IM, PUSH, UPDATE } + + @Id + private String id; + + @Column(nullable = false, length = 64) + private String appId; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 16) + private Platform platform; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 16) + private ServiceType serviceType; + + @Column(nullable = false) + private boolean enabled; + + @Column(nullable = false, unique = true, length = 128) + private String secretKey; + + @Column(columnDefinition = "TEXT") + private String config; + + @Column(nullable = false) + private LocalDateTime createdAt; + + public String getId() { return id; } + public void setId(String id) { this.id = id; } + + public String getAppId() { return appId; } + public void setAppId(String appId) { this.appId = appId; } + + public Platform getPlatform() { return platform; } + public void setPlatform(Platform platform) { this.platform = platform; } + + public ServiceType getServiceType() { return serviceType; } + public void setServiceType(ServiceType serviceType) { this.serviceType = serviceType; } + + public boolean isEnabled() { return enabled; } + public void setEnabled(boolean enabled) { this.enabled = enabled; } + + public String getSecretKey() { return secretKey; } + public void setSecretKey(String secretKey) { this.secretKey = secretKey; } + + public String getConfig() { return config; } + public void setConfig(String config) { this.config = config; } + + public LocalDateTime getCreatedAt() { return createdAt; } + public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } +} diff --git a/tenant-service/src/main/java/com/xuqm/tenant/entity/OpsAdminEntity.java b/tenant-service/src/main/java/com/xuqm/tenant/entity/OpsAdminEntity.java new file mode 100644 index 0000000..192f98b --- /dev/null +++ b/tenant-service/src/main/java/com/xuqm/tenant/entity/OpsAdminEntity.java @@ -0,0 +1,36 @@ +package com.xuqm.tenant.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.time.LocalDateTime; + +@Entity +@Table(name = "t_ops_admin") +public class OpsAdminEntity { + + @Id + private String id; + + @Column(nullable = false, unique = true, length = 64) + private String username; + + @Column(nullable = false, length = 128) + private String password; + + @Column(nullable = false) + private LocalDateTime createdAt; + + public String getId() { return id; } + public void setId(String id) { this.id = id; } + + public String getUsername() { return username; } + public void setUsername(String username) { this.username = username; } + + public String getPassword() { return password; } + public void setPassword(String password) { this.password = password; } + + public LocalDateTime getCreatedAt() { return createdAt; } + public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } +} diff --git a/tenant-service/src/main/java/com/xuqm/tenant/entity/TenantEntity.java b/tenant-service/src/main/java/com/xuqm/tenant/entity/TenantEntity.java new file mode 100644 index 0000000..229eed9 --- /dev/null +++ b/tenant-service/src/main/java/com/xuqm/tenant/entity/TenantEntity.java @@ -0,0 +1,79 @@ +package com.xuqm.tenant.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.time.LocalDateTime; + +@Entity +@Table(name = "t_tenant") +public class TenantEntity { + + public enum Type { MAIN, SUB } + public enum Status { ACTIVE, DISABLED, PENDING_EMAIL } + + @Id + private String id; + + @Column(nullable = false, unique = true, length = 64) + private String username; + + @Column(nullable = false, length = 128) + private String password; + + @Column(nullable = false, unique = true, length = 128) + private String email; + + @Column(nullable = false, length = 64) + private String nickname; + + @Column(length = 32) + private String phone; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 16) + private Type type; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private Status status; + + @Column(length = 64) + private String parentId; + + @Column(nullable = false) + private LocalDateTime createdAt; + + public String getId() { return id; } + public void setId(String id) { this.id = id; } + + public String getUsername() { return username; } + public void setUsername(String username) { this.username = username; } + + public String getPassword() { return password; } + public void setPassword(String password) { this.password = password; } + + public String getEmail() { return email; } + public void setEmail(String email) { this.email = email; } + + public String getNickname() { return nickname; } + public void setNickname(String nickname) { this.nickname = nickname; } + + public String getPhone() { return phone; } + public void setPhone(String phone) { this.phone = phone; } + + public Type getType() { return type; } + public void setType(Type type) { this.type = type; } + + public Status getStatus() { return status; } + public void setStatus(Status status) { this.status = status; } + + public String getParentId() { return parentId; } + public void setParentId(String parentId) { this.parentId = parentId; } + + public LocalDateTime getCreatedAt() { return createdAt; } + public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } +} diff --git a/tenant-service/src/main/java/com/xuqm/tenant/repository/AppRepository.java b/tenant-service/src/main/java/com/xuqm/tenant/repository/AppRepository.java new file mode 100644 index 0000000..096071c --- /dev/null +++ b/tenant-service/src/main/java/com/xuqm/tenant/repository/AppRepository.java @@ -0,0 +1,14 @@ +package com.xuqm.tenant.repository; + +import com.xuqm.tenant.entity.AppEntity; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface AppRepository extends JpaRepository { + List findByTenantId(String tenantId); + Optional findByAppKey(String appKey); + boolean existsByPackageNameAndTenantId(String packageName, String tenantId); + long count(); +} diff --git a/tenant-service/src/main/java/com/xuqm/tenant/repository/EmailVerificationRepository.java b/tenant-service/src/main/java/com/xuqm/tenant/repository/EmailVerificationRepository.java new file mode 100644 index 0000000..d4f5a8d --- /dev/null +++ b/tenant-service/src/main/java/com/xuqm/tenant/repository/EmailVerificationRepository.java @@ -0,0 +1,19 @@ +package com.xuqm.tenant.repository; + +import com.xuqm.tenant.entity.EmailVerificationEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; + +public interface EmailVerificationRepository extends JpaRepository { + Optional findTopByEmailAndPurposeAndUsedFalseOrderByCreatedAtDesc( + String email, String purpose); + + @Modifying + @Transactional + @Query("UPDATE EmailVerificationEntity e SET e.used = true WHERE e.email = :email AND e.purpose = :purpose") + void markAllUsed(String email, String purpose); +} diff --git a/tenant-service/src/main/java/com/xuqm/tenant/repository/FeatureServiceRepository.java b/tenant-service/src/main/java/com/xuqm/tenant/repository/FeatureServiceRepository.java new file mode 100644 index 0000000..35dae18 --- /dev/null +++ b/tenant-service/src/main/java/com/xuqm/tenant/repository/FeatureServiceRepository.java @@ -0,0 +1,16 @@ +package com.xuqm.tenant.repository; + +import com.xuqm.tenant.entity.FeatureServiceEntity; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface FeatureServiceRepository extends JpaRepository { + List findByAppId(String appId); + Optional findByAppIdAndPlatformAndServiceType( + String appId, + FeatureServiceEntity.Platform platform, + FeatureServiceEntity.ServiceType serviceType); + Optional findBySecretKey(String secretKey); +} diff --git a/tenant-service/src/main/java/com/xuqm/tenant/repository/OpsAdminRepository.java b/tenant-service/src/main/java/com/xuqm/tenant/repository/OpsAdminRepository.java new file mode 100644 index 0000000..034a13a --- /dev/null +++ b/tenant-service/src/main/java/com/xuqm/tenant/repository/OpsAdminRepository.java @@ -0,0 +1,10 @@ +package com.xuqm.tenant.repository; + +import com.xuqm.tenant.entity.OpsAdminEntity; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface OpsAdminRepository extends JpaRepository { + Optional findByUsername(String username); +} diff --git a/tenant-service/src/main/java/com/xuqm/tenant/repository/TenantRepository.java b/tenant-service/src/main/java/com/xuqm/tenant/repository/TenantRepository.java new file mode 100644 index 0000000..8a7c8d6 --- /dev/null +++ b/tenant-service/src/main/java/com/xuqm/tenant/repository/TenantRepository.java @@ -0,0 +1,27 @@ +package com.xuqm.tenant.repository; + +import com.xuqm.tenant.entity.TenantEntity; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +public interface TenantRepository extends JpaRepository { + Optional findByUsername(String username); + Optional findByEmail(String email); + boolean existsByUsername(String username); + boolean existsByEmail(String email); + List findByParentId(String parentId); + + @Query("SELECT t FROM TenantEntity t WHERE " + + "(:keyword IS NULL OR :keyword = '' OR t.username LIKE %:keyword% OR t.email LIKE %:keyword%)") + Page searchTenants(@Param("keyword") String keyword, Pageable pageable); + + long countByCreatedAtBetween(LocalDateTime start, LocalDateTime end); +} diff --git a/tenant-service/src/main/java/com/xuqm/tenant/service/AppService.java b/tenant-service/src/main/java/com/xuqm/tenant/service/AppService.java new file mode 100644 index 0000000..8888e0c --- /dev/null +++ b/tenant-service/src/main/java/com/xuqm/tenant/service/AppService.java @@ -0,0 +1,77 @@ +package com.xuqm.tenant.service; + +import com.xuqm.common.exception.BusinessException; +import com.xuqm.tenant.dto.CreateAppRequest; +import com.xuqm.tenant.entity.AppEntity; +import com.xuqm.tenant.repository.AppRepository; +import org.springframework.stereotype.Service; + +import java.security.SecureRandom; +import java.time.LocalDateTime; +import java.util.Base64; +import java.util.List; +import java.util.UUID; + +@Service +public class AppService { + + private final AppRepository appRepository; + private static final SecureRandom random = new SecureRandom(); + + public AppService(AppRepository appRepository) { + this.appRepository = appRepository; + } + + public List listByTenant(String tenantId) { + return appRepository.findByTenantId(tenantId); + } + + public AppEntity getById(String id, String tenantId) { + AppEntity app = appRepository.findById(id) + .orElseThrow(() -> new BusinessException(404, "应用不存在")); + if (!app.getTenantId().equals(tenantId)) { + throw new BusinessException(403, "无权访问该应用"); + } + return app; + } + + public AppEntity create(String tenantId, CreateAppRequest req) { + if (appRepository.existsByPackageNameAndTenantId(req.packageName(), tenantId)) { + throw new BusinessException("该包名下已存在同名应用"); + } + AppEntity app = new AppEntity(); + app.setId(UUID.randomUUID().toString()); + app.setTenantId(tenantId); + app.setPackageName(req.packageName()); + app.setName(req.name()); + app.setDescription(req.description()); + app.setIconUrl(req.iconUrl()); + app.setAppKey(generateAppKey()); + app.setAppSecret(generateSecret()); + app.setCreatedAt(LocalDateTime.now()); + return appRepository.save(app); + } + + public AppEntity update(String id, String tenantId, CreateAppRequest req) { + AppEntity app = getById(id, tenantId); + app.setName(req.name()); + app.setDescription(req.description()); + app.setIconUrl(req.iconUrl()); + return appRepository.save(app); + } + + public void delete(String id, String tenantId) { + AppEntity app = getById(id, tenantId); + appRepository.delete(app); + } + + private String generateAppKey() { + return "ak_" + UUID.randomUUID().toString().replace("-", "").substring(0, 24); + } + + private String generateSecret() { + byte[] bytes = new byte[32]; + random.nextBytes(bytes); + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + } +} diff --git a/tenant-service/src/main/java/com/xuqm/tenant/service/AuthService.java b/tenant-service/src/main/java/com/xuqm/tenant/service/AuthService.java new file mode 100644 index 0000000..e7b513f --- /dev/null +++ b/tenant-service/src/main/java/com/xuqm/tenant/service/AuthService.java @@ -0,0 +1,104 @@ +package com.xuqm.tenant.service; + +import com.xuqm.common.exception.BusinessException; +import com.xuqm.common.security.JwtUtil; +import com.xuqm.tenant.dto.LoginRequest; +import com.xuqm.tenant.dto.RegisterRequest; +import com.xuqm.tenant.entity.TenantEntity; +import com.xuqm.tenant.repository.TenantRepository; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +@Service +public class AuthService { + + private final TenantRepository tenantRepository; + private final PasswordEncoder passwordEncoder; + private final JwtUtil jwtUtil; + private final EmailService emailService; + private final StringRedisTemplate redis; + + private static final String CAPTCHA_PREFIX = "captcha:"; + + public AuthService(TenantRepository tenantRepository, PasswordEncoder passwordEncoder, + JwtUtil jwtUtil, EmailService emailService, StringRedisTemplate redis) { + this.tenantRepository = tenantRepository; + this.passwordEncoder = passwordEncoder; + this.jwtUtil = jwtUtil; + this.emailService = emailService; + this.redis = redis; + } + + public void register(RegisterRequest req) { + emailService.verify(req.email(), req.emailCode(), "REGISTER"); + + if (tenantRepository.existsByUsername(req.username())) { + throw new BusinessException("用户名已存在"); + } + if (tenantRepository.existsByEmail(req.email())) { + throw new BusinessException("邮箱已被注册"); + } + + TenantEntity tenant = new TenantEntity(); + tenant.setId(UUID.randomUUID().toString()); + tenant.setUsername(req.username()); + tenant.setPassword(passwordEncoder.encode(req.password())); + tenant.setEmail(req.email()); + tenant.setNickname(req.nickname()); + tenant.setPhone(req.phone()); + tenant.setType(TenantEntity.Type.MAIN); + tenant.setStatus(TenantEntity.Status.ACTIVE); + tenant.setCreatedAt(LocalDateTime.now()); + tenantRepository.save(tenant); + } + + public String login(LoginRequest req) { + String cachedCaptcha = redis.opsForValue().get(CAPTCHA_PREFIX + req.captchaKey()); + if (cachedCaptcha == null || !cachedCaptcha.equalsIgnoreCase(req.captchaCode())) { + throw new BusinessException("验证码错误或已过期"); + } + redis.delete(CAPTCHA_PREFIX + req.captchaKey()); + + TenantEntity tenant = tenantRepository.findByUsername(req.account()) + .or(() -> tenantRepository.findByEmail(req.account())) + .orElseThrow(() -> new BusinessException("账号不存在")); + + if (tenant.getStatus() == TenantEntity.Status.DISABLED) { + throw new BusinessException("账号已被禁用"); + } + if (!passwordEncoder.matches(req.password(), tenant.getPassword())) { + throw new BusinessException("密码错误"); + } + + return jwtUtil.generate(tenant.getId(), Map.of( + "username", tenant.getUsername(), + "nickname", tenant.getNickname(), + "type", tenant.getType().name() + )); + } + + public void forgotPassword(String email) { + if (!tenantRepository.existsByEmail(email)) { + throw new BusinessException("邮箱未注册"); + } + emailService.sendVerificationCode(email, "RESET_PASSWORD"); + } + + public void resetPassword(String email, String code, String newPassword) { + emailService.verify(email, code, "RESET_PASSWORD"); + TenantEntity tenant = tenantRepository.findByEmail(email) + .orElseThrow(() -> new BusinessException("账号不存在")); + tenant.setPassword(passwordEncoder.encode(newPassword)); + tenantRepository.save(tenant); + } + + public void storeCaptcha(String key, String code) { + redis.opsForValue().set(CAPTCHA_PREFIX + key, code, 300, TimeUnit.SECONDS); + } +} diff --git a/tenant-service/src/main/java/com/xuqm/tenant/service/EmailService.java b/tenant-service/src/main/java/com/xuqm/tenant/service/EmailService.java new file mode 100644 index 0000000..0f5ede9 --- /dev/null +++ b/tenant-service/src/main/java/com/xuqm/tenant/service/EmailService.java @@ -0,0 +1,72 @@ +package com.xuqm.tenant.service; + +import com.xuqm.common.exception.BusinessException; +import com.xuqm.tenant.entity.EmailVerificationEntity; +import com.xuqm.tenant.repository.EmailVerificationRepository; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.mail.SimpleMailMessage; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; + +import java.security.SecureRandom; +import java.time.LocalDateTime; +import java.util.UUID; + +@Service +public class EmailService { + + private final JavaMailSender mailSender; + private final EmailVerificationRepository verificationRepository; + + @Value("${spring.mail.username}") + private String fromAddress; + + @Value("${email-verify.expire-seconds:600}") + private int expireSeconds; + + private static final SecureRandom random = new SecureRandom(); + + public EmailService(JavaMailSender mailSender, EmailVerificationRepository verificationRepository) { + this.mailSender = mailSender; + this.verificationRepository = verificationRepository; + } + + @Async + public void sendVerificationCode(String email, String purpose) { + String code = String.format("%06d", random.nextInt(1_000_000)); + + EmailVerificationEntity entity = new EmailVerificationEntity(); + entity.setId(UUID.randomUUID().toString()); + entity.setEmail(email); + entity.setCode(code); + entity.setPurpose(purpose); + entity.setUsed(false); + entity.setCreatedAt(LocalDateTime.now()); + entity.setExpiresAt(LocalDateTime.now().plusSeconds(expireSeconds)); + verificationRepository.save(entity); + + SimpleMailMessage message = new SimpleMailMessage(); + message.setFrom(fromAddress); + message.setTo(email); + message.setSubject("XuqmGroup - 邮箱验证码"); + message.setText(String.format("您的验证码是:%s,%d分钟内有效。", code, expireSeconds / 60)); + mailSender.send(message); + } + + public void verify(String email, String code, String purpose) { + EmailVerificationEntity entity = verificationRepository + .findTopByEmailAndPurposeAndUsedFalseOrderByCreatedAtDesc(email, purpose) + .orElseThrow(() -> new BusinessException("验证码无效")); + + if (entity.getExpiresAt().isBefore(LocalDateTime.now())) { + throw new BusinessException("验证码已过期"); + } + if (!entity.getCode().equals(code)) { + throw new BusinessException("验证码错误"); + } + + entity.setUsed(true); + verificationRepository.save(entity); + } +} diff --git a/tenant-service/src/main/java/com/xuqm/tenant/service/FeatureServiceManager.java b/tenant-service/src/main/java/com/xuqm/tenant/service/FeatureServiceManager.java new file mode 100644 index 0000000..e514a99 --- /dev/null +++ b/tenant-service/src/main/java/com/xuqm/tenant/service/FeatureServiceManager.java @@ -0,0 +1,64 @@ +package com.xuqm.tenant.service; + +import com.xuqm.common.exception.BusinessException; +import com.xuqm.tenant.entity.FeatureServiceEntity; +import com.xuqm.tenant.repository.FeatureServiceRepository; +import org.springframework.stereotype.Service; + +import java.security.SecureRandom; +import java.time.LocalDateTime; +import java.util.Base64; +import java.util.List; +import java.util.UUID; + +@Service +public class FeatureServiceManager { + + private final FeatureServiceRepository repository; + private static final SecureRandom random = new SecureRandom(); + + public FeatureServiceManager(FeatureServiceRepository repository) { + this.repository = repository; + } + + public List listByApp(String appId) { + return repository.findByAppId(appId); + } + + public FeatureServiceEntity toggle(String appId, FeatureServiceEntity.Platform platform, + FeatureServiceEntity.ServiceType serviceType, boolean enable) { + FeatureServiceEntity entity = repository + .findByAppIdAndPlatformAndServiceType(appId, platform, serviceType) + .orElseGet(() -> { + FeatureServiceEntity e = new FeatureServiceEntity(); + e.setId(UUID.randomUUID().toString()); + e.setAppId(appId); + e.setPlatform(platform); + e.setServiceType(serviceType); + e.setSecretKey(generateSecretKey()); + e.setCreatedAt(LocalDateTime.now()); + return e; + }); + entity.setEnabled(enable); + return repository.save(entity); + } + + public FeatureServiceEntity getOrFail(String appId, FeatureServiceEntity.Platform platform, + FeatureServiceEntity.ServiceType serviceType) { + return repository.findByAppIdAndPlatformAndServiceType(appId, platform, serviceType) + .orElseThrow(() -> new BusinessException(404, "服务未配置")); + } + + public FeatureServiceEntity regenerateKey(String id) { + FeatureServiceEntity entity = repository.findById(id) + .orElseThrow(() -> new BusinessException(404, "服务不存在")); + entity.setSecretKey(generateSecretKey()); + return repository.save(entity); + } + + private String generateSecretKey() { + byte[] bytes = new byte[32]; + random.nextBytes(bytes); + return "sk_" + Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + } +} diff --git a/tenant-service/src/main/java/com/xuqm/tenant/service/OpsService.java b/tenant-service/src/main/java/com/xuqm/tenant/service/OpsService.java new file mode 100644 index 0000000..9124d22 --- /dev/null +++ b/tenant-service/src/main/java/com/xuqm/tenant/service/OpsService.java @@ -0,0 +1,89 @@ +package com.xuqm.tenant.service; + +import com.xuqm.common.security.JwtUtil; +import com.xuqm.tenant.entity.OpsAdminEntity; +import com.xuqm.tenant.entity.TenantEntity; +import com.xuqm.tenant.repository.AppRepository; +import com.xuqm.tenant.repository.OpsAdminRepository; +import com.xuqm.tenant.repository.TenantRepository; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.Map; +import java.util.UUID; + +@Service +public class OpsService { + + private final TenantRepository tenantRepository; + private final AppRepository appRepository; + private final OpsAdminRepository opsAdminRepository; + private final PasswordEncoder passwordEncoder; + private final JwtUtil jwtUtil; + + public OpsService(TenantRepository tenantRepository, AppRepository appRepository, + OpsAdminRepository opsAdminRepository, PasswordEncoder passwordEncoder, + JwtUtil jwtUtil) { + this.tenantRepository = tenantRepository; + this.appRepository = appRepository; + this.opsAdminRepository = opsAdminRepository; + this.passwordEncoder = passwordEncoder; + this.jwtUtil = jwtUtil; + } + + public String login(String username, String password) { + OpsAdminEntity admin = opsAdminRepository.findByUsername(username) + .orElseThrow(() -> new IllegalArgumentException("用户名或密码错误")); + if (!passwordEncoder.matches(password, admin.getPassword())) { + throw new IllegalArgumentException("用户名或密码错误"); + } + return jwtUtil.generate(admin.getId(), Map.of("username", username, "role", "OPS")); + } + + public Page listTenants(String keyword, int page, int size) { + return tenantRepository.searchTenants( + keyword, + PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt")) + ); + } + + public void toggleStatus(String tenantId) { + TenantEntity tenant = tenantRepository.findById(tenantId) + .orElseThrow(() -> new IllegalArgumentException("租户不存在")); + if (tenant.getStatus() == TenantEntity.Status.ACTIVE) { + tenant.setStatus(TenantEntity.Status.DISABLED); + } else { + tenant.setStatus(TenantEntity.Status.ACTIVE); + } + tenantRepository.save(tenant); + } + + public Map statistics() { + long totalTenants = tenantRepository.count(); + LocalDateTime todayStart = LocalDate.now().atStartOfDay(); + LocalDateTime todayEnd = todayStart.plusDays(1); + long todayNew = tenantRepository.countByCreatedAtBetween(todayStart, todayEnd); + long activeApps = appRepository.count(); + return Map.of( + "totalTenants", totalTenants, + "todayNew", todayNew, + "activeApps", activeApps, + "onlineUsers", 0 + ); + } + + public void initDefaultAdmin(String username, String rawPassword) { + if (opsAdminRepository.findByUsername(username).isPresent()) return; + OpsAdminEntity admin = new OpsAdminEntity(); + admin.setId(UUID.randomUUID().toString()); + admin.setUsername(username); + admin.setPassword(passwordEncoder.encode(rawPassword)); + admin.setCreatedAt(LocalDateTime.now()); + opsAdminRepository.save(admin); + } +} diff --git a/tenant-service/src/main/java/com/xuqm/tenant/service/SubAccountService.java b/tenant-service/src/main/java/com/xuqm/tenant/service/SubAccountService.java new file mode 100644 index 0000000..b813465 --- /dev/null +++ b/tenant-service/src/main/java/com/xuqm/tenant/service/SubAccountService.java @@ -0,0 +1,84 @@ +package com.xuqm.tenant.service; + +import com.xuqm.common.exception.BusinessException; +import com.xuqm.tenant.dto.CreateSubAccountRequest; +import com.xuqm.tenant.entity.TenantEntity; +import com.xuqm.tenant.repository.TenantRepository; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; + +import java.security.SecureRandom; +import java.time.LocalDateTime; +import java.util.Base64; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +@Service +public class SubAccountService { + + private final TenantRepository tenantRepository; + private final PasswordEncoder passwordEncoder; + private final EmailService emailService; + private final StringRedisTemplate redis; + + private static final String SUB_VERIFY_PREFIX = "sub_verified:"; + private static final SecureRandom random = new SecureRandom(); + + public SubAccountService(TenantRepository tenantRepository, PasswordEncoder passwordEncoder, + EmailService emailService, StringRedisTemplate redis) { + this.tenantRepository = tenantRepository; + this.passwordEncoder = passwordEncoder; + this.emailService = emailService; + this.redis = redis; + } + + public boolean isEmailVerifiedInSession(String tenantId) { + return Boolean.TRUE.toString().equals(redis.opsForValue().get(SUB_VERIFY_PREFIX + tenantId)); + } + + public void verifyEmail(String tenantId, String email, String code) { + emailService.verify(email, code, "SUB_ACCOUNT"); + redis.opsForValue().set(SUB_VERIFY_PREFIX + tenantId, Boolean.TRUE.toString(), 24, TimeUnit.HOURS); + } + + public TenantEntity create(String parentId, CreateSubAccountRequest req) { + if (tenantRepository.existsByUsername(req.username())) { + throw new BusinessException("用户名已存在"); + } + + TenantEntity sub = new TenantEntity(); + sub.setId(UUID.randomUUID().toString()); + sub.setUsername(req.username()); + sub.setPassword(passwordEncoder.encode(req.password())); + sub.setEmail(req.email() != null ? req.email() : ""); + sub.setNickname(req.nickname()); + sub.setPhone(req.phone()); + sub.setType(TenantEntity.Type.SUB); + sub.setStatus(TenantEntity.Status.ACTIVE); + sub.setParentId(parentId); + sub.setCreatedAt(LocalDateTime.now()); + return tenantRepository.save(sub); + } + + public List listByParent(String parentId) { + return tenantRepository.findByParentId(parentId); + } + + public void disable(String subId, String parentId) { + TenantEntity sub = tenantRepository.findById(subId) + .orElseThrow(() -> new BusinessException(404, "子账号不存在")); + if (!parentId.equals(sub.getParentId())) { + throw new BusinessException(403, "无权操作"); + } + sub.setStatus(TenantEntity.Status.DISABLED); + tenantRepository.save(sub); + } + + public String generatePassword() { + byte[] bytes = new byte[9]; + random.nextBytes(bytes); + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + } +} diff --git a/tenant-service/src/main/resources/application.yml b/tenant-service/src/main/resources/application.yml new file mode 100644 index 0000000..0e88549 --- /dev/null +++ b/tenant-service/src/main/resources/application.yml @@ -0,0 +1,65 @@ +server: + port: 8081 + +spring: + application: + name: tenant-service + datasource: + url: jdbc:mysql://localhost:3306/xuqm_tenant?useSSL=false&serverTimezone=UTC&createDatabaseIfNotExist=true&allowPublicKeyRetrieval=true + username: root + password: root + driver-class-name: com.mysql.cj.jdbc.Driver + jpa: + hibernate: + ddl-auto: update + show-sql: false + properties: + hibernate: + dialect: org.hibernate.dialect.MySQLDialect + format_sql: true + data: + redis: + host: localhost + port: 6379 + timeout: 5000ms + mail: + host: smtp.example.com + port: 587 + username: noreply@xuqm.com + password: changeme + properties: + mail: + smtp: + auth: true + starttls: + enable: true + jackson: + time-zone: UTC + serialization: + write-dates-as-timestamps: false + +jwt: + secret: xuqm-tenant-service-secret-key-must-be-at-least-256-bits-long-for-hmac + expiration: 86400000 + +captcha: + expire-seconds: 300 + +email-verify: + expire-seconds: 600 + sub-account-token-hours: 24 + +ops: + admin: + username: admin + password: Admin@123456 + +logging: + level: + com.xuqm: DEBUG + +management: + endpoints: + web: + exposure: + include: health,info diff --git a/update-service/pom.xml b/update-service/pom.xml new file mode 100644 index 0000000..7c7bbd8 --- /dev/null +++ b/update-service/pom.xml @@ -0,0 +1,82 @@ + + + 4.0.0 + + + com.xuqm + xuqmgroup-server-parent + 0.1.0-SNAPSHOT + ../pom.xml + + + update-service + update-service + App version management: APK release, RN bundle hot update + + + + com.xuqm + common + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.springframework.boot + spring-boot-starter-security + + + org.springframework.boot + spring-boot-starter-validation + + + io.jsonwebtoken + jjwt-api + + + io.jsonwebtoken + jjwt-impl + runtime + + + io.jsonwebtoken + jjwt-jackson + runtime + + + net.dongliu + apk-parser + 2.6.10 + + + com.fasterxml.jackson.datatype + jackson-datatype-jsr310 + + + com.mysql + mysql-connector-j + runtime + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/update-service/src/main/java/com/xuqm/update/UpdateServiceApplication.java b/update-service/src/main/java/com/xuqm/update/UpdateServiceApplication.java new file mode 100644 index 0000000..512337e --- /dev/null +++ b/update-service/src/main/java/com/xuqm/update/UpdateServiceApplication.java @@ -0,0 +1,13 @@ +package com.xuqm.update; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.ComponentScan; + +@SpringBootApplication +@ComponentScan(basePackages = {"com.xuqm.update", "com.xuqm.common"}) +public class UpdateServiceApplication { + public static void main(String[] args) { + SpringApplication.run(UpdateServiceApplication.class, args); + } +} diff --git a/update-service/src/main/java/com/xuqm/update/controller/AppVersionController.java b/update-service/src/main/java/com/xuqm/update/controller/AppVersionController.java new file mode 100644 index 0000000..46b1ceb --- /dev/null +++ b/update-service/src/main/java/com/xuqm/update/controller/AppVersionController.java @@ -0,0 +1,116 @@ +package com.xuqm.update.controller; + +import com.xuqm.common.model.ApiResponse; +import com.xuqm.update.entity.AppVersionEntity; +import com.xuqm.update.repository.AppVersionRepository; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +@RestController +@RequestMapping("/api/v1/updates") +public class AppVersionController { + + private final AppVersionRepository versionRepository; + + @Value("${update.upload-dir:/tmp/xuqm-update}") + private String uploadDir; + + @Value("${update.base-url:http://localhost:8084}") + private String baseUrl; + + public AppVersionController(AppVersionRepository versionRepository) { + this.versionRepository = versionRepository; + } + + @GetMapping("/app/check") + public ResponseEntity>> checkUpdate( + @RequestParam String appId, + @RequestParam AppVersionEntity.Platform platform, + @RequestParam int currentVersionCode) { + + Optional latest = versionRepository + .findTopByAppIdAndPlatformAndPublishStatusOrderByVersionCodeDesc( + appId, platform, AppVersionEntity.PublishStatus.PUBLISHED); + + if (latest.isEmpty() || latest.get().getVersionCode() <= currentVersionCode) { + return ResponseEntity.ok(ApiResponse.success(Map.of("needsUpdate", false))); + } + + AppVersionEntity v = latest.get(); + return ResponseEntity.ok(ApiResponse.success(Map.of( + "needsUpdate", true, + "versionName", v.getVersionName(), + "versionCode", v.getVersionCode(), + "downloadUrl", v.getDownloadUrl() != null ? v.getDownloadUrl() : "", + "changeLog", v.getChangeLog() != null ? v.getChangeLog() : "", + "forceUpdate", v.isForceUpdate(), + "appStoreUrl", v.getAppStoreUrl() != null ? v.getAppStoreUrl() : "", + "marketUrl", v.getMarketUrl() != null ? v.getMarketUrl() : "" + ))); + } + + @PostMapping("/app/upload") + public ResponseEntity> upload( + @RequestParam String appId, + @RequestParam AppVersionEntity.Platform platform, + @RequestParam String versionName, + @RequestParam int versionCode, + @RequestParam(required = false) String changeLog, + @RequestParam(defaultValue = "false") boolean forceUpdate, + @RequestParam(required = false) MultipartFile apkFile) throws IOException { + + String downloadUrl = null; + if (apkFile != null && !apkFile.isEmpty()) { + String filename = UUID.randomUUID() + "_" + apkFile.getOriginalFilename(); + Path dir = Paths.get(uploadDir, "apk"); + Files.createDirectories(dir); + Path dest = dir.resolve(filename); + apkFile.transferTo(dest.toFile()); + downloadUrl = baseUrl + "/files/apk/" + filename; + } + + AppVersionEntity entity = new AppVersionEntity(); + entity.setId(UUID.randomUUID().toString()); + entity.setAppId(appId); + entity.setPlatform(platform); + entity.setVersionName(versionName); + entity.setVersionCode(versionCode); + entity.setDownloadUrl(downloadUrl); + entity.setChangeLog(changeLog); + entity.setForceUpdate(forceUpdate); + entity.setPublishStatus(AppVersionEntity.PublishStatus.DRAFT); + entity.setCreatedAt(LocalDateTime.now()); + return ResponseEntity.ok(ApiResponse.success(versionRepository.save(entity))); + } + + @PostMapping("/app/{id}/publish") + public ResponseEntity> publish(@PathVariable String id) { + AppVersionEntity entity = versionRepository.findById(id).orElseThrow(); + entity.setPublishStatus(AppVersionEntity.PublishStatus.PUBLISHED); + return ResponseEntity.ok(ApiResponse.success(versionRepository.save(entity))); + } + + @GetMapping("/app/list") + public ResponseEntity>> list( + @RequestParam String appId, @RequestParam AppVersionEntity.Platform platform) { + return ResponseEntity.ok(ApiResponse.success( + versionRepository.findByAppIdAndPlatformOrderByVersionCodeDesc(appId, platform))); + } +} diff --git a/update-service/src/main/java/com/xuqm/update/controller/RnBundleController.java b/update-service/src/main/java/com/xuqm/update/controller/RnBundleController.java new file mode 100644 index 0000000..c96d578 --- /dev/null +++ b/update-service/src/main/java/com/xuqm/update/controller/RnBundleController.java @@ -0,0 +1,120 @@ +package com.xuqm.update.controller; + +import com.xuqm.common.model.ApiResponse; +import com.xuqm.update.entity.RnBundleEntity; +import com.xuqm.update.repository.RnBundleRepository; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.security.DigestInputStream; +import java.security.MessageDigest; +import java.time.LocalDateTime; +import java.util.HexFormat; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +@RestController +@RequestMapping("/api/v1/rn") +public class RnBundleController { + + private final RnBundleRepository bundleRepository; + + @Value("${update.upload-dir:/tmp/xuqm-update}") + private String uploadDir; + + @Value("${update.base-url:http://localhost:8084}") + private String baseUrl; + + public RnBundleController(RnBundleRepository bundleRepository) { + this.bundleRepository = bundleRepository; + } + + @GetMapping("/update/check") + public ResponseEntity>> checkUpdate( + @RequestParam String appId, + @RequestParam String moduleId, + @RequestParam String platform, + @RequestParam String currentVersion) { + + RnBundleEntity.Platform p = RnBundleEntity.Platform.valueOf(platform.toUpperCase()); + Optional latest = bundleRepository + .findTopByAppIdAndModuleIdAndPlatformAndPublishStatusOrderByCreatedAtDesc( + appId, moduleId, p, RnBundleEntity.PublishStatus.PUBLISHED); + + if (latest.isEmpty()) { + return ResponseEntity.ok(ApiResponse.success(Map.of("needsUpdate", false))); + } + + RnBundleEntity b = latest.get(); + boolean needsUpdate = !b.getVersion().equals(currentVersion); + return ResponseEntity.ok(ApiResponse.success(Map.of( + "needsUpdate", needsUpdate, + "latestVersion", b.getVersion(), + "downloadUrl", baseUrl + "/api/v1/rn/files/" + appId + "/" + platform.toLowerCase() + "/" + moduleId, + "md5", b.getMd5(), + "minCommonVersion", b.getMinCommonVersion() != null ? b.getMinCommonVersion() : "0.0.0", + "note", b.getNote() != null ? b.getNote() : "" + ))); + } + + @PostMapping("/upload") + public ResponseEntity> upload( + @RequestParam String appId, + @RequestParam String moduleId, + @RequestParam RnBundleEntity.Platform platform, + @RequestParam String version, + @RequestParam(required = false) String minCommonVersion, + @RequestParam(required = false) String note, + @RequestParam MultipartFile bundle) throws Exception { + + String filename = moduleId + "." + platform.name().toLowerCase() + ".bundle"; + Path dir = Paths.get(uploadDir, "rn", appId, platform.name().toLowerCase(), moduleId); + Files.createDirectories(dir); + Path dest = dir.resolve(filename); + + String md5 = computeMd5(bundle); + bundle.transferTo(dest.toFile()); + + RnBundleEntity entity = new RnBundleEntity(); + entity.setId(UUID.randomUUID().toString()); + entity.setAppId(appId); + entity.setModuleId(moduleId); + entity.setPlatform(platform); + entity.setVersion(version); + entity.setBundleUrl(dest.toAbsolutePath().toString()); + entity.setMd5(md5); + entity.setMinCommonVersion(minCommonVersion); + entity.setNote(note); + entity.setPublishStatus(RnBundleEntity.PublishStatus.DRAFT); + entity.setCreatedAt(LocalDateTime.now()); + return ResponseEntity.ok(ApiResponse.success(bundleRepository.save(entity))); + } + + @PostMapping("/{id}/publish") + public ResponseEntity> publish(@PathVariable String id) { + RnBundleEntity entity = bundleRepository.findById(id).orElseThrow(); + entity.setPublishStatus(RnBundleEntity.PublishStatus.PUBLISHED); + return ResponseEntity.ok(ApiResponse.success(bundleRepository.save(entity))); + } + + private String computeMd5(MultipartFile file) throws Exception { + MessageDigest digest = MessageDigest.getInstance("MD5"); + try (DigestInputStream dis = new DigestInputStream(file.getInputStream(), digest)) { + byte[] buf = new byte[8192]; + while (dis.read(buf) != -1) {} + } + return HexFormat.of().formatHex(digest.digest()); + } +} diff --git a/update-service/src/main/java/com/xuqm/update/entity/AppVersionEntity.java b/update-service/src/main/java/com/xuqm/update/entity/AppVersionEntity.java new file mode 100644 index 0000000..474960e --- /dev/null +++ b/update-service/src/main/java/com/xuqm/update/entity/AppVersionEntity.java @@ -0,0 +1,91 @@ +package com.xuqm.update.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.time.LocalDateTime; + +@Entity +@Table(name = "update_app_version") +public class AppVersionEntity { + + public enum Platform { ANDROID, IOS } + public enum PublishStatus { DRAFT, PUBLISHED, DEPRECATED } + + @Id + private String id; + + @Column(nullable = false, length = 64) + private String appId; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 16) + private Platform platform; + + @Column(nullable = false, length = 32) + private String versionName; + + @Column(nullable = false) + private int versionCode; + + @Column(length = 512) + private String downloadUrl; + + @Column(columnDefinition = "TEXT") + private String changeLog; + + @Column(nullable = false) + private boolean forceUpdate; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 16) + private PublishStatus publishStatus; + + @Column(length = 256) + private String appStoreUrl; + + @Column(length = 256) + private String marketUrl; + + @Column(nullable = false) + private LocalDateTime createdAt; + + public String getId() { return id; } + public void setId(String id) { this.id = id; } + + public String getAppId() { return appId; } + public void setAppId(String appId) { this.appId = appId; } + + public Platform getPlatform() { return platform; } + public void setPlatform(Platform platform) { this.platform = platform; } + + public String getVersionName() { return versionName; } + public void setVersionName(String versionName) { this.versionName = versionName; } + + public int getVersionCode() { return versionCode; } + public void setVersionCode(int versionCode) { this.versionCode = versionCode; } + + public String getDownloadUrl() { return downloadUrl; } + public void setDownloadUrl(String downloadUrl) { this.downloadUrl = downloadUrl; } + + public String getChangeLog() { return changeLog; } + public void setChangeLog(String changeLog) { this.changeLog = changeLog; } + + public boolean isForceUpdate() { return forceUpdate; } + public void setForceUpdate(boolean forceUpdate) { this.forceUpdate = forceUpdate; } + + public PublishStatus getPublishStatus() { return publishStatus; } + public void setPublishStatus(PublishStatus publishStatus) { this.publishStatus = publishStatus; } + + public String getAppStoreUrl() { return appStoreUrl; } + public void setAppStoreUrl(String appStoreUrl) { this.appStoreUrl = appStoreUrl; } + + public String getMarketUrl() { return marketUrl; } + public void setMarketUrl(String marketUrl) { this.marketUrl = marketUrl; } + + public LocalDateTime getCreatedAt() { return createdAt; } + public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } +} diff --git a/update-service/src/main/java/com/xuqm/update/entity/RnBundleEntity.java b/update-service/src/main/java/com/xuqm/update/entity/RnBundleEntity.java new file mode 100644 index 0000000..85fefc0 --- /dev/null +++ b/update-service/src/main/java/com/xuqm/update/entity/RnBundleEntity.java @@ -0,0 +1,85 @@ +package com.xuqm.update.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.time.LocalDateTime; + +@Entity +@Table(name = "update_rn_bundle") +public class RnBundleEntity { + + public enum Platform { ANDROID, IOS } + public enum PublishStatus { DRAFT, PUBLISHED, DEPRECATED } + + @Id + private String id; + + @Column(nullable = false, length = 64) + private String appId; + + @Column(nullable = false, length = 64) + private String moduleId; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 16) + private Platform platform; + + @Column(nullable = false, length = 32) + private String version; + + @Column(nullable = false, length = 512) + private String bundleUrl; + + @Column(nullable = false, length = 64) + private String md5; + + @Column(length = 32) + private String minCommonVersion; + + @Column(length = 512) + private String note; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 16) + private PublishStatus publishStatus; + + @Column(nullable = false) + private LocalDateTime createdAt; + + public String getId() { return id; } + public void setId(String id) { this.id = id; } + + public String getAppId() { return appId; } + public void setAppId(String appId) { this.appId = appId; } + + public String getModuleId() { return moduleId; } + public void setModuleId(String moduleId) { this.moduleId = moduleId; } + + public Platform getPlatform() { return platform; } + public void setPlatform(Platform platform) { this.platform = platform; } + + public String getVersion() { return version; } + public void setVersion(String version) { this.version = version; } + + public String getBundleUrl() { return bundleUrl; } + public void setBundleUrl(String bundleUrl) { this.bundleUrl = bundleUrl; } + + public String getMd5() { return md5; } + public void setMd5(String md5) { this.md5 = md5; } + + public String getMinCommonVersion() { return minCommonVersion; } + public void setMinCommonVersion(String minCommonVersion) { this.minCommonVersion = minCommonVersion; } + + public String getNote() { return note; } + public void setNote(String note) { this.note = note; } + + public PublishStatus getPublishStatus() { return publishStatus; } + public void setPublishStatus(PublishStatus publishStatus) { this.publishStatus = publishStatus; } + + public LocalDateTime getCreatedAt() { return createdAt; } + public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } +} diff --git a/update-service/src/main/java/com/xuqm/update/repository/AppVersionRepository.java b/update-service/src/main/java/com/xuqm/update/repository/AppVersionRepository.java new file mode 100644 index 0000000..f7529b0 --- /dev/null +++ b/update-service/src/main/java/com/xuqm/update/repository/AppVersionRepository.java @@ -0,0 +1,14 @@ +package com.xuqm.update.repository; + +import com.xuqm.update.entity.AppVersionEntity; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface AppVersionRepository extends JpaRepository { + List findByAppIdAndPlatformOrderByVersionCodeDesc( + String appId, AppVersionEntity.Platform platform); + Optional findTopByAppIdAndPlatformAndPublishStatusOrderByVersionCodeDesc( + String appId, AppVersionEntity.Platform platform, AppVersionEntity.PublishStatus status); +} diff --git a/update-service/src/main/java/com/xuqm/update/repository/RnBundleRepository.java b/update-service/src/main/java/com/xuqm/update/repository/RnBundleRepository.java new file mode 100644 index 0000000..0a888e0 --- /dev/null +++ b/update-service/src/main/java/com/xuqm/update/repository/RnBundleRepository.java @@ -0,0 +1,14 @@ +package com.xuqm.update.repository; + +import com.xuqm.update.entity.RnBundleEntity; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface RnBundleRepository extends JpaRepository { + List findByAppIdAndModuleIdAndPlatformOrderByCreatedAtDesc( + String appId, String moduleId, RnBundleEntity.Platform platform); + Optional findTopByAppIdAndModuleIdAndPlatformAndPublishStatusOrderByCreatedAtDesc( + String appId, String moduleId, RnBundleEntity.Platform platform, RnBundleEntity.PublishStatus status); +} diff --git a/update-service/src/main/resources/application.yml b/update-service/src/main/resources/application.yml new file mode 100644 index 0000000..c11b7a3 --- /dev/null +++ b/update-service/src/main/resources/application.yml @@ -0,0 +1,27 @@ +server: + port: 8084 + +spring: + application: + name: update-service + datasource: + url: jdbc:mysql://localhost:3306/xuqm_update?useSSL=false&serverTimezone=UTC&createDatabaseIfNotExist=true&allowPublicKeyRetrieval=true + username: root + password: root + driver-class-name: com.mysql.cj.jdbc.Driver + jpa: + hibernate: + ddl-auto: update + show-sql: false + servlet: + multipart: + max-file-size: 200MB + max-request-size: 200MB + +jwt: + secret: xuqm-update-service-secret-key-must-be-at-least-256-bits-long-for-hmac + expiration: 86400000 + +update: + upload-dir: ${UPDATE_UPLOAD_DIR:/tmp/xuqm-update} + base-url: ${UPDATE_BASE_URL:http://localhost:8084}