chore: initial commit

这个提交包含在:
XuqmGroup 2026-04-21 22:07:29 +08:00
当前提交 a719c08a5a
共有 82 个文件被更改,包括 4004 次插入0 次删除

10
.gitignore vendored 普通文件
查看文件

@ -0,0 +1,10 @@
node_modules/
dist/
.DS_Store
*.class
target/
build/
.gradle/
*.iml
.idea/
*.log

46
common/pom.xml 普通文件
查看文件

@ -0,0 +1,46 @@
<?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>common</artifactId>
<name>common</name>
<description>Shared utilities, models, and security for XuqmGroup services</description>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</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-validation</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>
</dependencies>
</project>

查看文件

@ -0,0 +1,20 @@
package com.xuqm.common.exception;
public class BusinessException extends RuntimeException {
private final int code;
public BusinessException(String message) {
super(message);
this.code = 400;
}
public BusinessException(int code, String message) {
super(message);
this.code = code;
}
public int getCode() {
return code;
}
}

查看文件

@ -0,0 +1,32 @@
package com.xuqm.common.model;
public record ApiResponse<T>(int code, String status, T data, String message) {
public static <T> ApiResponse<T> success(T data) {
return new ApiResponse<>(200, "0", data, "success");
}
public static <T> ApiResponse<T> success(T data, String message) {
return new ApiResponse<>(200, "0", data, message);
}
public static ApiResponse<Void> ok() {
return new ApiResponse<>(200, "0", null, "success");
}
public static <T> ApiResponse<T> error(int code, String message) {
return new ApiResponse<>(code, "1", null, message);
}
public static <T> ApiResponse<T> badRequest(String message) {
return new ApiResponse<>(400, "1", null, message);
}
public static <T> ApiResponse<T> unauthorized(String message) {
return new ApiResponse<>(401, "1", null, message);
}
public static <T> ApiResponse<T> forbidden(String message) {
return new ApiResponse<>(403, "1", null, message);
}
}

查看文件

@ -0,0 +1,10 @@
package com.xuqm.common.model;
import java.util.List;
public record PageResult<T>(List<T> items, long total, int page, int size) {
public static <T> PageResult<T> of(List<T> items, long total, int page, int size) {
return new PageResult<>(items, total, page, size);
}
}

查看文件

@ -0,0 +1,45 @@
package com.xuqm.common.security;
import io.jsonwebtoken.Claims;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.List;
public class JwtAuthFilter extends OncePerRequestFilter {
private final JwtUtil jwtUtil;
public JwtAuthFilter(JwtUtil jwtUtil) {
this.jwtUtil = jwtUtil;
}
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String header = request.getHeader("Authorization");
if (header != null && header.startsWith("Bearer ")) {
String token = header.substring(7);
if (jwtUtil.isValid(token)) {
Claims claims = jwtUtil.parse(token);
String subject = claims.getSubject();
String role = claims.get("role", String.class);
List<SimpleGrantedAuthority> authorities = role != null
? List.of(new SimpleGrantedAuthority("ROLE_" + role))
: List.of();
UsernamePasswordAuthenticationToken auth =
new UsernamePasswordAuthenticationToken(subject, null, authorities);
SecurityContextHolder.getContext().setAuthentication(auth);
}
}
filterChain.doFilter(request, response);
}
}

查看文件

@ -0,0 +1,62 @@
package com.xuqm.common.security;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.util.Date;
import java.util.Map;
@Component
public class JwtUtil {
@Value("${jwt.secret}")
private String secret;
@Value("${jwt.expiration:86400000}")
private long expiration;
private SecretKey getSigningKey() {
byte[] keyBytes = secret.getBytes(StandardCharsets.UTF_8);
return Keys.hmacShaKeyFor(keyBytes);
}
public String generate(String subject, Map<String, Object> claims) {
return Jwts.builder()
.subject(subject)
.claims(claims)
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + expiration))
.signWith(getSigningKey())
.compact();
}
public String generate(String subject) {
return generate(subject, Map.of());
}
public Claims parse(String token) {
return Jwts.parser()
.verifyWith(getSigningKey())
.build()
.parseSignedClaims(token)
.getPayload();
}
public String getSubject(String token) {
return parse(token).getSubject();
}
public boolean isValid(String token) {
try {
parse(token);
return true;
} catch (Exception e) {
return false;
}
}
}

89
im-service/pom.xml 普通文件
查看文件

@ -0,0 +1,89 @@
<?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>im-service</artifactId>
<name>im-service</name>
<description>Standalone IM service with WebSocket STOMP, groups, messages, webhooks</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-websocket</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-data-redis</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-validation</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,15 @@
package com.xuqm.im;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.scheduling.annotation.EnableAsync;
@SpringBootApplication
@ComponentScan(basePackages = {"com.xuqm.im", "com.xuqm.common"})
@EnableAsync
public class ImServiceApplication {
public static void main(String[] args) {
SpringApplication.run(ImServiceApplication.class, args);
}
}

查看文件

@ -0,0 +1,43 @@
package com.xuqm.im.config;
import com.xuqm.common.security.JwtAuthFilter;
import com.xuqm.common.security.JwtUtil;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Configuration
@EnableWebSecurity
public class SecurityConfig {
private final JwtUtil jwtUtil;
public SecurityConfig(JwtUtil jwtUtil) {
this.jwtUtil = jwtUtil;
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/im/auth/**", "/ws/**", "/actuator/**").permitAll()
.anyRequest().authenticated()
)
.addFilterBefore(new JwtAuthFilter(jwtUtil), UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}

查看文件

@ -0,0 +1,71 @@
package com.xuqm.im.config;
import com.xuqm.common.security.JwtUtil;
import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.Message;
import org.springframework.messaging.MessageChannel;
import org.springframework.messaging.simp.config.ChannelRegistration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.messaging.simp.stomp.StompCommand;
import org.springframework.messaging.simp.stomp.StompHeaderAccessor;
import org.springframework.messaging.support.ChannelInterceptor;
import org.springframework.messaging.support.MessageHeaderAccessor;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;
import java.util.List;
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
private final JwtUtil jwtUtil;
public WebSocketConfig(JwtUtil jwtUtil) {
this.jwtUtil = jwtUtil;
}
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
registry.enableSimpleBroker("/topic", "/queue");
registry.setApplicationDestinationPrefixes("/app");
registry.setUserDestinationPrefix("/user");
}
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/ws/im")
.setAllowedOriginPatterns("*")
.withSockJS();
registry.addEndpoint("/ws/im")
.setAllowedOriginPatterns("*");
}
@Override
public void configureClientInboundChannel(ChannelRegistration registration) {
registration.interceptors(new ChannelInterceptor() {
@Override
public Message<?> preSend(Message<?> message, MessageChannel channel) {
StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(
message, StompHeaderAccessor.class);
if (accessor != null && StompCommand.CONNECT.equals(accessor.getCommand())) {
String token = accessor.getFirstNativeHeader("Authorization");
if (token != null && token.startsWith("Bearer ")) {
token = token.substring(7);
if (jwtUtil.isValid(token)) {
String userId = jwtUtil.getSubject(token);
UsernamePasswordAuthenticationToken auth =
new UsernamePasswordAuthenticationToken(userId, null,
List.of(new SimpleGrantedAuthority("ROLE_USER")));
accessor.setUser(auth);
}
}
}
return message;
}
});
}
}

查看文件

@ -0,0 +1,33 @@
package com.xuqm.im.controller;
import com.xuqm.common.model.ApiResponse;
import com.xuqm.im.service.ImAccountService;
import jakarta.validation.constraints.NotBlank;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
@RestController
@RequestMapping("/api/im/auth")
public class AuthController {
private final ImAccountService accountService;
public AuthController(ImAccountService accountService) {
this.accountService = accountService;
}
@PostMapping("/login")
public ResponseEntity<ApiResponse<Map<String, String>>> login(
@RequestParam @NotBlank String appId,
@RequestParam @NotBlank String userId,
@RequestParam(required = false) String nickname,
@RequestParam(required = false) String avatar) {
String token = accountService.loginOrRegister(appId, userId, nickname, avatar);
return ResponseEntity.ok(ApiResponse.success(Map.of("token", token)));
}
}

查看文件

@ -0,0 +1,53 @@
package com.xuqm.im.controller;
import com.xuqm.common.model.ApiResponse;
import com.xuqm.im.entity.ImMessageEntity;
import com.xuqm.im.model.SendMessageRequest;
import com.xuqm.im.service.MessageService;
import io.jsonwebtoken.Claims;
import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/im/messages")
public class MessageController {
private final MessageService messageService;
public MessageController(MessageService messageService) {
this.messageService = messageService;
}
@PostMapping("/send")
public ResponseEntity<ApiResponse<ImMessageEntity>> send(
@Valid @RequestBody SendMessageRequest req,
@AuthenticationPrincipal String userId,
@RequestParam String appId) {
return ResponseEntity.ok(ApiResponse.success(messageService.send(appId, userId, req)));
}
@PostMapping("/{id}/revoke")
public ResponseEntity<ApiResponse<ImMessageEntity>> revoke(
@PathVariable String id,
@AuthenticationPrincipal String userId,
@RequestParam String appId) {
return ResponseEntity.ok(ApiResponse.success(messageService.revoke(appId, id, userId)));
}
@GetMapping("/history/{toId}")
public ResponseEntity<ApiResponse<?>> history(
@PathVariable String toId,
@RequestParam String appId,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
return ResponseEntity.ok(ApiResponse.success(messageService.history(appId, toId, page, size)));
}
}

查看文件

@ -0,0 +1,69 @@
package com.xuqm.im.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import java.time.LocalDateTime;
@Entity
@Table(name = "im_account",
uniqueConstraints = @UniqueConstraint(columnNames = {"appId", "userId"}))
public class ImAccountEntity {
public enum Gender { UNKNOWN, MALE, FEMALE }
public enum Status { ACTIVE, BANNED }
@Id
private String id;
@Column(nullable = false, length = 64)
private String appId;
@Column(nullable = false, length = 128)
private String userId;
@Column(length = 64)
private String nickname;
@Enumerated(EnumType.STRING)
@Column(length = 16)
private Gender gender;
@Column(length = 512)
private String avatar;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 16)
private Status status;
@Column(nullable = false)
private LocalDateTime createdAt;
public String getId() { return id; }
public void setId(String id) { this.id = id; }
public String getAppId() { return appId; }
public void setAppId(String appId) { this.appId = appId; }
public String getUserId() { return userId; }
public void setUserId(String userId) { this.userId = userId; }
public String getNickname() { return nickname; }
public void setNickname(String nickname) { this.nickname = nickname; }
public Gender getGender() { return gender; }
public void setGender(Gender gender) { this.gender = gender; }
public String getAvatar() { return avatar; }
public void setAvatar(String avatar) { this.avatar = avatar; }
public Status getStatus() { return status; }
public void setStatus(Status status) { this.status = status; }
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
}

查看文件

@ -0,0 +1,54 @@
package com.xuqm.im.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.LocalDateTime;
@Entity
@Table(name = "im_group")
public class ImGroupEntity {
@Id
private String id;
@Column(nullable = false, length = 64)
private String appId;
@Column(nullable = false, length = 128)
private String name;
@Column(nullable = false, length = 128)
private String creatorId;
@Column(nullable = false, columnDefinition = "TEXT")
private String memberIds;
@Column(nullable = false, columnDefinition = "TEXT")
private String adminIds;
@Column(nullable = false)
private LocalDateTime createdAt;
public String getId() { return id; }
public void setId(String id) { this.id = id; }
public String getAppId() { return appId; }
public void setAppId(String appId) { this.appId = appId; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getCreatorId() { return creatorId; }
public void setCreatorId(String creatorId) { this.creatorId = creatorId; }
public String getMemberIds() { return memberIds; }
public void setMemberIds(String memberIds) { this.memberIds = memberIds; }
public String getAdminIds() { return adminIds; }
public void setAdminIds(String adminIds) { this.adminIds = adminIds; }
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
}

查看文件

@ -0,0 +1,88 @@
package com.xuqm.im.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.Id;
import jakarta.persistence.Index;
import jakarta.persistence.Table;
import java.time.LocalDateTime;
@Entity
@Table(name = "im_message", indexes = {
@Index(name = "idx_app_from", columnList = "appId,fromUserId"),
@Index(name = "idx_app_to", columnList = "appId,toId")
})
public class ImMessageEntity {
public enum ChatType { SINGLE, GROUP }
public enum MsgType {
TEXT, IMAGE, VIDEO, AUDIO, FILE, CUSTOM, LOCATION, NOTIFY,
RICH_TEXT, CALL_AUDIO, CALL_VIDEO, REVOKED, FORWARD
}
public enum MsgStatus { SENT, DELIVERED, READ, REVOKED }
@Id
private String id;
@Column(nullable = false, length = 64)
private String appId;
@Column(nullable = false, length = 128)
private String fromUserId;
@Column(nullable = false, length = 128)
private String toId;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 16)
private ChatType chatType;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 16)
private MsgType msgType;
@Column(nullable = false, columnDefinition = "TEXT")
private String content;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 16)
private MsgStatus status;
@Column(length = 128)
private String mentionedUserIds;
@Column(nullable = false)
private LocalDateTime createdAt;
public String getId() { return id; }
public void setId(String id) { this.id = id; }
public String getAppId() { return appId; }
public void setAppId(String appId) { this.appId = appId; }
public String getFromUserId() { return fromUserId; }
public void setFromUserId(String fromUserId) { this.fromUserId = fromUserId; }
public String getToId() { return toId; }
public void setToId(String toId) { this.toId = toId; }
public ChatType getChatType() { return chatType; }
public void setChatType(ChatType chatType) { this.chatType = chatType; }
public MsgType getMsgType() { return msgType; }
public void setMsgType(MsgType msgType) { this.msgType = msgType; }
public String getContent() { return content; }
public void setContent(String content) { this.content = content; }
public MsgStatus getStatus() { return status; }
public void setStatus(MsgStatus status) { this.status = status; }
public String getMentionedUserIds() { return mentionedUserIds; }
public void setMentionedUserIds(String mentionedUserIds) { this.mentionedUserIds = mentionedUserIds; }
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
}

查看文件

@ -0,0 +1,56 @@
package com.xuqm.im.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.LocalDateTime;
@Entity
@Table(name = "im_keyword_filter")
public class KeywordFilterEntity {
public enum Action { REPLACE, BLOCK }
@Id
private String id;
@Column(nullable = false, length = 64)
private String appId;
@Column(nullable = false, length = 512)
private String pattern;
@Column(length = 128)
private String replacement;
@Column(nullable = false, length = 16)
private String action;
@Column(nullable = false)
private boolean enabled;
@Column(nullable = false)
private LocalDateTime createdAt;
public String getId() { return id; }
public void setId(String id) { this.id = id; }
public String getAppId() { return appId; }
public void setAppId(String appId) { this.appId = appId; }
public String getPattern() { return pattern; }
public void setPattern(String pattern) { this.pattern = pattern; }
public String getReplacement() { return replacement; }
public void setReplacement(String replacement) { this.replacement = replacement; }
public String getAction() { return action; }
public void setAction(String action) { this.action = action; }
public boolean isEnabled() { return enabled; }
public void setEnabled(boolean enabled) { this.enabled = enabled; }
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
}

查看文件

@ -0,0 +1,48 @@
package com.xuqm.im.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.LocalDateTime;
@Entity
@Table(name = "im_webhook_config")
public class WebhookConfigEntity {
@Id
private String id;
@Column(nullable = false, length = 64)
private String appId;
@Column(nullable = false, length = 512)
private String url;
@Column(length = 256)
private String secret;
@Column(nullable = false)
private boolean enabled;
@Column(nullable = false)
private LocalDateTime createdAt;
public String getId() { return id; }
public void setId(String id) { this.id = id; }
public String getAppId() { return appId; }
public void setAppId(String appId) { this.appId = appId; }
public String getUrl() { return url; }
public void setUrl(String url) { this.url = url; }
public String getSecret() { return secret; }
public void setSecret(String secret) { this.secret = secret; }
public boolean isEnabled() { return enabled; }
public void setEnabled(boolean enabled) { this.enabled = enabled; }
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
}

查看文件

@ -0,0 +1,13 @@
package com.xuqm.im.model;
import com.xuqm.im.entity.ImMessageEntity;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
public record SendMessageRequest(
@NotBlank String toId,
@NotNull ImMessageEntity.ChatType chatType,
@NotNull ImMessageEntity.MsgType msgType,
@NotBlank String content,
String mentionedUserIds
) {}

查看文件

@ -0,0 +1,10 @@
package com.xuqm.im.repository;
import com.xuqm.im.entity.ImAccountEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface ImAccountRepository extends JpaRepository<ImAccountEntity, String> {
Optional<ImAccountEntity> findByAppIdAndUserId(String appId, String userId);
boolean existsByAppIdAndUserId(String appId, String userId);
}

查看文件

@ -0,0 +1,9 @@
package com.xuqm.im.repository;
import com.xuqm.im.entity.ImGroupEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface ImGroupRepository extends JpaRepository<ImGroupEntity, String> {
List<ImGroupEntity> findByAppId(String appId);
}

查看文件

@ -0,0 +1,13 @@
package com.xuqm.im.repository;
import com.xuqm.im.entity.ImMessageEntity;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
public interface ImMessageRepository extends JpaRepository<ImMessageEntity, String> {
Page<ImMessageEntity> findByAppIdAndToIdOrderByCreatedAtDesc(
String appId, String toId, Pageable pageable);
Page<ImMessageEntity> findByAppIdAndFromUserIdAndToIdOrderByCreatedAtDesc(
String appId, String fromUserId, String toId, Pageable pageable);
}

查看文件

@ -0,0 +1,9 @@
package com.xuqm.im.repository;
import com.xuqm.im.entity.KeywordFilterEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface KeywordFilterRepository extends JpaRepository<KeywordFilterEntity, String> {
List<KeywordFilterEntity> findByAppIdAndEnabledTrue(String appId);
}

查看文件

@ -0,0 +1,9 @@
package com.xuqm.im.repository;
import com.xuqm.im.entity.WebhookConfigEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface WebhookConfigRepository extends JpaRepository<WebhookConfigEntity, String> {
List<WebhookConfigEntity> findByAppIdAndEnabledTrue(String appId);
}

查看文件

@ -0,0 +1,59 @@
package com.xuqm.im.service;
import com.xuqm.common.exception.BusinessException;
import com.xuqm.common.security.JwtUtil;
import com.xuqm.im.entity.ImAccountEntity;
import com.xuqm.im.repository.ImAccountRepository;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.Map;
import java.util.UUID;
@Service
public class ImAccountService {
private final ImAccountRepository accountRepository;
private final JwtUtil jwtUtil;
public ImAccountService(ImAccountRepository accountRepository, JwtUtil jwtUtil) {
this.accountRepository = accountRepository;
this.jwtUtil = jwtUtil;
}
public String loginOrRegister(String appId, String userId, String nickname, String avatar) {
ImAccountEntity account = accountRepository.findByAppIdAndUserId(appId, userId)
.orElseGet(() -> {
ImAccountEntity e = new ImAccountEntity();
e.setId(UUID.randomUUID().toString());
e.setAppId(appId);
e.setUserId(userId);
e.setNickname(nickname);
e.setAvatar(avatar);
e.setGender(ImAccountEntity.Gender.UNKNOWN);
e.setStatus(ImAccountEntity.Status.ACTIVE);
e.setCreatedAt(LocalDateTime.now());
return accountRepository.save(e);
});
if (account.getStatus() == ImAccountEntity.Status.BANNED) {
throw new BusinessException(403, "账号已被封禁");
}
return jwtUtil.generate(userId, Map.of("appId", appId, "role", "USER"));
}
public ImAccountEntity getAccount(String appId, String userId) {
return accountRepository.findByAppIdAndUserId(appId, userId)
.orElseThrow(() -> new BusinessException(404, "账号不存在"));
}
public ImAccountEntity updateAccount(String appId, String userId, String nickname,
String avatar, ImAccountEntity.Gender gender) {
ImAccountEntity account = getAccount(appId, userId);
if (nickname != null) account.setNickname(nickname);
if (avatar != null) account.setAvatar(avatar);
if (gender != null) account.setGender(gender);
return accountRepository.save(account);
}
}

查看文件

@ -0,0 +1,77 @@
package com.xuqm.im.service;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.xuqm.common.exception.BusinessException;
import com.xuqm.im.entity.ImGroupEntity;
import com.xuqm.im.repository.ImGroupRepository;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
@Service
public class ImGroupService {
private final ImGroupRepository groupRepository;
private final ObjectMapper objectMapper;
public ImGroupService(ImGroupRepository groupRepository, ObjectMapper objectMapper) {
this.groupRepository = groupRepository;
this.objectMapper = objectMapper;
}
public ImGroupEntity create(String appId, String name, String creatorId, List<String> memberIds) {
List<String> members = new ArrayList<>(memberIds);
if (!members.contains(creatorId)) members.add(creatorId);
ImGroupEntity group = new ImGroupEntity();
group.setId(UUID.randomUUID().toString());
group.setAppId(appId);
group.setName(name);
group.setCreatorId(creatorId);
group.setMemberIds(toJson(members));
group.setAdminIds(toJson(List.of(creatorId)));
group.setCreatedAt(LocalDateTime.now());
return groupRepository.save(group);
}
public ImGroupEntity addMember(String groupId, String userId) {
ImGroupEntity group = groupRepository.findById(groupId)
.orElseThrow(() -> new BusinessException(404, "群组不存在"));
List<String> members = fromJson(group.getMemberIds());
if (!members.contains(userId)) {
members.add(userId);
group.setMemberIds(toJson(members));
groupRepository.save(group);
}
return group;
}
public ImGroupEntity removeMember(String groupId, String userId, String operatorId) {
ImGroupEntity group = groupRepository.findById(groupId)
.orElseThrow(() -> new BusinessException(404, "群组不存在"));
List<String> admins = fromJson(group.getAdminIds());
if (!admins.contains(operatorId) && !group.getCreatorId().equals(operatorId)) {
throw new BusinessException(403, "无权操作");
}
List<String> members = new ArrayList<>(fromJson(group.getMemberIds()));
members.remove(userId);
group.setMemberIds(toJson(members));
return groupRepository.save(group);
}
public List<ImGroupEntity> listByApp(String appId) {
return groupRepository.findByAppId(appId);
}
private String toJson(List<String> list) {
try { return objectMapper.writeValueAsString(list); } catch (Exception e) { return "[]"; }
}
private List<String> fromJson(String json) {
try { return objectMapper.readValue(json, new TypeReference<>() {}); } catch (Exception e) { return new ArrayList<>(); }
}
}

查看文件

@ -0,0 +1,60 @@
package com.xuqm.im.service;
import com.xuqm.im.entity.KeywordFilterEntity;
import com.xuqm.im.repository.KeywordFilterRepository;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
import java.util.regex.Pattern;
@Service
public class KeywordFilterService {
private final KeywordFilterRepository repository;
public KeywordFilterService(KeywordFilterRepository repository) {
this.repository = repository;
}
public String filter(String appId, String content) {
List<KeywordFilterEntity> filters = repository.findByAppIdAndEnabledTrue(appId);
String result = content;
for (KeywordFilterEntity f : filters) {
try {
Pattern p = Pattern.compile(f.getPattern());
if ("BLOCK".equals(f.getAction())) {
if (p.matcher(result).find()) {
return null;
}
} else {
String replacement = f.getReplacement() != null ? f.getReplacement() : "***";
result = p.matcher(result).replaceAll(replacement);
}
} catch (Exception ignored) {
}
}
return result;
}
public KeywordFilterEntity add(String appId, String pattern, String replacement, String action) {
KeywordFilterEntity entity = new KeywordFilterEntity();
entity.setId(UUID.randomUUID().toString());
entity.setAppId(appId);
entity.setPattern(pattern);
entity.setReplacement(replacement);
entity.setAction(action);
entity.setEnabled(true);
entity.setCreatedAt(LocalDateTime.now());
return repository.save(entity);
}
public List<KeywordFilterEntity> list(String appId) {
return repository.findByAppIdAndEnabledTrue(appId);
}
public void delete(String id) {
repository.deleteById(id);
}
}

查看文件

@ -0,0 +1,122 @@
package com.xuqm.im.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.xuqm.common.exception.BusinessException;
import com.xuqm.im.entity.ImMessageEntity;
import com.xuqm.im.entity.WebhookConfigEntity;
import com.xuqm.im.model.SendMessageRequest;
import com.xuqm.im.repository.ImMessageRepository;
import com.xuqm.im.repository.WebhookConfigRepository;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
@Service
public class MessageService {
private final ImMessageRepository messageRepository;
private final WebhookConfigRepository webhookRepository;
private final KeywordFilterService keywordFilterService;
private final SimpMessagingTemplate messagingTemplate;
private final ObjectMapper objectMapper;
@Value("${im.webhook-timeout-ms:3000}")
private int webhookTimeoutMs;
public MessageService(ImMessageRepository messageRepository,
WebhookConfigRepository webhookRepository,
KeywordFilterService keywordFilterService,
SimpMessagingTemplate messagingTemplate,
ObjectMapper objectMapper) {
this.messageRepository = messageRepository;
this.webhookRepository = webhookRepository;
this.keywordFilterService = keywordFilterService;
this.messagingTemplate = messagingTemplate;
this.objectMapper = objectMapper;
}
public ImMessageEntity send(String appId, String fromUserId, SendMessageRequest req) {
String content = req.content();
if (req.msgType() == ImMessageEntity.MsgType.TEXT) {
content = keywordFilterService.filter(appId, content);
if (content == null) {
throw new BusinessException("消息包含违禁内容");
}
}
ImMessageEntity message = new ImMessageEntity();
message.setId(UUID.randomUUID().toString());
message.setAppId(appId);
message.setFromUserId(fromUserId);
message.setToId(req.toId());
message.setChatType(req.chatType());
message.setMsgType(req.msgType());
message.setContent(content);
message.setStatus(ImMessageEntity.MsgStatus.SENT);
message.setMentionedUserIds(req.mentionedUserIds());
message.setCreatedAt(LocalDateTime.now());
messageRepository.save(message);
String destination = req.chatType() == ImMessageEntity.ChatType.SINGLE
? "/user/" + req.toId() + "/queue/messages"
: "/topic/group/" + req.toId();
messagingTemplate.convertAndSend(destination, message);
dispatchWebhooks(appId, message);
return message;
}
public ImMessageEntity revoke(String appId, String messageId, String requestUserId) {
ImMessageEntity message = messageRepository.findById(messageId)
.orElseThrow(() -> new BusinessException(404, "消息不存在"));
if (!message.getAppId().equals(appId)) {
throw new BusinessException(403, "无权操作");
}
if (!message.getFromUserId().equals(requestUserId)) {
throw new BusinessException(403, "只能撤回自己发送的消息");
}
message.setStatus(ImMessageEntity.MsgStatus.REVOKED);
message.setMsgType(ImMessageEntity.MsgType.REVOKED);
return messageRepository.save(message);
}
public Page<ImMessageEntity> history(String appId, String toId, int page, int size) {
return messageRepository.findByAppIdAndToIdOrderByCreatedAtDesc(
appId, toId, PageRequest.of(page, size));
}
@Async
protected void dispatchWebhooks(String appId, ImMessageEntity message) {
List<WebhookConfigEntity> webhooks = webhookRepository.findByAppIdAndEnabledTrue(appId);
if (webhooks.isEmpty()) return;
try {
String body = objectMapper.writeValueAsString(message);
HttpClient client = HttpClient.newHttpClient();
for (WebhookConfigEntity webhook : webhooks) {
try {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(webhook.getUrl()))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
client.send(request, HttpResponse.BodyHandlers.ofString());
} catch (Exception ignored) {
}
}
} catch (Exception ignored) {
}
}
}

查看文件

@ -0,0 +1,46 @@
package com.xuqm.im.ws;
import com.xuqm.im.entity.ImMessageEntity;
import com.xuqm.im.model.SendMessageRequest;
import com.xuqm.im.service.MessageService;
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.handler.annotation.Payload;
import org.springframework.stereotype.Controller;
import java.security.Principal;
@Controller
public class ChatController {
private final MessageService messageService;
public ChatController(MessageService messageService) {
this.messageService = messageService;
}
@MessageMapping("/chat.send")
public void send(@Payload WsMessageRequest request, Principal principal) {
if (principal == null) return;
String userId = principal.getName();
SendMessageRequest req = new SendMessageRequest(
request.toId(), request.chatType(), request.msgType(),
request.content(), request.mentionedUserIds()
);
messageService.send(request.appId(), userId, req);
}
@MessageMapping("/chat.revoke")
public void revoke(@Payload WsRevokeRequest request, Principal principal) {
if (principal == null) return;
messageService.revoke(request.appId(), request.messageId(), principal.getName());
}
public record WsMessageRequest(
String appId, String toId,
ImMessageEntity.ChatType chatType,
ImMessageEntity.MsgType msgType,
String content, String mentionedUserIds
) {}
public record WsRevokeRequest(String appId, String messageId) {}
}

查看文件

@ -0,0 +1,39 @@
server:
port: 8082
spring:
application:
name: im-service
datasource:
url: jdbc:mysql://localhost:3306/xuqm_im?useSSL=false&serverTimezone=UTC&createDatabaseIfNotExist=true&allowPublicKeyRetrieval=true
username: root
password: root
driver-class-name: com.mysql.cj.jdbc.Driver
jpa:
hibernate:
ddl-auto: update
show-sql: false
properties:
hibernate:
dialect: org.hibernate.dialect.MySQLDialect
data:
redis:
host: localhost
port: 6379
jackson:
time-zone: UTC
serialization:
write-dates-as-timestamps: false
jwt:
secret: xuqm-im-service-secret-key-must-be-at-least-256-bits-long-for-hmac-sha
expiration: 86400000
im:
multi-login: true
message-history-days: 30
webhook-timeout-ms: 3000
logging:
level:
com.xuqm: DEBUG

85
pom.xml 普通文件
查看文件

@ -0,0 +1,85 @@
<?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>
<groupId>com.xuqm</groupId>
<artifactId>xuqmgroup-server-parent</artifactId>
<version>0.1.0-SNAPSHOT</version>
<packaging>pom</packaging>
<name>xuqmgroup-server-parent</name>
<description>XuqmGroup Platform — Multi-tenant SaaS backend microservices</description>
<modules>
<module>common</module>
<module>tenant-service</module>
<module>im-service</module>
<module>push-service</module>
<module>update-service</module>
</modules>
<properties>
<java.version>21</java.version>
<spring-boot.version>3.4.4</spring-boot.version>
<jjwt.version>0.12.6</jjwt.version>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<maven.compiler.release>21</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>${spring-boot.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>com.xuqm</groupId>
<artifactId>common</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>${jjwt.version}</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>${jjwt.version}</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>${jjwt.version}</version>
</dependency>
</dependencies>
</dependencyManagement>
<build>
<pluginManagement>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.13.0</version>
<configuration>
<release>${java.version}</release>
<encoding>${project.build.sourceEncoding}</encoding>
<parameters>true</parameters>
</configuration>
</plugin>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<version>${spring-boot.version}</version>
</plugin>
</plugins>
</pluginManagement>
</build>
</project>

77
push-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>push-service</artifactId>
<name>push-service</name>
<description>Offline push notification service: Huawei, Xiaomi, OPPO, vivo, Honor, APNs</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-validation</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,15 @@
package com.xuqm.push;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.scheduling.annotation.EnableAsync;
@SpringBootApplication
@ComponentScan(basePackages = {"com.xuqm.push", "com.xuqm.common"})
@EnableAsync
public class PushServiceApplication {
public static void main(String[] args) {
SpringApplication.run(PushServiceApplication.class, args);
}
}

查看文件

@ -0,0 +1,44 @@
package com.xuqm.push.controller;
import com.xuqm.common.model.ApiResponse;
import com.xuqm.push.entity.DeviceTokenEntity;
import com.xuqm.push.service.PushDispatcher;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/push")
public class PushController {
private final PushDispatcher pushDispatcher;
public PushController(PushDispatcher pushDispatcher) {
this.pushDispatcher = pushDispatcher;
}
@PostMapping("/register")
public ResponseEntity<ApiResponse<Void>> register(
@RequestParam @NotBlank String appId,
@RequestParam @NotBlank String userId,
@RequestParam @NotNull DeviceTokenEntity.Vendor vendor,
@RequestParam @NotBlank String token) {
pushDispatcher.registerToken(appId, userId, vendor, token);
return ResponseEntity.ok(ApiResponse.ok());
}
@PostMapping("/send")
public ResponseEntity<ApiResponse<Void>> send(
@RequestParam @NotBlank String appId,
@RequestParam @NotBlank String userId,
@RequestParam @NotBlank String title,
@RequestParam @NotBlank String body,
@RequestParam(required = false) String payload) {
pushDispatcher.pushToUser(appId, userId, title, body, payload);
return ResponseEntity.ok(ApiResponse.ok());
}
}

查看文件

@ -0,0 +1,61 @@
package com.xuqm.push.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import java.time.LocalDateTime;
@Entity
@Table(name = "push_device_token",
uniqueConstraints = @UniqueConstraint(columnNames = {"appId", "userId", "vendor"}))
public class DeviceTokenEntity {
public enum Vendor { HUAWEI, XIAOMI, OPPO, VIVO, HONOR, APNS, FCM }
@Id
private String id;
@Column(nullable = false, length = 64)
private String appId;
@Column(nullable = false, length = 128)
private String userId;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 16)
private Vendor vendor;
@Column(nullable = false, length = 512)
private String token;
@Column(nullable = false)
private LocalDateTime createdAt;
@Column(nullable = false)
private LocalDateTime updatedAt;
public String getId() { return id; }
public void setId(String id) { this.id = id; }
public String getAppId() { return appId; }
public void setAppId(String appId) { this.appId = appId; }
public String getUserId() { return userId; }
public void setUserId(String userId) { this.userId = userId; }
public Vendor getVendor() { return vendor; }
public void setVendor(Vendor vendor) { this.vendor = vendor; }
public String getToken() { return token; }
public void setToken(String token) { this.token = token; }
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
public LocalDateTime getUpdatedAt() { return updatedAt; }
public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; }
}

查看文件

@ -0,0 +1,13 @@
package com.xuqm.push.repository;
import com.xuqm.push.entity.DeviceTokenEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
public interface DeviceTokenRepository extends JpaRepository<DeviceTokenEntity, String> {
List<DeviceTokenEntity> findByAppIdAndUserId(String appId, String userId);
Optional<DeviceTokenEntity> findByAppIdAndUserIdAndVendor(
String appId, String userId, DeviceTokenEntity.Vendor vendor);
}

查看文件

@ -0,0 +1,59 @@
package com.xuqm.push.service;
import com.xuqm.push.entity.DeviceTokenEntity;
import com.xuqm.push.repository.DeviceTokenRepository;
import com.xuqm.push.service.provider.PushProvider;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.stream.Collectors;
@Service
public class PushDispatcher {
private static final Logger log = LoggerFactory.getLogger(PushDispatcher.class);
private final DeviceTokenRepository tokenRepository;
private final Map<String, PushProvider> providers;
public PushDispatcher(DeviceTokenRepository tokenRepository, List<PushProvider> providerList) {
this.tokenRepository = tokenRepository;
this.providers = providerList.stream()
.collect(Collectors.toMap(PushProvider::vendorName, p -> p));
}
@Async
public void pushToUser(String appId, String userId, String title, String body, String payload) {
List<DeviceTokenEntity> tokens = tokenRepository.findByAppIdAndUserId(appId, userId);
for (DeviceTokenEntity t : tokens) {
PushProvider provider = providers.get(t.getVendor().name());
if (provider != null) {
boolean ok = provider.send(t.getToken(), title, body, payload);
log.info("Push to {}@{} via {}: {}", userId, appId, t.getVendor(), ok ? "OK" : "FAIL");
}
}
}
public void registerToken(String appId, String userId, DeviceTokenEntity.Vendor vendor, String token) {
Optional<DeviceTokenEntity> existing = tokenRepository.findByAppIdAndUserIdAndVendor(appId, userId, vendor);
DeviceTokenEntity entity = existing.orElseGet(() -> {
DeviceTokenEntity e = new DeviceTokenEntity();
e.setId(UUID.randomUUID().toString());
e.setAppId(appId);
e.setUserId(userId);
e.setVendor(vendor);
e.setCreatedAt(LocalDateTime.now());
return e;
});
entity.setToken(token);
entity.setUpdatedAt(LocalDateTime.now());
tokenRepository.save(entity);
}
}

查看文件

@ -0,0 +1,82 @@
package com.xuqm.push.service.provider;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.Map;
@Component
public class HuaweiPushProvider implements PushProvider {
private static final Logger log = LoggerFactory.getLogger(HuaweiPushProvider.class);
@Value("${push.huawei.app-id:}")
private String appId;
@Value("${push.huawei.app-secret:}")
private String appSecret;
@Value("${push.huawei.token-url:https://oauth-login.cloud.huawei.com/oauth2/v3/token}")
private String tokenUrl;
@Value("${push.huawei.push-url:https://push-api.cloud.huawei.com/v1/{appId}/messages:send}")
private String pushUrl;
private final HttpClient httpClient = HttpClient.newHttpClient();
private final ObjectMapper objectMapper = new ObjectMapper();
@Override
public String vendorName() {
return "HUAWEI";
}
@Override
public boolean send(String token, String title, String body, String payload) {
if (appId.isBlank() || appSecret.isBlank()) {
log.warn("Huawei push not configured");
return false;
}
try {
String accessToken = getAccessToken();
String url = pushUrl.replace("{appId}", appId);
Map<String, Object> message = Map.of(
"message", Map.of(
"token", new String[]{token},
"notification", Map.of("title", title, "body", body),
"data", payload != null ? payload : "{}"
)
);
String requestBody = objectMapper.writeValueAsString(message);
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url))
.header("Content-Type", "application/json")
.header("Authorization", "Bearer " + accessToken)
.POST(HttpRequest.BodyPublishers.ofString(requestBody))
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
return response.statusCode() == 200;
} catch (Exception e) {
log.error("Huawei push failed: {}", e.getMessage());
return false;
}
}
private String getAccessToken() throws Exception {
String form = "grant_type=client_credentials&client_id=" + appId + "&client_secret=" + appSecret;
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(tokenUrl))
.header("Content-Type", "application/x-www-form-urlencoded")
.POST(HttpRequest.BodyPublishers.ofString(form))
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
Map<?, ?> json = objectMapper.readValue(response.body(), Map.class);
return (String) json.get("access_token");
}
}

查看文件

@ -0,0 +1,6 @@
package com.xuqm.push.service.provider;
public interface PushProvider {
String vendorName();
boolean send(String token, String title, String body, String payload);
}

查看文件

@ -0,0 +1,56 @@
package com.xuqm.push.service.provider;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
@Component
public class XiaomiPushProvider implements PushProvider {
private static final Logger log = LoggerFactory.getLogger(XiaomiPushProvider.class);
@Value("${push.xiaomi.app-secret:}")
private String appSecret;
@Value("${push.xiaomi.push-url:https://api.xmpush.xiaomi.com/v3/message/regid}")
private String pushUrl;
private final HttpClient httpClient = HttpClient.newHttpClient();
@Override
public String vendorName() { return "XIAOMI"; }
@Override
public boolean send(String token, String title, String body, String payload) {
if (appSecret.isBlank()) {
log.warn("Xiaomi push not configured");
return false;
}
try {
String form = "registration_id=" + URLEncoder.encode(token, StandardCharsets.UTF_8)
+ "&title=" + URLEncoder.encode(title, StandardCharsets.UTF_8)
+ "&description=" + URLEncoder.encode(body, StandardCharsets.UTF_8)
+ "&restricted_package_name=com.example.app"
+ "&notify_type=1";
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(pushUrl))
.header("Content-Type", "application/x-www-form-urlencoded")
.header("Authorization", "key=" + appSecret)
.POST(HttpRequest.BodyPublishers.ofString(form))
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
return response.statusCode() == 200;
} catch (Exception e) {
log.error("Xiaomi push failed: {}", e.getMessage());
return false;
}
}
}

查看文件

@ -0,0 +1,35 @@
server:
port: 8083
spring:
application:
name: push-service
datasource:
url: jdbc:mysql://localhost:3306/xuqm_push?useSSL=false&serverTimezone=UTC&createDatabaseIfNotExist=true&allowPublicKeyRetrieval=true
username: root
password: root
driver-class-name: com.mysql.cj.jdbc.Driver
jpa:
hibernate:
ddl-auto: update
show-sql: false
jwt:
secret: xuqm-push-service-secret-key-must-be-at-least-256-bits-long-for-hmac-sha
expiration: 86400000
push:
huawei:
app-id: ${HUAWEI_APP_ID:}
app-secret: ${HUAWEI_APP_SECRET:}
token-url: https://oauth-login.cloud.huawei.com/oauth2/v3/token
push-url: https://push-api.cloud.huawei.com/v1/{appId}/messages:send
xiaomi:
app-secret: ${XIAOMI_APP_SECRET:}
push-url: https://api.xmpush.xiaomi.com/v3/message/regid
apns:
key-id: ${APNS_KEY_ID:}
team-id: ${APNS_TEAM_ID:}
key-path: ${APNS_KEY_PATH:}
bundle-id: ${APNS_BUNDLE_ID:}
production: false

89
tenant-service/pom.xml 普通文件
查看文件

@ -0,0 +1,89 @@
<?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>tenant-service</artifactId>
<name>tenant-service</name>
<description>Tenant management: auth, sub-accounts, apps, feature services</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-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-mail</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-validation</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,15 @@
package com.xuqm.tenant;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.scheduling.annotation.EnableAsync;
@SpringBootApplication
@ComponentScan(basePackages = {"com.xuqm.tenant", "com.xuqm.common"})
@EnableAsync
public class TenantServiceApplication {
public static void main(String[] args) {
SpringApplication.run(TenantServiceApplication.class, args);
}
}

查看文件

@ -0,0 +1,28 @@
package com.xuqm.tenant.config;
import com.xuqm.tenant.service.OpsService;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.stereotype.Component;
@Component
public class OpsAdminInitializer implements ApplicationRunner {
private final OpsService opsService;
@Value("${ops.admin.username:admin}")
private String adminUsername;
@Value("${ops.admin.password:Admin@123456}")
private String adminPassword;
public OpsAdminInitializer(OpsService opsService) {
this.opsService = opsService;
}
@Override
public void run(ApplicationArguments args) {
opsService.initDefaultAdmin(adminUsername, adminPassword);
}
}

查看文件

@ -0,0 +1,49 @@
package com.xuqm.tenant.config;
import com.xuqm.common.security.JwtAuthFilter;
import com.xuqm.common.security.JwtUtil;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {
private final JwtUtil jwtUtil;
public SecurityConfig(JwtUtil jwtUtil) {
this.jwtUtil = jwtUtil;
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers(
"/api/auth/**",
"/actuator/health",
"/actuator/info"
).permitAll()
.anyRequest().authenticated()
)
.addFilterBefore(new JwtAuthFilter(jwtUtil), UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}

查看文件

@ -0,0 +1,19 @@
package com.xuqm.tenant.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOriginPatterns("*")
.allowedMethods("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS")
.allowedHeaders("*")
.allowCredentials(true)
.maxAge(3600);
}
}

查看文件

@ -0,0 +1,61 @@
package com.xuqm.tenant.controller;
import com.xuqm.common.model.ApiResponse;
import com.xuqm.tenant.dto.CreateAppRequest;
import com.xuqm.tenant.entity.AppEntity;
import com.xuqm.tenant.service.AppService;
import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequestMapping("/api/apps")
public class AppController {
private final AppService appService;
public AppController(AppService appService) {
this.appService = appService;
}
@GetMapping
public ResponseEntity<ApiResponse<List<AppEntity>>> list(@AuthenticationPrincipal String tenantId) {
return ResponseEntity.ok(ApiResponse.success(appService.listByTenant(tenantId)));
}
@GetMapping("/{id}")
public ResponseEntity<ApiResponse<AppEntity>> get(@PathVariable String id,
@AuthenticationPrincipal String tenantId) {
return ResponseEntity.ok(ApiResponse.success(appService.getById(id, tenantId)));
}
@PostMapping
public ResponseEntity<ApiResponse<AppEntity>> create(@Valid @RequestBody CreateAppRequest req,
@AuthenticationPrincipal String tenantId) {
return ResponseEntity.ok(ApiResponse.success(appService.create(tenantId, req)));
}
@PutMapping("/{id}")
public ResponseEntity<ApiResponse<AppEntity>> update(@PathVariable String id,
@Valid @RequestBody CreateAppRequest req,
@AuthenticationPrincipal String tenantId) {
return ResponseEntity.ok(ApiResponse.success(appService.update(id, tenantId, req)));
}
@DeleteMapping("/{id}")
public ResponseEntity<ApiResponse<Void>> delete(@PathVariable String id,
@AuthenticationPrincipal String tenantId) {
appService.delete(id, tenantId);
return ResponseEntity.ok(ApiResponse.ok());
}
}

查看文件

@ -0,0 +1,114 @@
package com.xuqm.tenant.controller;
import com.xuqm.common.model.ApiResponse;
import com.xuqm.tenant.dto.LoginRequest;
import com.xuqm.tenant.dto.RegisterRequest;
import com.xuqm.tenant.service.AuthService;
import com.xuqm.tenant.service.EmailService;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.awt.Color;
import java.awt.Font;
import java.awt.Graphics2D;
import java.awt.RenderingHints;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.security.SecureRandom;
import java.util.Base64;
import java.util.Map;
import java.util.UUID;
import javax.imageio.ImageIO;
@RestController
@RequestMapping("/api/auth")
public class AuthController {
private final AuthService authService;
private final EmailService emailService;
private static final SecureRandom random = new SecureRandom();
private static final String CHARS = "23456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz";
public AuthController(AuthService authService, EmailService emailService) {
this.authService = authService;
this.emailService = emailService;
}
@GetMapping("/captcha")
public ResponseEntity<ApiResponse<Map<String, String>>> captcha() throws Exception {
String key = UUID.randomUUID().toString();
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 4; i++) {
sb.append(CHARS.charAt(random.nextInt(CHARS.length())));
}
String code = sb.toString();
authService.storeCaptcha(key, code);
BufferedImage img = new BufferedImage(120, 40, BufferedImage.TYPE_INT_RGB);
Graphics2D g = img.createGraphics();
g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
g.setColor(new Color(245, 245, 245));
g.fillRect(0, 0, 120, 40);
g.setFont(new Font("Arial", Font.BOLD, 24));
for (int i = 0; i < code.length(); i++) {
g.setColor(new Color(random.nextInt(150), random.nextInt(150), random.nextInt(150)));
g.drawString(String.valueOf(code.charAt(i)), 10 + i * 28, 30);
}
for (int i = 0; i < 5; i++) {
g.setColor(new Color(random.nextInt(200), random.nextInt(200), random.nextInt(200)));
g.drawLine(random.nextInt(120), random.nextInt(40), random.nextInt(120), random.nextInt(40));
}
g.dispose();
ByteArrayOutputStream baos = new ByteArrayOutputStream();
ImageIO.write(img, "png", baos);
String base64 = Base64.getEncoder().encodeToString(baos.toByteArray());
return ResponseEntity.ok(ApiResponse.success(Map.of(
"key", key,
"image", "data:image/png;base64," + base64
)));
}
@PostMapping("/send-email-code")
public ResponseEntity<ApiResponse<Void>> sendEmailCode(@RequestParam @NotBlank @Email String email,
@RequestParam @NotBlank String purpose) {
emailService.sendVerificationCode(email, purpose);
return ResponseEntity.ok(ApiResponse.ok());
}
@PostMapping("/register")
public ResponseEntity<ApiResponse<Void>> register(@Valid @RequestBody RegisterRequest req) {
authService.register(req);
return ResponseEntity.ok(ApiResponse.ok());
}
@PostMapping("/login")
public ResponseEntity<ApiResponse<Map<String, String>>> login(@Valid @RequestBody LoginRequest req) {
String token = authService.login(req);
return ResponseEntity.ok(ApiResponse.success(Map.of("token", token)));
}
@PostMapping("/forgot-password")
public ResponseEntity<ApiResponse<Void>> forgotPassword(@RequestParam @NotBlank @Email String email) {
authService.forgotPassword(email);
return ResponseEntity.ok(ApiResponse.ok());
}
@PostMapping("/reset-password")
public ResponseEntity<ApiResponse<Void>> resetPassword(
@RequestParam @NotBlank @Email String email,
@RequestParam @NotBlank String code,
@RequestParam @NotBlank String newPassword) {
authService.resetPassword(email, code, newPassword);
return ResponseEntity.ok(ApiResponse.ok());
}
}

查看文件

@ -0,0 +1,57 @@
package com.xuqm.tenant.controller;
import com.xuqm.common.model.ApiResponse;
import com.xuqm.tenant.entity.FeatureServiceEntity;
import com.xuqm.tenant.service.AppService;
import com.xuqm.tenant.service.FeatureServiceManager;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequestMapping("/api/apps/{appId}/services")
public class FeatureServiceController {
private final FeatureServiceManager featureServiceManager;
private final AppService appService;
public FeatureServiceController(FeatureServiceManager featureServiceManager, AppService appService) {
this.featureServiceManager = featureServiceManager;
this.appService = appService;
}
@GetMapping
public ResponseEntity<ApiResponse<List<FeatureServiceEntity>>> list(
@PathVariable String appId, @AuthenticationPrincipal String tenantId) {
appService.getById(appId, tenantId);
return ResponseEntity.ok(ApiResponse.success(featureServiceManager.listByApp(appId)));
}
@PostMapping("/toggle")
public ResponseEntity<ApiResponse<FeatureServiceEntity>> toggle(
@PathVariable String appId,
@RequestParam FeatureServiceEntity.Platform platform,
@RequestParam FeatureServiceEntity.ServiceType serviceType,
@RequestParam boolean enable,
@AuthenticationPrincipal String tenantId) {
appService.getById(appId, tenantId);
return ResponseEntity.ok(ApiResponse.success(
featureServiceManager.toggle(appId, platform, serviceType, enable)));
}
@PostMapping("/{id}/regenerate-key")
public ResponseEntity<ApiResponse<FeatureServiceEntity>> regenerateKey(
@PathVariable String appId,
@PathVariable String id,
@AuthenticationPrincipal String tenantId) {
appService.getById(appId, tenantId);
return ResponseEntity.ok(ApiResponse.success(featureServiceManager.regenerateKey(id)));
}
}

查看文件

@ -0,0 +1,35 @@
package com.xuqm.tenant.controller;
import com.xuqm.common.exception.BusinessException;
import com.xuqm.common.model.ApiResponse;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.util.stream.Collectors;
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(BusinessException.class)
public ResponseEntity<ApiResponse<Void>> handle(BusinessException ex) {
return ResponseEntity.status(ex.getCode())
.body(ApiResponse.error(ex.getCode(), ex.getMessage()));
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiResponse<Void>> handle(MethodArgumentNotValidException ex) {
String message = ex.getBindingResult().getFieldErrors().stream()
.map(FieldError::getDefaultMessage)
.collect(Collectors.joining("; "));
return ResponseEntity.badRequest().body(ApiResponse.badRequest(message));
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiResponse<Void>> handle(Exception ex) {
return ResponseEntity.internalServerError()
.body(ApiResponse.error(500, "服务器内部错误: " + ex.getMessage()));
}
}

查看文件

@ -0,0 +1,62 @@
package com.xuqm.tenant.controller;
import com.xuqm.common.model.ApiResponse;
import com.xuqm.tenant.entity.TenantEntity;
import com.xuqm.tenant.service.OpsService;
import jakarta.validation.constraints.NotBlank;
import org.springframework.data.domain.Page;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
@RestController
@RequestMapping
public class OpsController {
private final OpsService opsService;
public OpsController(OpsService opsService) {
this.opsService = opsService;
}
@PostMapping("/api/auth/ops/login")
public ResponseEntity<ApiResponse<Map<String, String>>> opsLogin(@RequestBody Map<String, String> body) {
String token = opsService.login(body.get("username"), body.get("password"));
return ResponseEntity.ok(ApiResponse.success(Map.of("token", token)));
}
@GetMapping("/api/ops/tenants")
@PreAuthorize("hasAuthority('ROLE_OPS')")
public ResponseEntity<ApiResponse<Map<String, Object>>> listTenants(
@RequestParam(defaultValue = "") String keyword,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
Page<TenantEntity> result = opsService.listTenants(keyword, page, size);
return ResponseEntity.ok(ApiResponse.success(Map.of(
"content", result.getContent(),
"total", result.getTotalElements(),
"totalPages", result.getTotalPages()
)));
}
@PostMapping("/api/ops/tenants/{id}/toggle-status")
@PreAuthorize("hasAuthority('ROLE_OPS')")
public ResponseEntity<ApiResponse<Void>> toggleStatus(@PathVariable String id) {
opsService.toggleStatus(id);
return ResponseEntity.ok(ApiResponse.ok());
}
@GetMapping("/api/ops/statistics")
@PreAuthorize("hasAuthority('ROLE_OPS')")
public ResponseEntity<ApiResponse<Map<String, Object>>> statistics() {
return ResponseEntity.ok(ApiResponse.success(opsService.statistics()));
}
}

查看文件

@ -0,0 +1,78 @@
package com.xuqm.tenant.controller;
import com.xuqm.common.exception.BusinessException;
import com.xuqm.common.model.ApiResponse;
import com.xuqm.tenant.dto.CreateSubAccountRequest;
import com.xuqm.tenant.entity.TenantEntity;
import com.xuqm.tenant.service.EmailService;
import com.xuqm.tenant.service.SubAccountService;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/sub-accounts")
public class SubAccountController {
private final SubAccountService subAccountService;
private final EmailService emailService;
public SubAccountController(SubAccountService subAccountService, EmailService emailService) {
this.subAccountService = subAccountService;
this.emailService = emailService;
}
@GetMapping
public ResponseEntity<ApiResponse<List<TenantEntity>>> list(@AuthenticationPrincipal String tenantId) {
return ResponseEntity.ok(ApiResponse.success(subAccountService.listByParent(tenantId)));
}
@PostMapping("/send-verify-code")
public ResponseEntity<ApiResponse<Void>> sendVerifyCode(@RequestParam @NotBlank @Email String email,
@AuthenticationPrincipal String tenantId) {
emailService.sendVerificationCode(email, "SUB_ACCOUNT");
return ResponseEntity.ok(ApiResponse.ok());
}
@PostMapping("/verify-email")
public ResponseEntity<ApiResponse<Void>> verifyEmail(@RequestParam @NotBlank @Email String email,
@RequestParam @NotBlank String code,
@AuthenticationPrincipal String tenantId) {
subAccountService.verifyEmail(tenantId, email, code);
return ResponseEntity.ok(ApiResponse.ok());
}
@PostMapping
public ResponseEntity<ApiResponse<TenantEntity>> create(@Valid @RequestBody CreateSubAccountRequest req,
@AuthenticationPrincipal String tenantId) {
if (!subAccountService.isEmailVerifiedInSession(tenantId)) {
throw new BusinessException(403, "请先完成邮箱验证");
}
return ResponseEntity.ok(ApiResponse.success(subAccountService.create(tenantId, req)));
}
@DeleteMapping("/{id}")
public ResponseEntity<ApiResponse<Void>> disable(@PathVariable String id,
@AuthenticationPrincipal String tenantId) {
subAccountService.disable(id, tenantId);
return ResponseEntity.ok(ApiResponse.ok());
}
@GetMapping("/generate-password")
public ResponseEntity<ApiResponse<Map<String, String>>> generatePassword() {
return ResponseEntity.ok(ApiResponse.success(Map.of("password", subAccountService.generatePassword())));
}
}

查看文件

@ -0,0 +1,11 @@
package com.xuqm.tenant.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public record CreateAppRequest(
@NotBlank @Size(max = 128) String packageName,
@NotBlank @Size(max = 128) String name,
@Size(max = 512) String description,
String iconUrl
) {}

查看文件

@ -0,0 +1,12 @@
package com.xuqm.tenant.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public record CreateSubAccountRequest(
@NotBlank @Size(min = 3, max = 32) String username,
@NotBlank @Size(min = 6, max = 64) String password,
String email,
@NotBlank @Size(max = 32) String nickname,
String phone
) {}

查看文件

@ -0,0 +1,10 @@
package com.xuqm.tenant.dto;
import jakarta.validation.constraints.NotBlank;
public record LoginRequest(
@NotBlank String account,
@NotBlank String password,
@NotBlank String captchaKey,
@NotBlank String captchaCode
) {}

查看文件

@ -0,0 +1,14 @@
package com.xuqm.tenant.dto;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public record RegisterRequest(
@NotBlank @Size(min = 3, max = 32) String username,
@NotBlank @Size(min = 6, max = 64) String password,
@NotBlank @Email String email,
@NotBlank @Size(max = 32) String nickname,
String phone,
@NotBlank String emailCode
) {}

查看文件

@ -0,0 +1,66 @@
package com.xuqm.tenant.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.LocalDateTime;
@Entity
@Table(name = "t_app")
public class AppEntity {
@Id
private String id;
@Column(nullable = false, length = 64)
private String tenantId;
@Column(nullable = false, length = 128)
private String packageName;
@Column(nullable = false, length = 128)
private String name;
@Column(length = 512)
private String description;
@Column(length = 512)
private String iconUrl;
@Column(nullable = false, unique = true, length = 64)
private String appKey;
@Column(nullable = false, length = 128)
private String appSecret;
@Column(nullable = false)
private LocalDateTime createdAt;
public String getId() { return id; }
public void setId(String id) { this.id = id; }
public String getTenantId() { return tenantId; }
public void setTenantId(String tenantId) { this.tenantId = tenantId; }
public String getPackageName() { return packageName; }
public void setPackageName(String packageName) { this.packageName = packageName; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
public String getIconUrl() { return iconUrl; }
public void setIconUrl(String iconUrl) { this.iconUrl = iconUrl; }
public String getAppKey() { return appKey; }
public void setAppKey(String appKey) { this.appKey = appKey; }
public String getAppSecret() { return appSecret; }
public void setAppSecret(String appSecret) { this.appSecret = appSecret; }
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
}

查看文件

@ -0,0 +1,56 @@
package com.xuqm.tenant.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.LocalDateTime;
@Entity
@Table(name = "t_email_verification")
public class EmailVerificationEntity {
public enum Purpose { REGISTER, RESET_PASSWORD, SUB_ACCOUNT }
@Id
private String id;
@Column(nullable = false, length = 128)
private String email;
@Column(nullable = false, length = 16)
private String code;
@Column(nullable = false, length = 32)
private String purpose;
@Column(nullable = false)
private boolean used;
@Column(nullable = false)
private LocalDateTime expiresAt;
@Column(nullable = false)
private LocalDateTime createdAt;
public String getId() { return id; }
public void setId(String id) { this.id = id; }
public String getEmail() { return email; }
public void setEmail(String email) { this.email = email; }
public String getCode() { return code; }
public void setCode(String code) { this.code = code; }
public String getPurpose() { return purpose; }
public void setPurpose(String purpose) { this.purpose = purpose; }
public boolean isUsed() { return used; }
public void setUsed(boolean used) { this.used = used; }
public LocalDateTime getExpiresAt() { return expiresAt; }
public void setExpiresAt(LocalDateTime expiresAt) { this.expiresAt = expiresAt; }
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
}

查看文件

@ -0,0 +1,67 @@
package com.xuqm.tenant.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.LocalDateTime;
@Entity
@Table(name = "t_feature_service")
public class FeatureServiceEntity {
public enum Platform { ANDROID, IOS, HARMONY }
public enum ServiceType { IM, PUSH, UPDATE }
@Id
private String id;
@Column(nullable = false, length = 64)
private String appId;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 16)
private Platform platform;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 16)
private ServiceType serviceType;
@Column(nullable = false)
private boolean enabled;
@Column(nullable = false, unique = true, length = 128)
private String secretKey;
@Column(columnDefinition = "TEXT")
private String config;
@Column(nullable = false)
private LocalDateTime createdAt;
public String getId() { return id; }
public void setId(String id) { this.id = id; }
public String getAppId() { return appId; }
public void setAppId(String appId) { this.appId = appId; }
public Platform getPlatform() { return platform; }
public void setPlatform(Platform platform) { this.platform = platform; }
public ServiceType getServiceType() { return serviceType; }
public void setServiceType(ServiceType serviceType) { this.serviceType = serviceType; }
public boolean isEnabled() { return enabled; }
public void setEnabled(boolean enabled) { this.enabled = enabled; }
public String getSecretKey() { return secretKey; }
public void setSecretKey(String secretKey) { this.secretKey = secretKey; }
public String getConfig() { return config; }
public void setConfig(String config) { this.config = config; }
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
}

查看文件

@ -0,0 +1,36 @@
package com.xuqm.tenant.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.LocalDateTime;
@Entity
@Table(name = "t_ops_admin")
public class OpsAdminEntity {
@Id
private String id;
@Column(nullable = false, unique = true, length = 64)
private String username;
@Column(nullable = false, length = 128)
private String password;
@Column(nullable = false)
private LocalDateTime createdAt;
public String getId() { return id; }
public void setId(String id) { this.id = id; }
public String getUsername() { return username; }
public void setUsername(String username) { this.username = username; }
public String getPassword() { return password; }
public void setPassword(String password) { this.password = password; }
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
}

查看文件

@ -0,0 +1,79 @@
package com.xuqm.tenant.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.LocalDateTime;
@Entity
@Table(name = "t_tenant")
public class TenantEntity {
public enum Type { MAIN, SUB }
public enum Status { ACTIVE, DISABLED, PENDING_EMAIL }
@Id
private String id;
@Column(nullable = false, unique = true, length = 64)
private String username;
@Column(nullable = false, length = 128)
private String password;
@Column(nullable = false, unique = true, length = 128)
private String email;
@Column(nullable = false, length = 64)
private String nickname;
@Column(length = 32)
private String phone;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 16)
private Type type;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
private Status status;
@Column(length = 64)
private String parentId;
@Column(nullable = false)
private LocalDateTime createdAt;
public String getId() { return id; }
public void setId(String id) { this.id = id; }
public String getUsername() { return username; }
public void setUsername(String username) { this.username = username; }
public String getPassword() { return password; }
public void setPassword(String password) { this.password = password; }
public String getEmail() { return email; }
public void setEmail(String email) { this.email = email; }
public String getNickname() { return nickname; }
public void setNickname(String nickname) { this.nickname = nickname; }
public String getPhone() { return phone; }
public void setPhone(String phone) { this.phone = phone; }
public Type getType() { return type; }
public void setType(Type type) { this.type = type; }
public Status getStatus() { return status; }
public void setStatus(Status status) { this.status = status; }
public String getParentId() { return parentId; }
public void setParentId(String parentId) { this.parentId = parentId; }
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
}

查看文件

@ -0,0 +1,14 @@
package com.xuqm.tenant.repository;
import com.xuqm.tenant.entity.AppEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
public interface AppRepository extends JpaRepository<AppEntity, String> {
List<AppEntity> findByTenantId(String tenantId);
Optional<AppEntity> findByAppKey(String appKey);
boolean existsByPackageNameAndTenantId(String packageName, String tenantId);
long count();
}

查看文件

@ -0,0 +1,19 @@
package com.xuqm.tenant.repository;
import com.xuqm.tenant.entity.EmailVerificationEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.transaction.annotation.Transactional;
import java.util.Optional;
public interface EmailVerificationRepository extends JpaRepository<EmailVerificationEntity, String> {
Optional<EmailVerificationEntity> findTopByEmailAndPurposeAndUsedFalseOrderByCreatedAtDesc(
String email, String purpose);
@Modifying
@Transactional
@Query("UPDATE EmailVerificationEntity e SET e.used = true WHERE e.email = :email AND e.purpose = :purpose")
void markAllUsed(String email, String purpose);
}

查看文件

@ -0,0 +1,16 @@
package com.xuqm.tenant.repository;
import com.xuqm.tenant.entity.FeatureServiceEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
public interface FeatureServiceRepository extends JpaRepository<FeatureServiceEntity, String> {
List<FeatureServiceEntity> findByAppId(String appId);
Optional<FeatureServiceEntity> findByAppIdAndPlatformAndServiceType(
String appId,
FeatureServiceEntity.Platform platform,
FeatureServiceEntity.ServiceType serviceType);
Optional<FeatureServiceEntity> findBySecretKey(String secretKey);
}

查看文件

@ -0,0 +1,10 @@
package com.xuqm.tenant.repository;
import com.xuqm.tenant.entity.OpsAdminEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface OpsAdminRepository extends JpaRepository<OpsAdminEntity, String> {
Optional<OpsAdminEntity> findByUsername(String username);
}

查看文件

@ -0,0 +1,27 @@
package com.xuqm.tenant.repository;
import com.xuqm.tenant.entity.TenantEntity;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
public interface TenantRepository extends JpaRepository<TenantEntity, String> {
Optional<TenantEntity> findByUsername(String username);
Optional<TenantEntity> findByEmail(String email);
boolean existsByUsername(String username);
boolean existsByEmail(String email);
List<TenantEntity> findByParentId(String parentId);
@Query("SELECT t FROM TenantEntity t WHERE " +
"(:keyword IS NULL OR :keyword = '' OR t.username LIKE %:keyword% OR t.email LIKE %:keyword%)")
Page<TenantEntity> searchTenants(@Param("keyword") String keyword, Pageable pageable);
long countByCreatedAtBetween(LocalDateTime start, LocalDateTime end);
}

查看文件

@ -0,0 +1,77 @@
package com.xuqm.tenant.service;
import com.xuqm.common.exception.BusinessException;
import com.xuqm.tenant.dto.CreateAppRequest;
import com.xuqm.tenant.entity.AppEntity;
import com.xuqm.tenant.repository.AppRepository;
import org.springframework.stereotype.Service;
import java.security.SecureRandom;
import java.time.LocalDateTime;
import java.util.Base64;
import java.util.List;
import java.util.UUID;
@Service
public class AppService {
private final AppRepository appRepository;
private static final SecureRandom random = new SecureRandom();
public AppService(AppRepository appRepository) {
this.appRepository = appRepository;
}
public List<AppEntity> listByTenant(String tenantId) {
return appRepository.findByTenantId(tenantId);
}
public AppEntity getById(String id, String tenantId) {
AppEntity app = appRepository.findById(id)
.orElseThrow(() -> new BusinessException(404, "应用不存在"));
if (!app.getTenantId().equals(tenantId)) {
throw new BusinessException(403, "无权访问该应用");
}
return app;
}
public AppEntity create(String tenantId, CreateAppRequest req) {
if (appRepository.existsByPackageNameAndTenantId(req.packageName(), tenantId)) {
throw new BusinessException("该包名下已存在同名应用");
}
AppEntity app = new AppEntity();
app.setId(UUID.randomUUID().toString());
app.setTenantId(tenantId);
app.setPackageName(req.packageName());
app.setName(req.name());
app.setDescription(req.description());
app.setIconUrl(req.iconUrl());
app.setAppKey(generateAppKey());
app.setAppSecret(generateSecret());
app.setCreatedAt(LocalDateTime.now());
return appRepository.save(app);
}
public AppEntity update(String id, String tenantId, CreateAppRequest req) {
AppEntity app = getById(id, tenantId);
app.setName(req.name());
app.setDescription(req.description());
app.setIconUrl(req.iconUrl());
return appRepository.save(app);
}
public void delete(String id, String tenantId) {
AppEntity app = getById(id, tenantId);
appRepository.delete(app);
}
private String generateAppKey() {
return "ak_" + UUID.randomUUID().toString().replace("-", "").substring(0, 24);
}
private String generateSecret() {
byte[] bytes = new byte[32];
random.nextBytes(bytes);
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
}
}

查看文件

@ -0,0 +1,104 @@
package com.xuqm.tenant.service;
import com.xuqm.common.exception.BusinessException;
import com.xuqm.common.security.JwtUtil;
import com.xuqm.tenant.dto.LoginRequest;
import com.xuqm.tenant.dto.RegisterRequest;
import com.xuqm.tenant.entity.TenantEntity;
import com.xuqm.tenant.repository.TenantRepository;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
@Service
public class AuthService {
private final TenantRepository tenantRepository;
private final PasswordEncoder passwordEncoder;
private final JwtUtil jwtUtil;
private final EmailService emailService;
private final StringRedisTemplate redis;
private static final String CAPTCHA_PREFIX = "captcha:";
public AuthService(TenantRepository tenantRepository, PasswordEncoder passwordEncoder,
JwtUtil jwtUtil, EmailService emailService, StringRedisTemplate redis) {
this.tenantRepository = tenantRepository;
this.passwordEncoder = passwordEncoder;
this.jwtUtil = jwtUtil;
this.emailService = emailService;
this.redis = redis;
}
public void register(RegisterRequest req) {
emailService.verify(req.email(), req.emailCode(), "REGISTER");
if (tenantRepository.existsByUsername(req.username())) {
throw new BusinessException("用户名已存在");
}
if (tenantRepository.existsByEmail(req.email())) {
throw new BusinessException("邮箱已被注册");
}
TenantEntity tenant = new TenantEntity();
tenant.setId(UUID.randomUUID().toString());
tenant.setUsername(req.username());
tenant.setPassword(passwordEncoder.encode(req.password()));
tenant.setEmail(req.email());
tenant.setNickname(req.nickname());
tenant.setPhone(req.phone());
tenant.setType(TenantEntity.Type.MAIN);
tenant.setStatus(TenantEntity.Status.ACTIVE);
tenant.setCreatedAt(LocalDateTime.now());
tenantRepository.save(tenant);
}
public String login(LoginRequest req) {
String cachedCaptcha = redis.opsForValue().get(CAPTCHA_PREFIX + req.captchaKey());
if (cachedCaptcha == null || !cachedCaptcha.equalsIgnoreCase(req.captchaCode())) {
throw new BusinessException("验证码错误或已过期");
}
redis.delete(CAPTCHA_PREFIX + req.captchaKey());
TenantEntity tenant = tenantRepository.findByUsername(req.account())
.or(() -> tenantRepository.findByEmail(req.account()))
.orElseThrow(() -> new BusinessException("账号不存在"));
if (tenant.getStatus() == TenantEntity.Status.DISABLED) {
throw new BusinessException("账号已被禁用");
}
if (!passwordEncoder.matches(req.password(), tenant.getPassword())) {
throw new BusinessException("密码错误");
}
return jwtUtil.generate(tenant.getId(), Map.of(
"username", tenant.getUsername(),
"nickname", tenant.getNickname(),
"type", tenant.getType().name()
));
}
public void forgotPassword(String email) {
if (!tenantRepository.existsByEmail(email)) {
throw new BusinessException("邮箱未注册");
}
emailService.sendVerificationCode(email, "RESET_PASSWORD");
}
public void resetPassword(String email, String code, String newPassword) {
emailService.verify(email, code, "RESET_PASSWORD");
TenantEntity tenant = tenantRepository.findByEmail(email)
.orElseThrow(() -> new BusinessException("账号不存在"));
tenant.setPassword(passwordEncoder.encode(newPassword));
tenantRepository.save(tenant);
}
public void storeCaptcha(String key, String code) {
redis.opsForValue().set(CAPTCHA_PREFIX + key, code, 300, TimeUnit.SECONDS);
}
}

查看文件

@ -0,0 +1,72 @@
package com.xuqm.tenant.service;
import com.xuqm.common.exception.BusinessException;
import com.xuqm.tenant.entity.EmailVerificationEntity;
import com.xuqm.tenant.repository.EmailVerificationRepository;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import java.security.SecureRandom;
import java.time.LocalDateTime;
import java.util.UUID;
@Service
public class EmailService {
private final JavaMailSender mailSender;
private final EmailVerificationRepository verificationRepository;
@Value("${spring.mail.username}")
private String fromAddress;
@Value("${email-verify.expire-seconds:600}")
private int expireSeconds;
private static final SecureRandom random = new SecureRandom();
public EmailService(JavaMailSender mailSender, EmailVerificationRepository verificationRepository) {
this.mailSender = mailSender;
this.verificationRepository = verificationRepository;
}
@Async
public void sendVerificationCode(String email, String purpose) {
String code = String.format("%06d", random.nextInt(1_000_000));
EmailVerificationEntity entity = new EmailVerificationEntity();
entity.setId(UUID.randomUUID().toString());
entity.setEmail(email);
entity.setCode(code);
entity.setPurpose(purpose);
entity.setUsed(false);
entity.setCreatedAt(LocalDateTime.now());
entity.setExpiresAt(LocalDateTime.now().plusSeconds(expireSeconds));
verificationRepository.save(entity);
SimpleMailMessage message = new SimpleMailMessage();
message.setFrom(fromAddress);
message.setTo(email);
message.setSubject("XuqmGroup - 邮箱验证码");
message.setText(String.format("您的验证码是:%s,%d分钟内有效。", code, expireSeconds / 60));
mailSender.send(message);
}
public void verify(String email, String code, String purpose) {
EmailVerificationEntity entity = verificationRepository
.findTopByEmailAndPurposeAndUsedFalseOrderByCreatedAtDesc(email, purpose)
.orElseThrow(() -> new BusinessException("验证码无效"));
if (entity.getExpiresAt().isBefore(LocalDateTime.now())) {
throw new BusinessException("验证码已过期");
}
if (!entity.getCode().equals(code)) {
throw new BusinessException("验证码错误");
}
entity.setUsed(true);
verificationRepository.save(entity);
}
}

查看文件

@ -0,0 +1,64 @@
package com.xuqm.tenant.service;
import com.xuqm.common.exception.BusinessException;
import com.xuqm.tenant.entity.FeatureServiceEntity;
import com.xuqm.tenant.repository.FeatureServiceRepository;
import org.springframework.stereotype.Service;
import java.security.SecureRandom;
import java.time.LocalDateTime;
import java.util.Base64;
import java.util.List;
import java.util.UUID;
@Service
public class FeatureServiceManager {
private final FeatureServiceRepository repository;
private static final SecureRandom random = new SecureRandom();
public FeatureServiceManager(FeatureServiceRepository repository) {
this.repository = repository;
}
public List<FeatureServiceEntity> listByApp(String appId) {
return repository.findByAppId(appId);
}
public FeatureServiceEntity toggle(String appId, FeatureServiceEntity.Platform platform,
FeatureServiceEntity.ServiceType serviceType, boolean enable) {
FeatureServiceEntity entity = repository
.findByAppIdAndPlatformAndServiceType(appId, platform, serviceType)
.orElseGet(() -> {
FeatureServiceEntity e = new FeatureServiceEntity();
e.setId(UUID.randomUUID().toString());
e.setAppId(appId);
e.setPlatform(platform);
e.setServiceType(serviceType);
e.setSecretKey(generateSecretKey());
e.setCreatedAt(LocalDateTime.now());
return e;
});
entity.setEnabled(enable);
return repository.save(entity);
}
public FeatureServiceEntity getOrFail(String appId, FeatureServiceEntity.Platform platform,
FeatureServiceEntity.ServiceType serviceType) {
return repository.findByAppIdAndPlatformAndServiceType(appId, platform, serviceType)
.orElseThrow(() -> new BusinessException(404, "服务未配置"));
}
public FeatureServiceEntity regenerateKey(String id) {
FeatureServiceEntity entity = repository.findById(id)
.orElseThrow(() -> new BusinessException(404, "服务不存在"));
entity.setSecretKey(generateSecretKey());
return repository.save(entity);
}
private String generateSecretKey() {
byte[] bytes = new byte[32];
random.nextBytes(bytes);
return "sk_" + Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
}
}

查看文件

@ -0,0 +1,89 @@
package com.xuqm.tenant.service;
import com.xuqm.common.security.JwtUtil;
import com.xuqm.tenant.entity.OpsAdminEntity;
import com.xuqm.tenant.entity.TenantEntity;
import com.xuqm.tenant.repository.AppRepository;
import com.xuqm.tenant.repository.OpsAdminRepository;
import com.xuqm.tenant.repository.TenantRepository;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.Map;
import java.util.UUID;
@Service
public class OpsService {
private final TenantRepository tenantRepository;
private final AppRepository appRepository;
private final OpsAdminRepository opsAdminRepository;
private final PasswordEncoder passwordEncoder;
private final JwtUtil jwtUtil;
public OpsService(TenantRepository tenantRepository, AppRepository appRepository,
OpsAdminRepository opsAdminRepository, PasswordEncoder passwordEncoder,
JwtUtil jwtUtil) {
this.tenantRepository = tenantRepository;
this.appRepository = appRepository;
this.opsAdminRepository = opsAdminRepository;
this.passwordEncoder = passwordEncoder;
this.jwtUtil = jwtUtil;
}
public String login(String username, String password) {
OpsAdminEntity admin = opsAdminRepository.findByUsername(username)
.orElseThrow(() -> new IllegalArgumentException("用户名或密码错误"));
if (!passwordEncoder.matches(password, admin.getPassword())) {
throw new IllegalArgumentException("用户名或密码错误");
}
return jwtUtil.generate(admin.getId(), Map.of("username", username, "role", "OPS"));
}
public Page<TenantEntity> listTenants(String keyword, int page, int size) {
return tenantRepository.searchTenants(
keyword,
PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt"))
);
}
public void toggleStatus(String tenantId) {
TenantEntity tenant = tenantRepository.findById(tenantId)
.orElseThrow(() -> new IllegalArgumentException("租户不存在"));
if (tenant.getStatus() == TenantEntity.Status.ACTIVE) {
tenant.setStatus(TenantEntity.Status.DISABLED);
} else {
tenant.setStatus(TenantEntity.Status.ACTIVE);
}
tenantRepository.save(tenant);
}
public Map<String, Object> statistics() {
long totalTenants = tenantRepository.count();
LocalDateTime todayStart = LocalDate.now().atStartOfDay();
LocalDateTime todayEnd = todayStart.plusDays(1);
long todayNew = tenantRepository.countByCreatedAtBetween(todayStart, todayEnd);
long activeApps = appRepository.count();
return Map.of(
"totalTenants", totalTenants,
"todayNew", todayNew,
"activeApps", activeApps,
"onlineUsers", 0
);
}
public void initDefaultAdmin(String username, String rawPassword) {
if (opsAdminRepository.findByUsername(username).isPresent()) return;
OpsAdminEntity admin = new OpsAdminEntity();
admin.setId(UUID.randomUUID().toString());
admin.setUsername(username);
admin.setPassword(passwordEncoder.encode(rawPassword));
admin.setCreatedAt(LocalDateTime.now());
opsAdminRepository.save(admin);
}
}

查看文件

@ -0,0 +1,84 @@
package com.xuqm.tenant.service;
import com.xuqm.common.exception.BusinessException;
import com.xuqm.tenant.dto.CreateSubAccountRequest;
import com.xuqm.tenant.entity.TenantEntity;
import com.xuqm.tenant.repository.TenantRepository;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import java.security.SecureRandom;
import java.time.LocalDateTime;
import java.util.Base64;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
@Service
public class SubAccountService {
private final TenantRepository tenantRepository;
private final PasswordEncoder passwordEncoder;
private final EmailService emailService;
private final StringRedisTemplate redis;
private static final String SUB_VERIFY_PREFIX = "sub_verified:";
private static final SecureRandom random = new SecureRandom();
public SubAccountService(TenantRepository tenantRepository, PasswordEncoder passwordEncoder,
EmailService emailService, StringRedisTemplate redis) {
this.tenantRepository = tenantRepository;
this.passwordEncoder = passwordEncoder;
this.emailService = emailService;
this.redis = redis;
}
public boolean isEmailVerifiedInSession(String tenantId) {
return Boolean.TRUE.toString().equals(redis.opsForValue().get(SUB_VERIFY_PREFIX + tenantId));
}
public void verifyEmail(String tenantId, String email, String code) {
emailService.verify(email, code, "SUB_ACCOUNT");
redis.opsForValue().set(SUB_VERIFY_PREFIX + tenantId, Boolean.TRUE.toString(), 24, TimeUnit.HOURS);
}
public TenantEntity create(String parentId, CreateSubAccountRequest req) {
if (tenantRepository.existsByUsername(req.username())) {
throw new BusinessException("用户名已存在");
}
TenantEntity sub = new TenantEntity();
sub.setId(UUID.randomUUID().toString());
sub.setUsername(req.username());
sub.setPassword(passwordEncoder.encode(req.password()));
sub.setEmail(req.email() != null ? req.email() : "");
sub.setNickname(req.nickname());
sub.setPhone(req.phone());
sub.setType(TenantEntity.Type.SUB);
sub.setStatus(TenantEntity.Status.ACTIVE);
sub.setParentId(parentId);
sub.setCreatedAt(LocalDateTime.now());
return tenantRepository.save(sub);
}
public List<TenantEntity> listByParent(String parentId) {
return tenantRepository.findByParentId(parentId);
}
public void disable(String subId, String parentId) {
TenantEntity sub = tenantRepository.findById(subId)
.orElseThrow(() -> new BusinessException(404, "子账号不存在"));
if (!parentId.equals(sub.getParentId())) {
throw new BusinessException(403, "无权操作");
}
sub.setStatus(TenantEntity.Status.DISABLED);
tenantRepository.save(sub);
}
public String generatePassword() {
byte[] bytes = new byte[9];
random.nextBytes(bytes);
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
}
}

查看文件

@ -0,0 +1,65 @@
server:
port: 8081
spring:
application:
name: tenant-service
datasource:
url: jdbc:mysql://localhost:3306/xuqm_tenant?useSSL=false&serverTimezone=UTC&createDatabaseIfNotExist=true&allowPublicKeyRetrieval=true
username: root
password: root
driver-class-name: com.mysql.cj.jdbc.Driver
jpa:
hibernate:
ddl-auto: update
show-sql: false
properties:
hibernate:
dialect: org.hibernate.dialect.MySQLDialect
format_sql: true
data:
redis:
host: localhost
port: 6379
timeout: 5000ms
mail:
host: smtp.example.com
port: 587
username: noreply@xuqm.com
password: changeme
properties:
mail:
smtp:
auth: true
starttls:
enable: true
jackson:
time-zone: UTC
serialization:
write-dates-as-timestamps: false
jwt:
secret: xuqm-tenant-service-secret-key-must-be-at-least-256-bits-long-for-hmac
expiration: 86400000
captcha:
expire-seconds: 300
email-verify:
expire-seconds: 600
sub-account-token-hours: 24
ops:
admin:
username: admin
password: Admin@123456
logging:
level:
com.xuqm: DEBUG
management:
endpoints:
web:
exposure:
include: health,info

82
update-service/pom.xml 普通文件
查看文件

@ -0,0 +1,82 @@
<?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>update-service</artifactId>
<name>update-service</name>
<description>App version management: APK release, RN bundle hot update</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-validation</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>net.dongliu</groupId>
<artifactId>apk-parser</artifactId>
<version>2.6.10</version>
</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,13 @@
package com.xuqm.update;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.ComponentScan;
@SpringBootApplication
@ComponentScan(basePackages = {"com.xuqm.update", "com.xuqm.common"})
public class UpdateServiceApplication {
public static void main(String[] args) {
SpringApplication.run(UpdateServiceApplication.class, args);
}
}

查看文件

@ -0,0 +1,116 @@
package com.xuqm.update.controller;
import com.xuqm.common.model.ApiResponse;
import com.xuqm.update.entity.AppVersionEntity;
import com.xuqm.update.repository.AppVersionRepository;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
@RestController
@RequestMapping("/api/v1/updates")
public class AppVersionController {
private final AppVersionRepository versionRepository;
@Value("${update.upload-dir:/tmp/xuqm-update}")
private String uploadDir;
@Value("${update.base-url:http://localhost:8084}")
private String baseUrl;
public AppVersionController(AppVersionRepository versionRepository) {
this.versionRepository = versionRepository;
}
@GetMapping("/app/check")
public ResponseEntity<ApiResponse<Map<String, Object>>> checkUpdate(
@RequestParam String appId,
@RequestParam AppVersionEntity.Platform platform,
@RequestParam int currentVersionCode) {
Optional<AppVersionEntity> latest = versionRepository
.findTopByAppIdAndPlatformAndPublishStatusOrderByVersionCodeDesc(
appId, platform, AppVersionEntity.PublishStatus.PUBLISHED);
if (latest.isEmpty() || latest.get().getVersionCode() <= currentVersionCode) {
return ResponseEntity.ok(ApiResponse.success(Map.of("needsUpdate", false)));
}
AppVersionEntity v = latest.get();
return ResponseEntity.ok(ApiResponse.success(Map.of(
"needsUpdate", true,
"versionName", v.getVersionName(),
"versionCode", v.getVersionCode(),
"downloadUrl", v.getDownloadUrl() != null ? v.getDownloadUrl() : "",
"changeLog", v.getChangeLog() != null ? v.getChangeLog() : "",
"forceUpdate", v.isForceUpdate(),
"appStoreUrl", v.getAppStoreUrl() != null ? v.getAppStoreUrl() : "",
"marketUrl", v.getMarketUrl() != null ? v.getMarketUrl() : ""
)));
}
@PostMapping("/app/upload")
public ResponseEntity<ApiResponse<AppVersionEntity>> upload(
@RequestParam String appId,
@RequestParam AppVersionEntity.Platform platform,
@RequestParam String versionName,
@RequestParam int versionCode,
@RequestParam(required = false) String changeLog,
@RequestParam(defaultValue = "false") boolean forceUpdate,
@RequestParam(required = false) MultipartFile apkFile) throws IOException {
String downloadUrl = null;
if (apkFile != null && !apkFile.isEmpty()) {
String filename = UUID.randomUUID() + "_" + apkFile.getOriginalFilename();
Path dir = Paths.get(uploadDir, "apk");
Files.createDirectories(dir);
Path dest = dir.resolve(filename);
apkFile.transferTo(dest.toFile());
downloadUrl = baseUrl + "/files/apk/" + filename;
}
AppVersionEntity entity = new AppVersionEntity();
entity.setId(UUID.randomUUID().toString());
entity.setAppId(appId);
entity.setPlatform(platform);
entity.setVersionName(versionName);
entity.setVersionCode(versionCode);
entity.setDownloadUrl(downloadUrl);
entity.setChangeLog(changeLog);
entity.setForceUpdate(forceUpdate);
entity.setPublishStatus(AppVersionEntity.PublishStatus.DRAFT);
entity.setCreatedAt(LocalDateTime.now());
return ResponseEntity.ok(ApiResponse.success(versionRepository.save(entity)));
}
@PostMapping("/app/{id}/publish")
public ResponseEntity<ApiResponse<AppVersionEntity>> publish(@PathVariable String id) {
AppVersionEntity entity = versionRepository.findById(id).orElseThrow();
entity.setPublishStatus(AppVersionEntity.PublishStatus.PUBLISHED);
return ResponseEntity.ok(ApiResponse.success(versionRepository.save(entity)));
}
@GetMapping("/app/list")
public ResponseEntity<ApiResponse<List<AppVersionEntity>>> list(
@RequestParam String appId, @RequestParam AppVersionEntity.Platform platform) {
return ResponseEntity.ok(ApiResponse.success(
versionRepository.findByAppIdAndPlatformOrderByVersionCodeDesc(appId, platform)));
}
}

查看文件

@ -0,0 +1,120 @@
package com.xuqm.update.controller;
import com.xuqm.common.model.ApiResponse;
import com.xuqm.update.entity.RnBundleEntity;
import com.xuqm.update.repository.RnBundleRepository;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.security.DigestInputStream;
import java.security.MessageDigest;
import java.time.LocalDateTime;
import java.util.HexFormat;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
@RestController
@RequestMapping("/api/v1/rn")
public class RnBundleController {
private final RnBundleRepository bundleRepository;
@Value("${update.upload-dir:/tmp/xuqm-update}")
private String uploadDir;
@Value("${update.base-url:http://localhost:8084}")
private String baseUrl;
public RnBundleController(RnBundleRepository bundleRepository) {
this.bundleRepository = bundleRepository;
}
@GetMapping("/update/check")
public ResponseEntity<ApiResponse<Map<String, Object>>> checkUpdate(
@RequestParam String appId,
@RequestParam String moduleId,
@RequestParam String platform,
@RequestParam String currentVersion) {
RnBundleEntity.Platform p = RnBundleEntity.Platform.valueOf(platform.toUpperCase());
Optional<RnBundleEntity> latest = bundleRepository
.findTopByAppIdAndModuleIdAndPlatformAndPublishStatusOrderByCreatedAtDesc(
appId, moduleId, p, RnBundleEntity.PublishStatus.PUBLISHED);
if (latest.isEmpty()) {
return ResponseEntity.ok(ApiResponse.success(Map.of("needsUpdate", false)));
}
RnBundleEntity b = latest.get();
boolean needsUpdate = !b.getVersion().equals(currentVersion);
return ResponseEntity.ok(ApiResponse.success(Map.of(
"needsUpdate", needsUpdate,
"latestVersion", b.getVersion(),
"downloadUrl", baseUrl + "/api/v1/rn/files/" + appId + "/" + platform.toLowerCase() + "/" + moduleId,
"md5", b.getMd5(),
"minCommonVersion", b.getMinCommonVersion() != null ? b.getMinCommonVersion() : "0.0.0",
"note", b.getNote() != null ? b.getNote() : ""
)));
}
@PostMapping("/upload")
public ResponseEntity<ApiResponse<RnBundleEntity>> upload(
@RequestParam String appId,
@RequestParam String moduleId,
@RequestParam RnBundleEntity.Platform platform,
@RequestParam String version,
@RequestParam(required = false) String minCommonVersion,
@RequestParam(required = false) String note,
@RequestParam MultipartFile bundle) throws Exception {
String filename = moduleId + "." + platform.name().toLowerCase() + ".bundle";
Path dir = Paths.get(uploadDir, "rn", appId, platform.name().toLowerCase(), moduleId);
Files.createDirectories(dir);
Path dest = dir.resolve(filename);
String md5 = computeMd5(bundle);
bundle.transferTo(dest.toFile());
RnBundleEntity entity = new RnBundleEntity();
entity.setId(UUID.randomUUID().toString());
entity.setAppId(appId);
entity.setModuleId(moduleId);
entity.setPlatform(platform);
entity.setVersion(version);
entity.setBundleUrl(dest.toAbsolutePath().toString());
entity.setMd5(md5);
entity.setMinCommonVersion(minCommonVersion);
entity.setNote(note);
entity.setPublishStatus(RnBundleEntity.PublishStatus.DRAFT);
entity.setCreatedAt(LocalDateTime.now());
return ResponseEntity.ok(ApiResponse.success(bundleRepository.save(entity)));
}
@PostMapping("/{id}/publish")
public ResponseEntity<ApiResponse<RnBundleEntity>> publish(@PathVariable String id) {
RnBundleEntity entity = bundleRepository.findById(id).orElseThrow();
entity.setPublishStatus(RnBundleEntity.PublishStatus.PUBLISHED);
return ResponseEntity.ok(ApiResponse.success(bundleRepository.save(entity)));
}
private String computeMd5(MultipartFile file) throws Exception {
MessageDigest digest = MessageDigest.getInstance("MD5");
try (DigestInputStream dis = new DigestInputStream(file.getInputStream(), digest)) {
byte[] buf = new byte[8192];
while (dis.read(buf) != -1) {}
}
return HexFormat.of().formatHex(digest.digest());
}
}

查看文件

@ -0,0 +1,91 @@
package com.xuqm.update.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.LocalDateTime;
@Entity
@Table(name = "update_app_version")
public class AppVersionEntity {
public enum Platform { ANDROID, IOS }
public enum PublishStatus { DRAFT, PUBLISHED, DEPRECATED }
@Id
private String id;
@Column(nullable = false, length = 64)
private String appId;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 16)
private Platform platform;
@Column(nullable = false, length = 32)
private String versionName;
@Column(nullable = false)
private int versionCode;
@Column(length = 512)
private String downloadUrl;
@Column(columnDefinition = "TEXT")
private String changeLog;
@Column(nullable = false)
private boolean forceUpdate;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 16)
private PublishStatus publishStatus;
@Column(length = 256)
private String appStoreUrl;
@Column(length = 256)
private String marketUrl;
@Column(nullable = false)
private LocalDateTime createdAt;
public String getId() { return id; }
public void setId(String id) { this.id = id; }
public String getAppId() { return appId; }
public void setAppId(String appId) { this.appId = appId; }
public Platform getPlatform() { return platform; }
public void setPlatform(Platform platform) { this.platform = platform; }
public String getVersionName() { return versionName; }
public void setVersionName(String versionName) { this.versionName = versionName; }
public int getVersionCode() { return versionCode; }
public void setVersionCode(int versionCode) { this.versionCode = versionCode; }
public String getDownloadUrl() { return downloadUrl; }
public void setDownloadUrl(String downloadUrl) { this.downloadUrl = downloadUrl; }
public String getChangeLog() { return changeLog; }
public void setChangeLog(String changeLog) { this.changeLog = changeLog; }
public boolean isForceUpdate() { return forceUpdate; }
public void setForceUpdate(boolean forceUpdate) { this.forceUpdate = forceUpdate; }
public PublishStatus getPublishStatus() { return publishStatus; }
public void setPublishStatus(PublishStatus publishStatus) { this.publishStatus = publishStatus; }
public String getAppStoreUrl() { return appStoreUrl; }
public void setAppStoreUrl(String appStoreUrl) { this.appStoreUrl = appStoreUrl; }
public String getMarketUrl() { return marketUrl; }
public void setMarketUrl(String marketUrl) { this.marketUrl = marketUrl; }
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
}

查看文件

@ -0,0 +1,85 @@
package com.xuqm.update.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.LocalDateTime;
@Entity
@Table(name = "update_rn_bundle")
public class RnBundleEntity {
public enum Platform { ANDROID, IOS }
public enum PublishStatus { DRAFT, PUBLISHED, DEPRECATED }
@Id
private String id;
@Column(nullable = false, length = 64)
private String appId;
@Column(nullable = false, length = 64)
private String moduleId;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 16)
private Platform platform;
@Column(nullable = false, length = 32)
private String version;
@Column(nullable = false, length = 512)
private String bundleUrl;
@Column(nullable = false, length = 64)
private String md5;
@Column(length = 32)
private String minCommonVersion;
@Column(length = 512)
private String note;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 16)
private PublishStatus publishStatus;
@Column(nullable = false)
private LocalDateTime createdAt;
public String getId() { return id; }
public void setId(String id) { this.id = id; }
public String getAppId() { return appId; }
public void setAppId(String appId) { this.appId = appId; }
public String getModuleId() { return moduleId; }
public void setModuleId(String moduleId) { this.moduleId = moduleId; }
public Platform getPlatform() { return platform; }
public void setPlatform(Platform platform) { this.platform = platform; }
public String getVersion() { return version; }
public void setVersion(String version) { this.version = version; }
public String getBundleUrl() { return bundleUrl; }
public void setBundleUrl(String bundleUrl) { this.bundleUrl = bundleUrl; }
public String getMd5() { return md5; }
public void setMd5(String md5) { this.md5 = md5; }
public String getMinCommonVersion() { return minCommonVersion; }
public void setMinCommonVersion(String minCommonVersion) { this.minCommonVersion = minCommonVersion; }
public String getNote() { return note; }
public void setNote(String note) { this.note = note; }
public PublishStatus getPublishStatus() { return publishStatus; }
public void setPublishStatus(PublishStatus publishStatus) { this.publishStatus = publishStatus; }
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
}

查看文件

@ -0,0 +1,14 @@
package com.xuqm.update.repository;
import com.xuqm.update.entity.AppVersionEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
public interface AppVersionRepository extends JpaRepository<AppVersionEntity, String> {
List<AppVersionEntity> findByAppIdAndPlatformOrderByVersionCodeDesc(
String appId, AppVersionEntity.Platform platform);
Optional<AppVersionEntity> findTopByAppIdAndPlatformAndPublishStatusOrderByVersionCodeDesc(
String appId, AppVersionEntity.Platform platform, AppVersionEntity.PublishStatus status);
}

查看文件

@ -0,0 +1,14 @@
package com.xuqm.update.repository;
import com.xuqm.update.entity.RnBundleEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
public interface RnBundleRepository extends JpaRepository<RnBundleEntity, String> {
List<RnBundleEntity> findByAppIdAndModuleIdAndPlatformOrderByCreatedAtDesc(
String appId, String moduleId, RnBundleEntity.Platform platform);
Optional<RnBundleEntity> findTopByAppIdAndModuleIdAndPlatformAndPublishStatusOrderByCreatedAtDesc(
String appId, String moduleId, RnBundleEntity.Platform platform, RnBundleEntity.PublishStatus status);
}

查看文件

@ -0,0 +1,27 @@
server:
port: 8084
spring:
application:
name: update-service
datasource:
url: jdbc:mysql://localhost:3306/xuqm_update?useSSL=false&serverTimezone=UTC&createDatabaseIfNotExist=true&allowPublicKeyRetrieval=true
username: root
password: root
driver-class-name: com.mysql.cj.jdbc.Driver
jpa:
hibernate:
ddl-auto: update
show-sql: false
servlet:
multipart:
max-file-size: 200MB
max-request-size: 200MB
jwt:
secret: xuqm-update-service-secret-key-must-be-at-least-256-bits-long-for-hmac
expiration: 86400000
update:
upload-dir: ${UPDATE_UPLOAD_DIR:/tmp/xuqm-update}
base-url: ${UPDATE_BASE_URL:http://localhost:8084}