feat: add demo-service, file-service; SDK remote config endpoint; IM fuzzy search

- demo-service (port 8085): auth, user profile, demo-to-IM token bridge
- file-service (port 8086): SHA-256 dedup upload, ImageIO thumbnails, 30-day reclaim
- tenant-service: GET /api/sdk/config?appId returns imWsUrl/fileServiceUrl/features
- im-service: GET /api/im/admin/users/search fuzzy match by userId or nickname

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
这个提交包含在:
XuqmGroup 2026-04-25 16:41:10 +08:00
父节点 6ce29edb4c
当前提交 526f3cf944
共有 27 个文件被更改,包括 1434 次插入1 次删除

查看文件

@ -10,6 +10,8 @@ COPY tenant-service ./tenant-service
COPY im-service ./im-service COPY im-service ./im-service
COPY push-service ./push-service COPY push-service ./push-service
COPY update-service ./update-service COPY update-service ./update-service
COPY demo-service ./demo-service
COPY file-service ./file-service
RUN mvn -pl ${SERVICE_MODULE} -am -DskipTests package RUN mvn -pl ${SERVICE_MODULE} -am -DskipTests package

2
Jenkinsfile vendored
查看文件

@ -2,7 +2,7 @@ pipeline {
agent any agent any
parameters { 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') string(name: 'IMAGE_TAG', defaultValue: 'latest', description: '镜像 Tag如 v1.2.3 或 latest')
booleanParam(name: 'DEPLOY', defaultValue: true, description: '构建后是否自动部署到生产服务器') booleanParam(name: 'DEPLOY', defaultValue: true, description: '构建后是否自动部署到生产服务器')
} }

77
demo-service/pom.xml 普通文件
查看文件

@ -0,0 +1,77 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.xuqm</groupId>
<artifactId>xuqmgroup-server-parent</artifactId>
<version>0.1.0-SNAPSHOT</version>
<relativePath>../pom.xml</relativePath>
</parent>
<artifactId>demo-service</artifactId>
<name>demo-service</name>
<description>Demo tenant service — user auth, file upload/dedup, IM account bridge</description>
<dependencies>
<dependency>
<groupId>com.xuqm</groupId>
<artifactId>common</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-jsr310</artifactId>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>

查看文件

@ -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);
}
}

查看文件

@ -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<Void> handleBusiness(BusinessException ex) {
return ApiResponse.error(ex.getCode(), ex.getMessage());
}
@ExceptionHandler(MaxUploadSizeExceededException.class)
@ResponseStatus(HttpStatus.PAYLOAD_TOO_LARGE)
public ApiResponse<Void> handleMaxUploadSize(MaxUploadSizeExceededException ex) {
return ApiResponse.error(413, "File size exceeds the maximum allowed limit");
}
@ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public ApiResponse<Void> handleGeneric(Exception ex) {
log.error("Unhandled exception", ex);
return ApiResponse.error(500, "Internal server error");
}
}

查看文件

@ -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();
}
}

查看文件

@ -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<Map<String, Object>> 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<Map<String, Object>> 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<String, Object> 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) {}
}

查看文件

@ -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<DemoUserService.UserProfile> getProfile(
@RequestParam String appId,
Authentication auth) {
String userId = resolveUserId(auth);
return ApiResponse.success(userService.getProfile(appId, userId));
}
@PutMapping("/user/profile")
public ApiResponse<DemoUserService.UserProfile> 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<Void> 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<List<DemoUserService.UserProfile>> 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) {}
}

查看文件

@ -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; }
}

查看文件

@ -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<DemoUserEntity, String> {
Optional<DemoUserEntity> 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<DemoUserEntity> searchByKeyword(@Param("appId") String appId, @Param("keyword") String keyword);
}

查看文件

@ -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()
);
}
}

查看文件

@ -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<UserProfile> 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()
);
}
}

查看文件

@ -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

77
file-service/pom.xml 普通文件
查看文件

@ -0,0 +1,77 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.xuqm</groupId>
<artifactId>xuqmgroup-server-parent</artifactId>
<version>0.1.0-SNAPSHOT</version>
<relativePath>../pom.xml</relativePath>
</parent>
<artifactId>file-service</artifactId>
<name>file-service</name>
<description>File storage service — content-addressed upload, deduplication, thumbnail generation</description>
<dependencies>
<dependency>
<groupId>com.xuqm</groupId>
<artifactId>common</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-jsr310</artifactId>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>

查看文件

@ -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);
}
}

查看文件

@ -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();
}
}

查看文件

@ -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<FileStorageService.UploadResult> 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);
}
}
}

查看文件

@ -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; }
}

查看文件

@ -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<FileEntity, String> {
Optional<FileEntity> findByHash(String hash);
@Query("SELECT f FROM FileEntity f WHERE f.lastAccessedAt < :cutoff")
List<FileEntity> findExpired(@Param("cutoff") Instant cutoff);
}

查看文件

@ -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<FileEntity> 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<FileEntity> 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()
);
}
}

查看文件

@ -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

查看文件

@ -86,6 +86,16 @@ public class ImAdminController {
groupService.create(appId, req.name(), req.creatorId(), req.memberIds()))); groupService.create(appId, req.name(), req.creatorId(), req.memberIds())));
} }
/** Fuzzy search users by userId or nickname. */
@GetMapping("/users/search")
public ResponseEntity<ApiResponse<List<ImAccountEntity>>> searchUsers(
@RequestParam String appId,
@RequestParam String keyword,
@RequestParam(defaultValue = "20") int size) {
List<ImAccountEntity> results = accountRepository.searchByKeyword(appId, keyword, PageRequest.of(0, size));
return ResponseEntity.ok(ApiResponse.success(results));
}
/** Message statistics for the given appId. */ /** Message statistics for the given appId. */
@GetMapping("/stats") @GetMapping("/stats")
public ResponseEntity<ApiResponse<Map<String, Object>>> stats(@RequestParam String appId) { public ResponseEntity<ApiResponse<Map<String, Object>>> stats(@RequestParam String appId) {

查看文件

@ -4,6 +4,9 @@ import com.xuqm.im.entity.ImAccountEntity;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository; 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; import java.util.Optional;
public interface ImAccountRepository extends JpaRepository<ImAccountEntity, String> { public interface ImAccountRepository extends JpaRepository<ImAccountEntity, String> {
@ -11,4 +14,8 @@ public interface ImAccountRepository extends JpaRepository<ImAccountEntity, Stri
boolean existsByAppIdAndUserId(String appId, String userId); boolean existsByAppIdAndUserId(String appId, String userId);
Page<ImAccountEntity> findByAppId(String appId, Pageable pageable); Page<ImAccountEntity> findByAppId(String appId, Pageable pageable);
long countByAppId(String appId); 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<ImAccountEntity> searchByKeyword(@Param("appId") String appId, @Param("kw") String keyword, Pageable pageable);
} }

查看文件

@ -17,6 +17,8 @@
<module>im-service</module> <module>im-service</module>
<module>push-service</module> <module>push-service</module>
<module>update-service</module> <module>update-service</module>
<module>demo-service</module>
<module>file-service</module>
</modules> </modules>
<properties> <properties>

查看文件

@ -33,6 +33,7 @@ public class SecurityConfig {
.authorizeHttpRequests(auth -> auth .authorizeHttpRequests(auth -> auth
.requestMatchers( .requestMatchers(
"/api/auth/**", "/api/auth/**",
"/api/sdk/**",
"/actuator/health", "/actuator/health",
"/actuator/info" "/actuator/info"
).permitAll() ).permitAll()

查看文件

@ -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<ApiResponse<SdkConfigResponse>> getConfig(
@RequestParam String appId) {
if (!appRepository.existsById(appId)) {
return ResponseEntity.status(404)
.body(ApiResponse.error(404, "App not found: " + appId));
}
List<FeatureServiceEntity> 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<String, Boolean> features
) {}
}

查看文件

@ -78,3 +78,8 @@ management:
web: web:
exposure: exposure:
include: health,info 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}