新增个人中心页面与用户资料自助修改接口
- 新增 GET/PUT /api/me:登录用户可查看并修改自己的 nickname/gender/birthday,空串清空、缺省保持不变,生日校验格式与未来日期 - users 表新增 gender/birthday 字段(迁移 v6)及 model.Date 日期类型,管理员用户接口同步支持 - 前端新增 /profile 个人中心页面、头像下拉入口与登录守卫,保存后同步会话用户信息 - 静态服务对未命中的无扩展名路径回退 index.html,避免刷新前端路由 404 - 重新生成 Swagger 文档,补充 /api/me 与资料字段相关测试
This commit is contained in:
22 files changed
+1302
-18
No files matched your search
+152
-2
@@ -151,6 +151,104 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"/me": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Return the authenticated user's profile, including groups.",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"profile"
|
||||
],
|
||||
"summary": "Get current user profile",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/model.User"
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "unauthorized or session expired",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/httpx.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "account disabled",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/httpx.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"put": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Update the authenticated user's nickname, gender, and birthday. birthday accepts YYYY-MM-DD, an empty string clears it, and omitting it keeps the current value.",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"profile"
|
||||
],
|
||||
"summary": "Update current user profile",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Fields to update",
|
||||
"name": "profile",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/auth.UpdateProfileRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/model.User"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "invalid request",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/httpx.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "unauthorized or session expired",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/httpx.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "account disabled",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/httpx.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/httpx.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/notes": {
|
||||
"get": {
|
||||
"security": [
|
||||
@@ -873,7 +971,7 @@ const docTemplate = `{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Create a user and assign groups. username and email are unique; password is 6-72 chars; defaults to the regular user group (id 1) when group_ids is omitted.",
|
||||
"description": "Create a user and assign groups. username and email are unique; password is 6-72 chars; gender is male/female/other; birthday is YYYY-MM-DD and cannot be in the future; defaults to the regular user group (id 1) when group_ids is omitted.",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -1005,7 +1103,7 @@ const docTemplate = `{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Update user fields. group_ids replaces all groups; a non-empty password resets the password.",
|
||||
"description": "Update user fields. group_ids replaces all groups; a non-empty password resets the password; birthday accepts YYYY-MM-DD, an empty string clears it, and omitting it keeps the current value.",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -1210,6 +1308,24 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"auth.UpdateProfileRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"birthday": {
|
||||
"type": "string",
|
||||
"example": "1995-06-15"
|
||||
},
|
||||
"gender": {
|
||||
"type": "string",
|
||||
"example": "male"
|
||||
},
|
||||
"nickname": {
|
||||
"type": "string",
|
||||
"maxLength": 50,
|
||||
"example": "Alice"
|
||||
}
|
||||
}
|
||||
},
|
||||
"httpx.ErrorResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -1245,12 +1361,20 @@ const docTemplate = `{
|
||||
"avatar": {
|
||||
"type": "string"
|
||||
},
|
||||
"birthday": {
|
||||
"type": "string",
|
||||
"example": "1995-06-15"
|
||||
},
|
||||
"created_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"email": {
|
||||
"type": "string"
|
||||
},
|
||||
"gender": {
|
||||
"type": "string",
|
||||
"example": "male"
|
||||
},
|
||||
"groups": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
@@ -1350,11 +1474,24 @@ const docTemplate = `{
|
||||
"maxLength": 255,
|
||||
"example": "https://example.com/avatar.png"
|
||||
},
|
||||
"birthday": {
|
||||
"type": "string",
|
||||
"example": "1995-06-15"
|
||||
},
|
||||
"email": {
|
||||
"type": "string",
|
||||
"maxLength": 255,
|
||||
"example": "alice@example.com"
|
||||
},
|
||||
"gender": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"male",
|
||||
"female",
|
||||
"other"
|
||||
],
|
||||
"example": "male"
|
||||
},
|
||||
"group_ids": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
@@ -1421,6 +1558,19 @@ const docTemplate = `{
|
||||
"maxLength": 255,
|
||||
"example": "https://example.com/avatar.png"
|
||||
},
|
||||
"birthday": {
|
||||
"type": "string",
|
||||
"example": "1995-06-15"
|
||||
},
|
||||
"gender": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"male",
|
||||
"female",
|
||||
"other"
|
||||
],
|
||||
"example": "male"
|
||||
},
|
||||
"group_ids": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
|
||||
+152
-2
@@ -144,6 +144,104 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/me": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Return the authenticated user's profile, including groups.",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"profile"
|
||||
],
|
||||
"summary": "Get current user profile",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/model.User"
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "unauthorized or session expired",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/httpx.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "account disabled",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/httpx.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"put": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Update the authenticated user's nickname, gender, and birthday. birthday accepts YYYY-MM-DD, an empty string clears it, and omitting it keeps the current value.",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"profile"
|
||||
],
|
||||
"summary": "Update current user profile",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Fields to update",
|
||||
"name": "profile",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/auth.UpdateProfileRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/model.User"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "invalid request",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/httpx.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "unauthorized or session expired",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/httpx.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "account disabled",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/httpx.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/httpx.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/notes": {
|
||||
"get": {
|
||||
"security": [
|
||||
@@ -866,7 +964,7 @@
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Create a user and assign groups. username and email are unique; password is 6-72 chars; defaults to the regular user group (id 1) when group_ids is omitted.",
|
||||
"description": "Create a user and assign groups. username and email are unique; password is 6-72 chars; gender is male/female/other; birthday is YYYY-MM-DD and cannot be in the future; defaults to the regular user group (id 1) when group_ids is omitted.",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -998,7 +1096,7 @@
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Update user fields. group_ids replaces all groups; a non-empty password resets the password.",
|
||||
"description": "Update user fields. group_ids replaces all groups; a non-empty password resets the password; birthday accepts YYYY-MM-DD, an empty string clears it, and omitting it keeps the current value.",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -1203,6 +1301,24 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"auth.UpdateProfileRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"birthday": {
|
||||
"type": "string",
|
||||
"example": "1995-06-15"
|
||||
},
|
||||
"gender": {
|
||||
"type": "string",
|
||||
"example": "male"
|
||||
},
|
||||
"nickname": {
|
||||
"type": "string",
|
||||
"maxLength": 50,
|
||||
"example": "Alice"
|
||||
}
|
||||
}
|
||||
},
|
||||
"httpx.ErrorResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -1238,12 +1354,20 @@
|
||||
"avatar": {
|
||||
"type": "string"
|
||||
},
|
||||
"birthday": {
|
||||
"type": "string",
|
||||
"example": "1995-06-15"
|
||||
},
|
||||
"created_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"email": {
|
||||
"type": "string"
|
||||
},
|
||||
"gender": {
|
||||
"type": "string",
|
||||
"example": "male"
|
||||
},
|
||||
"groups": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
@@ -1343,11 +1467,24 @@
|
||||
"maxLength": 255,
|
||||
"example": "https://example.com/avatar.png"
|
||||
},
|
||||
"birthday": {
|
||||
"type": "string",
|
||||
"example": "1995-06-15"
|
||||
},
|
||||
"email": {
|
||||
"type": "string",
|
||||
"maxLength": 255,
|
||||
"example": "alice@example.com"
|
||||
},
|
||||
"gender": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"male",
|
||||
"female",
|
||||
"other"
|
||||
],
|
||||
"example": "male"
|
||||
},
|
||||
"group_ids": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
@@ -1414,6 +1551,19 @@
|
||||
"maxLength": 255,
|
||||
"example": "https://example.com/avatar.png"
|
||||
},
|
||||
"birthday": {
|
||||
"type": "string",
|
||||
"example": "1995-06-15"
|
||||
},
|
||||
"gender": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"male",
|
||||
"female",
|
||||
"other"
|
||||
],
|
||||
"example": "male"
|
||||
},
|
||||
"group_ids": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
|
||||
+108
-3
@@ -53,6 +53,19 @@ definitions:
|
||||
- password
|
||||
- username
|
||||
type: object
|
||||
auth.UpdateProfileRequest:
|
||||
properties:
|
||||
birthday:
|
||||
example: "1995-06-15"
|
||||
type: string
|
||||
gender:
|
||||
example: male
|
||||
type: string
|
||||
nickname:
|
||||
example: Alice
|
||||
maxLength: 50
|
||||
type: string
|
||||
type: object
|
||||
httpx.ErrorResponse:
|
||||
properties:
|
||||
error:
|
||||
@@ -76,10 +89,16 @@ definitions:
|
||||
properties:
|
||||
avatar:
|
||||
type: string
|
||||
birthday:
|
||||
example: "1995-06-15"
|
||||
type: string
|
||||
created_at:
|
||||
type: string
|
||||
email:
|
||||
type: string
|
||||
gender:
|
||||
example: male
|
||||
type: string
|
||||
groups:
|
||||
items:
|
||||
$ref: '#/definitions/model.UserGroup'
|
||||
@@ -144,10 +163,20 @@ definitions:
|
||||
example: https://example.com/avatar.png
|
||||
maxLength: 255
|
||||
type: string
|
||||
birthday:
|
||||
example: "1995-06-15"
|
||||
type: string
|
||||
email:
|
||||
example: alice@example.com
|
||||
maxLength: 255
|
||||
type: string
|
||||
gender:
|
||||
enum:
|
||||
- male
|
||||
- female
|
||||
- other
|
||||
example: male
|
||||
type: string
|
||||
group_ids:
|
||||
example:
|
||||
- 1
|
||||
@@ -200,6 +229,16 @@ definitions:
|
||||
example: https://example.com/avatar.png
|
||||
maxLength: 255
|
||||
type: string
|
||||
birthday:
|
||||
example: "1995-06-15"
|
||||
type: string
|
||||
gender:
|
||||
enum:
|
||||
- male
|
||||
- female
|
||||
- other
|
||||
example: male
|
||||
type: string
|
||||
group_ids:
|
||||
example:
|
||||
- 1
|
||||
@@ -353,6 +392,70 @@ paths:
|
||||
summary: Health check
|
||||
tags:
|
||||
- system
|
||||
/me:
|
||||
get:
|
||||
description: Return the authenticated user's profile, including groups.
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/model.User'
|
||||
"401":
|
||||
description: unauthorized or session expired
|
||||
schema:
|
||||
$ref: '#/definitions/httpx.ErrorResponse'
|
||||
"403":
|
||||
description: account disabled
|
||||
schema:
|
||||
$ref: '#/definitions/httpx.ErrorResponse'
|
||||
security:
|
||||
- BearerAuth: []
|
||||
summary: Get current user profile
|
||||
tags:
|
||||
- profile
|
||||
put:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Update the authenticated user's nickname, gender, and birthday.
|
||||
birthday accepts YYYY-MM-DD, an empty string clears it, and omitting it keeps
|
||||
the current value.
|
||||
parameters:
|
||||
- description: Fields to update
|
||||
in: body
|
||||
name: profile
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/auth.UpdateProfileRequest'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/model.User'
|
||||
"400":
|
||||
description: invalid request
|
||||
schema:
|
||||
$ref: '#/definitions/httpx.ErrorResponse'
|
||||
"401":
|
||||
description: unauthorized or session expired
|
||||
schema:
|
||||
$ref: '#/definitions/httpx.ErrorResponse'
|
||||
"403":
|
||||
description: account disabled
|
||||
schema:
|
||||
$ref: '#/definitions/httpx.ErrorResponse'
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
$ref: '#/definitions/httpx.ErrorResponse'
|
||||
security:
|
||||
- BearerAuth: []
|
||||
summary: Update current user profile
|
||||
tags:
|
||||
- profile
|
||||
/notes:
|
||||
get:
|
||||
description: List notes ordered by id DESC. page starts at 1; page_size is 1-100,
|
||||
@@ -827,8 +930,9 @@ paths:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Create a user and assign groups. username and email are unique;
|
||||
password is 6-72 chars; defaults to the regular user group (id 1) when group_ids
|
||||
is omitted.
|
||||
password is 6-72 chars; gender is male/female/other; birthday is YYYY-MM-DD
|
||||
and cannot be in the future; defaults to the regular user group (id 1) when
|
||||
group_ids is omitted.
|
||||
parameters:
|
||||
- description: User payload
|
||||
in: body
|
||||
@@ -954,7 +1058,8 @@ paths:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Update user fields. group_ids replaces all groups; a non-empty
|
||||
password resets the password.
|
||||
password resets the password; birthday accepts YYYY-MM-DD, an empty string
|
||||
clears it, and omitting it keeps the current value.
|
||||
parameters:
|
||||
- description: User ID
|
||||
example: 1
|
||||
|
||||
@@ -13,6 +13,8 @@ export interface AuthUser {
|
||||
email: string
|
||||
nickname: string
|
||||
avatar: string
|
||||
gender: string
|
||||
birthday: string | null
|
||||
status: number
|
||||
groups: AuthUserGroup[]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { request } from './http'
|
||||
import type { AuthUser } from './auth'
|
||||
|
||||
export interface UpdateProfilePayload {
|
||||
nickname: string
|
||||
gender: string
|
||||
birthday: string
|
||||
}
|
||||
|
||||
export function getProfile(): Promise<AuthUser> {
|
||||
return request<AuthUser>('/me')
|
||||
}
|
||||
|
||||
export function updateProfile(payload: UpdateProfilePayload): Promise<AuthUser> {
|
||||
return request<AuthUser>('/me', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
@@ -95,6 +95,13 @@ function logout() {
|
||||
v-if="menuOpen"
|
||||
class="absolute right-0 top-full mt-2 w-36 overflow-hidden rounded-xl border border-line bg-surface py-1 shadow-lg"
|
||||
>
|
||||
<RouterLink
|
||||
:to="{ name: 'profile' }"
|
||||
class="block w-full px-4 py-2 text-left text-sm text-content-2 transition-colors hover:bg-page hover:text-primary"
|
||||
@click="menuOpen = false"
|
||||
>
|
||||
{{ t('header.profile') }}
|
||||
</RouterLink>
|
||||
<button
|
||||
type="button"
|
||||
class="block w-full px-4 py-2 text-left text-sm text-content-2 transition-colors hover:bg-page hover:text-primary"
|
||||
|
||||
@@ -10,6 +10,7 @@ const enUS: MessageSchema = {
|
||||
register: 'Sign up',
|
||||
login: 'Log in',
|
||||
userMenu: 'User menu',
|
||||
profile: 'Profile',
|
||||
logout: 'Log out',
|
||||
},
|
||||
home: {
|
||||
@@ -93,6 +94,30 @@ const enUS: MessageSchema = {
|
||||
sessionExpired: 'Session expired, please log in again',
|
||||
},
|
||||
},
|
||||
profile: {
|
||||
title: 'Profile',
|
||||
subtitle: 'View and edit your basic information',
|
||||
basicInfo: 'Basic information',
|
||||
nickname: 'Nickname',
|
||||
nicknamePlaceholder: 'Falls back to username when empty',
|
||||
gender: 'Gender',
|
||||
genderOptions: {
|
||||
unset: 'Not set',
|
||||
male: 'Male',
|
||||
female: 'Female',
|
||||
other: 'Other',
|
||||
},
|
||||
birthday: 'Birthday',
|
||||
save: 'Save changes',
|
||||
saveSuccess: 'Your profile has been updated',
|
||||
errors: {
|
||||
nicknameLength: 'Nickname must be at most 50 characters',
|
||||
birthdayInvalid: 'Invalid birthday format',
|
||||
birthdayFuture: 'Birthday cannot be in the future',
|
||||
invalid: 'The submitted information is invalid, please check and retry',
|
||||
network: 'Network error, please try again later',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export default enUS
|
||||
@@ -10,6 +10,7 @@ const jaJP: MessageSchema = {
|
||||
register: '新規登録',
|
||||
login: 'ログイン',
|
||||
userMenu: 'ユーザーメニュー',
|
||||
profile: 'マイページ',
|
||||
logout: 'ログアウト',
|
||||
},
|
||||
home: {
|
||||
@@ -93,6 +94,30 @@ const jaJP: MessageSchema = {
|
||||
sessionExpired: 'ログインの有効期限が切れました。もう一度ログインしてください',
|
||||
},
|
||||
},
|
||||
profile: {
|
||||
title: 'マイページ',
|
||||
subtitle: '基本情報の確認と編集',
|
||||
basicInfo: '基本情報',
|
||||
nickname: 'ニックネーム',
|
||||
nicknamePlaceholder: '未入力の場合はユーザー名を表示します',
|
||||
gender: '性別',
|
||||
genderOptions: {
|
||||
unset: '未設定',
|
||||
male: '男性',
|
||||
female: '女性',
|
||||
other: 'その他',
|
||||
},
|
||||
birthday: '誕生日',
|
||||
save: '変更を保存',
|
||||
saveSuccess: 'プロフィールを更新しました',
|
||||
errors: {
|
||||
nicknameLength: 'ニックネームは 50 文字以内で入力してください',
|
||||
birthdayInvalid: '誕生日の形式が正しくありません',
|
||||
birthdayFuture: '誕生日は今日より後に設定できません',
|
||||
invalid: '入力内容が無効です。確認してもう一度お試しください',
|
||||
network: 'ネットワークエラーが発生しました。後でもう一度お試しください',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export default jaJP
|
||||
@@ -8,6 +8,7 @@ const zhCN = {
|
||||
register: '注册',
|
||||
login: '登录',
|
||||
userMenu: '用户菜单',
|
||||
profile: '个人中心',
|
||||
logout: '退出登录',
|
||||
},
|
||||
home: {
|
||||
@@ -91,6 +92,30 @@ const zhCN = {
|
||||
sessionExpired: '登录已过期,请重新登录',
|
||||
},
|
||||
},
|
||||
profile: {
|
||||
title: '个人中心',
|
||||
subtitle: '查看并修改你的基本信息',
|
||||
basicInfo: '基本信息',
|
||||
nickname: '昵称',
|
||||
nicknamePlaceholder: '未填写时显示用户名',
|
||||
gender: '性别',
|
||||
genderOptions: {
|
||||
unset: '未设置',
|
||||
male: '男',
|
||||
female: '女',
|
||||
other: '其他',
|
||||
},
|
||||
birthday: '生日',
|
||||
save: '保存修改',
|
||||
saveSuccess: '个人资料已更新',
|
||||
errors: {
|
||||
nicknameLength: '昵称最多 50 个字符',
|
||||
birthdayInvalid: '生日格式不正确',
|
||||
birthdayFuture: '生日不能晚于今天',
|
||||
invalid: '提交的信息无效,请检查后重试',
|
||||
network: '网络异常,请稍后重试',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export type MessageSchema = typeof zhCN
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import HomeView from '../views/HomeView.vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
declare module 'vue-router' {
|
||||
interface RouteMeta {
|
||||
blank?: boolean
|
||||
requiresAuth?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +29,24 @@ const router = createRouter({
|
||||
component: () => import('../views/RegisterView.vue'),
|
||||
meta: { blank: true },
|
||||
},
|
||||
{
|
||||
path: '/profile',
|
||||
name: 'profile',
|
||||
component: () => import('../views/ProfileView.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
router.beforeEach((to) => {
|
||||
if (!to.meta.requiresAuth) {
|
||||
return true
|
||||
}
|
||||
const auth = useAuthStore()
|
||||
if (auth.isAuthenticated) {
|
||||
return true
|
||||
}
|
||||
return { name: 'login', query: { redirect: to.fullPath } }
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -7,6 +7,11 @@ import {
|
||||
type LoginResponse,
|
||||
type RegisterPayload,
|
||||
} from '@/api/auth'
|
||||
import {
|
||||
getProfile as getProfileRequest,
|
||||
updateProfile as updateProfileRequest,
|
||||
type UpdateProfilePayload,
|
||||
} from '@/api/profile'
|
||||
import { setAuthToken } from '@/api/http'
|
||||
|
||||
const TOKEN_KEY = 'rill-token'
|
||||
@@ -54,6 +59,11 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
setAuthToken(null)
|
||||
}
|
||||
|
||||
function applyUser(data: AuthUser) {
|
||||
user.value = data
|
||||
localStorage.setItem(USER_KEY, JSON.stringify(data))
|
||||
}
|
||||
|
||||
function applySession(data: LoginResponse) {
|
||||
token.value = data.token
|
||||
user.value = data.user
|
||||
@@ -74,6 +84,18 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
return registerRequest(payload)
|
||||
}
|
||||
|
||||
async function updateProfile(payload: UpdateProfilePayload): Promise<AuthUser> {
|
||||
const data = await updateProfileRequest(payload)
|
||||
applyUser(data)
|
||||
return data
|
||||
}
|
||||
|
||||
async function refreshProfile(): Promise<AuthUser> {
|
||||
const data = await getProfileRequest()
|
||||
applyUser(data)
|
||||
return data
|
||||
}
|
||||
|
||||
function logout() {
|
||||
clear()
|
||||
}
|
||||
@@ -96,6 +118,8 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
isAdmin,
|
||||
login,
|
||||
register,
|
||||
updateProfile,
|
||||
refreshProfile,
|
||||
logout,
|
||||
restore,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useForm } from 'vee-validate'
|
||||
import { toTypedSchema } from '@vee-validate/zod'
|
||||
import { z } from 'zod'
|
||||
import { ApiError } from '@/api/http'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
interface ProfileForm {
|
||||
nickname: string
|
||||
gender: string
|
||||
birthday: string
|
||||
}
|
||||
|
||||
const { t } = useI18n()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const today = new Date().toLocaleDateString('sv-SE')
|
||||
|
||||
const genderOptions = [
|
||||
{ value: '', labelKey: 'profile.genderOptions.unset' },
|
||||
{ value: 'male', labelKey: 'profile.genderOptions.male' },
|
||||
{ value: 'female', labelKey: 'profile.genderOptions.female' },
|
||||
{ value: 'other', labelKey: 'profile.genderOptions.other' },
|
||||
] as const satisfies { value: string; labelKey: string }[]
|
||||
|
||||
const validationSchema = computed(() =>
|
||||
toTypedSchema(
|
||||
z.object({
|
||||
nickname: z.string().max(50, t('profile.errors.nicknameLength')),
|
||||
gender: z.enum(['', 'male', 'female', 'other']),
|
||||
birthday: z
|
||||
.string()
|
||||
.refine(
|
||||
(value) => value === '' || /^\d{4}-\d{2}-\d{2}$/.test(value),
|
||||
t('profile.errors.birthdayInvalid'),
|
||||
)
|
||||
.refine((value) => value === '' || value <= today, t('profile.errors.birthdayFuture')),
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const { defineField, handleSubmit, errors, isSubmitting, setValues } = useForm<ProfileForm>({
|
||||
validationSchema,
|
||||
initialValues: {
|
||||
nickname: auth.user?.nickname ?? '',
|
||||
gender: auth.user?.gender ?? '',
|
||||
birthday: auth.user?.birthday ?? '',
|
||||
},
|
||||
})
|
||||
|
||||
const [nickname, nicknameProps] = defineField('nickname')
|
||||
const [gender, genderProps] = defineField('gender')
|
||||
const [birthday, birthdayProps] = defineField('birthday')
|
||||
|
||||
const submitError = ref('')
|
||||
const saved = ref(false)
|
||||
|
||||
const avatarInitial = computed(() => auth.displayName.slice(0, 1).toUpperCase())
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const profile = await auth.refreshProfile()
|
||||
setValues({
|
||||
nickname: profile.nickname ?? '',
|
||||
gender: profile.gender ?? '',
|
||||
birthday: profile.birthday ?? '',
|
||||
})
|
||||
} catch {
|
||||
// 401 由全局未授权处理,其余错误保留本地缓存的用户信息
|
||||
}
|
||||
})
|
||||
|
||||
const onSubmit = handleSubmit(async (values) => {
|
||||
submitError.value = ''
|
||||
saved.value = false
|
||||
try {
|
||||
await auth.updateProfile({
|
||||
nickname: values.nickname,
|
||||
gender: values.gender,
|
||||
birthday: values.birthday,
|
||||
})
|
||||
saved.value = true
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.status >= 400 && error.status < 500) {
|
||||
submitError.value = t('profile.errors.invalid')
|
||||
} else {
|
||||
submitError.value = t('profile.errors.network')
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mx-auto max-w-[1600px] px-4 py-6">
|
||||
<div class="mx-auto max-w-2xl space-y-4">
|
||||
<section class="flex items-center gap-4 rounded-2xl bg-surface p-6 shadow-sm ring-1 ring-line">
|
||||
<img
|
||||
v-if="auth.user?.avatar"
|
||||
:src="auth.user.avatar"
|
||||
alt=""
|
||||
class="h-16 w-16 shrink-0 rounded-full object-cover"
|
||||
/>
|
||||
<span
|
||||
v-else
|
||||
class="grid h-16 w-16 shrink-0 place-items-center rounded-full bg-primary/10 text-xl font-semibold text-primary"
|
||||
>
|
||||
{{ avatarInitial }}
|
||||
</span>
|
||||
<div class="min-w-0">
|
||||
<h1 class="truncate text-xl font-semibold">{{ t('profile.title') }}</h1>
|
||||
<p class="mt-1 text-sm text-content-3">{{ t('profile.subtitle') }}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<form
|
||||
class="rounded-2xl bg-surface p-6 shadow-sm ring-1 ring-line sm:p-8"
|
||||
novalidate
|
||||
@submit="onSubmit"
|
||||
>
|
||||
<h2 class="text-base font-semibold">{{ t('profile.basicInfo') }}</h2>
|
||||
|
||||
<div class="mt-6 grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label for="username" class="mb-1.5 block text-sm text-content-2">
|
||||
{{ t('auth.username') }}
|
||||
</label>
|
||||
<input
|
||||
id="username"
|
||||
:value="auth.user?.username"
|
||||
type="text"
|
||||
disabled
|
||||
class="h-10 w-full rounded-lg border border-line bg-page px-3 text-sm text-content-3"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="email" class="mb-1.5 block text-sm text-content-2">
|
||||
{{ t('auth.email') }}
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
:value="auth.user?.email"
|
||||
type="text"
|
||||
disabled
|
||||
class="h-10 w-full rounded-lg border border-line bg-page px-3 text-sm text-content-3"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<label for="nickname" class="mb-1.5 block text-sm text-content-2">
|
||||
{{ t('profile.nickname') }}
|
||||
</label>
|
||||
<input
|
||||
id="nickname"
|
||||
v-model="nickname"
|
||||
v-bind="nicknameProps"
|
||||
type="text"
|
||||
maxlength="50"
|
||||
:placeholder="t('profile.nicknamePlaceholder')"
|
||||
class="h-10 w-full rounded-lg border bg-page px-3 text-sm outline-none transition-colors placeholder:text-content-3 focus:border-primary"
|
||||
:class="errors.nickname ? 'border-red-500' : 'border-line'"
|
||||
/>
|
||||
<p v-if="errors.nickname" class="mt-1 text-xs text-red-500 dark:text-red-400">
|
||||
{{ errors.nickname }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<span class="mb-1.5 block text-sm text-content-2">{{ t('profile.gender') }}</span>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<label
|
||||
v-for="option in genderOptions"
|
||||
:key="option.value"
|
||||
class="cursor-pointer"
|
||||
>
|
||||
<input
|
||||
v-model="gender"
|
||||
v-bind="genderProps"
|
||||
type="radio"
|
||||
:value="option.value"
|
||||
class="peer sr-only"
|
||||
/>
|
||||
<span
|
||||
class="inline-flex h-9 items-center rounded-full border border-line px-4 text-sm text-content-2 transition-colors peer-checked:border-primary peer-checked:bg-primary/10 peer-checked:text-primary peer-focus-visible:ring-2 peer-focus-visible:ring-primary/40"
|
||||
>
|
||||
{{ t(option.labelKey) }}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<p v-if="errors.gender" class="mt-1 text-xs text-red-500 dark:text-red-400">
|
||||
{{ errors.gender }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<label for="birthday" class="mb-1.5 block text-sm text-content-2">
|
||||
{{ t('profile.birthday') }}
|
||||
</label>
|
||||
<input
|
||||
id="birthday"
|
||||
v-model="birthday"
|
||||
v-bind="birthdayProps"
|
||||
type="date"
|
||||
:max="today"
|
||||
class="h-10 w-full rounded-lg border bg-page px-3 text-sm outline-none transition-colors focus:border-primary sm:max-w-52"
|
||||
:class="errors.birthday ? 'border-red-500' : 'border-line'"
|
||||
/>
|
||||
<p v-if="errors.birthday" class="mt-1 text-xs text-red-500 dark:text-red-400">
|
||||
{{ errors.birthday }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="submitError"
|
||||
class="mt-4 rounded-lg bg-red-500/10 px-3 py-2 text-xs text-red-500 dark:text-red-400"
|
||||
>
|
||||
{{ submitError }}
|
||||
</p>
|
||||
<p v-else-if="saved" class="mt-4 rounded-lg bg-primary/10 px-3 py-2 text-xs text-primary">
|
||||
{{ t('profile.saveSuccess') }}
|
||||
</p>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="isSubmitting"
|
||||
class="mt-6 h-10 w-full rounded-full bg-primary text-sm font-medium text-on-primary transition-colors hover:bg-primary-hover disabled:cursor-not-allowed disabled:opacity-60 sm:w-auto sm:px-8"
|
||||
>
|
||||
{{ isSubmitting ? t('common.submitting') : t('profile.save') }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+4
-1
@@ -18,7 +18,7 @@ import (
|
||||
"rill/internal/usergroup"
|
||||
)
|
||||
|
||||
// RegisterRoutes 注册 API 路由。health、swagger、auth 公开;notes 需登录;用户与用户组管理仅限管理员。
|
||||
// RegisterRoutes 注册 API 路由。health、swagger、auth 公开;notes 与个人资料需登录;用户与用户组管理仅限管理员。
|
||||
func RegisterRoutes(rg *gin.RouterGroup, db *gorm.DB, cfg *config.Config) {
|
||||
authn := auth.NewAuthenticator(cfg)
|
||||
|
||||
@@ -40,6 +40,9 @@ func RegisterRoutes(rg *gin.RouterGroup, db *gorm.DB, cfg *config.Config) {
|
||||
|
||||
authed := rg.Group("", authn.RequireAuth(db))
|
||||
{
|
||||
authed.GET("/me", auth.Me())
|
||||
authed.PUT("/me", auth.UpdateMe(db))
|
||||
|
||||
notes := authed.Group("/notes")
|
||||
{
|
||||
notes.GET("", note.List(db))
|
||||
|
||||
@@ -49,6 +49,9 @@ func TestRegister(t *testing.T) {
|
||||
if len(user.Groups) != 1 || user.Groups[0].ID != model.GroupIDUser {
|
||||
t.Fatalf("注册用户组异常: %+v", user.Groups)
|
||||
}
|
||||
if user.Gender != "" || !user.Birthday.IsZero() {
|
||||
t.Errorf("注册默认资料字段应为空: gender=%q birthday=%v", user.Gender, user.Birthday.Time)
|
||||
}
|
||||
|
||||
w := testutil.Call(t, r, http.MethodPost, "/api/auth/register", map[string]string{
|
||||
"username": "alice2", "email": "alice2@example.com", "password": "secret123",
|
||||
@@ -249,3 +252,101 @@ func TestAuthMiddleware(t *testing.T) {
|
||||
t.Errorf("管理员访问 users 状态码 = %d, 期望 %d", w.Code, http.StatusOK)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfile(t *testing.T) {
|
||||
env := testutil.Setup(t)
|
||||
r := env.Router("")
|
||||
registered := registerViaAPI(t, r, "alice", "alice@example.com", "secret123", nil)
|
||||
authed := env.Router(env.Sign(registered.ID))
|
||||
|
||||
if w := testutil.Call(t, env.Router(""), http.MethodGet, "/api/me", nil); w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("匿名获取个人资料状态码 = %d, 期望 %d", w.Code, http.StatusUnauthorized)
|
||||
}
|
||||
|
||||
w := testutil.Call(t, authed, http.MethodGet, "/api/me", nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("获取个人资料状态码 = %d, 期望 %d, body=%s", w.Code, http.StatusOK, w.Body.String())
|
||||
}
|
||||
me := testutil.DecodeUser(t, w)
|
||||
if me.ID != registered.ID || me.Username != "alice" || me.Email != "alice@example.com" {
|
||||
t.Fatalf("个人资料异常: %+v", me)
|
||||
}
|
||||
if len(me.Groups) != 1 || me.Groups[0].ID != model.GroupIDUser {
|
||||
t.Errorf("个人资料用户组异常: %+v", me.Groups)
|
||||
}
|
||||
|
||||
w = testutil.Call(t, authed, http.MethodPut, "/api/me", map[string]any{
|
||||
"nickname": "Alice",
|
||||
"gender": model.GenderMale,
|
||||
"birthday": "1995-06-15",
|
||||
})
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("更新个人资料状态码 = %d, 期望 %d, body=%s", w.Code, http.StatusOK, w.Body.String())
|
||||
}
|
||||
updated := testutil.DecodeUser(t, w)
|
||||
if updated.Nickname != "Alice" || updated.Gender != model.GenderMale {
|
||||
t.Errorf("更新结果异常: %+v", updated)
|
||||
}
|
||||
if updated.Birthday.IsZero() || updated.Birthday.Format("2006-01-02") != "1995-06-15" {
|
||||
t.Errorf("更新生日异常: %v", updated.Birthday.Time)
|
||||
}
|
||||
|
||||
var stored model.User
|
||||
if err := env.DB.First(&stored, registered.ID).Error; err != nil {
|
||||
t.Fatalf("查询数据库失败: %v", err)
|
||||
}
|
||||
if stored.Nickname != "Alice" || stored.Gender != model.GenderMale || stored.Birthday.Format("2006-01-02") != "1995-06-15" {
|
||||
t.Errorf("数据库未更新: %+v", stored)
|
||||
}
|
||||
|
||||
// 只传部分字段时其余字段保持不变,附带的管理员字段不得生效。
|
||||
w = testutil.Call(t, authed, http.MethodPut, "/api/me", map[string]any{
|
||||
"gender": model.GenderFemale,
|
||||
"status": 0,
|
||||
"group_ids": []uint{model.GroupIDAdmin},
|
||||
})
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("部分更新状态码 = %d, 期望 %d, body=%s", w.Code, http.StatusOK, w.Body.String())
|
||||
}
|
||||
partial := testutil.DecodeUser(t, w)
|
||||
if partial.Gender != model.GenderFemale || partial.Nickname != "Alice" {
|
||||
t.Errorf("部分更新结果异常: %+v", partial)
|
||||
}
|
||||
if partial.Birthday.IsZero() || partial.Birthday.Format("2006-01-02") != "1995-06-15" {
|
||||
t.Errorf("未提供 birthday 时不应修改: %v", partial.Birthday.Time)
|
||||
}
|
||||
if partial.Status != 1 || len(partial.Groups) != 1 || partial.Groups[0].ID != model.GroupIDUser {
|
||||
t.Errorf("普通用户不得修改状态或用户组: %+v", partial)
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
body map[string]any
|
||||
}{
|
||||
{"非法性别", map[string]any{"gender": "unknown"}},
|
||||
{"生日格式非法", map[string]any{"birthday": "15-06-1995"}},
|
||||
{"生日在未来", map[string]any{"birthday": time.Now().AddDate(1, 0, 0).Format("2006-01-02")}},
|
||||
{"昵称超长", map[string]any{"nickname": strings.Repeat("a", 51)}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
w := testutil.Call(t, authed, http.MethodPut, "/api/me", tc.body)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("%s 状态码 = %d, 期望 %d, body=%s", tc.name, w.Code, http.StatusBadRequest, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
w = testutil.Call(t, authed, http.MethodPut, "/api/me", map[string]any{"nickname": "", "gender": "", "birthday": ""})
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("清空个人资料状态码 = %d, 期望 %d, body=%s", w.Code, http.StatusOK, w.Body.String())
|
||||
}
|
||||
if cleared := testutil.DecodeUser(t, w); cleared.Nickname != "" || cleared.Gender != "" || !cleared.Birthday.IsZero() {
|
||||
t.Errorf("清空失败: %+v", cleared)
|
||||
}
|
||||
var clearedStored model.User
|
||||
if err := env.DB.First(&clearedStored, registered.ID).Error; err != nil {
|
||||
t.Fatalf("查询数据库失败: %v", err)
|
||||
}
|
||||
if clearedStored.Nickname != "" || clearedStored.Gender != "" || !clearedStored.Birthday.IsZero() {
|
||||
t.Errorf("数据库未清空: %+v", clearedStored)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"rill/internal/httpx"
|
||||
"rill/internal/user"
|
||||
)
|
||||
|
||||
// UpdateProfileRequest 更新个人资料请求,仅更新请求中提供的字段;gender 空串表示清空。
|
||||
type UpdateProfileRequest struct {
|
||||
Nickname *string `json:"nickname" binding:"omitempty,max=50" example:"Alice"`
|
||||
Gender *string `json:"gender" binding:"omitempty,len=0|oneof=male female other" example:"male"`
|
||||
Birthday *string `json:"birthday" binding:"omitempty" example:"1995-06-15"`
|
||||
}
|
||||
|
||||
// @Summary Get current user profile
|
||||
// @Description Return the authenticated user's profile, including groups.
|
||||
// @Tags profile
|
||||
// @Produce json
|
||||
// @Success 200 {object} model.User
|
||||
// @Security BearerAuth
|
||||
// @Failure 401 {object} httpx.ErrorResponse "unauthorized or session expired"
|
||||
// @Failure 403 {object} httpx.ErrorResponse "account disabled"
|
||||
// @Router /me [get]
|
||||
func Me() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
current, ok := currentUser(c)
|
||||
if !ok {
|
||||
httpx.RespondUnauthorized(c)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, current)
|
||||
}
|
||||
}
|
||||
|
||||
// @Summary Update current user profile
|
||||
// @Description Update the authenticated user's nickname, gender, and birthday. birthday accepts YYYY-MM-DD, an empty string clears it, and omitting it keeps the current value.
|
||||
// @Tags profile
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param profile body auth.UpdateProfileRequest true "Fields to update"
|
||||
// @Success 200 {object} model.User
|
||||
// @Failure 400 {object} httpx.ErrorResponse "invalid request"
|
||||
// @Security BearerAuth
|
||||
// @Failure 401 {object} httpx.ErrorResponse "unauthorized or session expired"
|
||||
// @Failure 403 {object} httpx.ErrorResponse "account disabled"
|
||||
// @Failure 500 {object} httpx.ErrorResponse
|
||||
// @Router /me [put]
|
||||
func UpdateMe(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req UpdateProfileRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, httpx.ErrorResponse{Error: "invalid request: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
current, ok := currentUser(c)
|
||||
if !ok {
|
||||
httpx.RespondUnauthorized(c)
|
||||
return
|
||||
}
|
||||
|
||||
updates := make(map[string]any, 3)
|
||||
if req.Nickname != nil {
|
||||
updates["nickname"] = *req.Nickname
|
||||
current.Nickname = *req.Nickname
|
||||
}
|
||||
if req.Gender != nil {
|
||||
updates["gender"] = *req.Gender
|
||||
current.Gender = *req.Gender
|
||||
}
|
||||
if req.Birthday != nil {
|
||||
birthday, err := user.NormalizeBirthday(req.Birthday)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, httpx.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
if birthday.IsZero() {
|
||||
updates["birthday"] = nil
|
||||
} else {
|
||||
updates["birthday"] = birthday
|
||||
}
|
||||
current.Birthday = birthday
|
||||
}
|
||||
if len(updates) == 0 {
|
||||
c.JSON(http.StatusOK, current)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
if err := db.WithContext(ctx).Model(¤t).Updates(updates).Error; err != nil {
|
||||
httpx.RespondDBError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, current)
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
@@ -100,6 +101,14 @@ func TestMigrateIdempotentAndCRUD(t *testing.T) {
|
||||
if admin.Nickname != "Administrator" {
|
||||
t.Errorf("初始管理员昵称应为英文: %q", admin.Nickname)
|
||||
}
|
||||
for _, column := range []string{"gender", "birthday"} {
|
||||
if !db.Migrator().HasColumn(&model.User{}, column) {
|
||||
t.Errorf("users 表缺少列 %s", column)
|
||||
}
|
||||
}
|
||||
if admin.Gender != "" || !admin.Birthday.IsZero() {
|
||||
t.Errorf("初始管理员资料字段应为空: gender=%q birthday=%v", admin.Gender, admin.Birthday.Time)
|
||||
}
|
||||
if _, err := bcrypt.Cost([]byte(admin.PasswordHash)); err != nil {
|
||||
t.Errorf("初始管理员密码哈希无效: %v", err)
|
||||
}
|
||||
@@ -180,6 +189,57 @@ func TestGeneratePassword(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddProfileFieldsMigration(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
ctx := context.Background()
|
||||
if err := Migrate(ctx, db); err != nil {
|
||||
t.Fatalf("执行迁移失败: %v", err)
|
||||
}
|
||||
|
||||
migrator := db.Migrator()
|
||||
for _, column := range []string{"gender", "birthday"} {
|
||||
if err := migrator.DropColumn(&model.User{}, column); err != nil {
|
||||
t.Fatalf("删除列 %s 失败: %v", column, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 用原始 SQL 模拟旧版本数据(当时表里还没有 gender/birthday 列)。
|
||||
now := time.Now()
|
||||
if err := db.WithContext(ctx).Exec(
|
||||
"INSERT INTO users (username, email, password_hash, nickname, avatar, status, created_at, updated_at) VALUES (?, ?, ?, '', '', 1, ?, ?)",
|
||||
"legacy", "legacy@example.com", "x", now, now,
|
||||
).Error; err != nil {
|
||||
t.Fatalf("创建存量用户失败: %v", err)
|
||||
}
|
||||
|
||||
var addProfile Migration
|
||||
for _, m := range migrations {
|
||||
if m.Version == 6 {
|
||||
addProfile = m
|
||||
}
|
||||
}
|
||||
if addProfile.Up == nil {
|
||||
t.Fatal("未找到 v6 迁移")
|
||||
}
|
||||
if err := addProfile.Up(db.WithContext(ctx)); err != nil {
|
||||
t.Fatalf("执行 v6 迁移失败: %v", err)
|
||||
}
|
||||
|
||||
for _, column := range []string{"gender", "birthday"} {
|
||||
if !migrator.HasColumn(&model.User{}, column) {
|
||||
t.Errorf("v6 未补齐列 %s", column)
|
||||
}
|
||||
}
|
||||
|
||||
var got model.User
|
||||
if err := db.WithContext(ctx).Where("username = ?", "legacy").First(&got).Error; err != nil {
|
||||
t.Fatalf("查询存量用户失败: %v", err)
|
||||
}
|
||||
if got.Gender != "" || !got.Birthday.IsZero() {
|
||||
t.Errorf("存量用户资料字段应默认空: gender=%q birthday=%v", got.Gender, got.Birthday.Time)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranslateBuiltinDataMigration(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -83,6 +83,13 @@ var migrations = []Migration{
|
||||
return nil
|
||||
},
|
||||
},
|
||||
{
|
||||
Version: 6,
|
||||
Name: "add_user_profile_fields",
|
||||
Up: func(tx *gorm.DB) error {
|
||||
return tx.AutoMigrate(&model.User{})
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// schemaMigration 记录已应用的迁移版本。
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
const dateLayout = "2006-01-02"
|
||||
|
||||
// Date 仅日期字段(YYYY-MM-DD),零值表示未设置,JSON 序列化为 null。
|
||||
type Date struct {
|
||||
time.Time
|
||||
}
|
||||
|
||||
// IsZero 是否未设置。
|
||||
func (d Date) IsZero() bool {
|
||||
return d.Time.IsZero()
|
||||
}
|
||||
|
||||
// Value 实现 driver.Valuer:零值写入 NULL。
|
||||
func (d Date) Value() (driver.Value, error) {
|
||||
if d.Time.IsZero() {
|
||||
return nil, nil
|
||||
}
|
||||
return d.Time.Format(dateLayout), nil
|
||||
}
|
||||
|
||||
// Scan 实现 sql.Scanner,兼容 time.Time、字符串与 []byte。
|
||||
func (d *Date) Scan(value any) error {
|
||||
switch v := value.(type) {
|
||||
case nil:
|
||||
d.Time = time.Time{}
|
||||
case time.Time:
|
||||
d.Time = v
|
||||
case string:
|
||||
return d.parse(v)
|
||||
case []byte:
|
||||
return d.parse(string(v))
|
||||
default:
|
||||
return fmt.Errorf("unsupported date value: %T", value)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Date) parse(value string) error {
|
||||
if value == "" {
|
||||
d.Time = time.Time{}
|
||||
return nil
|
||||
}
|
||||
for _, layout := range []string{dateLayout, time.RFC3339} {
|
||||
if parsed, err := time.Parse(layout, value); err == nil {
|
||||
d.Time = parsed
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return errors.New("invalid date: " + value)
|
||||
}
|
||||
|
||||
// MarshalJSON 输出 YYYY-MM-DD,未设置时输出 null。
|
||||
func (d Date) MarshalJSON() ([]byte, error) {
|
||||
if d.Time.IsZero() {
|
||||
return []byte("null"), nil
|
||||
}
|
||||
return json.Marshal(d.Time.Format(dateLayout))
|
||||
}
|
||||
|
||||
// UnmarshalJSON 支持 null、空串与 YYYY-MM-DD。
|
||||
func (d *Date) UnmarshalJSON(data []byte) error {
|
||||
if string(data) == "null" {
|
||||
d.Time = time.Time{}
|
||||
return nil
|
||||
}
|
||||
var value string
|
||||
if err := json.Unmarshal(data, &value); err != nil {
|
||||
return err
|
||||
}
|
||||
return d.parse(value)
|
||||
}
|
||||
@@ -6,6 +6,13 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// 性别取值,空字符串表示未设置。
|
||||
const (
|
||||
GenderMale = "male"
|
||||
GenderFemale = "female"
|
||||
GenderOther = "other"
|
||||
)
|
||||
|
||||
// User 用户,与用户组为多对多关系。
|
||||
type User struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
@@ -14,6 +21,8 @@ type User struct {
|
||||
PasswordHash string `gorm:"size:255;not null" json:"-"`
|
||||
Nickname string `gorm:"size:50" json:"nickname"`
|
||||
Avatar string `gorm:"size:255" json:"avatar"`
|
||||
Gender string `gorm:"size:10;not null;default:''" json:"gender" example:"male"`
|
||||
Birthday Date `gorm:"type:date" json:"birthday" swaggertype:"string" example:"1995-06-15"`
|
||||
Status int8 `gorm:"not null;default:1" json:"status"`
|
||||
Groups []UserGroup `gorm:"-" json:"groups"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
|
||||
+55
-9
@@ -5,6 +5,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
@@ -21,19 +22,23 @@ var ErrGroupsNotFound = errors.New("user group not found")
|
||||
|
||||
// CreateRequest 创建用户请求。
|
||||
type CreateRequest struct {
|
||||
Username string `json:"username" binding:"required,max=50" example:"alice"`
|
||||
Email string `json:"email" binding:"required,email,max=255" example:"alice@example.com"`
|
||||
Password string `json:"password" binding:"required,min=6,max=72" example:"secret123"`
|
||||
Nickname string `json:"nickname" binding:"max=50" example:"Alice"`
|
||||
Avatar string `json:"avatar" binding:"max=255" example:"https://example.com/avatar.png"`
|
||||
Status *int8 `json:"status" binding:"omitempty,oneof=0 1" example:"1"`
|
||||
GroupIDs []uint `json:"group_ids" example:"1"`
|
||||
Username string `json:"username" binding:"required,max=50" example:"alice"`
|
||||
Email string `json:"email" binding:"required,email,max=255" example:"alice@example.com"`
|
||||
Password string `json:"password" binding:"required,min=6,max=72" example:"secret123"`
|
||||
Nickname string `json:"nickname" binding:"max=50" example:"Alice"`
|
||||
Avatar string `json:"avatar" binding:"max=255" example:"https://example.com/avatar.png"`
|
||||
Gender string `json:"gender" binding:"omitempty,oneof=male female other" example:"male"`
|
||||
Birthday *string `json:"birthday" binding:"omitempty" example:"1995-06-15"`
|
||||
Status *int8 `json:"status" binding:"omitempty,oneof=0 1" example:"1"`
|
||||
GroupIDs []uint `json:"group_ids" example:"1"`
|
||||
}
|
||||
|
||||
// UpdateRequest 更新用户请求,仅更新请求中提供的字段。
|
||||
type UpdateRequest struct {
|
||||
Nickname string `json:"nickname" binding:"max=50" example:"Alice"`
|
||||
Avatar string `json:"avatar" binding:"max=255" example:"https://example.com/avatar.png"`
|
||||
Gender string `json:"gender" binding:"omitempty,oneof=male female other" example:"male"`
|
||||
Birthday *string `json:"birthday" binding:"omitempty" example:"1995-06-15"`
|
||||
Status *int8 `json:"status" binding:"omitempty,oneof=0 1" example:"1"`
|
||||
Password string `json:"password" binding:"omitempty,min=6,max=72" example:"secret123"`
|
||||
GroupIDs *[]uint `json:"group_ids" example:"1"`
|
||||
@@ -94,7 +99,7 @@ func List(db *gorm.DB) gin.HandlerFunc {
|
||||
}
|
||||
|
||||
// @Summary Create a user
|
||||
// @Description Create a user and assign groups. username and email are unique; password is 6-72 chars; defaults to the regular user group (id 1) when group_ids is omitted.
|
||||
// @Description Create a user and assign groups. username and email are unique; password is 6-72 chars; gender is male/female/other; birthday is YYYY-MM-DD and cannot be in the future; defaults to the regular user group (id 1) when group_ids is omitted.
|
||||
// @Tags users
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
@@ -121,6 +126,11 @@ func Create(db *gorm.DB) gin.HandlerFunc {
|
||||
httpx.RespondServerError(c, err, "生成密码哈希失败")
|
||||
return
|
||||
}
|
||||
birthday, err := NormalizeBirthday(req.Birthday)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, httpx.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
status := int8(1)
|
||||
if req.Status != nil {
|
||||
@@ -147,6 +157,8 @@ func Create(db *gorm.DB) gin.HandlerFunc {
|
||||
PasswordHash: string(hash),
|
||||
Nickname: req.Nickname,
|
||||
Avatar: req.Avatar,
|
||||
Gender: req.Gender,
|
||||
Birthday: birthday,
|
||||
Status: status,
|
||||
}
|
||||
err = db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
@@ -202,7 +214,7 @@ func Get(db *gorm.DB) gin.HandlerFunc {
|
||||
}
|
||||
|
||||
// @Summary Update a user
|
||||
// @Description Update user fields. group_ids replaces all groups; a non-empty password resets the password.
|
||||
// @Description Update user fields. group_ids replaces all groups; a non-empty password resets the password; birthday accepts YYYY-MM-DD, an empty string clears it, and omitting it keeps the current value.
|
||||
// @Tags users
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
@@ -250,6 +262,21 @@ func Update(db *gorm.DB) gin.HandlerFunc {
|
||||
updates := map[string]any{
|
||||
"nickname": req.Nickname,
|
||||
"avatar": req.Avatar,
|
||||
"gender": req.Gender,
|
||||
}
|
||||
var birthday model.Date
|
||||
if req.Birthday != nil {
|
||||
value, err := NormalizeBirthday(req.Birthday)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, httpx.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
birthday = value
|
||||
if birthday.IsZero() {
|
||||
updates["birthday"] = nil
|
||||
} else {
|
||||
updates["birthday"] = birthday
|
||||
}
|
||||
}
|
||||
if req.Status != nil {
|
||||
updates["status"] = *req.Status
|
||||
@@ -285,6 +312,10 @@ func Update(db *gorm.DB) gin.HandlerFunc {
|
||||
|
||||
user.Nickname = req.Nickname
|
||||
user.Avatar = req.Avatar
|
||||
user.Gender = req.Gender
|
||||
if req.Birthday != nil {
|
||||
user.Birthday = birthday
|
||||
}
|
||||
if req.Status != nil {
|
||||
user.Status = *req.Status
|
||||
}
|
||||
@@ -427,6 +458,21 @@ func ReplaceGroups(tx *gorm.DB, userID uint, groupIDs []uint) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// NormalizeBirthday 校验生日格式与范围,空串表示清空(返回零值)。
|
||||
func NormalizeBirthday(value *string) (model.Date, error) {
|
||||
if value == nil || *value == "" {
|
||||
return model.Date{}, nil
|
||||
}
|
||||
birthday, err := time.Parse("2006-01-02", *value)
|
||||
if err != nil {
|
||||
return model.Date{}, errors.New("invalid birthday")
|
||||
}
|
||||
if birthday.After(time.Now()) {
|
||||
return model.Date{}, errors.New("birthday cannot be in the future")
|
||||
}
|
||||
return model.Date{Time: birthday}, nil
|
||||
}
|
||||
|
||||
// findGroups 查询用户组并校验全部存在。
|
||||
func findGroups(ctx context.Context, db *gorm.DB, ids []uint) ([]model.UserGroup, error) {
|
||||
unique := dedupeIDs(ids)
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
@@ -158,3 +159,83 @@ func TestUserValidation(t *testing.T) {
|
||||
t.Errorf("非法 id 状态码 = %d, 期望 %d", w.Code, http.StatusBadRequest)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserProfileFields(t *testing.T) {
|
||||
env := testutil.Setup(t)
|
||||
r := env.AdminRouter()
|
||||
|
||||
w := testutil.Call(t, r, http.MethodPost, "/api/users", map[string]any{
|
||||
"username": "dave",
|
||||
"email": "dave@example.com",
|
||||
"password": "secret123",
|
||||
"gender": "male",
|
||||
"birthday": "1995-06-15",
|
||||
})
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("创建状态码 = %d, 期望 %d, body=%s", w.Code, http.StatusCreated, w.Body.String())
|
||||
}
|
||||
created := testutil.DecodeUser(t, w)
|
||||
if created.Gender != model.GenderMale {
|
||||
t.Errorf("gender = %q, 期望 %q", created.Gender, model.GenderMale)
|
||||
}
|
||||
if created.Birthday.IsZero() || created.Birthday.Format("2006-01-02") != "1995-06-15" {
|
||||
t.Errorf("birthday 异常: %v", created.Birthday.Time)
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
body map[string]any
|
||||
}{
|
||||
{"非法性别", map[string]any{"username": "e1", "email": "e1@example.com", "password": "secret123", "gender": "unknown"}},
|
||||
{"生日格式非法", map[string]any{"username": "e2", "email": "e2@example.com", "password": "secret123", "birthday": "15-06-1995"}},
|
||||
{"生日在未来", map[string]any{"username": "e3", "email": "e3@example.com", "password": "secret123", "birthday": time.Now().AddDate(1, 0, 0).Format("2006-01-02")}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
w := testutil.Call(t, r, http.MethodPost, "/api/users", tc.body)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("%s 状态码 = %d, 期望 %d, body=%s", tc.name, w.Code, http.StatusBadRequest, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
detailPath := fmt.Sprintf("/api/users/%d", created.ID)
|
||||
|
||||
w = testutil.Call(t, r, http.MethodPut, detailPath, map[string]any{
|
||||
"nickname": "Dave",
|
||||
"gender": model.GenderFemale,
|
||||
"birthday": "1990-01-01",
|
||||
})
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("更新状态码 = %d, 期望 %d, body=%s", w.Code, http.StatusOK, w.Body.String())
|
||||
}
|
||||
updated := testutil.DecodeUser(t, w)
|
||||
if updated.Gender != model.GenderFemale || updated.Birthday.IsZero() || updated.Birthday.Format("2006-01-02") != "1990-01-01" {
|
||||
t.Fatalf("更新结果异常: gender=%q birthday=%v", updated.Gender, updated.Birthday.Time)
|
||||
}
|
||||
|
||||
w = testutil.Call(t, r, http.MethodPut, detailPath, map[string]any{"gender": model.GenderFemale})
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("更新状态码 = %d, 期望 %d", w.Code, http.StatusOK)
|
||||
}
|
||||
kept := testutil.DecodeUser(t, w)
|
||||
if kept.Birthday.IsZero() {
|
||||
t.Error("未提供 birthday 时不应变为 null")
|
||||
} else if got := kept.Birthday.Format("2006-01-02"); got != "1990-01-01" {
|
||||
t.Errorf("未提供 birthday 不应修改: %q", got)
|
||||
}
|
||||
|
||||
w = testutil.Call(t, r, http.MethodPut, detailPath, map[string]any{"gender": "", "birthday": ""})
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("清空状态码 = %d, 期望 %d, body=%s", w.Code, http.StatusOK, w.Body.String())
|
||||
}
|
||||
if cleared := testutil.DecodeUser(t, w); cleared.Gender != "" || !cleared.Birthday.IsZero() {
|
||||
t.Errorf("清空失败: gender=%q birthday=%v", cleared.Gender, cleared.Birthday.Time)
|
||||
}
|
||||
|
||||
var stored model.User
|
||||
if err := env.DB.First(&stored, created.ID).Error; err != nil {
|
||||
t.Fatalf("查询数据库失败: %v", err)
|
||||
}
|
||||
if stored.Gender != "" || !stored.Birthday.IsZero() {
|
||||
t.Errorf("数据库未清空: gender=%q birthday=%v", stored.Gender, stored.Birthday.Time)
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
@@ -95,6 +96,7 @@ func main() {
|
||||
|
||||
// 静态文件服务
|
||||
fs := http.FileServer(http.Dir(cfg.Static.Dir))
|
||||
indexFile := filepath.Join(cfg.Static.Dir, "index.html")
|
||||
// 中间件处理路由
|
||||
r.Use(func(c *gin.Context) {
|
||||
if strings.HasPrefix(c.Request.URL.Path, cfg.API.Prefix) {
|
||||
@@ -102,7 +104,13 @@ func main() {
|
||||
return
|
||||
}
|
||||
|
||||
// 处理静态文件
|
||||
// 处理静态文件;未命中的无扩展名路径回退到 index.html,支持前端 history 路由
|
||||
cleanPath := filepath.Clean(c.Request.URL.Path)
|
||||
if _, err := os.Stat(filepath.Join(cfg.Static.Dir, cleanPath)); err != nil && filepath.Ext(cleanPath) == "" {
|
||||
c.File(indexFile)
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
fs.ServeHTTP(c.Writer, c.Request)
|
||||
c.Abort()
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user