diff --git a/Dockerfile b/Dockerfile index d9477d3..42bbb8a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,6 +10,8 @@ COPY tenant-service ./tenant-service COPY im-service ./im-service COPY push-service ./push-service COPY update-service ./update-service +COPY demo-service ./demo-service +COPY file-service ./file-service RUN mvn -pl ${SERVICE_MODULE} -am -DskipTests package diff --git a/Jenkinsfile b/Jenkinsfile index 7c69bf2..628391d 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -2,7 +2,7 @@ pipeline { agent any parameters { - choice(name: 'SERVICE', choices: ['tenant-service', 'im-service', 'push-service', 'update-service'], description: '要构建的服务模块') + choice(name: 'SERVICE', choices: ['tenant-service', 'im-service', 'push-service', 'update-service', 'demo-service', 'file-service'], description: '要构建的服务模块') string(name: 'IMAGE_TAG', defaultValue: 'latest', description: '镜像 Tag(如 v1.2.3 或 latest)') booleanParam(name: 'DEPLOY', defaultValue: true, description: '构建后是否自动部署到生产服务器') } diff --git a/demo-service/pom.xml b/demo-service/pom.xml new file mode 100644 index 0000000..533bf79 --- /dev/null +++ b/demo-service/pom.xml @@ -0,0 +1,77 @@ + + + 4.0.0 + + + com.xuqm + xuqmgroup-server-parent + 0.1.0-SNAPSHOT + ../pom.xml + + + demo-service + demo-service + Demo tenant service — user auth, file upload/dedup, IM account bridge + + + + 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-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/demo-service/src/main/java/com/xuqm/demo/DemoServiceApplication.java b/demo-service/src/main/java/com/xuqm/demo/DemoServiceApplication.java new file mode 100644 index 0000000..0eaa444 --- /dev/null +++ b/demo-service/src/main/java/com/xuqm/demo/DemoServiceApplication.java @@ -0,0 +1,14 @@ +package com.xuqm.demo; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableScheduling; + +@SpringBootApplication(scanBasePackages = {"com.xuqm.demo", "com.xuqm.common"}) +@EnableScheduling +public class DemoServiceApplication { + + public static void main(String[] args) { + SpringApplication.run(DemoServiceApplication.class, args); + } +} diff --git a/demo-service/src/main/java/com/xuqm/demo/config/GlobalExceptionHandler.java b/demo-service/src/main/java/com/xuqm/demo/config/GlobalExceptionHandler.java new file mode 100644 index 0000000..feb2745 --- /dev/null +++ b/demo-service/src/main/java/com/xuqm/demo/config/GlobalExceptionHandler.java @@ -0,0 +1,35 @@ +package com.xuqm.demo.config; + +import com.xuqm.common.exception.BusinessException; +import com.xuqm.common.model.ApiResponse; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.multipart.MaxUploadSizeExceededException; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class); + + @ExceptionHandler(BusinessException.class) + public ApiResponse handleBusiness(BusinessException ex) { + return ApiResponse.error(ex.getCode(), ex.getMessage()); + } + + @ExceptionHandler(MaxUploadSizeExceededException.class) + @ResponseStatus(HttpStatus.PAYLOAD_TOO_LARGE) + public ApiResponse handleMaxUploadSize(MaxUploadSizeExceededException ex) { + return ApiResponse.error(413, "File size exceeds the maximum allowed limit"); + } + + @ExceptionHandler(Exception.class) + @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) + public ApiResponse handleGeneric(Exception ex) { + log.error("Unhandled exception", ex); + return ApiResponse.error(500, "Internal server error"); + } +} diff --git a/demo-service/src/main/java/com/xuqm/demo/config/SecurityConfig.java b/demo-service/src/main/java/com/xuqm/demo/config/SecurityConfig.java new file mode 100644 index 0000000..6b3d2e7 --- /dev/null +++ b/demo-service/src/main/java/com/xuqm/demo/config/SecurityConfig.java @@ -0,0 +1,52 @@ +package com.xuqm.demo.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; +import org.springframework.web.client.RestTemplate; + +@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/demo/auth/**", + "/actuator/**" + ).permitAll() + .anyRequest().authenticated() + ) + .addFilterBefore(new JwtAuthFilter(jwtUtil), UsernamePasswordAuthenticationFilter.class); + return http.build(); + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + @Bean + public RestTemplate restTemplate() { + return new RestTemplate(); + } +} diff --git a/demo-service/src/main/java/com/xuqm/demo/controller/DemoAuthController.java b/demo-service/src/main/java/com/xuqm/demo/controller/DemoAuthController.java new file mode 100644 index 0000000..4a342e6 --- /dev/null +++ b/demo-service/src/main/java/com/xuqm/demo/controller/DemoAuthController.java @@ -0,0 +1,65 @@ +package com.xuqm.demo.controller; + +import com.xuqm.common.model.ApiResponse; +import com.xuqm.demo.service.DemoAuthService; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; + +@RestController +@RequestMapping("/api/demo/auth") +public class DemoAuthController { + + private final DemoAuthService authService; + + public DemoAuthController(DemoAuthService authService) { + this.authService = authService; + } + + @PostMapping("/register") + public ApiResponse> register(@RequestBody RegisterRequest body) { + if (body.appId() == null || body.appId().isBlank()) { + return ApiResponse.badRequest("appId is required"); + } + if (body.userId() == null || body.userId().isBlank()) { + return ApiResponse.badRequest("userId is required"); + } + if (body.password() == null || body.password().length() < 6) { + return ApiResponse.badRequest("password must be at least 6 characters"); + } + + DemoAuthService.AuthResult result = authService.register( + body.appId(), body.userId(), body.password(), body.nickname()); + + return ApiResponse.success(buildResponse(result)); + } + + @PostMapping("/login") + public ApiResponse> login(@RequestBody LoginRequest body) { + if (body.appId() == null || body.appId().isBlank()) { + return ApiResponse.badRequest("appId is required"); + } + if (body.userId() == null || body.userId().isBlank()) { + return ApiResponse.badRequest("userId is required"); + } + if (body.password() == null || body.password().isBlank()) { + return ApiResponse.badRequest("password is required"); + } + + DemoAuthService.AuthResult result = authService.login( + body.appId(), body.userId(), body.password()); + + return ApiResponse.success(buildResponse(result)); + } + + private Map buildResponse(DemoAuthService.AuthResult result) { + return Map.of( + "demoToken", result.demoToken() != null ? result.demoToken() : "", + "imToken", result.imToken() != null ? result.imToken() : "", + "profile", result.profile() + ); + } + + public record RegisterRequest(String appId, String userId, String password, String nickname) {} + public record LoginRequest(String appId, String userId, String password) {} +} diff --git a/demo-service/src/main/java/com/xuqm/demo/controller/DemoUserController.java b/demo-service/src/main/java/com/xuqm/demo/controller/DemoUserController.java new file mode 100644 index 0000000..b8ea433 --- /dev/null +++ b/demo-service/src/main/java/com/xuqm/demo/controller/DemoUserController.java @@ -0,0 +1,68 @@ +package com.xuqm.demo.controller; + +import com.xuqm.common.exception.BusinessException; +import com.xuqm.common.model.ApiResponse; +import com.xuqm.demo.service.DemoUserService; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/demo") +public class DemoUserController { + + private final DemoUserService userService; + + public DemoUserController(DemoUserService userService) { + this.userService = userService; + } + + @GetMapping("/user/profile") + public ApiResponse getProfile( + @RequestParam String appId, + Authentication auth) { + String userId = resolveUserId(auth); + return ApiResponse.success(userService.getProfile(appId, userId)); + } + + @PutMapping("/user/profile") + public ApiResponse updateProfile( + @RequestParam String appId, + Authentication auth, + @RequestBody UpdateProfileRequest body) { + String userId = resolveUserId(auth); + return ApiResponse.success( + userService.updateProfile(appId, userId, body.nickname(), body.avatar(), body.gender())); + } + + @PostMapping("/user/reset-password") + public ApiResponse resetPassword( + @RequestParam String appId, + Authentication auth, + @RequestBody ResetPasswordRequest body) { + String userId = resolveUserId(auth); + if (body.oldPassword() == null || body.newPassword() == null) { + return ApiResponse.badRequest("oldPassword and newPassword are required"); + } + userService.resetPassword(appId, userId, body.oldPassword(), body.newPassword()); + return ApiResponse.ok(); + } + + @GetMapping("/users/search") + public ApiResponse> searchUsers( + @RequestParam String appId, + @RequestParam String keyword) { + return ApiResponse.success(userService.searchUsers(appId, keyword)); + } + + private String resolveUserId(Authentication auth) { + if (auth == null || !auth.isAuthenticated()) { + throw new BusinessException(401, "Not authenticated"); + } + return (String) auth.getPrincipal(); + } + + public record UpdateProfileRequest(String nickname, String avatar, String gender) {} + public record ResetPasswordRequest(String oldPassword, String newPassword) {} +} diff --git a/demo-service/src/main/java/com/xuqm/demo/entity/DemoUserEntity.java b/demo-service/src/main/java/com/xuqm/demo/entity/DemoUserEntity.java new file mode 100644 index 0000000..fcc844e --- /dev/null +++ b/demo-service/src/main/java/com/xuqm/demo/entity/DemoUserEntity.java @@ -0,0 +1,66 @@ +package com.xuqm.demo.entity; + +import jakarta.persistence.*; +import java.time.Instant; + +@Entity +@Table( + name = "demo_user", + uniqueConstraints = @UniqueConstraint(name = "uq_demo_user_appid_userid", columnNames = {"app_id", "user_id"}) +) +public class DemoUserEntity { + + public enum Gender { + UNKNOWN, MALE, FEMALE + } + + @Id + @Column(name = "id", length = 36, nullable = false, updatable = false) + private String id; + + @Column(name = "app_id", length = 64, nullable = false) + private String appId; + + @Column(name = "user_id", length = 128, nullable = false) + private String userId; + + @Column(name = "password_hash", length = 128, nullable = false) + private String passwordHash; + + @Column(name = "nickname", length = 64) + private String nickname; + + @Column(name = "avatar", length = 512) + private String avatar; + + @Enumerated(EnumType.STRING) + @Column(name = "gender", length = 16) + private Gender gender = Gender.UNKNOWN; + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant 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 getPasswordHash() { return passwordHash; } + public void setPasswordHash(String passwordHash) { this.passwordHash = passwordHash; } + + public String getNickname() { return nickname; } + public void setNickname(String nickname) { this.nickname = nickname; } + + public String getAvatar() { return avatar; } + public void setAvatar(String avatar) { this.avatar = avatar; } + + public Gender getGender() { return gender; } + public void setGender(Gender gender) { this.gender = gender; } + + public Instant getCreatedAt() { return createdAt; } + public void setCreatedAt(Instant createdAt) { this.createdAt = createdAt; } +} diff --git a/demo-service/src/main/java/com/xuqm/demo/repository/DemoUserRepository.java b/demo-service/src/main/java/com/xuqm/demo/repository/DemoUserRepository.java new file mode 100644 index 0000000..4c1847e --- /dev/null +++ b/demo-service/src/main/java/com/xuqm/demo/repository/DemoUserRepository.java @@ -0,0 +1,21 @@ +package com.xuqm.demo.repository; + +import com.xuqm.demo.entity.DemoUserEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Optional; + +public interface DemoUserRepository extends JpaRepository { + + Optional findByAppIdAndUserId(String appId, String userId); + + boolean existsByAppIdAndUserId(String appId, String userId); + + @Query("SELECT u FROM DemoUserEntity u WHERE u.appId = :appId AND " + + "(LOWER(u.userId) LIKE LOWER(CONCAT('%', :keyword, '%')) OR " + + "LOWER(u.nickname) LIKE LOWER(CONCAT('%', :keyword, '%')))") + List searchByKeyword(@Param("appId") String appId, @Param("keyword") String keyword); +} diff --git a/demo-service/src/main/java/com/xuqm/demo/service/DemoAuthService.java b/demo-service/src/main/java/com/xuqm/demo/service/DemoAuthService.java new file mode 100644 index 0000000..f031e1f --- /dev/null +++ b/demo-service/src/main/java/com/xuqm/demo/service/DemoAuthService.java @@ -0,0 +1,125 @@ +package com.xuqm.demo.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.xuqm.common.exception.BusinessException; +import com.xuqm.common.security.JwtUtil; +import com.xuqm.demo.entity.DemoUserEntity; +import com.xuqm.demo.repository.DemoUserRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.util.UriComponentsBuilder; + +import java.time.Instant; +import java.util.Map; +import java.util.UUID; + +@Service +public class DemoAuthService { + + private static final Logger log = LoggerFactory.getLogger(DemoAuthService.class); + + private final DemoUserRepository userRepository; + private final JwtUtil jwtUtil; + private final PasswordEncoder passwordEncoder; + private final RestTemplate restTemplate; + + @Value("${demo.im-service-url:http://xuqm-im-service:8082}") + private String imServiceUrl; + + public DemoAuthService(DemoUserRepository userRepository, + JwtUtil jwtUtil, + PasswordEncoder passwordEncoder, + RestTemplate restTemplate) { + this.userRepository = userRepository; + this.jwtUtil = jwtUtil; + this.passwordEncoder = passwordEncoder; + this.restTemplate = restTemplate; + } + + public record AuthResult(String demoToken, String imToken, UserProfile profile) {} + + public record UserProfile(String appId, String userId, String nickname, String avatar, String gender) {} + + @Transactional + public AuthResult register(String appId, String userId, String password, String nickname) { + if (userRepository.existsByAppIdAndUserId(appId, userId)) { + throw new BusinessException(409, "User already exists: " + userId); + } + + DemoUserEntity user = new DemoUserEntity(); + user.setId(UUID.randomUUID().toString()); + user.setAppId(appId); + user.setUserId(userId); + user.setPasswordHash(passwordEncoder.encode(password)); + user.setNickname(nickname != null ? nickname : userId); + user.setGender(DemoUserEntity.Gender.UNKNOWN); + user.setCreatedAt(Instant.now()); + userRepository.save(user); + + String demoToken = generateDemoToken(appId, userId); + String imToken = callImServiceLogin(appId, userId, user.getNickname()); + + return new AuthResult(demoToken, imToken, toProfile(user)); + } + + @Transactional(readOnly = true) + public AuthResult login(String appId, String userId, String password) { + DemoUserEntity user = userRepository.findByAppIdAndUserId(appId, userId) + .orElseThrow(() -> new BusinessException(401, "Invalid credentials")); + + if (!passwordEncoder.matches(password, user.getPasswordHash())) { + throw new BusinessException(401, "Invalid credentials"); + } + + String demoToken = generateDemoToken(appId, userId); + String imToken = callImServiceLogin(appId, userId, user.getNickname()); + + return new AuthResult(demoToken, imToken, toProfile(user)); + } + + private String generateDemoToken(String appId, String userId) { + return jwtUtil.generate(userId, Map.of("appId", appId, "role", "USER")); + } + + /** + * Calls im-service to ensure the IM account exists and obtain an IM token. + * GET {imServiceUrl}/api/im/auth/login?appId={appId}&userId={userId}&nickname={nickname} + * Response: {"code":200,"data":{"token":"..."}} + */ + private String callImServiceLogin(String appId, String userId, String nickname) { + String url = UriComponentsBuilder.fromHttpUrl(imServiceUrl) + .path("/api/im/auth/login") + .queryParam("appId", appId) + .queryParam("userId", userId) + .queryParam("nickname", nickname != null ? nickname : userId) + .toUriString(); + + try { + JsonNode response = restTemplate.getForObject(url, JsonNode.class); + if (response != null && response.path("code").asInt() == 200) { + return response.path("data").path("token").asText(); + } + log.warn("im-service login returned unexpected response for appId={} userId={}: {}", appId, userId, response); + return null; + } catch (RestClientException e) { + log.error("Failed to call im-service login for appId={} userId={}: {}", appId, userId, e.getMessage()); + return null; + } + } + + private UserProfile toProfile(DemoUserEntity user) { + return new UserProfile( + user.getAppId(), + user.getUserId(), + user.getNickname(), + user.getAvatar(), + user.getGender() != null ? user.getGender().name() : DemoUserEntity.Gender.UNKNOWN.name() + ); + } +} diff --git a/demo-service/src/main/java/com/xuqm/demo/service/DemoUserService.java b/demo-service/src/main/java/com/xuqm/demo/service/DemoUserService.java new file mode 100644 index 0000000..ea6fc82 --- /dev/null +++ b/demo-service/src/main/java/com/xuqm/demo/service/DemoUserService.java @@ -0,0 +1,91 @@ +package com.xuqm.demo.service; + +import com.xuqm.common.exception.BusinessException; +import com.xuqm.demo.entity.DemoUserEntity; +import com.xuqm.demo.repository.DemoUserRepository; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +public class DemoUserService { + + private final DemoUserRepository userRepository; + private final PasswordEncoder passwordEncoder; + + public DemoUserService(DemoUserRepository userRepository, PasswordEncoder passwordEncoder) { + this.userRepository = userRepository; + this.passwordEncoder = passwordEncoder; + } + + public record UserProfile(String appId, String userId, String nickname, String avatar, String gender) {} + + @Transactional(readOnly = true) + public UserProfile getProfile(String appId, String userId) { + DemoUserEntity user = userRepository.findByAppIdAndUserId(appId, userId) + .orElseThrow(() -> new BusinessException(404, "User not found")); + return toProfile(user); + } + + @Transactional + public UserProfile updateProfile(String appId, String userId, String nickname, String avatar, String gender) { + DemoUserEntity user = userRepository.findByAppIdAndUserId(appId, userId) + .orElseThrow(() -> new BusinessException(404, "User not found")); + + if (nickname != null && !nickname.isBlank()) { + user.setNickname(nickname); + } + if (avatar != null) { + user.setAvatar(avatar.isBlank() ? null : avatar); + } + if (gender != null) { + try { + user.setGender(DemoUserEntity.Gender.valueOf(gender.toUpperCase())); + } catch (IllegalArgumentException e) { + throw new BusinessException(400, "Invalid gender value: " + gender); + } + } + + userRepository.save(user); + return toProfile(user); + } + + @Transactional + public void resetPassword(String appId, String userId, String oldPassword, String newPassword) { + DemoUserEntity user = userRepository.findByAppIdAndUserId(appId, userId) + .orElseThrow(() -> new BusinessException(404, "User not found")); + + if (!passwordEncoder.matches(oldPassword, user.getPasswordHash())) { + throw new BusinessException(401, "Old password is incorrect"); + } + if (newPassword == null || newPassword.length() < 6) { + throw new BusinessException(400, "New password must be at least 6 characters"); + } + + user.setPasswordHash(passwordEncoder.encode(newPassword)); + userRepository.save(user); + } + + @Transactional(readOnly = true) + public List searchUsers(String appId, String keyword) { + if (keyword == null || keyword.isBlank()) { + throw new BusinessException(400, "Search keyword must not be blank"); + } + return userRepository.searchByKeyword(appId, keyword.trim()) + .stream() + .map(this::toProfile) + .toList(); + } + + private UserProfile toProfile(DemoUserEntity user) { + return new UserProfile( + user.getAppId(), + user.getUserId(), + user.getNickname(), + user.getAvatar(), + user.getGender() != null ? user.getGender().name() : DemoUserEntity.Gender.UNKNOWN.name() + ); + } +} diff --git a/demo-service/src/main/resources/application.yml b/demo-service/src/main/resources/application.yml new file mode 100644 index 0000000..4bda86a --- /dev/null +++ b/demo-service/src/main/resources/application.yml @@ -0,0 +1,42 @@ +server: + port: 8085 + +spring: + application: + name: demo-service + threads: + virtual: + enabled: true + datasource: + url: jdbc:mysql://39.107.53.187:3306/xuqm_demo?useUnicode=true&characterEncoding=utf8&useSSL=true&serverTimezone=GMT%2B8&allowPublicKeyRetrieval=true + username: xuqm + password: Xuqm@2026 + driver-class-name: com.mysql.cj.jdbc.Driver + hikari: + minimum-idle: 2 + maximum-pool-size: 15 + connection-timeout: 30000 + idle-timeout: 300000 + max-lifetime: 900000 + jpa: + hibernate: + ddl-auto: update + show-sql: false + properties: + hibernate: + dialect: org.hibernate.dialect.MySQLDialect + jackson: + time-zone: UTC + serialization: + write-dates-as-timestamps: false + +jwt: + secret: ${XUQM_JWT_SECRET:xuqm-tenant-service-secret-key-must-be-at-least-256-bits-long-for-hmac} + expiration: 86400000 + +demo: + im-service-url: ${IM_SERVICE_URL:http://xuqm-im-service:8082} + +logging: + level: + com.xuqm: DEBUG diff --git a/file-service/pom.xml b/file-service/pom.xml new file mode 100644 index 0000000..6a61df6 --- /dev/null +++ b/file-service/pom.xml @@ -0,0 +1,77 @@ + + + 4.0.0 + + + com.xuqm + xuqmgroup-server-parent + 0.1.0-SNAPSHOT + ../pom.xml + + + file-service + file-service + File storage service — content-addressed upload, deduplication, thumbnail generation + + + + 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-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/file-service/src/main/java/com/xuqm/file/FileServiceApplication.java b/file-service/src/main/java/com/xuqm/file/FileServiceApplication.java new file mode 100644 index 0000000..679fe10 --- /dev/null +++ b/file-service/src/main/java/com/xuqm/file/FileServiceApplication.java @@ -0,0 +1,14 @@ +package com.xuqm.file; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableScheduling; + +@SpringBootApplication(scanBasePackages = {"com.xuqm.file", "com.xuqm.common"}) +@EnableScheduling +public class FileServiceApplication { + + public static void main(String[] args) { + SpringApplication.run(FileServiceApplication.class, args); + } +} diff --git a/file-service/src/main/java/com/xuqm/file/config/SecurityConfig.java b/file-service/src/main/java/com/xuqm/file/config/SecurityConfig.java new file mode 100644 index 0000000..48715e3 --- /dev/null +++ b/file-service/src/main/java/com/xuqm/file/config/SecurityConfig.java @@ -0,0 +1,41 @@ +package com.xuqm.file.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.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 + // Public: serve files by hash and thumbnails + .requestMatchers("/api/file/*/thumbnail").permitAll() + .requestMatchers("/api/file/*").permitAll() + // Actuator health & info + .requestMatchers("/actuator/**").permitAll() + // Upload requires authentication + .anyRequest().authenticated() + ) + .addFilterBefore(new JwtAuthFilter(jwtUtil), UsernamePasswordAuthenticationFilter.class); + return http.build(); + } +} diff --git a/file-service/src/main/java/com/xuqm/file/controller/FileController.java b/file-service/src/main/java/com/xuqm/file/controller/FileController.java new file mode 100644 index 0000000..41ba35e --- /dev/null +++ b/file-service/src/main/java/com/xuqm/file/controller/FileController.java @@ -0,0 +1,89 @@ +package com.xuqm.file.controller; + +import com.xuqm.common.model.ApiResponse; +import com.xuqm.file.entity.FileEntity; +import com.xuqm.file.service.FileStorageService; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + +@RestController +@RequestMapping("/api/file") +public class FileController { + + private final FileStorageService fileStorageService; + + public FileController(FileStorageService fileStorageService) { + this.fileStorageService = fileStorageService; + } + + /** + * Upload a file (auth required). Accepts optional thumbnail for video files. + * Returns deduplicated result if the same content was already uploaded. + */ + @PostMapping(value = "/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ApiResponse upload( + @RequestPart("file") MultipartFile file, + @RequestPart(value = "thumbnail", required = false) MultipartFile thumbnail) { + return ApiResponse.success(fileStorageService.upload(file, thumbnail)); + } + + /** + * Stream a file by its SHA-256 hash. Public, heavily cached. + */ + @GetMapping("/{hash}") + public void serveFile(@PathVariable String hash, HttpServletResponse response) throws IOException { + FileEntity entity = fileStorageService.findByHashAndTouch(hash); + serveFromPath(entity.getStoragePath(), entity.getMimeType(), entity.getOriginalName(), response); + } + + /** + * Stream the thumbnail for a given hash. Falls back to the original if no thumbnail exists. + */ + @GetMapping("/{hash}/thumbnail") + public void serveThumbnail(@PathVariable String hash, HttpServletResponse response) throws IOException { + FileEntity entity = fileStorageService.findByHash(hash); + if (entity.getThumbnailPath() != null) { + serveFromPath(entity.getThumbnailPath(), "image/jpeg", null, response); + } else { + // No thumbnail — redirect to original file + response.sendRedirect("/api/file/" + hash); + } + } + + // ------------------------------------------------------------------------- + + private void serveFromPath(String filePath, String mimeType, String originalName, + HttpServletResponse response) throws IOException { + Path path = Paths.get(filePath); + if (!Files.exists(path)) { + response.setStatus(HttpServletResponse.SC_NOT_FOUND); + return; + } + + String contentType = mimeType != null ? mimeType : "application/octet-stream"; + response.setContentType(contentType); + response.setContentLengthLong(Files.size(path)); + response.setHeader("Cache-Control", "public, max-age=31536000, immutable"); + + if (originalName != null + && !contentType.startsWith("image/") + && !contentType.startsWith("video/") + && !contentType.startsWith("audio/")) { + response.setHeader("Content-Disposition", "attachment; filename=\"" + originalName + "\""); + } else { + response.setHeader("Content-Disposition", "inline"); + } + + try (OutputStream out = response.getOutputStream()) { + Files.copy(path, out); + } + } +} diff --git a/file-service/src/main/java/com/xuqm/file/entity/FileEntity.java b/file-service/src/main/java/com/xuqm/file/entity/FileEntity.java new file mode 100644 index 0000000..d42ba97 --- /dev/null +++ b/file-service/src/main/java/com/xuqm/file/entity/FileEntity.java @@ -0,0 +1,76 @@ +package com.xuqm.file.entity; + +import jakarta.persistence.*; +import java.time.Instant; + +@Entity +@Table(name = "file_record") +public class FileEntity { + + @Id + @Column(name = "id", length = 36, nullable = false, updatable = false) + private String id; + + @Column(name = "hash", length = 64, nullable = false, unique = true) + private String hash; + + @Column(name = "original_name", length = 255) + private String originalName; + + @Column(name = "mime_type", length = 128) + private String mimeType; + + @Column(name = "size", nullable = false) + private long size; + + @Column(name = "ext", length = 16) + private String ext; + + @Column(name = "storage_path", length = 512, nullable = false) + private String storagePath; + + @Column(name = "thumbnail_path", length = 512) + private String thumbnailPath; + + @Column(name = "thumbnail_size", nullable = false) + private long thumbnailSize; + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + @Column(name = "last_accessed_at", nullable = false) + private Instant lastAccessedAt; + + public String getId() { return id; } + public void setId(String id) { this.id = id; } + + public String getHash() { return hash; } + public void setHash(String hash) { this.hash = hash; } + + public String getOriginalName() { return originalName; } + public void setOriginalName(String originalName) { this.originalName = originalName; } + + public String getMimeType() { return mimeType; } + public void setMimeType(String mimeType) { this.mimeType = mimeType; } + + public long getSize() { return size; } + public void setSize(long size) { this.size = size; } + + public String getExt() { return ext; } + public void setExt(String ext) { this.ext = ext; } + + public String getStoragePath() { return storagePath; } + public void setStoragePath(String storagePath) { this.storagePath = storagePath; } + + public String getThumbnailPath() { return thumbnailPath; } + public void setThumbnailPath(String thumbnailPath) { this.thumbnailPath = thumbnailPath; } + + public long getThumbnailSize() { return thumbnailSize; } + public void setThumbnailSize(long thumbnailSize) { this.thumbnailSize = thumbnailSize; } + + public Instant getCreatedAt() { return createdAt; } + public void setCreatedAt(Instant createdAt) { this.createdAt = createdAt; } + + public Instant getLastAccessedAt() { return lastAccessedAt; } + public void setLastAccessedAt(Instant lastAccessedAt) { this.lastAccessedAt = lastAccessedAt; } +} diff --git a/file-service/src/main/java/com/xuqm/file/repository/FileRepository.java b/file-service/src/main/java/com/xuqm/file/repository/FileRepository.java new file mode 100644 index 0000000..a3f4985 --- /dev/null +++ b/file-service/src/main/java/com/xuqm/file/repository/FileRepository.java @@ -0,0 +1,18 @@ +package com.xuqm.file.repository; + +import com.xuqm.file.entity.FileEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.Instant; +import java.util.List; +import java.util.Optional; + +public interface FileRepository extends JpaRepository { + + Optional findByHash(String hash); + + @Query("SELECT f FROM FileEntity f WHERE f.lastAccessedAt < :cutoff") + List findExpired(@Param("cutoff") Instant cutoff); +} diff --git a/file-service/src/main/java/com/xuqm/file/service/FileStorageService.java b/file-service/src/main/java/com/xuqm/file/service/FileStorageService.java new file mode 100644 index 0000000..2cb2d15 --- /dev/null +++ b/file-service/src/main/java/com/xuqm/file/service/FileStorageService.java @@ -0,0 +1,304 @@ +package com.xuqm.file.service; + +import com.xuqm.common.exception.BusinessException; +import com.xuqm.file.entity.FileEntity; +import com.xuqm.file.repository.FileRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import javax.imageio.ImageIO; +import java.awt.*; +import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.HexFormat; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +@Service +public class FileStorageService { + + private static final Logger log = LoggerFactory.getLogger(FileStorageService.class); + private static final int THUMBNAIL_MAX_DIM = 320; + + @Value("${file.upload-dir:/tmp/xuqm-file-upload}") + private String uploadDir; + + @Value("${file.base-url:https://sentry.xuqinmin.com}") + private String baseUrl; + + private final FileRepository fileRepository; + + public FileStorageService(FileRepository fileRepository) { + this.fileRepository = fileRepository; + } + + public record UploadResult( + String url, + String thumbnailUrl, + String hash, + long size, + String originalName, + String mimeType, + String ext + ) {} + + @Transactional + public UploadResult upload(MultipartFile file, MultipartFile thumbnail) { + if (file == null || file.isEmpty()) { + throw new BusinessException(400, "File must not be empty"); + } + + byte[] bytes; + try { + bytes = file.getBytes(); + } catch (IOException e) { + throw new BusinessException(500, "Failed to read uploaded file"); + } + + String hash = sha256Hex(bytes); + String mimeType = resolveMimeType(file); + String originalName = file.getOriginalFilename(); + + Optional existing = fileRepository.findByHash(hash); + if (existing.isPresent()) { + FileEntity entity = existing.get(); + entity.setLastAccessedAt(Instant.now()); + fileRepository.save(entity); + return toUploadResult(entity); + } + + String ext = resolveExtension(originalName, mimeType); + Path storageDir = Paths.get(uploadDir); + ensureDirectory(storageDir); + + Path storagePath = storageDir.resolve(hash + ext); + try { + Files.write(storagePath, bytes); + } catch (IOException e) { + throw new BusinessException(500, "Failed to save uploaded file"); + } + + String thumbnailStoragePath = null; + long thumbnailSize = 0L; + + if (thumbnail != null && !thumbnail.isEmpty()) { + thumbnailStoragePath = saveThumbnailFile(thumbnail, hash); + if (thumbnailStoragePath != null) { + try { + thumbnailSize = Files.size(Paths.get(thumbnailStoragePath)); + } catch (IOException e) { + thumbnailSize = 0L; + } + } + } else if (mimeType != null && mimeType.startsWith("image/")) { + thumbnailStoragePath = generateImageThumbnail(bytes, hash); + if (thumbnailStoragePath != null) { + try { + thumbnailSize = Files.size(Paths.get(thumbnailStoragePath)); + } catch (IOException e) { + thumbnailSize = 0L; + } + } + } + + Instant now = Instant.now(); + FileEntity entity = new FileEntity(); + entity.setId(UUID.randomUUID().toString()); + entity.setHash(hash); + entity.setOriginalName(originalName); + entity.setMimeType(mimeType); + entity.setSize(bytes.length); + entity.setExt(ext); + entity.setStoragePath(storagePath.toAbsolutePath().toString()); + entity.setThumbnailPath(thumbnailStoragePath); + entity.setThumbnailSize(thumbnailSize); + entity.setCreatedAt(now); + entity.setLastAccessedAt(now); + fileRepository.save(entity); + + return toUploadResult(entity); + } + + @Transactional(readOnly = true) + public FileEntity findByHash(String hash) { + return fileRepository.findByHash(hash) + .orElseThrow(() -> new BusinessException(404, "File not found: " + hash)); + } + + @Transactional + public FileEntity findByHashAndTouch(String hash) { + FileEntity entity = fileRepository.findByHash(hash) + .orElseThrow(() -> new BusinessException(404, "File not found: " + hash)); + entity.setLastAccessedAt(Instant.now()); + fileRepository.save(entity); + return entity; + } + + @Scheduled(cron = "0 0 3 * * *") + @Transactional + public void reclaimExpiredFiles() { + Instant cutoff = Instant.now().minus(30, ChronoUnit.DAYS); + List expired = fileRepository.findExpired(cutoff); + if (expired.isEmpty()) { + log.info("File reclaim: no expired files found"); + return; + } + log.info("File reclaim: deleting {} expired file record(s)", expired.size()); + for (FileEntity entity : expired) { + deleteDiskFile(entity.getStoragePath()); + if (entity.getThumbnailPath() != null) { + deleteDiskFile(entity.getThumbnailPath()); + } + } + fileRepository.deleteAll(expired); + } + + private void deleteDiskFile(String path) { + if (path == null) return; + try { + Files.deleteIfExists(Paths.get(path)); + } catch (IOException e) { + log.warn("Failed to delete file from disk: {}", path, e); + } + } + + private String saveThumbnailFile(MultipartFile thumbnail, String hash) { + Path thumbPath = Paths.get(uploadDir).resolve(hash + "_thumb.jpg"); + try { + thumbnail.transferTo(thumbPath.toFile()); + return thumbPath.toAbsolutePath().toString(); + } catch (IOException e) { + log.warn("Failed to save provided thumbnail for hash {}: {}", hash, e.getMessage()); + return null; + } + } + + private String generateImageThumbnail(byte[] imageBytes, String hash) { + try { + BufferedImage original = ImageIO.read(new ByteArrayInputStream(imageBytes)); + if (original == null) { + return null; + } + int origW = original.getWidth(); + int origH = original.getHeight(); + + int newW, newH; + if (origW > origH) { + newW = Math.min(origW, THUMBNAIL_MAX_DIM); + newH = (int) Math.round((double) origH / origW * newW); + } else { + newH = Math.min(origH, THUMBNAIL_MAX_DIM); + newW = (int) Math.round((double) origW / origH * newH); + } + if (newW == 0) newW = 1; + if (newH == 0) newH = 1; + + BufferedImage thumb = new BufferedImage(newW, newH, BufferedImage.TYPE_INT_RGB); + Graphics2D g2d = thumb.createGraphics(); + g2d.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR); + g2d.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY); + g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + g2d.drawImage(original, 0, 0, newW, newH, null); + g2d.dispose(); + + Path thumbPath = Paths.get(uploadDir).resolve(hash + "_thumb.jpg"); + ImageIO.write(thumb, "jpg", thumbPath.toFile()); + return thumbPath.toAbsolutePath().toString(); + } catch (IOException e) { + log.warn("Failed to generate thumbnail for hash {}: {}", hash, e.getMessage()); + return null; + } + } + + private String sha256Hex(byte[] data) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hashBytes = digest.digest(data); + return HexFormat.of().formatHex(hashBytes); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 not available", e); + } + } + + private String resolveMimeType(MultipartFile file) { + String ct = file.getContentType(); + if (ct != null && !ct.isBlank() && !ct.equals("application/octet-stream")) { + return ct; + } + String name = file.getOriginalFilename(); + if (name != null) { + String lower = name.toLowerCase(); + if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg"; + if (lower.endsWith(".png")) return "image/png"; + if (lower.endsWith(".gif")) return "image/gif"; + if (lower.endsWith(".webp")) return "image/webp"; + if (lower.endsWith(".mp4")) return "video/mp4"; + if (lower.endsWith(".mov")) return "video/quicktime"; + if (lower.endsWith(".m4a")) return "audio/mp4"; + if (lower.endsWith(".mp3")) return "audio/mpeg"; + if (lower.endsWith(".aac")) return "audio/aac"; + if (lower.endsWith(".pdf")) return "application/pdf"; + } + return ct != null ? ct : "application/octet-stream"; + } + + private String resolveExtension(String originalName, String mimeType) { + if (originalName != null && originalName.contains(".")) { + return originalName.substring(originalName.lastIndexOf('.')); + } + if (mimeType != null) { + return switch (mimeType) { + case "image/jpeg" -> ".jpg"; + case "image/png" -> ".png"; + case "image/gif" -> ".gif"; + case "image/webp" -> ".webp"; + case "video/mp4" -> ".mp4"; + case "video/quicktime" -> ".mov"; + case "audio/mp4" -> ".m4a"; + case "audio/mpeg" -> ".mp3"; + case "audio/aac" -> ".aac"; + case "application/pdf" -> ".pdf"; + default -> ""; + }; + } + return ""; + } + + private void ensureDirectory(Path dir) { + try { + Files.createDirectories(dir); + } catch (IOException e) { + throw new BusinessException(500, "Failed to create upload directory: " + dir); + } + } + + private UploadResult toUploadResult(FileEntity entity) { + String url = baseUrl + "/api/file/" + entity.getHash(); + String thumbnailUrl = entity.getThumbnailPath() != null + ? baseUrl + "/api/file/" + entity.getHash() + "/thumbnail" + : null; + return new UploadResult( + url, + thumbnailUrl, + entity.getHash(), + entity.getSize(), + entity.getOriginalName(), + entity.getMimeType(), + entity.getExt() + ); + } +} diff --git a/file-service/src/main/resources/application.yml b/file-service/src/main/resources/application.yml new file mode 100644 index 0000000..f7cdf93 --- /dev/null +++ b/file-service/src/main/resources/application.yml @@ -0,0 +1,48 @@ +server: + port: 8086 + +spring: + application: + name: file-service + threads: + virtual: + enabled: true + datasource: + url: jdbc:mysql://39.107.53.187:3306/xuqm_file?useUnicode=true&characterEncoding=utf8&useSSL=true&serverTimezone=GMT%2B8&allowPublicKeyRetrieval=true + username: xuqm + password: Xuqm@2026 + driver-class-name: com.mysql.cj.jdbc.Driver + hikari: + minimum-idle: 2 + maximum-pool-size: 15 + connection-timeout: 30000 + idle-timeout: 300000 + max-lifetime: 900000 + jpa: + hibernate: + ddl-auto: update + show-sql: false + properties: + hibernate: + dialect: org.hibernate.dialect.MySQLDialect + servlet: + multipart: + enabled: true + max-file-size: 500MB + max-request-size: 510MB + jackson: + time-zone: UTC + serialization: + write-dates-as-timestamps: false + +jwt: + secret: ${XUQM_JWT_SECRET:xuqm-tenant-service-secret-key-must-be-at-least-256-bits-long-for-hmac} + expiration: 86400000 + +file: + upload-dir: ${FILE_UPLOAD_DIR:/tmp/xuqm-file-upload} + base-url: ${FILE_BASE_URL:https://sentry.xuqinmin.com} + +logging: + level: + com.xuqm: DEBUG diff --git a/im-service/src/main/java/com/xuqm/im/controller/ImAdminController.java b/im-service/src/main/java/com/xuqm/im/controller/ImAdminController.java index 0a1b29f..f90b01d 100644 --- a/im-service/src/main/java/com/xuqm/im/controller/ImAdminController.java +++ b/im-service/src/main/java/com/xuqm/im/controller/ImAdminController.java @@ -86,6 +86,16 @@ public class ImAdminController { groupService.create(appId, req.name(), req.creatorId(), req.memberIds()))); } + /** Fuzzy search users by userId or nickname. */ + @GetMapping("/users/search") + public ResponseEntity>> searchUsers( + @RequestParam String appId, + @RequestParam String keyword, + @RequestParam(defaultValue = "20") int size) { + List results = accountRepository.searchByKeyword(appId, keyword, PageRequest.of(0, size)); + return ResponseEntity.ok(ApiResponse.success(results)); + } + /** Message statistics for the given appId. */ @GetMapping("/stats") public ResponseEntity>> stats(@RequestParam String appId) { 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 index 3e7e889..ca71c14 100644 --- a/im-service/src/main/java/com/xuqm/im/repository/ImAccountRepository.java +++ b/im-service/src/main/java/com/xuqm/im/repository/ImAccountRepository.java @@ -4,6 +4,9 @@ import com.xuqm.im.entity.ImAccountEntity; 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.util.List; import java.util.Optional; public interface ImAccountRepository extends JpaRepository { @@ -11,4 +14,8 @@ public interface ImAccountRepository extends JpaRepository findByAppId(String appId, Pageable pageable); long countByAppId(String appId); + + @Query("SELECT a FROM ImAccountEntity a WHERE a.appId = :appId AND a.status = 'ACTIVE' AND " + + "(LOWER(a.userId) LIKE LOWER(CONCAT('%',:kw,'%')) OR LOWER(a.nickname) LIKE LOWER(CONCAT('%',:kw,'%')))") + List searchByKeyword(@Param("appId") String appId, @Param("kw") String keyword, Pageable pageable); } diff --git a/pom.xml b/pom.xml index bc7e4d9..3c2570b 100644 --- a/pom.xml +++ b/pom.xml @@ -17,6 +17,8 @@ im-service push-service update-service + demo-service + file-service 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 index 091d8ff..86814cb 100644 --- a/tenant-service/src/main/java/com/xuqm/tenant/config/SecurityConfig.java +++ b/tenant-service/src/main/java/com/xuqm/tenant/config/SecurityConfig.java @@ -33,6 +33,7 @@ public class SecurityConfig { .authorizeHttpRequests(auth -> auth .requestMatchers( "/api/auth/**", + "/api/sdk/**", "/actuator/health", "/actuator/info" ).permitAll() diff --git a/tenant-service/src/main/java/com/xuqm/tenant/controller/SdkConfigController.java b/tenant-service/src/main/java/com/xuqm/tenant/controller/SdkConfigController.java new file mode 100644 index 0000000..e0ac9e6 --- /dev/null +++ b/tenant-service/src/main/java/com/xuqm/tenant/controller/SdkConfigController.java @@ -0,0 +1,83 @@ +package com.xuqm.tenant.controller; + +import com.xuqm.common.model.ApiResponse; +import com.xuqm.tenant.entity.FeatureServiceEntity; +import com.xuqm.tenant.repository.AppRepository; +import com.xuqm.tenant.repository.FeatureServiceRepository; +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.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/sdk") +public class SdkConfigController { + + private final AppRepository appRepository; + private final FeatureServiceRepository featureServiceRepository; + + @Value("${sdk.im-ws-url:wss://sentry.xuqinmin.com/ws/im}") + private String imWsUrl; + + @Value("${sdk.file-service-url:https://sentry.xuqinmin.com}") + private String fileServiceUrl; + + @Value("${sdk.im-api-url:https://sentry.xuqinmin.com}") + private String imApiUrl; + + public SdkConfigController(AppRepository appRepository, + FeatureServiceRepository featureServiceRepository) { + this.appRepository = appRepository; + this.featureServiceRepository = featureServiceRepository; + } + + /** + * GET /api/sdk/config?appId=XXX — public, no auth required. + * + * Returns SDK configuration URLs and enabled feature flags for the given appId. + * Returns 404 if the appId does not exist in the system. + */ + @GetMapping("/config") + public ResponseEntity> getConfig( + @RequestParam String appId) { + + if (!appRepository.existsById(appId)) { + return ResponseEntity.status(404) + .body(ApiResponse.error(404, "App not found: " + appId)); + } + + List features = featureServiceRepository.findByAppId(appId); + + boolean imEnabled = features.stream() + .anyMatch(f -> f.getServiceType() == FeatureServiceEntity.ServiceType.IM && f.isEnabled()); + boolean pushEnabled = features.stream() + .anyMatch(f -> f.getServiceType() == FeatureServiceEntity.ServiceType.PUSH && f.isEnabled()); + boolean updateEnabled = features.stream() + .anyMatch(f -> f.getServiceType() == FeatureServiceEntity.ServiceType.UPDATE && f.isEnabled()); + + SdkConfigResponse response = new SdkConfigResponse( + imWsUrl, + fileServiceUrl, + imApiUrl, + Map.of( + "im", imEnabled, + "push", pushEnabled, + "update", updateEnabled + ) + ); + + return ResponseEntity.ok(ApiResponse.success(response)); + } + + public record SdkConfigResponse( + String imWsUrl, + String fileServiceUrl, + String imApiUrl, + Map features + ) {} +} diff --git a/tenant-service/src/main/resources/application.yml b/tenant-service/src/main/resources/application.yml index 4e44808..9cd7a0f 100644 --- a/tenant-service/src/main/resources/application.yml +++ b/tenant-service/src/main/resources/application.yml @@ -78,3 +78,8 @@ management: web: exposure: include: health,info + +sdk: + im-ws-url: ${SDK_IM_WS_URL:wss://sentry.xuqinmin.com/ws/im} + file-service-url: ${SDK_FILE_SERVICE_URL:https://sentry.xuqinmin.com} + im-api-url: ${SDK_IM_API_URL:https://sentry.xuqinmin.com}