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

8
.idea/.gitignore generated vendored Normal file
View File

@@ -0,0 +1,8 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

18
.idea/compiler.xml generated Normal file
View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<annotationProcessing>
<profile name="Maven default annotation processors profile" enabled="true">
<sourceOutputDir name="target/generated-sources/annotations" />
<sourceTestOutputDir name="target/generated-test-sources/test-annotations" />
<outputRelativeToContentRoot value="true" />
<module name="server" />
</profile>
</annotationProcessing>
</component>
<component name="JavacSettings">
<option name="ADDITIONAL_OPTIONS_OVERRIDE">
<module name="server" options="-parameters -parameters" />
</option>
</component>
</project>

6
.idea/encodings.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Encoding">
<file url="file://$PROJECT_DIR$/src/main/java" charset="UTF-8" />
</component>
</project>

20
.idea/jarRepositories.xml generated Normal file
View File

@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="RemoteRepositoriesConfiguration">
<remote-repository>
<option name="id" value="central" />
<option name="name" value="Central Repository" />
<option name="url" value="https://repo.maven.apache.org/maven2" />
</remote-repository>
<remote-repository>
<option name="id" value="central" />
<option name="name" value="Maven Central repository" />
<option name="url" value="https://repo1.maven.org/maven2" />
</remote-repository>
<remote-repository>
<option name="id" value="jboss.community" />
<option name="name" value="JBoss Community repository" />
<option name="url" value="https://repository.jboss.org/nexus/content/repositories/public/" />
</remote-repository>
</component>
</project>

14
.idea/misc.xml generated Normal file
View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="MavenProjectsManager">
<option name="originalFiles">
<list>
<option value="$PROJECT_DIR$/pom.xml" />
</list>
</option>
</component>
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="graalvm-jdk-21" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/out" />
</component>
</project>

6
.idea/vcs.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>

62
README.md Normal file
View File

@@ -0,0 +1,62 @@
# Game Platform Server (Spring Boot WebFlux + MyBatis + MySQL)
A minimal backend scaffold using Spring Boot 3, WebFlux, MyBatis, and MySQL. Includes a sample `User` CRUD implemented via MyBatis (blocking JDBC) safely wrapped in reactive APIs.
## Tech Stack
- Spring Boot 3 (WebFlux, Actuator)
- MyBatis Spring Boot Starter (JDBC)
- MySQL Connector/J
- Java 17
## Blocking JDBC with WebFlux
MyBatis uses JDBC which is blocking. To keep WebFlux event loop non-blocking, all data access is offloaded to `Schedulers.boundedElastic()` (see `UserService`). This is a common and safe pattern when you need WebFlux endpoints but must use JDBC/MyBatis.
## Project Layout
- `pom.xml` Maven build, dependencies
- `src/main/resources/application.yml` datasource + mybatis config
- `src/main/resources/mapper/*.xml` MyBatis mappers (XML)
- `src/main/resources/schema.sql` initial table
- `src/main/java/com/gameplatform/server` application code
## Configure Database
1. Create database:
```sql
CREATE DATABASE game_platform CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
```
2. Update `spring.datasource.*` in `application.yml` with your MySQL credentials.
3. Run the SQL init:
```sql
USE game_platform;
-- then execute contents of src/main/resources/schema.sql
```
## Build & Run
Requires JDK 17+ and Maven 3.9+.
```bash
mvn spring-boot:run
```
The app starts on `http://localhost:8080`.
## Endpoints
- `GET /actuator/health` health check
- `GET /api/users` list users
- `GET /api/users/{id}` get user by id
- `POST /api/users` create user
- body:
```json
{"username": "alice", "email": "alice@example.com"}
```
- `DELETE /api/users/{id}` delete user
## Notes
- If you need true end-to-end reactive IO, consider R2DBC instead of JDBC/MyBatis. Here we keep MyBatis for mapping convenience and use bounded elastic threads to avoid blocking the event loop.

174
docs/game.sql Normal file
View File

@@ -0,0 +1,174 @@
-- =============================================================================
-- 上号系统 - 数据库结构 (MySQL 8+)
-- =============================================================================
-- 可选:创建并使用独立库
CREATE DATABASE IF NOT EXISTS login_task_db
DEFAULT CHARACTER SET utf8mb4
DEFAULT COLLATE utf8mb4_0900_ai_ci;
USE login_task_db;
SET NAMES utf8mb4;
SET sql_mode = 'STRICT_ALL_TABLES,NO_ZERO_DATE,NO_ZERO_IN_DATE,ERROR_FOR_DIVISION_BY_ZERO';
-- -----------------------------------------------------------------------------
-- 1) 管理员(平台侧)
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS admin_user (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
username VARCHAR(64) NOT NULL UNIQUE,
password_hash VARBINARY(100) NOT NULL, -- 建议存储 bcrypt/argon2 等
role ENUM('SUPER','ADMIN') NOT NULL DEFAULT 'ADMIN',
status ENUM('ENABLED','DISABLED') NOT NULL DEFAULT 'ENABLED',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- -----------------------------------------------------------------------------
-- 2) 代理商(商家)
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS agent (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(100) NOT NULL,
login_account VARCHAR(64) NOT NULL UNIQUE,
password_hash VARBINARY(100) NOT NULL,
status ENUM('ENABLED','DISABLED') NOT NULL DEFAULT 'ENABLED',
points_balance BIGINT UNSIGNED NOT NULL DEFAULT 0, -- 当前可用点数
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
CONSTRAINT chk_agent_points_nonneg CHECK (points_balance >= 0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- -----------------------------------------------------------------------------
-- 3) 代理商点数流水
-- 记录加点/扣点(生成链接时扣 Times×Quantity×BatchSize
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS agent_points_tx (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
agent_id BIGINT UNSIGNED NOT NULL,
type ENUM('ADD','DEDUCT') NOT NULL,
before_points BIGINT UNSIGNED NOT NULL,
delta_points BIGINT SIGNED NOT NULL, -- 可为负/正;与 type 对应
after_points BIGINT UNSIGNED NOT NULL,
reason ENUM('create_links','manual','refund_no_rollback','other') NOT NULL DEFAULT 'other',
ref_id BIGINT UNSIGNED NULL, -- 可关联到 link_batch.id
operator_id BIGINT UNSIGNED NULL, -- 操作者(管理员)
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
INDEX idx_agent_points_tx_agent_time (agent_id, created_at),
CONSTRAINT fk_apx_agent FOREIGN KEY (agent_id) REFERENCES agent(id),
CONSTRAINT fk_apx_operator FOREIGN KEY (operator_id) REFERENCES admin_user(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- -----------------------------------------------------------------------------
-- 4) 链接批次(一次生成 N 个链接,按统一设置扣费)
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS link_batch (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
agent_id BIGINT UNSIGNED NOT NULL,
quantity INT UNSIGNED NOT NULL, -- 每次奖励数量(如 50
times INT UNSIGNED NOT NULL, -- 重复执行次数(如 20
batch_size INT UNSIGNED NOT NULL, -- 本批生成链接数量(如 10
deduct_points BIGINT UNSIGNED NOT NULL, -- 扣点=quantity*times*batch_size
operator_id BIGINT UNSIGNED NULL, -- 操作者(管理员或代理自己为空)
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
INDEX idx_lb_agent_time (agent_id, created_at),
CONSTRAINT chk_lb_quantity_pos CHECK (quantity > 0),
CONSTRAINT chk_lb_times_pos CHECK (times > 0),
CONSTRAINT chk_lb_batch_pos CHECK (batch_size > 0),
CONSTRAINT fk_lb_agent FOREIGN KEY (agent_id) REFERENCES agent(id),
CONSTRAINT fk_lb_operator FOREIGN KEY (operator_id) REFERENCES admin_user(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- -----------------------------------------------------------------------------
-- 5) 单链接任务(用户访问的“加密链接”对应的实体)
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS link_task (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
batch_id BIGINT UNSIGNED NOT NULL,
agent_id BIGINT UNSIGNED NOT NULL,
code_no VARCHAR(32) NOT NULL, -- 后端生成的全局唯一编号
token_hash CHAR(64) NOT NULL, -- 加密token的SHA-256十六进制用于失效/撤销)
expire_at DATETIME(3) NOT NULL, -- 链接有效期(默认 24h
status ENUM('NEW','USING','LOGGED_IN','REFUNDED','EXPIRED') NOT NULL DEFAULT 'NEW',
region ENUM('Q','V') NULL, -- 选区;未选择前为 NULL
machine_id VARCHAR(64) NULL, -- 绑定的脚本端机器编号
login_at DATETIME(3) NULL,
refund_at DATETIME(3) NULL,
revoked_at DATETIME(3) NULL, -- 主动撤销(如需要立即失效)
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
UNIQUE KEY uk_code_no (code_no),
UNIQUE KEY uk_token_hash (token_hash),
INDEX idx_agent_status (agent_id, status),
INDEX idx_expire_at (expire_at),
INDEX idx_created_at (created_at),
CONSTRAINT fk_lt_batch FOREIGN KEY (batch_id) REFERENCES link_batch(id),
CONSTRAINT fk_lt_agent FOREIGN KEY (agent_id) REFERENCES agent(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- -----------------------------------------------------------------------------
-- 6) 操作日志(审计/可观测性)
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS operation_log (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
actor_type ENUM('admin','agent','system','user') NOT NULL,
actor_id BIGINT UNSIGNED NULL, -- 不强制外键,避免多态复杂度
code_no VARCHAR(32) NULL,
op VARCHAR(64) NOT NULL, -- 如create_links / refund / select_region / create_qr / poll_login / release_machine
detail JSON NULL, -- 具体参数/返回(注意脱敏)
client_ip VARCHAR(45) NULL,
user_agent VARCHAR(255) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
INDEX idx_log_code_time (code_no, created_at),
INDEX idx_log_time (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- -----------------------------------------------------------------------------
-- 7) 公告
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS announcement (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
title VARCHAR(100) NOT NULL,
content TEXT NOT NULL, -- 简单文本
enabled TINYINT(1) NOT NULL DEFAULT 1,
jump_url VARCHAR(255) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
INDEX idx_announce_enabled (enabled)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- -----------------------------------------------------------------------------
-- 8) 平台级配置(商家不可配)
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS sys_config (
cfg_key VARCHAR(64) PRIMARY KEY,
cfg_value TEXT NOT NULL,
description VARCHAR(255) NULL,
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 预置关键配置(可按需改值)
INSERT INTO sys_config (cfg_key, cfg_value, description) VALUES
('LINK_EXPIRE_HOURS', '24', '链接有效期(小时)'),
('QR_EXPIRE_SECONDS', '60', '二维码过期秒数脚本端未返回TTL时使用'),
('REFRESH_WAIT_SECONDS', '10', '刷新后强制等待秒数(商家不可配置)'),
('MACHINE_COOLDOWN_MINUTES','10', '同一机器复用冷却时长(分钟)'),
('DEFAULT_BATCH_SIZE', '10', '批量生成默认数量'),
('SECOND_SCREEN_URL_TEMPLATE','https://你的域名/{codeNo}','二界面URL模板包含编号占位')
ON DUPLICATE KEY UPDATE
cfg_value = VALUES(cfg_value),
description = VALUES(description);
-- -----------------------------------------------------------------------------
-- 推荐的视图 / 物化统计可按需追加(此处略)
-- -----------------------------------------------------------------------------
-- =============================================================================
-- 说明:
-- 1) 生成链接时,请在业务层完成:
-- - 计算扣点 = quantity * times * batch_size
-- - 扣减 agent.points_balance并写入 agent_points_tx
-- - 写入 link_batch 及其下的若干 link_task生成 code_no / token 及 token_hash、expire_at
-- 2) 用户端所有页面均以“加密链接 token”为入口通过 token_hash 可实现失效/撤销。
-- 3) operation_log.detail 建议仅存必要字段并脱敏;日志保留 90 天可通过定时归档或分区处理。
-- =============================================================================

21
docs/需求文档.md Normal file
View File

@@ -0,0 +1,21 @@
明白✅
1. 二界面 URL 模板
我已把配置改成:**`域名/{codeNo}`**(文档已更新为 `https://你的域名/{codeNo}`,按运行环境替换域名即可)。
2. 关于 `http://36.138.184.60:12345/编号/二维码.png`
这个是“**脚本端**”暴露出来的**静态图片**地址——**正常情况下会直接返回 PNG 图片**。如果你在页面里看不到图,常见原因有:
* **混合内容被拦截**:你的前端如果是 **HTTPS**,而这张图是 **HTTP**,浏览器会拦截(安全策略)。
解决:给 `36.138.184.60:12345`**HTTPS**,或让后端提供一个 **HTTPS 代理端点**(例如:`GET /api/link/{token}/qr.png`)去转发这张图片,前端只请求自己的 HTTPS 域名即可。(我已在文档里加了这条注意事项)
* **二维码尚未生成 / 已过期**:脚本端还没把 `二维码.png` 写到对应 `编号/` 目录,或已被轮转清理 → 会 404 或显示旧图。
解决:确保先触发“生成二维码”动作,并用返回的 `createdAt/expireAt` 做本地倒计时;必要时加 `?t=时间戳` 防缓存。
* **编号不匹配**:前端用的 `编号` 与实际分配的机器编号不同。
解决:以后端返回的 `machineId/编号` 为准。
* **端口/防火墙**12345 端口未对外放通或临时不可达。
解决:主机/网关放行该端口并做健康检查。
* **跨域非问题**`<img>` 加载图片不受 CORS 限制,但若被混合内容拦截,同样会显示不出。
如果你愿意最省心的做法:我们在后端新增一个**图片代理接口**HTTPS前端统一拿这个接口的 URL当脚本端换 IP/端口或做 HTTPS这边都不用改前端。
需要的话我可以顺手把这个代理端点的 Spring Boot 代码骨架也给你(带缓存/超时/错误降级)。

122
pom.xml Normal file
View File

@@ -0,0 +1,122 @@
<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 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.3</version>
<relativePath/>
</parent>
<groupId>com.gameplatform</groupId>
<artifactId>server</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>gameplatform-server</name>
<description>Spring Boot WebFlux + MyBatis + MySQL backend</description>
<properties>
<java.version>17</java.version>
<mybatis.spring.boot.version>3.0.3</mybatis.spring.boot.version>
</properties>
<dependencies>
<!-- Reactive web stack -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
<!-- Security for authentication/JWT protection -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- MyBatis starter (JDBC, blocking) -->
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>${mybatis.spring.boot.version}</version>
</dependency>
<!-- MySQL JDBC driver -->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<!-- Validation for request DTOs -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<!-- Actuator (health/info) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<!-- JWT (JJWT) -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.11.5</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
<!-- Lombok (optional) -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
<exclusions>
<exclusion>
<groupId>org.junit.vintage</groupId>
<artifactId>junit-vintage-engine</artifactId>
</exclusion>
</exclusions>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.11.0</version>
<configuration>
<source>${java.version}</source>
<target>${java.version}</target>
<encoding>UTF-8</encoding>
<compilerArgs>
<arg>-parameters</arg>
</compilerArgs>
</configuration>
</plugin>
</plugins>
</build>
</project>

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

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