first commit

This commit is contained in:
zyh
2025-08-24 15:33:03 +08:00
commit be437a360d
54 changed files with 1273 additions and 0 deletions

View File

@@ -0,0 +1,14 @@
package com.gameplatform.server;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
@MapperScan("com.gameplatform.server.mapper")
public class GamePlatformServerApplication {
public static void main(String[] args) {
SpringApplication.run(GamePlatformServerApplication.class, args);
}
}

View File

@@ -0,0 +1,44 @@
package com.gameplatform.server.controller;
import com.gameplatform.server.model.User;
import com.gameplatform.server.service.UserService;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
@RestController
@RequestMapping("/api/users")
public class UserController {
private final UserService userService;
public UserController(UserService userService) {
this.userService = userService;
}
@GetMapping("/{id}")
public Mono<User> getById(@PathVariable Long id) {
return userService.getById(id);
}
@GetMapping
public Flux<User> listAll() {
return userService.listAll();
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public Mono<User> create(@Valid @RequestBody User user) {
return userService.create(user);
}
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public Mono<Void> delete(@PathVariable Long id) {
return userService.deleteById(id)
.filter(Boolean::booleanValue)
.then();
}
}

View File

@@ -0,0 +1,48 @@
package com.gameplatform.server.controller.auth;
import com.gameplatform.server.model.dto.auth.LoginRequest;
import com.gameplatform.server.model.dto.auth.LoginResponse;
import com.gameplatform.server.security.JwtService;
import io.jsonwebtoken.Claims;
import jakarta.validation.Valid;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;
import reactor.core.publisher.Mono;
@RestController
@RequestMapping("/api/auth")
public class AuthController {
private final com.gameplatform.server.service.auth.AuthService authService;
private final JwtService jwtService;
public AuthController(com.gameplatform.server.service.auth.AuthService authService, JwtService jwtService) {
this.authService = authService;
this.jwtService = jwtService;
}
@PostMapping("/login")
@ResponseStatus(HttpStatus.OK)
public Mono<LoginResponse> login(@Valid @RequestBody LoginRequest req) {
return authService.login(req);
}
@GetMapping("/me")
public Mono<Object> me(@RequestHeader(HttpHeaders.AUTHORIZATION) String authorization) {
String token = authorization != null && authorization.startsWith("Bearer ") ? authorization.substring(7) : authorization;
return Mono.fromCallable(() -> jwtService.parse(token))
.map(this::claimsToMe);
}
private Object claimsToMe(Claims c) {
return new java.util.LinkedHashMap<>() {{
put("userType", c.get("userType"));
put("userId", c.get("userId"));
put("username", c.get("username"));
put("role", c.get("role"));
put("exp", c.getExpiration());
}};
}
}

View File

@@ -0,0 +1,40 @@
package com.gameplatform.server.exception;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.time.Instant;
import java.util.LinkedHashMap;
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(IllegalArgumentException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public Object handleBadRequest(IllegalArgumentException e) {
return body(HttpStatus.BAD_REQUEST.value(), e.getMessage());
}
@ExceptionHandler(IllegalStateException.class)
@ResponseStatus(HttpStatus.FORBIDDEN)
public Object handleForbidden(IllegalStateException e) {
return body(HttpStatus.FORBIDDEN.value(), e.getMessage());
}
@ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public Object handleOther(Exception e) {
return body(HttpStatus.INTERNAL_SERVER_ERROR.value(), "服务器内部错误");
}
private Object body(int code, String message) {
return new LinkedHashMap<>() {{
put("code", code);
put("message", message);
put("timestamp", Instant.now().toString());
}};
}
}

View File

@@ -0,0 +1,17 @@
package com.gameplatform.server.mapper;
import com.gameplatform.server.model.User;
import org.apache.ibatis.annotations.Param;
import java.util.List;
public interface UserMapper {
User findById(@Param("id") Long id);
List<User> findAll();
int insert(User user);
int deleteById(@Param("id") Long id);
}

View File

@@ -0,0 +1,9 @@
package com.gameplatform.server.mapper.admin;
import com.gameplatform.server.model.entity.admin.AdminUser;
import org.apache.ibatis.annotations.Param;
public interface AdminUserMapper {
AdminUser findByUsername(@Param("username") String username);
}

View File

@@ -0,0 +1,9 @@
package com.gameplatform.server.mapper.agent;
import com.gameplatform.server.model.entity.agent.Agent;
import org.apache.ibatis.annotations.Param;
public interface AgentMapper {
Agent findByLoginAccount(@Param("loginAccount") String loginAccount);
}

View File

@@ -0,0 +1,51 @@
package com.gameplatform.server.model;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import java.time.LocalDateTime;
public class User {
private Long id;
@NotBlank
private String username;
@Email
private String email;
private LocalDateTime createdAt;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
public String getEmail() {
return email;
}
public void setEmail(String email) {
this.email = email;
}
public LocalDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(LocalDateTime createdAt) {
this.createdAt = createdAt;
}
}

View File

@@ -0,0 +1,21 @@
package com.gameplatform.server.model.dto.auth;
import jakarta.validation.constraints.NotBlank;
public class LoginRequest {
// userType: admin | agent
@NotBlank
private String userType;
@NotBlank
private String username; // admin: username, agent: loginAccount
@NotBlank
private String password;
public String getUserType() { return userType; }
public void setUserType(String userType) { this.userType = userType; }
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; }
}

View File

@@ -0,0 +1,24 @@
package com.gameplatform.server.model.dto.auth;
public class LoginResponse {
private String tokenType = "Bearer";
private String accessToken;
private long expiresIn; // seconds
private String userType; // admin | agent
private Long userId;
private String username;
public String getTokenType() { return tokenType; }
public void setTokenType(String tokenType) { this.tokenType = tokenType; }
public String getAccessToken() { return accessToken; }
public void setAccessToken(String accessToken) { this.accessToken = accessToken; }
public long getExpiresIn() { return expiresIn; }
public void setExpiresIn(long expiresIn) { this.expiresIn = expiresIn; }
public String getUserType() { return userType; }
public void setUserType(String userType) { this.userType = userType; }
public Long getUserId() { return userId; }
public void setUserId(Long userId) { this.userId = userId; }
public String getUsername() { return username; }
public void setUsername(String username) { this.username = username; }
}

View File

@@ -0,0 +1,29 @@
package com.gameplatform.server.model.entity.admin;
import java.time.LocalDateTime;
public class AdminUser {
private Long id;
private String username;
private String passwordHash;
private String role; // SUPER / ADMIN
private String status; // ENABLED / DISABLED
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getUsername() { return username; }
public void setUsername(String username) { this.username = username; }
public String getPasswordHash() { return passwordHash; }
public void setPasswordHash(String passwordHash) { this.passwordHash = passwordHash; }
public String getRole() { return role; }
public void setRole(String role) { this.role = role; }
public String getStatus() { return status; }
public void setStatus(String status) { this.status = status; }
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; }
}

View File

@@ -0,0 +1,32 @@
package com.gameplatform.server.model.entity.agent;
import java.time.LocalDateTime;
public class Agent {
private Long id;
private String name;
private String loginAccount;
private String passwordHash;
private String status; // ENABLED / DISABLED
private Long pointsBalance;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getLoginAccount() { return loginAccount; }
public void setLoginAccount(String loginAccount) { this.loginAccount = loginAccount; }
public String getPasswordHash() { return passwordHash; }
public void setPasswordHash(String passwordHash) { this.passwordHash = passwordHash; }
public String getStatus() { return status; }
public void setStatus(String status) { this.status = status; }
public Long getPointsBalance() { return pointsBalance; }
public void setPointsBalance(Long pointsBalance) { this.pointsBalance = pointsBalance; }
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; }
}

View File

@@ -0,0 +1,49 @@
package com.gameplatform.server.security;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import javax.crypto.SecretKey;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Date;
import java.util.Map;
@Component
public class JwtService {
private final SecretKey key;
private final long accessTokenMinutes;
public JwtService(@Value("${security.jwt.secret}") String secret,
@Value("${security.jwt.access-token-minutes:30}") long accessTokenMinutes) {
// accept raw text secret; if base64 provided, still works with Decoders
byte[] bytes = secret.length() < 32 ? (secret + "_pad_to_32_chars_secret_key_value").getBytes() : secret.getBytes();
this.key = Keys.hmacShaKeyFor(bytes);
this.accessTokenMinutes = accessTokenMinutes;
}
public String generateToken(String subject, String userType, Long userId, String username, Map<String, Object> extra) {
Instant now = Instant.now();
var builder = Jwts.builder()
.setSubject(subject)
.setIssuedAt(Date.from(now))
.setExpiration(Date.from(now.plus(accessTokenMinutes, ChronoUnit.MINUTES)))
.claim("userType", userType)
.claim("userId", userId)
.claim("username", username);
if (extra != null) {
extra.forEach(builder::claim);
}
return builder.signWith(key, SignatureAlgorithm.HS256).compact();
}
public io.jsonwebtoken.Claims parse(String token) {
return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody();
}
}

View File

@@ -0,0 +1,37 @@
package com.gameplatform.server.security;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity;
import org.springframework.security.config.web.server.ServerHttpSecurity;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.server.SecurityWebFilterChain;
@Configuration
@EnableWebFluxSecurity
public class SecurityConfig {
@Bean
public SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) {
return http
.csrf(ServerHttpSecurity.CsrfSpec::disable)
.cors(ServerHttpSecurity.CorsSpec::disable)
.httpBasic(ServerHttpSecurity.HttpBasicSpec::disable)
.formLogin(ServerHttpSecurity.FormLoginSpec::disable)
.authorizeExchange(ex -> ex
.pathMatchers("/actuator/**").permitAll()
.pathMatchers(HttpMethod.POST, "/api/auth/login").permitAll()
.pathMatchers(HttpMethod.GET, "/api/auth/me").permitAll()
.anyExchange().permitAll() // 其他接口后续再收紧
)
.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}

View File

@@ -0,0 +1,45 @@
package com.gameplatform.server.service;
import com.gameplatform.server.mapper.UserMapper;
import com.gameplatform.server.model.User;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;
import java.util.Objects;
@Service
public class UserService {
private final UserMapper userMapper;
public UserService(UserMapper userMapper) {
this.userMapper = userMapper;
}
public Mono<User> getById(Long id) {
return Mono.fromCallable(() -> userMapper.findById(id))
.subscribeOn(Schedulers.boundedElastic())
.filter(Objects::nonNull);
}
public Flux<User> listAll() {
return Mono.fromCallable(userMapper::findAll)
.subscribeOn(Schedulers.boundedElastic())
.flatMapMany(Flux::fromIterable);
}
public Mono<User> create(User user) {
return Mono.fromCallable(() -> {
userMapper.insert(user);
return user;
})
.subscribeOn(Schedulers.boundedElastic());
}
public Mono<Boolean> deleteById(Long id) {
return Mono.fromCallable(() -> userMapper.deleteById(id) > 0)
.subscribeOn(Schedulers.boundedElastic());
}
}

View File

@@ -0,0 +1,93 @@
package com.gameplatform.server.service.auth;
import com.gameplatform.server.mapper.admin.AdminUserMapper;
import com.gameplatform.server.mapper.agent.AgentMapper;
import com.gameplatform.server.model.dto.auth.LoginRequest;
import com.gameplatform.server.model.dto.auth.LoginResponse;
import com.gameplatform.server.model.entity.admin.AdminUser;
import com.gameplatform.server.model.entity.agent.Agent;
import com.gameplatform.server.security.JwtService;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;
import java.util.Map;
@Service
public class AuthService {
private final AdminUserMapper adminUserMapper;
private final AgentMapper agentMapper;
private final PasswordEncoder passwordEncoder;
private final JwtService jwtService;
public AuthService(AdminUserMapper adminUserMapper,
AgentMapper agentMapper,
PasswordEncoder passwordEncoder,
JwtService jwtService) {
this.adminUserMapper = adminUserMapper;
this.agentMapper = agentMapper;
this.passwordEncoder = passwordEncoder;
this.jwtService = jwtService;
}
public Mono<LoginResponse> login(LoginRequest req) {
String userType = req.getUserType().toLowerCase();
if ("admin".equals(userType)) {
return Mono.fromCallable(() -> adminUserMapper.findByUsername(req.getUsername()))
.subscribeOn(Schedulers.boundedElastic())
.flatMap(admin -> validateAdminPassword(admin, req.getPassword()));
} else if ("agent".equals(userType)) {
return Mono.fromCallable(() -> agentMapper.findByLoginAccount(req.getUsername()))
.subscribeOn(Schedulers.boundedElastic())
.flatMap(agent -> validateAgentPassword(agent, req.getPassword()));
} else {
return Mono.error(new IllegalArgumentException("unsupported userType: " + userType));
}
}
private Mono<LoginResponse> validateAdminPassword(AdminUser admin, String rawPassword) {
if (admin == null || admin.getPasswordHash() == null) {
return Mono.error(new IllegalArgumentException("用户名或密码错误"));
}
boolean ok = passwordEncoder.matches(rawPassword, admin.getPasswordHash());
if (!ok) return Mono.error(new IllegalArgumentException("用户名或密码错误"));
if (!"ENABLED".equalsIgnoreCase(admin.getStatus())) {
return Mono.error(new IllegalStateException("账户已禁用"));
}
String token = jwtService.generateToken(
"admin:" + admin.getId(),
"admin", admin.getId(), admin.getUsername(), Map.of("role", admin.getRole())
);
LoginResponse resp = new LoginResponse();
resp.setAccessToken(token);
resp.setUserType("admin");
resp.setUserId(admin.getId());
resp.setUsername(admin.getUsername());
resp.setExpiresIn(60L * 30); // align with default 30min
return Mono.just(resp);
}
private Mono<LoginResponse> validateAgentPassword(Agent agent, String rawPassword) {
if (agent == null || agent.getPasswordHash() == null) {
return Mono.error(new IllegalArgumentException("用户名或密码错误"));
}
boolean ok = passwordEncoder.matches(rawPassword, agent.getPasswordHash());
if (!ok) return Mono.error(new IllegalArgumentException("用户名或密码错误"));
if (!"ENABLED".equalsIgnoreCase(agent.getStatus())) {
return Mono.error(new IllegalStateException("账户已禁用"));
}
String token = jwtService.generateToken(
"agent:" + agent.getId(),
"agent", agent.getId(), agent.getLoginAccount(), Map.of("name", agent.getName())
);
LoginResponse resp = new LoginResponse();
resp.setAccessToken(token);
resp.setUserType("agent");
resp.setUserId(agent.getId());
resp.setUsername(agent.getLoginAccount());
resp.setExpiresIn(60L * 30);
return Mono.just(resp);
}
}

View File

@@ -0,0 +1,39 @@
spring:
application:
name: gameplatform-server
datasource:
url: jdbc:mysql://localhost:3306/login_task_db?useSSL=false&serverTimezone=UTC&characterEncoding=utf8&allowPublicKeyRetrieval=true
username: root
password: root
driver-class-name: com.mysql.cj.jdbc.Driver
hikari:
maximum-pool-size: 10
minimum-idle: 2
connection-timeout: 30000
mybatis:
mapper-locations: classpath:mapper/*.xml
type-aliases-package: com.gameplatform.server.model
configuration:
map-underscore-to-camel-case: true
server:
port: 18080
management:
endpoints:
web:
exposure:
include: health,info
logging:
level:
root: info
com.gameplatform.server: debug
security:
jwt:
secret: "change-this-secret-to-a-long-random-string-please"
access-token-minutes: 30
refresh-token-days: 7

View File

@@ -0,0 +1,40 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.gameplatform.server.mapper.UserMapper">
<resultMap id="UserResultMap" type="com.gameplatform.server.model.User">
<id property="id" column="id" />
<result property="username" column="username" />
<result property="email" column="email" />
<result property="createdAt" column="created_at" />
</resultMap>
<sql id="Base_Column_List">
id, username, email, created_at
</sql>
<select id="findById" parameterType="long" resultMap="UserResultMap">
SELECT <include refid="Base_Column_List"/>
FROM users
WHERE id = #{id}
</select>
<select id="findAll" resultMap="UserResultMap">
SELECT <include refid="Base_Column_List"/>
FROM users
ORDER BY id DESC
</select>
<insert id="insert" parameterType="com.gameplatform.server.model.User" useGeneratedKeys="true" keyProperty="id">
INSERT INTO users (username, email, created_at)
VALUES (#{username}, #{email}, NOW())
</insert>
<delete id="deleteById" parameterType="long">
DELETE FROM users WHERE id = #{id}
</delete>
</mapper>

View File

@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.gameplatform.server.mapper.admin.AdminUserMapper">
<resultMap id="AdminUserMap" type="com.gameplatform.server.model.entity.admin.AdminUser">
<id property="id" column="id" />
<result property="username" column="username" />
<result property="passwordHash" column="password_hash" />
<result property="role" column="role" />
<result property="status" column="status" />
<result property="createdAt" column="created_at" />
<result property="updatedAt" column="updated_at" />
</resultMap>
<select id="findByUsername" parameterType="string" resultMap="AdminUserMap">
SELECT id, username, password_hash, role, status, created_at, updated_at
FROM admin_user
WHERE username = #{username}
LIMIT 1
</select>
</mapper>

View File

@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.gameplatform.server.mapper.agent.AgentMapper">
<resultMap id="AgentMap" type="com.gameplatform.server.model.entity.agent.Agent">
<id property="id" column="id" />
<result property="name" column="name" />
<result property="loginAccount" column="login_account" />
<result property="passwordHash" column="password_hash" />
<result property="status" column="status" />
<result property="pointsBalance" column="points_balance" />
<result property="createdAt" column="created_at" />
<result property="updatedAt" column="updated_at" />
</resultMap>
<select id="findByLoginAccount" parameterType="string" resultMap="AgentMap">
SELECT id, name, login_account, password_hash, status, points_balance, created_at, updated_at
FROM agent
WHERE login_account = #{loginAccount}
LIMIT 1
</select>
</mapper>

View File

@@ -0,0 +1,8 @@
-- Initial database schema for game_platform
CREATE TABLE IF NOT EXISTS users (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
username VARCHAR(50) NOT NULL,
email VARCHAR(120) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);