Compare commits
3 Commits
98e94699ae
...
5026c9b39c
| Author | SHA1 | Date | |
|---|---|---|---|
| 5026c9b39c | |||
| 05ce76ad7a | |||
| fe1ffd73b2 |
576
docs/openapi.json
Normal file
576
docs/openapi.json
Normal file
@@ -0,0 +1,576 @@
|
|||||||
|
{
|
||||||
|
"openapi": "3.0.3",
|
||||||
|
"info": {
|
||||||
|
"title": "WebGame 后端接口文档",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "WebGame 游戏后端 REST API 接口文档。认证采用双 Token 机制:Access Token(30 分钟有效,每次请求携带) + Refresh Token(30 天有效,仅用于刷新 Access Token)。"
|
||||||
|
},
|
||||||
|
"servers": [
|
||||||
|
{
|
||||||
|
"url": "http://localhost:8080",
|
||||||
|
"description": "本地开发环境"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
{ "name": "账号", "description": "账号相关接口(登录、注册、刷新令牌,无需认证)" },
|
||||||
|
{ "name": "用户", "description": "用户相关接口(需 Access Token 认证)" }
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"/account/login": {
|
||||||
|
"post": {
|
||||||
|
"tags": ["账号"],
|
||||||
|
"summary": "用户登录",
|
||||||
|
"description": "使用用户名和密码进行登录认证,成功返回 Access Token 和 Refresh Token。该接口无需登录即可调用。",
|
||||||
|
"operationId": "login",
|
||||||
|
"security": [],
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": { "$ref": "#/components/schemas/LoginRequest" },
|
||||||
|
"example": {
|
||||||
|
"username": "testuser",
|
||||||
|
"password": "123456"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "登录成功,返回双 Token",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"allOf": [
|
||||||
|
{ "$ref": "#/components/schemas/ApiResponse" },
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"data": { "$ref": "#/components/schemas/LoginResponse" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"example": {
|
||||||
|
"code": 0,
|
||||||
|
"message": "success",
|
||||||
|
"data": {
|
||||||
|
"accessToken": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
|
||||||
|
"refreshToken": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": { "$ref": "#/components/responses/BadRequest" },
|
||||||
|
"500": { "$ref": "#/components/responses/InternalError" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/account/register": {
|
||||||
|
"post": {
|
||||||
|
"tags": ["账号"],
|
||||||
|
"summary": "用户注册",
|
||||||
|
"description": "注册新账号。用户名长度 3-32 位,密码长度 6-64 位。注册成功后自动登录并返回双 Token。该接口无需登录即可调用。",
|
||||||
|
"operationId": "register",
|
||||||
|
"security": [],
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": { "$ref": "#/components/schemas/RegisterRequest" },
|
||||||
|
"example": {
|
||||||
|
"username": "newuser",
|
||||||
|
"password": "abc123"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "注册成功,自动登录返回双 Token",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"allOf": [
|
||||||
|
{ "$ref": "#/components/schemas/ApiResponse" },
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"data": { "$ref": "#/components/schemas/LoginResponse" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"example": {
|
||||||
|
"code": 0,
|
||||||
|
"message": "success",
|
||||||
|
"data": {
|
||||||
|
"accessToken": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
|
||||||
|
"refreshToken": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": { "$ref": "#/components/responses/BadRequest" },
|
||||||
|
"500": { "$ref": "#/components/responses/InternalError" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/account/refresh": {
|
||||||
|
"post": {
|
||||||
|
"tags": ["账号"],
|
||||||
|
"summary": "刷新 Access Token",
|
||||||
|
"description": "当 Access Token 过期(30 分钟)后,使用 Refresh Token 换取新的 Access Token。Refresh Token 保持不变,仅在满 30 天或重新登录时更新。该接口无需登录即可调用。",
|
||||||
|
"operationId": "refresh",
|
||||||
|
"security": [],
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": { "$ref": "#/components/schemas/RefreshTokenRequest" },
|
||||||
|
"example": {
|
||||||
|
"refreshToken": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "刷新成功,返回新的 Access Token 和原 Refresh Token",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"allOf": [
|
||||||
|
{ "$ref": "#/components/schemas/ApiResponse" },
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"data": { "$ref": "#/components/schemas/LoginResponse" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"example": {
|
||||||
|
"code": 0,
|
||||||
|
"message": "success",
|
||||||
|
"data": {
|
||||||
|
"accessToken": "yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy",
|
||||||
|
"refreshToken": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": { "$ref": "#/components/responses/BadRequest" },
|
||||||
|
"500": { "$ref": "#/components/responses/InternalError" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/user/info": {
|
||||||
|
"get": {
|
||||||
|
"tags": ["用户"],
|
||||||
|
"summary": "获取当前登录用户信息",
|
||||||
|
"description": "根据请求头中的 saToken(Access Token)获取当前登录用户的详细信息,包括昵称、头像、个性签名、年龄、性别、生日。需登录认证。",
|
||||||
|
"operationId": "getUserInfo",
|
||||||
|
"security": [{ "saToken": [] }],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "查询成功,返回用户信息",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"allOf": [
|
||||||
|
{ "$ref": "#/components/schemas/ApiResponse" },
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"data": { "$ref": "#/components/schemas/UserInfoResponse" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"example": {
|
||||||
|
"code": 0,
|
||||||
|
"message": "success",
|
||||||
|
"data": {
|
||||||
|
"nickName": "玩家001",
|
||||||
|
"avatar": "https://cdn.example.com/avatars/default.png",
|
||||||
|
"signature": "这个人很懒,什么都没写",
|
||||||
|
"age": 25,
|
||||||
|
"sex": 1,
|
||||||
|
"birthday": "2001-01-01"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": { "$ref": "#/components/responses/Unauthorized" },
|
||||||
|
"500": { "$ref": "#/components/responses/InternalError" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"components": {
|
||||||
|
"securitySchemes": {
|
||||||
|
"saToken": {
|
||||||
|
"type": "apiKey",
|
||||||
|
"in": "header",
|
||||||
|
"name": "saToken",
|
||||||
|
"description": "Access Token(登录/注册/刷新接口返回的 accessToken 值)。有效期 30 分钟,过期后使用 /account/refresh 接口获取新 Token。"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"schemas": {
|
||||||
|
"ApiResponse": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "统一 API 响应信封。所有接口均使用此格式返回。",
|
||||||
|
"required": ["code", "message", "data"],
|
||||||
|
"properties": {
|
||||||
|
"code": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32",
|
||||||
|
"description": "业务状态码。0 表示成功,非 0 表示失败。详细错误码见 ErrorCode 枚举。"
|
||||||
|
},
|
||||||
|
"message": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "响应消息。成功时固定为 \"success\",失败时为具体错误描述。"
|
||||||
|
},
|
||||||
|
"data": {
|
||||||
|
"nullable": true,
|
||||||
|
"description": "响应数据负载。成功时携带对应的业务数据对象,失败时为 null。"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"LoginRequest": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "登录请求体",
|
||||||
|
"required": ["username", "password"],
|
||||||
|
"properties": {
|
||||||
|
"username": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "用户名,不能为空",
|
||||||
|
"minLength": 1,
|
||||||
|
"example": "testuser"
|
||||||
|
},
|
||||||
|
"password": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "password",
|
||||||
|
"description": "密码,不能为空",
|
||||||
|
"minLength": 1,
|
||||||
|
"example": "123456"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"RegisterRequest": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "注册请求体",
|
||||||
|
"required": ["username", "password"],
|
||||||
|
"properties": {
|
||||||
|
"username": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "用户名,长度限制 3-32 位,不能为空",
|
||||||
|
"minLength": 3,
|
||||||
|
"maxLength": 32,
|
||||||
|
"example": "newuser"
|
||||||
|
},
|
||||||
|
"password": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "password",
|
||||||
|
"description": "密码,长度限制 6-64 位,不能为空",
|
||||||
|
"minLength": 6,
|
||||||
|
"maxLength": 64,
|
||||||
|
"example": "abc123"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"RefreshTokenRequest": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "刷新令牌请求体",
|
||||||
|
"required": ["refreshToken"],
|
||||||
|
"properties": {
|
||||||
|
"refreshToken": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "刷新令牌,登录/注册时返回的 refreshToken 值,不能为空",
|
||||||
|
"minLength": 1,
|
||||||
|
"example": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"LoginResponse": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "登录、注册或刷新令牌成功时返回的认证令牌对",
|
||||||
|
"required": ["accessToken", "refreshToken"],
|
||||||
|
"properties": {
|
||||||
|
"accessToken": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Access Token(UUID 格式),有效期 30 分钟。后续请求需在 Header 中通过 saToken 字段携带此值。过期后使用 /account/refresh 接口刷新。",
|
||||||
|
"example": "12345678-1234-1234-1234-123456789abc"
|
||||||
|
},
|
||||||
|
"refreshToken": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Refresh Token(64 位十六进制随机字符串),有效期 30 天。仅用于 /account/refresh 接口换取新的 Access Token。刷新时不变,重新登录时替换。",
|
||||||
|
"example": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"UserInfoResponse": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "当前登录用户的详细信息",
|
||||||
|
"properties": {
|
||||||
|
"nickName": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "用户昵称",
|
||||||
|
"maxLength": 16,
|
||||||
|
"example": "玩家001"
|
||||||
|
},
|
||||||
|
"avatar": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "头像图片 URL 地址,可能为 null",
|
||||||
|
"maxLength": 512,
|
||||||
|
"nullable": true,
|
||||||
|
"example": "https://cdn.example.com/avatars/default.png"
|
||||||
|
},
|
||||||
|
"signature": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "个性签名,可能为 null",
|
||||||
|
"maxLength": 64,
|
||||||
|
"nullable": true,
|
||||||
|
"example": "这个人很懒,什么都没写"
|
||||||
|
},
|
||||||
|
"age": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32",
|
||||||
|
"description": "年龄,可能为 null",
|
||||||
|
"nullable": true,
|
||||||
|
"example": 25
|
||||||
|
},
|
||||||
|
"sex": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32",
|
||||||
|
"description": "性别。1=男,2=女,可能为 null",
|
||||||
|
"nullable": true,
|
||||||
|
"enum": [1, 2],
|
||||||
|
"x-enum-descriptions": {
|
||||||
|
"1": "男",
|
||||||
|
"2": "女"
|
||||||
|
},
|
||||||
|
"example": 1
|
||||||
|
},
|
||||||
|
"birthday": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date",
|
||||||
|
"description": "生日(ISO 8601 日期格式),可能为 null",
|
||||||
|
"nullable": true,
|
||||||
|
"example": "2001-01-01"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ErrorResponse": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "错误响应体模板。code 为具体错误码,message 为错误描述,data 恒为 null。",
|
||||||
|
"required": ["code", "message", "data"],
|
||||||
|
"properties": {
|
||||||
|
"code": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32",
|
||||||
|
"description": "错误码,详见 ErrorCode 枚举表"
|
||||||
|
},
|
||||||
|
"message": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "人类可读的错误描述信息"
|
||||||
|
},
|
||||||
|
"data": {
|
||||||
|
"type": "null",
|
||||||
|
"description": "错误时恒为 null"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"BadRequest": {
|
||||||
|
"description": "请求参数校验失败或业务逻辑错误(HTTP 200 但 code ≠ 0)",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": { "$ref": "#/components/schemas/ErrorResponse" },
|
||||||
|
"examples": {
|
||||||
|
"validation": {
|
||||||
|
"summary": "参数校验失败",
|
||||||
|
"value": {
|
||||||
|
"code": 400,
|
||||||
|
"message": "username 用户名不能为空; password 密码长度6-64位",
|
||||||
|
"data": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"usernameExists": {
|
||||||
|
"summary": "用户名已存在",
|
||||||
|
"value": {
|
||||||
|
"code": 1001,
|
||||||
|
"message": "用户名已存在",
|
||||||
|
"data": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"badCredentials": {
|
||||||
|
"summary": "用户名或密码错误",
|
||||||
|
"value": {
|
||||||
|
"code": 1002,
|
||||||
|
"message": "用户名或密码错误",
|
||||||
|
"data": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"refreshTokenInvalid": {
|
||||||
|
"summary": "刷新令牌无效或已过期",
|
||||||
|
"value": {
|
||||||
|
"code": 1003,
|
||||||
|
"message": "刷新令牌无效或已过期",
|
||||||
|
"data": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"refreshTokenMissing": {
|
||||||
|
"summary": "缺少刷新令牌",
|
||||||
|
"value": {
|
||||||
|
"code": 1004,
|
||||||
|
"message": "缺少刷新令牌",
|
||||||
|
"data": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"userNotFound": {
|
||||||
|
"summary": "用户不存在",
|
||||||
|
"value": {
|
||||||
|
"code": 2001,
|
||||||
|
"message": "用户不存在",
|
||||||
|
"data": null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Unauthorized": {
|
||||||
|
"description": "未登录或 Access Token 无效/过期(HTTP 200 但 code=401)",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": { "$ref": "#/components/schemas/ErrorResponse" },
|
||||||
|
"example": {
|
||||||
|
"code": 401,
|
||||||
|
"message": "请先登录",
|
||||||
|
"data": null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Forbidden": {
|
||||||
|
"description": "无权限或无角色访问(HTTP 200 但 code=403)",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": { "$ref": "#/components/schemas/ErrorResponse" },
|
||||||
|
"example": {
|
||||||
|
"code": 403,
|
||||||
|
"message": "无权限",
|
||||||
|
"data": null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"NotFound": {
|
||||||
|
"description": "请求的资源不存在",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": { "$ref": "#/components/schemas/ErrorResponse" },
|
||||||
|
"example": {
|
||||||
|
"code": 404,
|
||||||
|
"message": "资源不存在",
|
||||||
|
"data": null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"MethodNotAllowed": {
|
||||||
|
"description": "请求方法不支持(如 GET 访问 POST 接口)",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": { "$ref": "#/components/schemas/ErrorResponse" },
|
||||||
|
"example": {
|
||||||
|
"code": 405,
|
||||||
|
"message": "请求方法不支持",
|
||||||
|
"data": null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"InternalError": {
|
||||||
|
"description": "服务器内部错误(HTTP 200 但 code=5000)",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": { "$ref": "#/components/schemas/ErrorResponse" },
|
||||||
|
"example": {
|
||||||
|
"code": 5000,
|
||||||
|
"message": "服务器内部错误",
|
||||||
|
"data": null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"x-enums": {
|
||||||
|
"ErrorCode": {
|
||||||
|
"description": "业务错误码枚举(com.webgame.webgamebackend.common.exception.ErrorCode)",
|
||||||
|
"values": {
|
||||||
|
"SUCCESS": {
|
||||||
|
"code": 0,
|
||||||
|
"message": "success",
|
||||||
|
"description": "操作成功"
|
||||||
|
},
|
||||||
|
"BAD_REQUEST": {
|
||||||
|
"code": 400,
|
||||||
|
"message": "参数校验失败",
|
||||||
|
"description": "请求参数不符合校验规则"
|
||||||
|
},
|
||||||
|
"USERNAME_EXISTS": {
|
||||||
|
"code": 1001,
|
||||||
|
"message": "用户名已存在",
|
||||||
|
"description": "注册时用户名已被占用"
|
||||||
|
},
|
||||||
|
"BAD_CREDENTIALS": {
|
||||||
|
"code": 1002,
|
||||||
|
"message": "用户名或密码错误",
|
||||||
|
"description": "登录时用户名或密码不匹配"
|
||||||
|
},
|
||||||
|
"REFRESH_TOKEN_INVALID": {
|
||||||
|
"code": 1003,
|
||||||
|
"message": "刷新令牌无效或已过期",
|
||||||
|
"description": "Refresh Token 不存在或已过期(30 天),需重新登录"
|
||||||
|
},
|
||||||
|
"REFRESH_TOKEN_MISSING": {
|
||||||
|
"code": 1004,
|
||||||
|
"message": "缺少刷新令牌",
|
||||||
|
"description": "请求体中的 refreshToken 字段为空"
|
||||||
|
},
|
||||||
|
"USER_NOT_FOUND": {
|
||||||
|
"code": 2001,
|
||||||
|
"message": "用户不存在",
|
||||||
|
"description": "查询的用户记录不存在"
|
||||||
|
},
|
||||||
|
"INTERNAL_ERROR": {
|
||||||
|
"code": 5000,
|
||||||
|
"message": "服务器内部错误",
|
||||||
|
"description": "未预期的服务器异常"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Sex": {
|
||||||
|
"description": "性别枚举(UserEntity.sex 字段)",
|
||||||
|
"values": {
|
||||||
|
"MALE": {
|
||||||
|
"code": 1,
|
||||||
|
"label": "男"
|
||||||
|
},
|
||||||
|
"FEMALE": {
|
||||||
|
"code": 2,
|
||||||
|
"label": "女"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
8
docs/prompt.txt
Normal file
8
docs/prompt.txt
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
--- api接口文档提示词 ---
|
||||||
|
你现在是后端开发,梳理当前项目全部业务接口,输出标准 OpenAPI 3.0 完整JSON文档,要求:
|
||||||
|
1. 包含全部GET/POST/PUT/DELETE接口;
|
||||||
|
2. 区分路径参数、查询参数、Body请求体;
|
||||||
|
3. 所有字段标注类型、是否必填、中文注释;
|
||||||
|
4. 包含成功返回体、500/401/400错误返回结构;
|
||||||
|
5. 枚举值完整列出;
|
||||||
|
只输出JSON文本,不要多余解释。
|
||||||
@@ -2,15 +2,39 @@ package com.webgame.webgamebackend.common.config;
|
|||||||
|
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
import org.springframework.data.redis.cache.RedisCacheConfiguration;
|
import org.springframework.context.annotation.Primary;
|
||||||
import org.springframework.data.redis.cache.RedisCacheManager;
|
|
||||||
import org.springframework.data.redis.connection.RedisConnectionFactory;
|
import org.springframework.data.redis.connection.RedisConnectionFactory;
|
||||||
|
import org.springframework.data.redis.core.RedisTemplate;
|
||||||
import org.springframework.data.redis.listener.RedisMessageListenerContainer;
|
import org.springframework.data.redis.listener.RedisMessageListenerContainer;
|
||||||
|
import org.springframework.data.redis.serializer.StringRedisSerializer;
|
||||||
|
|
||||||
import java.time.Duration;
|
/**
|
||||||
|
* Redis 配置类:序列化方式 + 消息监听容器
|
||||||
|
*/
|
||||||
@Configuration
|
@Configuration
|
||||||
public class RedisConfigurer {
|
public class RedisConfigurer {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 配置 RedisTemplate,使用 String 序列化器替代 Java 原生序列化
|
||||||
|
* <p>
|
||||||
|
* 不配置此项的话,Spring Boot 默认使用 JdkSerializationRedisSerializer,
|
||||||
|
* 存入 Redis 的数据会带有 Java 序列化魔数前缀(\xAC\xED\x00\x05),
|
||||||
|
* 导致人类不可读且无法被其他语言客户端读取。
|
||||||
|
*/
|
||||||
|
@Bean
|
||||||
|
@Primary
|
||||||
|
public RedisTemplate<String, String> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
|
||||||
|
RedisTemplate<String, String> template = new RedisTemplate<>();
|
||||||
|
template.setConnectionFactory(redisConnectionFactory);
|
||||||
|
StringRedisSerializer stringSerializer = new StringRedisSerializer();
|
||||||
|
template.setKeySerializer(stringSerializer);
|
||||||
|
template.setValueSerializer(stringSerializer);
|
||||||
|
template.setHashKeySerializer(stringSerializer);
|
||||||
|
template.setHashValueSerializer(stringSerializer);
|
||||||
|
template.afterPropertiesSet();
|
||||||
|
return template;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建并配置 Redis 消息监听容器
|
* 创建并配置 Redis 消息监听容器
|
||||||
* 用于实现 Redis 的发布/订阅功能,支持监听特定频道的消息
|
* 用于实现 Redis 的发布/订阅功能,支持监听特定频道的消息
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package com.webgame.webgamebackend.common.dto.account;
|
package com.webgame.webgamebackend.common.dto.account;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 登录/注册响应
|
* 登录/注册/刷新响应,包含 accessToken 和 refreshToken
|
||||||
*/
|
*/
|
||||||
public record LoginResponse(String token) {
|
public record LoginResponse(String accessToken, String refreshToken) {
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package com.webgame.webgamebackend.common.dto.account;
|
||||||
|
|
||||||
|
import com.webgame.webgamebackend.common.dto.base.BaseVo;
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 刷新令牌请求
|
||||||
|
*/
|
||||||
|
public record RefreshTokenRequest(
|
||||||
|
@NotNull(message = "刷新令牌不能为null")
|
||||||
|
@NotBlank(message = "刷新令牌不能为空")
|
||||||
|
String refreshToken
|
||||||
|
) implements BaseVo {
|
||||||
|
}
|
||||||
@@ -16,6 +16,8 @@ public enum ErrorCode {
|
|||||||
// ========== 账号模块 ==========
|
// ========== 账号模块 ==========
|
||||||
USERNAME_EXISTS(1001, "用户名已存在"),
|
USERNAME_EXISTS(1001, "用户名已存在"),
|
||||||
BAD_CREDENTIALS(1002, "用户名或密码错误"),
|
BAD_CREDENTIALS(1002, "用户名或密码错误"),
|
||||||
|
REFRESH_TOKEN_INVALID(1003, "刷新令牌无效或已过期"),
|
||||||
|
REFRESH_TOKEN_MISSING(1004, "缺少刷新令牌"),
|
||||||
|
|
||||||
// ========== 用户模块 ==========
|
// ========== 用户模块 ==========
|
||||||
USER_NOT_FOUND(2001, "用户不存在"),
|
USER_NOT_FOUND(2001, "用户不存在"),
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import cn.dev33.satoken.annotation.SaIgnore;
|
|||||||
import com.webgame.webgamebackend.common.dto.ApiResponse;
|
import com.webgame.webgamebackend.common.dto.ApiResponse;
|
||||||
import com.webgame.webgamebackend.common.dto.account.LoginRequest;
|
import com.webgame.webgamebackend.common.dto.account.LoginRequest;
|
||||||
import com.webgame.webgamebackend.common.dto.account.LoginResponse;
|
import com.webgame.webgamebackend.common.dto.account.LoginResponse;
|
||||||
|
import com.webgame.webgamebackend.common.dto.account.RefreshTokenRequest;
|
||||||
import com.webgame.webgamebackend.common.dto.account.RegisterRequest;
|
import com.webgame.webgamebackend.common.dto.account.RegisterRequest;
|
||||||
import com.webgame.webgamebackend.service.AccountService;
|
import com.webgame.webgamebackend.service.AccountService;
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
@@ -44,4 +45,14 @@ public class AccountController {
|
|||||||
LoginResponse result = accountService.register(request);
|
LoginResponse result = accountService.register(request);
|
||||||
return ApiResponse.ok(result);
|
return ApiResponse.ok(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 刷新令牌,用 refresh token 换取新的 access token 和 refresh token
|
||||||
|
*/
|
||||||
|
@SaIgnore
|
||||||
|
@PostMapping("/refresh")
|
||||||
|
public ApiResponse<LoginResponse> refresh(@Valid @RequestBody RefreshTokenRequest request) {
|
||||||
|
LoginResponse result = accountService.refresh(request.refreshToken());
|
||||||
|
return ApiResponse.ok(result);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,15 @@ public interface AccountService {
|
|||||||
* 注册
|
* 注册
|
||||||
*
|
*
|
||||||
* @param request 注册请求
|
* @param request 注册请求
|
||||||
* @return 注册结果(含 token,注册后自动登录)
|
* @return 注册结果(含双 token,注册后自动登录)
|
||||||
*/
|
*/
|
||||||
LoginResponse register(RegisterRequest request);
|
LoginResponse register(RegisterRequest request);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 刷新令牌
|
||||||
|
*
|
||||||
|
* @param refreshToken 刷新令牌
|
||||||
|
* @return 新的双 token(accessToken + refreshToken)
|
||||||
|
*/
|
||||||
|
LoginResponse refresh(String refreshToken);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package com.webgame.webgamebackend.service;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 刷新令牌服务接口,管理 refresh token 的生命周期
|
||||||
|
*/
|
||||||
|
public interface RefreshTokenService {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 为用户创建新的刷新令牌,存入 Redis,TTL 30 天
|
||||||
|
*
|
||||||
|
* @param userId 用户ID
|
||||||
|
* @return 生成的 refresh token 字符串
|
||||||
|
*/
|
||||||
|
String create(String userId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验刷新令牌是否有效
|
||||||
|
*
|
||||||
|
* @param token 刷新令牌
|
||||||
|
* @return 对应的用户ID
|
||||||
|
* @throws com.webgame.webgamebackend.common.exception.BusinessException 令牌无效时抛出
|
||||||
|
*/
|
||||||
|
String validate(String token);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 轮换刷新令牌:删除旧 token,生成新 token(防重放)
|
||||||
|
*
|
||||||
|
* @param oldToken 旧的刷新令牌
|
||||||
|
* @return 新的刷新令牌
|
||||||
|
* @throws com.webgame.webgamebackend.common.exception.BusinessException 旧令牌无效时抛出
|
||||||
|
*/
|
||||||
|
String rotate(String oldToken);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 撤销刷新令牌(登出时调用)
|
||||||
|
*
|
||||||
|
* @param token 要撤销的刷新令牌
|
||||||
|
*/
|
||||||
|
void revoke(String token);
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import com.webgame.webgamebackend.entities.UserEntity;
|
|||||||
import com.webgame.webgamebackend.repository.AccountRepository;
|
import com.webgame.webgamebackend.repository.AccountRepository;
|
||||||
import com.webgame.webgamebackend.repository.UserRepository;
|
import com.webgame.webgamebackend.repository.UserRepository;
|
||||||
import com.webgame.webgamebackend.service.AccountService;
|
import com.webgame.webgamebackend.service.AccountService;
|
||||||
|
import com.webgame.webgamebackend.service.RefreshTokenService;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||||
@@ -30,6 +31,7 @@ public class AccountServiceImpl implements AccountService {
|
|||||||
private final AccountRepository accountRepository;
|
private final AccountRepository accountRepository;
|
||||||
private final UserRepository userRepository;
|
private final UserRepository userRepository;
|
||||||
private final BCryptPasswordEncoder passwordEncoder;
|
private final BCryptPasswordEncoder passwordEncoder;
|
||||||
|
private final RefreshTokenService refreshTokenService;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public LoginResponse login(LoginRequest request) {
|
public LoginResponse login(LoginRequest request) {
|
||||||
@@ -38,11 +40,13 @@ public class AccountServiceImpl implements AccountService {
|
|||||||
if (!passwordEncoder.matches(request.password(), account.getPassword())) {
|
if (!passwordEncoder.matches(request.password(), account.getPassword())) {
|
||||||
throw new BusinessException(ErrorCode.BAD_CREDENTIALS);
|
throw new BusinessException(ErrorCode.BAD_CREDENTIALS);
|
||||||
}
|
}
|
||||||
// Sa-Token 登录
|
// Sa-Token 登录,生成 access token
|
||||||
StpUtil.login(account.getId());
|
StpUtil.login(account.getId());
|
||||||
String token = StpUtil.getTokenValue();
|
String accessToken = StpUtil.getTokenValue();
|
||||||
|
// 生成 refresh token
|
||||||
|
String refreshToken = refreshTokenService.create(account.getId());
|
||||||
log.info("用户登录成功: username={}", request.username());
|
log.info("用户登录成功: username={}", request.username());
|
||||||
return new LoginResponse(token);
|
return new LoginResponse(accessToken, refreshToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -67,10 +71,22 @@ public class AccountServiceImpl implements AccountService {
|
|||||||
userRepository.save(user);
|
userRepository.save(user);
|
||||||
|
|
||||||
log.info("用户注册成功: username={}", request.username());
|
log.info("用户注册成功: username={}", request.username());
|
||||||
// 注册后自动登录
|
// 注册后自动登录,生成 access token 和 refresh token
|
||||||
StpUtil.login(account.getId());
|
StpUtil.login(account.getId());
|
||||||
String token = StpUtil.getTokenValue();
|
String accessToken = StpUtil.getTokenValue();
|
||||||
return new LoginResponse(token);
|
String refreshToken = refreshTokenService.create(account.getId());
|
||||||
|
return new LoginResponse(accessToken, refreshToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public LoginResponse refresh(String refreshToken) {
|
||||||
|
// 校验 refresh token,有效则用它签发新的 access token
|
||||||
|
String userId = refreshTokenService.validate(refreshToken);
|
||||||
|
StpUtil.login(userId);
|
||||||
|
String accessToken = StpUtil.getTokenValue();
|
||||||
|
log.info("令牌刷新成功: userId={}", userId);
|
||||||
|
// refresh token 保持不变,只返回新的 access token
|
||||||
|
return new LoginResponse(accessToken, refreshToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
package com.webgame.webgamebackend.service.impl;
|
||||||
|
|
||||||
|
import com.webgame.webgamebackend.common.exception.BusinessException;
|
||||||
|
import com.webgame.webgamebackend.common.exception.ErrorCode;
|
||||||
|
import com.webgame.webgamebackend.common.utils.RedisCacheUtil;
|
||||||
|
import com.webgame.webgamebackend.service.RefreshTokenService;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.security.SecureRandom;
|
||||||
|
import java.util.HexFormat;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 刷新令牌服务实现,基于 Redis 存储
|
||||||
|
*
|
||||||
|
* <p>数据结构:</p>
|
||||||
|
* <ul>
|
||||||
|
* <li>xzg:refresh:{token} → userId(正向映射,用于校验)</li>
|
||||||
|
* <li>xzg:refresh:user:{userId} → token(反向映射,用于登录时清除旧 token)</li>
|
||||||
|
* </ul>
|
||||||
|
* <p>TTL: 30 天,由 Redis 自动过期</p>
|
||||||
|
* <p>一个用户同一时间只保留一个有效的 refresh token,新登录会使旧 token 失效</p>
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class RefreshTokenServiceImpl implements RefreshTokenService {
|
||||||
|
|
||||||
|
/** Redis key 前缀 */
|
||||||
|
private static final String PREFIX = "refresh";
|
||||||
|
|
||||||
|
/** 用户→token 反向映射 key 前缀 */
|
||||||
|
private static final String USER_PREFIX = "refresh:user";
|
||||||
|
|
||||||
|
/** 刷新令牌有效期:30 天(毫秒) */
|
||||||
|
private static final long EXPIRE_MS = 2_592_000_000L;
|
||||||
|
|
||||||
|
/** 安全随机数生成器 */
|
||||||
|
private static final SecureRandom RANDOM = new SecureRandom();
|
||||||
|
|
||||||
|
private final RedisCacheUtil redisCacheUtil;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String create(String userId) {
|
||||||
|
// 先清除该用户之前的 refresh token,确保一个用户只有一个有效 token
|
||||||
|
String oldToken = redisCacheUtil.getCache(USER_PREFIX, userId, String.class, EXPIRE_MS);
|
||||||
|
if (oldToken != null) {
|
||||||
|
redisCacheUtil.removeCache(PREFIX, oldToken);
|
||||||
|
log.debug("已清除旧的刷新令牌: userId={}", userId);
|
||||||
|
}
|
||||||
|
// 创建新 token
|
||||||
|
String token = generateToken();
|
||||||
|
boolean ok = redisCacheUtil.cacheValue(PREFIX, token, userId, EXPIRE_MS);
|
||||||
|
if (!ok) {
|
||||||
|
log.error("刷新令牌写入 Redis 失败: userId={}", userId);
|
||||||
|
throw new BusinessException(ErrorCode.INTERNAL_ERROR, "刷新令牌生成失败");
|
||||||
|
}
|
||||||
|
// 维护反向映射
|
||||||
|
redisCacheUtil.cacheValue(USER_PREFIX, userId, token, EXPIRE_MS);
|
||||||
|
log.debug("刷新令牌已创建: userId={}", userId);
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String validate(String token) {
|
||||||
|
if (token == null || token.isBlank()) {
|
||||||
|
throw new BusinessException(ErrorCode.REFRESH_TOKEN_MISSING);
|
||||||
|
}
|
||||||
|
String userId = redisCacheUtil.getCache(PREFIX, token, String.class, EXPIRE_MS);
|
||||||
|
if (userId == null) {
|
||||||
|
throw new BusinessException(ErrorCode.REFRESH_TOKEN_INVALID);
|
||||||
|
}
|
||||||
|
return userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String rotate(String oldToken) {
|
||||||
|
String userId = validate(oldToken);
|
||||||
|
redisCacheUtil.removeCache(PREFIX, oldToken);
|
||||||
|
redisCacheUtil.removeCache(USER_PREFIX, userId);
|
||||||
|
log.debug("旧刷新令牌已删除: userId={}", userId);
|
||||||
|
return create(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void revoke(String token) {
|
||||||
|
if (token != null && !token.isBlank()) {
|
||||||
|
String userId = redisCacheUtil.getCache(PREFIX, token, String.class, EXPIRE_MS);
|
||||||
|
if (userId != null) {
|
||||||
|
redisCacheUtil.removeCache(USER_PREFIX, userId);
|
||||||
|
}
|
||||||
|
redisCacheUtil.removeCache(PREFIX, token);
|
||||||
|
log.debug("刷新令牌已撤销");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成 64 位十六进制随机字符串作为刷新令牌
|
||||||
|
*/
|
||||||
|
private String generateToken() {
|
||||||
|
byte[] bytes = new byte[32];
|
||||||
|
RANDOM.nextBytes(bytes);
|
||||||
|
return HexFormat.of().formatHex(bytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -71,8 +71,8 @@ logging:
|
|||||||
sa-token:
|
sa-token:
|
||||||
# token 名称(同时也是 cookie 名称)
|
# token 名称(同时也是 cookie 名称)
|
||||||
token-name: saToken
|
token-name: saToken
|
||||||
# token 有效期(单位:秒) 默认30天,-1 代表永久有效
|
# token 有效期(单位:秒)access token 30分钟,refresh token 由应用层管理
|
||||||
timeout: 2592000
|
timeout: 1800
|
||||||
# token 最低活跃频率(单位:秒),如果 token 超过此时间没有访问系统就会被冻结,默认-1 代表不限制,永不冻结
|
# token 最低活跃频率(单位:秒),如果 token 超过此时间没有访问系统就会被冻结,默认-1 代表不限制,永不冻结
|
||||||
active-timeout: -1
|
active-timeout: -1
|
||||||
# 是否允许同一账号多地同时登录 (为 true 时允许一起登录, 为 false 时新登录挤掉旧登录)
|
# 是否允许同一账号多地同时登录 (为 true 时允许一起登录, 为 false 时新登录挤掉旧登录)
|
||||||
|
|||||||
Reference in New Issue
Block a user