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>
这个提交包含在:
父节点
6ce29edb4c
当前提交
526f3cf944
@ -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
|
||||
|
||||
|
||||
2
Jenkinsfile
vendored
2
Jenkinsfile
vendored
@ -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: '构建后是否自动部署到生产服务器')
|
||||
}
|
||||
|
||||
77
demo-service/pom.xml
普通文件
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
普通文件
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())));
|
||||
}
|
||||
|
||||
/** 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. */
|
||||
@GetMapping("/stats")
|
||||
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.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<ImAccountEntity, String> {
|
||||
@ -11,4 +14,8 @@ public interface ImAccountRepository extends JpaRepository<ImAccountEntity, Stri
|
||||
boolean existsByAppIdAndUserId(String appId, String userId);
|
||||
Page<ImAccountEntity> 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<ImAccountEntity> searchByKeyword(@Param("appId") String appId, @Param("kw") String keyword, Pageable pageable);
|
||||
}
|
||||
|
||||
2
pom.xml
2
pom.xml
@ -17,6 +17,8 @@
|
||||
<module>im-service</module>
|
||||
<module>push-service</module>
|
||||
<module>update-service</module>
|
||||
<module>demo-service</module>
|
||||
<module>file-service</module>
|
||||
</modules>
|
||||
|
||||
<properties>
|
||||
|
||||
@ -33,6 +33,7 @@ public class SecurityConfig {
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
.requestMatchers(
|
||||
"/api/auth/**",
|
||||
"/api/sdk/**",
|
||||
"/actuator/health",
|
||||
"/actuator/info"
|
||||
).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:
|
||||
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}
|
||||
|
||||
正在加载...
在新工单中引用
屏蔽一个用户