This commit is contained in:
ryan
2026-06-08 08:19:22 +08:00
parent 4b72419a96
commit 9d0f9f0576
42 changed files with 1412 additions and 809 deletions
+174 -77
View File
@@ -1164,6 +1164,79 @@ const docTemplate = `{
}
}
}
},
"post": {
"security": [
{
"SessionCookie": []
}
],
"description": "创建一个本地密码登录的新用户,需要管理员权限",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"admin"
],
"summary": "创建用户",
"parameters": [
{
"description": "创建用户参数",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/user.createUserRequest"
}
}
],
"responses": {
"200": {
"description": "创建成功",
"schema": {
"allOf": [
{
"$ref": "#/definitions/util.ResponseAny"
},
{
"type": "object",
"properties": {
"data": {
"$ref": "#/definitions/user.user"
}
}
}
]
}
},
"400": {
"description": "参数错误或用户名已存在",
"schema": {
"$ref": "#/definitions/util.ResponseAny"
}
},
"401": {
"description": "未登录",
"schema": {
"$ref": "#/definitions/util.ResponseAny"
}
},
"403": {
"description": "无管理员权限",
"schema": {
"$ref": "#/definitions/util.ResponseAny"
}
},
"500": {
"description": "内部错误",
"schema": {
"$ref": "#/definitions/util.ResponseAny"
}
}
}
}
},
"/api/v1/admin/users/{id}/status": {
@@ -2223,6 +2296,64 @@ const docTemplate = `{
}
}
},
"/api/v1/user/change-password": {
"post": {
"description": "修改当前登录用户的密码。修改成功后,如果是首次明文登录的升级提示,则清除修改密码的提示状态。",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"user"
],
"summary": "修改用户密码",
"parameters": [
{
"description": "修改密码请求参数",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/user.changePasswordRequest"
}
}
],
"responses": {
"200": {
"description": "修改密码成功",
"schema": {
"allOf": [
{
"$ref": "#/definitions/util.ResponseAny"
},
{
"type": "object",
"properties": {
"data": {
"type": "string"
}
}
}
]
}
},
"400": {
"description": "原密码错误或新密码不符合要求",
"schema": {
"$ref": "#/definitions/util.ResponseAny"
}
},
"401": {
"description": "请先登录",
"schema": {
"$ref": "#/definitions/util.ResponseAny"
}
}
}
}
},
"/api/v1/user/login": {
"post": {
"description": "使用用户名和密码登录,登录成功后建立 Session。若管理员已关闭密码登录功能则返回错误。",
@@ -2665,23 +2796,6 @@ const docTemplate = `{
"TaskExecutionStatusFailed"
]
},
"model.TrustLevel": {
"type": "integer",
"enum": [
0,
1,
2,
3,
4
],
"x-enum-varnames": [
"TrustLevelNewUser",
"TrustLevelBasicUser",
"TrustLevelUser",
"TrustLevelActiveUser",
"TrustLevelLeader"
]
},
"model.Upload": {
"type": "object",
"properties": {
@@ -2834,54 +2948,21 @@ const docTemplate = `{
"oauth.BasicUserInfo": {
"type": "object",
"properties": {
"available_balance": {
"type": "number"
},
"avatar_url": {
"type": "string"
},
"community_balance": {
"type": "number"
},
"daily_limit": {
"type": "integer"
},
"id": {
"type": "integer"
},
"is_admin": {
"type": "boolean"
},
"need_change_password": {
"type": "boolean"
},
"nickname": {
"type": "string"
},
"pay_level": {
"type": "string"
},
"pay_score": {
"type": "integer"
},
"pending_balance": {
"type": "number"
},
"remain_quota": {
"type": "number"
},
"total_community": {
"type": "number"
},
"total_payment": {
"type": "number"
},
"total_receive": {
"type": "number"
},
"total_transfer": {
"type": "number"
},
"trust_level": {
"$ref": "#/definitions/model.TrustLevel"
},
"username": {
"type": "string"
}
@@ -3051,6 +3132,17 @@ const docTemplate = `{
}
}
},
"user.changePasswordRequest": {
"type": "object",
"properties": {
"new_password": {
"type": "string"
},
"old_password": {
"type": "string"
}
}
},
"user.createTokenRequest": {
"type": "object",
"properties": {
@@ -3059,6 +3151,35 @@ const docTemplate = `{
}
}
},
"user.createUserRequest": {
"type": "object",
"required": [
"password",
"username"
],
"properties": {
"is_active": {
"type": "boolean"
},
"is_admin": {
"type": "boolean"
},
"nickname": {
"type": "string",
"maxLength": 64
},
"password": {
"type": "string",
"maxLength": 64,
"minLength": 8
},
"username": {
"type": "string",
"maxLength": 64,
"minLength": 3
}
}
},
"user.listUsersResponse": {
"type": "object",
"properties": {
@@ -3123,15 +3244,9 @@ const docTemplate = `{
"user.user": {
"type": "object",
"properties": {
"available_balance": {
"type": "number"
},
"avatar_url": {
"type": "string"
},
"community_balance": {
"type": "number"
},
"created_at": {
"type": "string"
},
@@ -3150,24 +3265,6 @@ const docTemplate = `{
"nickname": {
"type": "string"
},
"pay_score": {
"type": "integer"
},
"total_community": {
"type": "number"
},
"total_payment": {
"type": "number"
},
"total_receive": {
"type": "number"
},
"total_transfer": {
"type": "number"
},
"trust_level": {
"$ref": "#/definitions/model.TrustLevel"
},
"updated_at": {
"type": "string"
},
+174 -77
View File
@@ -1157,6 +1157,79 @@
}
}
}
},
"post": {
"security": [
{
"SessionCookie": []
}
],
"description": "创建一个本地密码登录的新用户,需要管理员权限",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"admin"
],
"summary": "创建用户",
"parameters": [
{
"description": "创建用户参数",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/user.createUserRequest"
}
}
],
"responses": {
"200": {
"description": "创建成功",
"schema": {
"allOf": [
{
"$ref": "#/definitions/util.ResponseAny"
},
{
"type": "object",
"properties": {
"data": {
"$ref": "#/definitions/user.user"
}
}
}
]
}
},
"400": {
"description": "参数错误或用户名已存在",
"schema": {
"$ref": "#/definitions/util.ResponseAny"
}
},
"401": {
"description": "未登录",
"schema": {
"$ref": "#/definitions/util.ResponseAny"
}
},
"403": {
"description": "无管理员权限",
"schema": {
"$ref": "#/definitions/util.ResponseAny"
}
},
"500": {
"description": "内部错误",
"schema": {
"$ref": "#/definitions/util.ResponseAny"
}
}
}
}
},
"/api/v1/admin/users/{id}/status": {
@@ -2216,6 +2289,64 @@
}
}
},
"/api/v1/user/change-password": {
"post": {
"description": "修改当前登录用户的密码。修改成功后,如果是首次明文登录的升级提示,则清除修改密码的提示状态。",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"user"
],
"summary": "修改用户密码",
"parameters": [
{
"description": "修改密码请求参数",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/user.changePasswordRequest"
}
}
],
"responses": {
"200": {
"description": "修改密码成功",
"schema": {
"allOf": [
{
"$ref": "#/definitions/util.ResponseAny"
},
{
"type": "object",
"properties": {
"data": {
"type": "string"
}
}
}
]
}
},
"400": {
"description": "原密码错误或新密码不符合要求",
"schema": {
"$ref": "#/definitions/util.ResponseAny"
}
},
"401": {
"description": "请先登录",
"schema": {
"$ref": "#/definitions/util.ResponseAny"
}
}
}
}
},
"/api/v1/user/login": {
"post": {
"description": "使用用户名和密码登录,登录成功后建立 Session。若管理员已关闭密码登录功能则返回错误。",
@@ -2658,23 +2789,6 @@
"TaskExecutionStatusFailed"
]
},
"model.TrustLevel": {
"type": "integer",
"enum": [
0,
1,
2,
3,
4
],
"x-enum-varnames": [
"TrustLevelNewUser",
"TrustLevelBasicUser",
"TrustLevelUser",
"TrustLevelActiveUser",
"TrustLevelLeader"
]
},
"model.Upload": {
"type": "object",
"properties": {
@@ -2827,54 +2941,21 @@
"oauth.BasicUserInfo": {
"type": "object",
"properties": {
"available_balance": {
"type": "number"
},
"avatar_url": {
"type": "string"
},
"community_balance": {
"type": "number"
},
"daily_limit": {
"type": "integer"
},
"id": {
"type": "integer"
},
"is_admin": {
"type": "boolean"
},
"need_change_password": {
"type": "boolean"
},
"nickname": {
"type": "string"
},
"pay_level": {
"type": "string"
},
"pay_score": {
"type": "integer"
},
"pending_balance": {
"type": "number"
},
"remain_quota": {
"type": "number"
},
"total_community": {
"type": "number"
},
"total_payment": {
"type": "number"
},
"total_receive": {
"type": "number"
},
"total_transfer": {
"type": "number"
},
"trust_level": {
"$ref": "#/definitions/model.TrustLevel"
},
"username": {
"type": "string"
}
@@ -3044,6 +3125,17 @@
}
}
},
"user.changePasswordRequest": {
"type": "object",
"properties": {
"new_password": {
"type": "string"
},
"old_password": {
"type": "string"
}
}
},
"user.createTokenRequest": {
"type": "object",
"properties": {
@@ -3052,6 +3144,35 @@
}
}
},
"user.createUserRequest": {
"type": "object",
"required": [
"password",
"username"
],
"properties": {
"is_active": {
"type": "boolean"
},
"is_admin": {
"type": "boolean"
},
"nickname": {
"type": "string",
"maxLength": 64
},
"password": {
"type": "string",
"maxLength": 64,
"minLength": 8
},
"username": {
"type": "string",
"maxLength": 64,
"minLength": 3
}
}
},
"user.listUsersResponse": {
"type": "object",
"properties": {
@@ -3116,15 +3237,9 @@
"user.user": {
"type": "object",
"properties": {
"available_balance": {
"type": "number"
},
"avatar_url": {
"type": "string"
},
"community_balance": {
"type": "number"
},
"created_at": {
"type": "string"
},
@@ -3143,24 +3258,6 @@
"nickname": {
"type": "string"
},
"pay_score": {
"type": "integer"
},
"total_community": {
"type": "number"
},
"total_payment": {
"type": "number"
},
"total_receive": {
"type": "number"
},
"total_transfer": {
"type": "number"
},
"trust_level": {
"$ref": "#/definitions/model.TrustLevel"
},
"updated_at": {
"type": "string"
},
+109 -54
View File
@@ -156,20 +156,6 @@ definitions:
- TaskExecutionStatusRunning
- TaskExecutionStatusSucceeded
- TaskExecutionStatusFailed
model.TrustLevel:
enum:
- 0
- 1
- 2
- 3
- 4
type: integer
x-enum-varnames:
- TrustLevelNewUser
- TrustLevelBasicUser
- TrustLevelUser
- TrustLevelActiveUser
- TrustLevelLeader
model.Upload:
properties:
created_at:
@@ -276,38 +262,16 @@ definitions:
type: object
oauth.BasicUserInfo:
properties:
available_balance:
type: number
avatar_url:
type: string
community_balance:
type: number
daily_limit:
type: integer
id:
type: integer
is_admin:
type: boolean
need_change_password:
type: boolean
nickname:
type: string
pay_level:
type: string
pay_score:
type: integer
pending_balance:
type: number
remain_quota:
type: number
total_community:
type: number
total_payment:
type: number
total_receive:
type: number
total_transfer:
type: number
trust_level:
$ref: '#/definitions/model.TrustLevel'
username:
type: string
type: object
@@ -421,11 +385,39 @@ definitions:
total:
type: integer
type: object
user.changePasswordRequest:
properties:
new_password:
type: string
old_password:
type: string
type: object
user.createTokenRequest:
properties:
name:
type: string
type: object
user.createUserRequest:
properties:
is_active:
type: boolean
is_admin:
type: boolean
nickname:
maxLength: 64
type: string
password:
maxLength: 64
minLength: 8
type: string
username:
maxLength: 64
minLength: 3
type: string
required:
- password
- username
type: object
user.listUsersResponse:
properties:
total:
@@ -467,12 +459,8 @@ definitions:
type: object
user.user:
properties:
available_balance:
type: number
avatar_url:
type: string
community_balance:
type: number
created_at:
type: string
id:
@@ -485,18 +473,6 @@ definitions:
type: string
nickname:
type: string
pay_score:
type: integer
total_community:
type: number
total_payment:
type: number
total_receive:
type: number
total_transfer:
type: number
trust_level:
$ref: '#/definitions/model.TrustLevel'
updated_at:
type: string
username:
@@ -1208,6 +1184,50 @@ paths:
summary: 获取用户列表
tags:
- admin
post:
consumes:
- application/json
description: 创建一个本地密码登录的新用户,需要管理员权限
parameters:
- description: 创建用户参数
in: body
name: request
required: true
schema:
$ref: '#/definitions/user.createUserRequest'
produces:
- application/json
responses:
"200":
description: 创建成功
schema:
allOf:
- $ref: '#/definitions/util.ResponseAny'
- properties:
data:
$ref: '#/definitions/user.user'
type: object
"400":
description: 参数错误或用户名已存在
schema:
$ref: '#/definitions/util.ResponseAny'
"401":
description: 未登录
schema:
$ref: '#/definitions/util.ResponseAny'
"403":
description: 无管理员权限
schema:
$ref: '#/definitions/util.ResponseAny'
"500":
description: 内部错误
schema:
$ref: '#/definitions/util.ResponseAny'
security:
- SessionCookie: []
summary: 创建用户
tags:
- admin
/api/v1/admin/users/{id}/status:
put:
consumes:
@@ -1838,6 +1858,41 @@ paths:
summary: 轮换一个 AccessToken
tags:
- user
/api/v1/user/change-password:
post:
consumes:
- application/json
description: 修改当前登录用户的密码。修改成功后,如果是首次明文登录的升级提示,则清除修改密码的提示状态。
parameters:
- description: 修改密码请求参数
in: body
name: request
required: true
schema:
$ref: '#/definitions/user.changePasswordRequest'
produces:
- application/json
responses:
"200":
description: 修改密码成功
schema:
allOf:
- $ref: '#/definitions/util.ResponseAny'
- properties:
data:
type: string
type: object
"400":
description: 原密码错误或新密码不符合要求
schema:
$ref: '#/definitions/util.ResponseAny'
"401":
description: 请先登录
schema:
$ref: '#/definitions/util.ResponseAny'
summary: 修改用户密码
tags:
- user
/api/v1/user/login:
post:
consumes:
@@ -1,4 +1,4 @@
import {FilesMain} from "@/components/common/settings/files"
import {FilesMain} from "@/components/common/admin/files"
export default function FilesPage() {
return <FilesMain />
+8 -9
View File
@@ -46,12 +46,12 @@ export function LoginForm() {
const publicConfigQuery = useQuery({
queryKey: ["public-config"],
queryFn: services.config.getPublicConfig,
queryFn: () => services.config.getPublicConfig(),
})
const authSourcesQuery = useQuery({
queryKey: ["auth-sources"],
queryFn: services.auth.getAuthSources,
queryFn: () => services.auth.getAuthSources(),
enabled: publicConfigQuery.data?.oidc_login_enabled ?? true,
})
@@ -61,7 +61,7 @@ export function LoginForm() {
)
const loginMutation = useMutation({
mutationFn: services.auth.login,
mutationFn: (req: any) => services.auth.login(req),
onSuccess: (user) => {
setUser(user)
router.replace(redirectTarget)
@@ -72,7 +72,7 @@ export function LoginForm() {
})
const registerMutation = useMutation({
mutationFn: services.auth.register,
mutationFn: (req: any) => services.auth.register(req),
onSuccess: (user) => {
setUser(user)
router.replace(redirectTarget)
@@ -125,9 +125,6 @@ export function LoginForm() {
<h2 className="text-xl font-semibold tracking-tight text-foreground">
账号登录
</h2>
<p className="text-sm text-muted-foreground">
使用账号密码或第三方 OIDC 认证源登录
</p>
</div>
<Tabs value={mode} onValueChange={(value) => setMode(value as "login" | "register")}>
@@ -165,6 +162,7 @@ export function LoginForm() {
<Button
type="button"
className="w-full"
variant={"secondary"}
onClick={handlePasswordLogin}
disabled={!passwordLoginEnabled || loginMutation.isPending}
>
@@ -176,7 +174,7 @@ export function LoginForm() {
) : (
<>
<KeyRound className="mr-2 size-4" />
账号密码登录
登录
</>
)}
</Button>
@@ -216,6 +214,7 @@ export function LoginForm() {
<Button
type="button"
className="w-full"
variant="secondary"
onClick={handleRegister}
disabled={!registrationEnabled || registerMutation.isPending}
>
@@ -258,7 +257,7 @@ export function LoginForm() {
</div>
))
) : null}
</CardContent>
</Card>
)
+6 -12
View File
@@ -142,14 +142,12 @@ export function LoginPage() {
}}
className="relative z-10 w-full max-w-sm px-4"
>
<div className="text-center mb-8 space-y-2">
<h1 className="text-3xl font-bold tracking-tight text-foreground">
LINUX DO <span className="font-serif italic text-primary">Credit</span>
</h1>
<p className="text-sm text-muted-foreground font-light">
简单、安全,专为社区设计
</p>
</div>
{/*Title Do Not Remove*/}
{/*<div className="text-center mb-8 space-y-2">*/}
{/* <h1 className="text-3xl font-bold tracking-tight text-foreground">*/}
{/* Refresh*/}
{/* </h1>*/}
{/*</div>*/}
<AnimatePresence mode="wait">
{isProcessingCallback || isCheckingSession ? (
@@ -209,10 +207,6 @@ export function LoginPage() {
</motion.div>
)}
</AnimatePresence>
<div className="mt-8 text-center text-xs text-muted-foreground">
&copy; {new Date().getFullYear()} LINUX DO Credit. 版权所有
</div>
</motion.div>
</AuroraBackground>
)
@@ -0,0 +1,160 @@
"use client"
import {useEffect, useState} from "react"
import {Button} from "@/components/ui/button"
import {Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle} from "@/components/ui/dialog"
import {Input} from "@/components/ui/input"
import {Label} from "@/components/ui/label"
import {Switch} from "@/components/ui/switch"
import {useAdminUsers} from "@/contexts/admin-users-context"
import type {CreateUserRequest} from "@/lib/services/admin"
const emptyForm: CreateUserRequest = {
username: "",
password: "",
nickname: "",
is_active: true,
is_admin: false,
}
export function CreateUserModal({
isOpen,
onClose,
}: {
isOpen: boolean
onClose: () => void
}) {
const { createUser } = useAdminUsers()
const [form, setForm] = useState<CreateUserRequest>(emptyForm)
const [saving, setSaving] = useState(false)
const [errors, setErrors] = useState<Record<string, string>>({})
useEffect(() => {
if (isOpen) {
setForm(emptyForm)
setErrors({})
} else {
setSaving(false)
}
}, [isOpen])
const validate = () => {
const newErrors: Record<string, string> = {}
if (!form.username.trim()) {
newErrors.username = "用户名不能为空"
} else if (form.username.trim().length < 3) {
newErrors.username = "用户名长度不能少于 3 位"
}
if (!form.password) {
newErrors.password = "密码不能为空"
} else if (form.password.length < 8) {
newErrors.password = "密码长度不能少于 8 位"
}
setErrors(newErrors)
return Object.keys(newErrors).length === 0
}
const handleSave = async (e: React.FormEvent) => {
e.preventDefault()
if (!validate()) return
setSaving(true)
try {
await createUser({
...form,
username: form.username.trim(),
nickname: form.nickname?.trim() || undefined,
})
onClose()
} catch {
// Errors are already handled by context toast notifications
} finally {
setSaving(false)
}
}
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>新增用户</DialogTitle>
<DialogDescription>
直接创建一个本地密码登录的新账户。
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSave} className="space-y-4 pt-2">
<div className="space-y-1.5">
<Label htmlFor="username">用户名</Label>
<Input
id="username"
value={form.username}
onChange={(e) => setForm((prev) => ({ ...prev, username: e.target.value }))}
placeholder="请输入用户名 (至少 3 位)"
/>
{errors.username && (
<p className="text-xs text-destructive">{errors.username}</p>
)}
</div>
<div className="space-y-1.5">
<Label htmlFor="nickname">昵称 (选填)</Label>
<Input
id="nickname"
value={form.nickname}
onChange={(e) => setForm((prev) => ({ ...prev, nickname: e.target.value }))}
placeholder="请输入昵称"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="password">密码</Label>
<Input
id="password"
type="password"
value={form.password}
onChange={(e) => setForm((prev) => ({ ...prev, password: e.target.value }))}
placeholder="请输入登录密码 (至少 8 位)"
/>
{errors.password && (
<p className="text-xs text-destructive">{errors.password}</p>
)}
</div>
<div className="flex items-center justify-between rounded-lg border border-dashed p-3 bg-muted/10">
<div>
<div className="font-medium text-sm">启用账户</div>
<div className="text-xs text-muted-foreground">禁用后此账号将无法登录系统。</div>
</div>
<Switch
checked={form.is_active}
onCheckedChange={(checked) => setForm((prev) => ({ ...prev, is_active: checked }))}
/>
</div>
<div className="flex items-center justify-between rounded-lg border border-dashed p-3 bg-muted/10">
<div>
<div className="font-medium text-sm">管理员权限</div>
<div className="text-xs text-muted-foreground">开启后此账号将拥有后台管理权限。</div>
</div>
<Switch
checked={form.is_admin}
onCheckedChange={(checked) => setForm((prev) => ({ ...prev, is_admin: checked }))}
/>
</div>
<div className="flex justify-end gap-2 pt-2 border-t mt-2">
<Button variant="outline" type="button" onClick={onClose}>
取消
</Button>
<Button type="submit" disabled={saving} variant="secondary">
{saving ? "创建中..." : "创建"}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
)
}
@@ -147,7 +147,7 @@ export function FilesMain() {
文件管理
</h1>
<p className="text-sm text-muted-foreground">
管理您上传的所有文件,支持下载和批量操作
管理您上传的所有文件,支持下载 and 批量操作
</p>
</div>
</div>
+39 -22
View File
@@ -1,25 +1,38 @@
"use client"
import { useState, useEffect } from "react"
import { Button } from "@/components/ui/button"
import { Switch } from "@/components/ui/switch"
import { Separator } from "@/components/ui/separator"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Sheet, SheetTitle, SheetContent } from "@/components/ui/sheet"
import { Badge } from "@/components/ui/badge"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Layers, Search, UserX, UserCheck, Eye, ShieldCheck, Filter, X, ChevronDown, ChevronLeft, ChevronRight, Loader2 } from "lucide-react"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
import {useEffect, useState} from "react"
import {Button} from "@/components/ui/button"
import {Switch} from "@/components/ui/switch"
import {Separator} from "@/components/ui/separator"
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from "@/components/ui/table"
import {Sheet, SheetContent, SheetTitle} from "@/components/ui/sheet"
import {Badge} from "@/components/ui/badge"
import {Avatar, AvatarFallback, AvatarImage} from "@/components/ui/avatar"
import {
ChevronDown,
ChevronLeft,
ChevronRight,
Eye,
Filter,
Layers,
Loader2,
Plus,
Search,
ShieldCheck,
UserCheck,
UserX,
X
} from "lucide-react"
import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from "@/components/ui/tooltip"
import { AdminUser, AdminService } from "@/lib/services"
import { toast } from "sonner"
import { formatDateTime } from "@/lib/utils"
import { EmptyStateWithBorder } from "@/components/layout/empty"
import { LoadingStateWithBorder } from "@/components/layout/loading"
import { ErrorInline } from "@/components/layout/error"
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
import { cn } from "@/lib/utils"
import { useAdminUsers } from "@/contexts/admin-users-context"
import {AdminUser} from "@/lib/services"
import {cn, formatDateTime} from "@/lib/utils"
import {EmptyStateWithBorder} from "@/components/layout/empty"
import {LoadingStateWithBorder} from "@/components/layout/loading"
import {ErrorInline} from "@/components/layout/error"
import {DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger} from "@/components/ui/dropdown-menu"
import {useAdminUsers} from "@/contexts/admin-users-context"
import {CreateUserModal} from "./create-user-modal"
export function UsersManager() {
const {
@@ -43,6 +56,7 @@ export function UsersManager() {
const [selectedUser, setSelectedUser] = useState<AdminUser | null>(null)
const [detailOpen, setDetailOpen] = useState(false)
const [createModalOpen, setCreateModalOpen] = useState(false)
useEffect(() => {
fetchUsers()
@@ -277,6 +291,10 @@ export function UsersManager() {
<div className="flex flex-col gap-1">
<div className="text-2xl font-semibold">用户管理</div>
</div>
<Button variant="secondary" size="sm" className="h-7 text-xs" onClick={() => setCreateModalOpen(true)}>
<Plus className="size-3.5 mr-1" />
新增用户
</Button>
</div>
{renderFilterBar()}
@@ -409,9 +427,6 @@ export function UsersManager() {
<Badge variant="secondary" className="h-4.5 px-1.5 text-[9px] uppercase font-medium">
UID: {selectedUser.id}
</Badge>
<Badge variant="secondary" className="h-4.5 px-1.5 text-[9px] uppercase font-medium">
Lv.{selectedUser.trust_level}
</Badge>
{selectedUser.is_admin && (
<Badge className="h-4.5 px-1.5 text-[9px] uppercase font-medium bg-primary text-primary-foreground">
Admin
@@ -482,6 +497,8 @@ export function UsersManager() {
)}
</SheetContent>
</Sheet>
<CreateUserModal isOpen={createModalOpen} onClose={() => setCreateModalOpen(false)} />
</div>
)
}
@@ -24,11 +24,7 @@ export const termsSections: PolicySection[] = [
title: "2. 服务定义与性质界定",
content: (
<div className="space-y-4 text-sm leading-relaxed">
<p><strong>2.1 纯技术研发脚手架:</strong>本平台是一个开源/闭源授权的技术二次开发底座。我们仅提供用户注册、API 调用、安全配置维护等<strong>纯软件技术服务</strong>。</p>
<p><strong>2.2 非金融机构与无承兑申明:</strong></p>
<ul className="list-disc pl-4 md:pl-5 space-y-2">
<li><strong>非持牌金融或支付机构:</strong>本系统不是银行、商户收单或清算结算机构。</li>
<li><strong>不提供资金管理:</strong>系统无真实法币、加密货币或商业代金券充值、存储与兑现功能。若在二开中加入了积分等属性,其最终性质也应限制于虚拟软件积分。</li>
<li><strong>责任自担:</strong>关于用户对本系统进行二次开发并应用于其他生产环境产生的任何业务行为,由二开部署运营主体承担全部合规责任。</li>
</ul>
</div>
@@ -9,7 +9,7 @@ import {ArrowRight, ExternalLink, FileText, HelpCircle, Layers, Shield, ShieldCh
import Link from "next/link"
export function HomeMain() {
const { user, getTrustLevelLabel } = useUser()
const { user } = useUser()
const quickLinks = [
{
@@ -90,56 +90,56 @@ export function AuthSourceModal({
<div className="grid gap-4 md:grid-cols-2 pt-2">
<div className="space-y-2">
<Label>标识符 (英文名称)</Label>
<Input
value={form.name}
<Input
value={form.name}
disabled={!!source}
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
placeholder="例如: github"
/>
</div>
<div className="space-y-2">
<Label>展示名称</Label>
<Input
value={form.display_name}
onChange={(e) => setForm((prev) => ({ ...prev, display_name: e.target.value }))}
<Input
value={form.display_name}
onChange={(e) => setForm((prev) => ({ ...prev, display_name: e.target.value }))}
placeholder="例如: GitHub 登录"
/>
</div>
<div className="space-y-2">
<Label>Client ID</Label>
<Input
value={form.client_id}
onChange={(e) => setForm((prev) => ({ ...prev, client_id: e.target.value }))}
<Input
value={form.client_id}
onChange={(e) => setForm((prev) => ({ ...prev, client_id: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label>Client Secret</Label>
<Input
value={form.client_secret}
onChange={(e) => setForm((prev) => ({ ...prev, client_secret: e.target.value }))}
placeholder={source ? "留空则保留原值" : ""}
<Input
value={form.client_secret}
onChange={(e) => setForm((prev) => ({ ...prev, client_secret: e.target.value }))}
placeholder={source ? "留空则保留原值" : ""}
/>
</div>
<div className="space-y-2 md:col-span-2">
<Label>Discovery URL (OIDC 发行方 URL)</Label>
<Input
value={form.openid_discovery_url}
onChange={(e) => setForm((prev) => ({ ...prev, openid_discovery_url: e.target.value }))}
<Input
value={form.openid_discovery_url}
onChange={(e) => setForm((prev) => ({ ...prev, openid_discovery_url: e.target.value }))}
placeholder="https://..."
/>
</div>
<div className="space-y-2">
<Label>Scopes</Label>
<Input
value={form.scopes}
onChange={(e) => setForm((prev) => ({ ...prev, scopes: e.target.value }))}
<Input
value={form.scopes}
onChange={(e) => setForm((prev) => ({ ...prev, scopes: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label>图标 URL (可选)</Label>
<Input
value={form.icon_url}
onChange={(e) => setForm((prev) => ({ ...prev, icon_url: e.target.value }))}
<Input
value={form.icon_url}
onChange={(e) => setForm((prev) => ({ ...prev, icon_url: e.target.value }))}
placeholder="https://... 或留空"
/>
</div>
@@ -154,7 +154,7 @@ export function AuthSourceModal({
<Button variant="outline" type="button" onClick={onClose}>
取消
</Button>
<Button type="button" onClick={saveSource} disabled={saving} className="bg-indigo-600 hover:bg-indigo-700 text-white shadow-md shadow-indigo-600/10">
<Button type="button" onClick={saveSource} disabled={saving} variant="secondary">
{saving ? "保存中..." : "保存"}
</Button>
</div>
+104 -6
View File
@@ -14,18 +14,53 @@ import {
BreadcrumbSeparator
} from "@/components/ui/breadcrumb"
import {useUser} from "@/contexts/user-context"
import {ArrowRight, Link2, Loader2, Shield, Unlink} from "lucide-react"
import {ArrowRight, Info, Link2, Loader2, Lock, Shield, Unlink} from "lucide-react"
import {Button} from "@/components/ui/button"
import {Input} from "@/components/ui/input"
import {Separator} from "@/components/ui/separator"
import {AuthService} from "@/lib/services"
import {toast} from "sonner"
export function ProfileMain() {
const { user, loading, getTrustLevelLabel } = useUser()
const { user, loading, refetch } = useUser()
const controls = useAnimation()
const isAnimatingRef = React.useRef(false)
const queryClient = useQueryClient()
const [oldPassword, setOldPassword] = React.useState("")
const [newPassword, setNewPassword] = React.useState("")
const [confirmPassword, setConfirmPassword] = React.useState("")
const changePasswordMutation = useMutation({
mutationFn: (req: any) => AuthService.changePassword(req),
onSuccess: () => {
toast.success("密码修改成功")
setOldPassword("")
setNewPassword("")
setConfirmPassword("")
void refetch()
},
onError: (error: Error) => {
toast.error(error.message || "修改密码失败,请重试")
},
})
const handlePasswordChange = (e: React.FormEvent) => {
e.preventDefault()
if (newPassword !== confirmPassword) {
toast.error("两次输入的新密码不一致")
return
}
if (newPassword.length < 8) {
toast.error("新密码长度不能少于 8 位")
return
}
changePasswordMutation.mutate({
old_password: oldPassword,
new_password: newPassword,
})
}
const externalAccountBindingsQuery = useQuery({
queryKey: ["auth", "external-accounts"],
queryFn: () => AuthService.getExternalAccountBindings(),
@@ -135,10 +170,7 @@ export function ProfileMain() {
<div className="text-sm font-mono font-semibold">{user.id}</div>
</div>
<div className="space-y-1">
<div className="text-xs text-muted-foreground">信任等级</div>
<div className="text-sm font-semibold">{getTrustLevelLabel(user.trust_level)}</div>
</div>
<div className="space-y-1">
<div className="text-xs text-muted-foreground">管理员身份</div>
@@ -158,6 +190,72 @@ export function ProfileMain() {
</div>
</div>
{/* 修改密码面板 */}
<div className="space-y-6 bg-card border border-dashed rounded-lg p-6">
<div className="border-b pb-4 flex items-center gap-2">
<div className="p-1.5 rounded-lg bg-amber-500/10 text-amber-500">
<Lock className="size-4" />
</div>
<div>
<h2 className="text-lg font-semibold tracking-tight">修改密码</h2>
<p className="text-xs text-muted-foreground">更改您的账号密码以确保安全。密码长度不能少于 8 位。</p>
</div>
</div>
{user.need_change_password && (
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 px-3.5 py-3 text-xs text-amber-500 flex items-start gap-2.5">
<Info className="size-4 shrink-0 mt-0.5" />
<div>
<p className="font-semibold">密码风险提示</p>
<p className="mt-0.5 text-amber-500/80 leading-relaxed font-normal">
为了账号安全,您必须修改初始密码。
</p>
</div>
</div>
)}
<form onSubmit={handlePasswordChange} className="space-y-4 pt-2 max-w-md">
<div className="space-y-2">
<label className="text-xs font-medium text-muted-foreground">当前密码</label>
<Input
type="password"
placeholder="请输入当前密码"
value={oldPassword}
onChange={(e) => setOldPassword(e.target.value)}
required
/>
</div>
<div className="space-y-2">
<label className="text-xs font-medium text-muted-foreground">新密码</label>
<Input
type="password"
placeholder="新密码(至少 8 位)"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
required
/>
</div>
<div className="space-y-2">
<label className="text-xs font-medium text-muted-foreground">确认新密码</label>
<Input
type="password"
placeholder="确认新密码"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
/>
</div>
<Button
type="submit"
className="w-full sm:w-auto"
disabled={changePasswordMutation.isPending}
>
{changePasswordMutation.isPending ? "提交中..." : "确认修改"}
</Button>
</form>
</div>
{/* 账号绑定面板 */}
<div className="space-y-6 bg-card border border-dashed rounded-lg p-6">
<div className="border-b pb-4 flex items-center gap-2">
+28 -12
View File
@@ -1,17 +1,17 @@
"use client"
import { useState, useEffect, memo } from "react"
import { AnimatePresence, motion } from "motion/react"
import { Button } from "@/components/ui/button"
import { Bell, Plus, Settings, Search, Moon, Sun, Maximize2, Minimize2 } from "lucide-react"
import { useUser } from "@/contexts/user-context"
import { useBellRing } from "@/contexts/bell-ring-context"
import { useNotificationSettings } from "@/contexts/notification-settings-context"
import { SidebarTrigger } from "@/components/ui/sidebar"
import { useTheme } from "next-themes"
import { useRouter } from "next/navigation"
import { SearchDialog } from "@/components/layout/search-dialog"
import { Kbd } from "@/components/ui/kbd"
import {memo, useEffect, useState} from "react"
import {AnimatePresence, motion} from "motion/react"
import {Button} from "@/components/ui/button"
import {Bell, Info, Maximize2, Minimize2, Moon, Search, Settings, Sun} from "lucide-react"
import {useUser} from "@/contexts/user-context"
import {useBellRing} from "@/contexts/bell-ring-context"
import {useNotificationSettings} from "@/contexts/notification-settings-context"
import {SidebarTrigger} from "@/components/ui/sidebar"
import {useTheme} from "next-themes"
import {useRouter} from "next/navigation"
import {SearchDialog} from "@/components/layout/search-dialog"
import {Kbd} from "@/components/ui/kbd"
/**
@@ -75,6 +75,22 @@ export function SiteHeader({ isFullWidth = false, onToggleFullWidth }: { isFullW
return (
<div className="flex flex-col w-full">
<AppBanner />
{user?.need_change_password && (
<div className="bg-amber-500/10 border-b border-amber-500/20 px-4 py-3 text-amber-500 text-xs sm:text-sm flex items-center justify-between gap-4">
<div className="flex items-center gap-2">
<Info className="size-4 shrink-0 text-amber-500" />
<span>为了您的账号安全,请立即修改密码!</span>
</div>
<Button
variant="outline"
size="sm"
className="text-amber-500 hover:text-amber-600 border-amber-500/30 hover:border-amber-500/50 bg-transparent shrink-0 h-8 px-3 text-xs"
onClick={() => router.push("/settings/profile")}
>
去修改密码
</Button>
</div>
)}
<header className="flex h-(--header-height) shrink-0 items-center bg-background px-4 md:px-0">
<div className="flex w-full items-center justify-between md:hidden">
<div className="flex items-center gap-2">
+4 -4
View File
@@ -65,13 +65,13 @@ import {useUser} from "@/contexts/user-context"
const data = {
navMain: [
{ title: "首页", url: "/home", icon: Home },
{ title: "文件管理", url: "/settings/files", icon: FolderOpen },
],
admin: [
{ title: "系统设置", url: "/admin/settings", icon: Settings },
{ title: "系统配置", url: "/admin/system", icon: ShieldCheck },
{ title: "用户管理", url: "/admin/users", icon: UserRound },
{ title: "任务管理", url: "/admin/tasks", icon: Layers },
{ title: "文件管理", url: "/admin/files", icon: FolderOpen },
],
document: [
{ title: "接口文档", url: "/docs/api", icon: CreditCard, external: true },
@@ -92,7 +92,7 @@ const data = {
*/
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
const { toggleSidebar, state, isMobile, setOpenMobile } = useSidebar()
const { user, getTrustLevelLabel, logout } = useUser()
const { user, logout } = useUser()
const [showLogoutDialog, setShowLogoutDialog] = React.useState(false)
const [isLoggingOut, setIsLoggingOut] = React.useState(false)
const pathname = usePathname()
@@ -185,7 +185,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
{user?.nickname || user?.username || "Unknown User"}
</span>
<span className="text-[11px] font-medium text-muted-foreground/100 truncate w-full text-left ml-2">
{user ? getTrustLevelLabel(user.trust_level) : "Trust Level Unknown"}
{user?.is_admin ? "系统管理员" : "普通用户"}
</span>
</div>
<ChevronDown className="size-4 text-muted-foreground ml-auto group-data-[collapsible=icon]:hidden" />
@@ -226,7 +226,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
{user?.nickname || user?.username || "Unknown User"}
</span>
<span className="text-xs font-base text-muted-foreground">
{user ? getTrustLevelLabel(user.trust_level) : "Trust Level Unknown"}
{user?.is_admin ? "系统管理员" : "普通用户"}
</span>
</div>
</DropdownMenuLabel>
+26 -9
View File
@@ -1,9 +1,9 @@
"use client"
import * as React from "react"
import { createContext, useContext, useCallback, useState, useRef, useEffect } from "react"
import { toast } from "sonner"
import { AdminService, AdminUser, ListUsersRequest } from "@/lib/services"
import {createContext, useCallback, useContext, useEffect, useRef, useState} from "react"
import {toast} from "sonner"
import {AdminService, AdminUser, CreateUserRequest, ListUsersRequest} from "@/lib/services"
/** 用户列表查询参数 */
export interface UserQueryParams {
@@ -38,6 +38,7 @@ interface AdminUsersContextState {
fetchUsers: (force?: boolean) => Promise<void>
refresh: () => Promise<void>
updateUserStatus: (user: AdminUser) => Promise<void>
createUser: (req: CreateUserRequest) => Promise<AdminUser>
}
const CACHE_DURATION = 5 * 60 * 1000 // 5 minutes cache
@@ -107,17 +108,17 @@ export function AdminUsersProvider({ children }: { children: React.ReactNode })
const requestId = ++latestRequestIdRef.current
try {
// Current API doesn't support status filter in listUsers?
// The previous implementation did client-side filtering.
// Current API doesn't support status filter in listUsers?
// The previous implementation did client-side filtering.
// Ideally backend supports it. If not, we fetch and filter?
// "AdminService.listUsers" in previous code only took page, page_size, username.
// So status filter was client side.
// So status filter was client side.
// However, caching client-filtered result is tricky if we don't have all data.
// But previous implementation fetched *paged* data then filtered? No, that would be wrong (filtering 20 items might leave 0).
// Let's check previous implementation:
// Let's check previous implementation:
// "const data = await AdminService.listUsers(...) ... let filteredUsers = data.users ... if (statusFilter...) filtered..."
// This means filtering happens ONLY on the current page of results! This is technically buggy if the user wants "all inactive users".
// But preserving that behavior for now.
// But preserving that behavior for now.
const requestParams: ListUsersRequest = {
page,
@@ -188,6 +189,21 @@ export function AdminUsersProvider({ children }: { children: React.ReactNode })
}
}
const createUser = async (req: CreateUserRequest) => {
try {
const newUser = await AdminService.createUser(req)
setUsers(prev => [newUser, ...prev])
setTotal(prev => prev + 1)
// Clear cache because data changed
cacheRef.current = {}
toast.success(`已成功创建用户 ${ newUser.username }`)
return newUser
} catch (err) {
toast.error(err instanceof Error ? err.message : '创建用户失败')
throw err
}
}
const value = {
users,
total,
@@ -205,7 +221,8 @@ export function AdminUsersProvider({ children }: { children: React.ReactNode })
setStatusFilter,
fetchUsers,
refresh,
updateUserStatus
updateUserStatus,
createUser
}
return (
+1 -28
View File
@@ -3,7 +3,7 @@
import {createContext, ReactNode, useCallback, useContext, useEffect, useRef, useState} from 'react'
import services from '@/lib/services'
import {PayLevel, TrustLevel, User} from '@/lib/services/auth/types'
import {User} from '@/lib/services/auth/types'
/** 用户状态接口 */
@@ -17,27 +17,10 @@ interface UserState {
interface UserContextValue extends UserState {
setUser: (user: User) => void
refetch: () => Promise<void>
getTrustLevelLabel: (trustLevel: TrustLevel) => string
getPayLevelLabel: (payLevel: PayLevel) => string
logout: () => Promise<void>
}
/** 信任等级映射 */
const TRUST_LEVEL_LABELS: Record<TrustLevel, string> = {
[TrustLevel.New]: '新用户',
[TrustLevel.Basic]: '基本用户',
[TrustLevel.Member]: '成员',
[TrustLevel.Regular]: '活跃用户',
[TrustLevel.Leader]: '领导者',
}
/** 支付等级映射 */
const PAY_LEVEL_LABELS: Record<PayLevel, string> = {
[PayLevel.BlackGold]: '黑金',
[PayLevel.WhiteGold]: '白金',
[PayLevel.Gold]: '黄金',
[PayLevel.Ordinary]: '普通',
}
/** 用户上下文 */
const UserContext = createContext<UserContextValue | undefined>(undefined)
@@ -65,15 +48,7 @@ export function UserProvider({ children }: { children: ReactNode }) {
const isMountedRef = useRef(true)
/** 获取信任等级标签 */
const getTrustLevelLabel = useCallback((trustLevel: TrustLevel): string => {
return TRUST_LEVEL_LABELS[trustLevel] || '未知'
}, [])
/** 获取支付等级标签 */
const getPayLevelLabel = useCallback((payLevel: PayLevel): string => {
return PAY_LEVEL_LABELS[payLevel] || '未知'
}, [])
/** 获取用户信息 */
const fetchUser = useCallback(async () => {
@@ -144,8 +119,6 @@ export function UserProvider({ children }: { children: ReactNode }) {
...state,
setUser,
refetch,
getTrustLevelLabel,
getPayLevelLabel,
logout,
}}
>
@@ -1,8 +1,10 @@
import {BaseService} from '../core/base.service';
import type {
AdminUser,
AuthSource,
AuthSourceRequest,
CreateSystemConfigRequest,
CreateUserRequest,
DispatchTaskRequest,
ListTaskExecutionsRequest,
ListTaskExecutionsResponse,
@@ -307,4 +309,13 @@ export class AdminService extends BaseService {
): Promise<void> {
return this.put<void>(`/users/${ id }/status`, request);
}
/**
* 创建用户
* @param request - 创建用户请求参数
* @returns 创建成功的用户信息
*/
static async createUser(request: CreateUserRequest): Promise<AdminUser> {
return this.post<AdminUser>('/users', request);
}
}
+1
View File
@@ -23,6 +23,7 @@ export { AdminService } from './admin.service';
export type {
SystemConfig,
CreateSystemConfigRequest,
CreateUserRequest,
UpdateSystemConfigRequest,
AuthSource,
AuthSourceRequest,
+16 -14
View File
@@ -160,20 +160,6 @@ export interface AdminUser {
nickname: string;
/** 头像 URL */
avatar_url: string;
/** 信任等级 */
trust_level: number;
/** 支付积分 */
pay_score: number;
/** 累计收入 */
total_receive: string;
/** 累计支出 */
total_payment: string;
/** 累计社区积分 */
total_community: string;
/** 社区余额 */
community_balance: string;
/** 可用余额 */
available_balance: string;
/** 是否激活 */
is_active: boolean;
/** 是否管理员 */
@@ -218,6 +204,22 @@ export interface UpdateUserStatusRequest {
is_active: boolean;
}
/**
* 创建用户请求参数
*/
export interface CreateUserRequest {
/** 用户名 */
username: string;
/** 密码 */
password: string;
/** 昵称 */
nickname?: string;
/** 是否激活 */
is_active?: boolean;
/** 是否管理员 */
is_admin?: boolean;
}
/**
* 认证源信息
*/
@@ -1,6 +1,7 @@
import {BaseService} from '../core/base.service';
import type {
AuthSource,
ChangePasswordRequest,
ExternalAccountBinding,
LoginRequest,
OAuthAuthorizeResponse,
@@ -110,6 +111,10 @@ export class AuthService extends BaseService {
return this.post<User>('/user/register', request);
}
static async changePassword(request: ChangePasswordRequest): Promise<void> {
return this.post<void>('/user/change-password', request);
}
static async getExternalAccountBindings(): Promise<ExternalAccountBinding[]> {
return this.get<ExternalAccountBinding[]>('/oauth/external-accounts');
}
+1 -1
View File
@@ -26,7 +26,6 @@
*/
export { AuthService } from './auth.service';
export { TrustLevel, PayLevel } from './types';
export type {
User,
OAuthLoginUrlResponse,
@@ -37,4 +36,5 @@ export type {
OAuthCallbackResult,
AuthSource,
ExternalAccountBinding,
ChangePasswordRequest,
} from './types';
+7 -52
View File
@@ -1,33 +1,3 @@
/**
* 信任等级
*/
export enum TrustLevel {
/** 新用户 */
New = 0,
/** 基础用户 */
Basic = 1,
/** 成员 */
Member = 2,
/** 常规用户 */
Regular = 3,
/** 领导者 */
Leader = 4,
}
/**
* 支付等级
*/
export enum PayLevel {
/** 普通 */
Ordinary = 0,
/** 黄金 */
Gold = 1,
/** 白金 */
WhiteGold = 2,
/** 黑金 */
BlackGold = 3,
}
/**
* 用户基本信息
*/
@@ -38,32 +8,12 @@ export interface User {
username: string;
/** 昵称 */
nickname: string;
/** 信任等级 */
trust_level: TrustLevel;
/** 头像 URL */
avatar_url: string;
/** 总接收金额 */
total_receive: string;
/** 总支付金额 */
total_payment: string;
/** 总社区金额 */
total_community: string;
/** 社区余额 */
community_balance: string;
/** 可用余额 */
available_balance: string;
/** 在途资金(延迟到账中) */
pending_balance: string;
/** 支付分数 */
pay_score: number;
/** 是否为管理员 */
is_admin: boolean;
/** 当日剩余配额 */
remain_quota: string;
/** 支付等级 */
pay_level: PayLevel;
/** 每日限额 */
daily_limit: number | null;
/** 是否需要修改密码 */
need_change_password?: boolean;
}
/**
@@ -122,3 +72,8 @@ export interface ExternalAccountBinding {
email: string;
created_at: string;
}
export interface ChangePasswordRequest {
old_password: string;
new_password: string;
}
+3 -2
View File
@@ -81,8 +81,8 @@ export type {
// ==================== 业务服务导出 ====================
// 认证服务
export { AuthService, TrustLevel } from './auth';
export type { User, OAuthLoginUrlResponse, OAuthCallbackRequest, AuthSource, ExternalAccountBinding } from './auth';
export { AuthService } from './auth';
export type { User, OAuthLoginUrlResponse, OAuthCallbackRequest, AuthSource, ExternalAccountBinding, ChangePasswordRequest } from './auth';
// 配置服务
export { ConfigService } from './config';
@@ -93,6 +93,7 @@ export { AdminService } from './admin';
export type {
SystemConfig,
CreateSystemConfigRequest,
CreateUserRequest,
UpdateSystemConfigRequest,
TaskMeta,
TaskExecution,
@@ -68,7 +68,7 @@ func TestListAuthSources(t *testing.T) {
}
dbConn.Create(&source)
adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true, SignKey: "admin_key"}
adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true}
router := setupTestRouter(adminUser)
req, _ := http.NewRequest("GET", "/api/v1/admin/auth-sources", nil)
@@ -105,7 +105,7 @@ func TestCreateAuthSource(t *testing.T) {
dbConn, _, cleanup := testhelper.SetupTestEnvironment(t)
defer cleanup()
adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true, SignKey: "admin_key"}
adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true}
router := setupTestRouter(adminUser)
t.Run("create successfully", func(t *testing.T) {
@@ -175,7 +175,7 @@ func TestUpdateAuthSource(t *testing.T) {
}
dbConn.Create(&source)
adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true, SignKey: "admin_key"}
adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true}
router := setupTestRouter(adminUser)
t.Run("update keep client secret", func(t *testing.T) {
@@ -252,7 +252,7 @@ func TestToggleAuthSource(t *testing.T) {
}
dbConn.Create(&source)
adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true, SignKey: "admin_key"}
adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true}
router := setupTestRouter(adminUser)
t.Run("cannot activate without credentials", func(t *testing.T) {
@@ -318,7 +318,7 @@ func TestDeleteAuthSource(t *testing.T) {
}
dbConn.Create(&externalAccount)
adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true, SignKey: "admin_key"}
adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true}
router := setupTestRouter(adminUser)
req, _ := http.NewRequest("DELETE", "/api/v1/admin/auth-sources/1", nil)
@@ -62,7 +62,7 @@ func TestCreateSystemConfig(t *testing.T) {
dbConn, _, cleanup := testhelper.SetupTestEnvironment(t)
defer cleanup()
adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true, SignKey: "admin_key"}
adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true}
router := setupTestRouter(adminUser)
t.Run("create successfully", func(t *testing.T) {
@@ -124,7 +124,7 @@ func TestListSystemConfigs(t *testing.T) {
_, _, cleanup := testhelper.SetupTestEnvironment(t)
defer cleanup()
adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true, SignKey: "admin_key"}
adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true}
router := setupTestRouter(adminUser)
t.Run("list all seeded configurations", func(t *testing.T) {
@@ -171,7 +171,7 @@ func TestGetSystemConfig(t *testing.T) {
_, _, cleanup := testhelper.SetupTestEnvironment(t)
defer cleanup()
adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true, SignKey: "admin_key"}
adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true}
router := setupTestRouter(adminUser)
t.Run("get existing configuration", func(t *testing.T) {
@@ -210,7 +210,7 @@ func TestUpdateSystemConfig(t *testing.T) {
dbConn, _, cleanup := testhelper.SetupTestEnvironment(t)
defer cleanup()
adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true, SignKey: "admin_key"}
adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true}
router := setupTestRouter(adminUser)
t.Run("update successfully", func(t *testing.T) {
@@ -263,7 +263,7 @@ func TestDeleteSystemConfig(t *testing.T) {
dbConn, _, cleanup := testhelper.SetupTestEnvironment(t)
defer cleanup()
adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true, SignKey: "admin_key"}
adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true}
router := setupTestRouter(adminUser)
t.Run("delete successfully", func(t *testing.T) {
+6 -6
View File
@@ -61,7 +61,7 @@ func TestListTaskTypes(t *testing.T) {
_, _, cleanup := testhelper.SetupTestEnvironment(t)
defer cleanup()
adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true, SignKey: "admin_key"}
adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true}
router := setupTestRouter(adminUser)
req, _ := http.NewRequest("GET", "/api/v1/admin/tasks/types", nil)
@@ -99,7 +99,7 @@ func TestDispatchTask(t *testing.T) {
_, _, cleanup := testhelper.SetupTestEnvironment(t)
defer cleanup()
adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true, SignKey: "admin_key"}
adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true}
router := setupTestRouter(adminUser)
t.Run("dispatch valid task successfully", func(t *testing.T) {
@@ -156,7 +156,7 @@ func TestListTaskExecutions(t *testing.T) {
_, _, cleanup := testhelper.SetupTestEnvironment(t)
defer cleanup()
adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true, SignKey: "admin_key"}
adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true}
router := setupTestRouter(adminUser)
ctx := context.Background()
@@ -245,7 +245,7 @@ func TestGetTaskExecution(t *testing.T) {
_, _, cleanup := testhelper.SetupTestEnvironment(t)
defer cleanup()
adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true, SignKey: "admin_key"}
adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true}
router := setupTestRouter(adminUser)
ctx := context.Background()
@@ -308,7 +308,7 @@ func TestRetryTask(t *testing.T) {
_, _, cleanup := testhelper.SetupTestEnvironment(t)
defer cleanup()
adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true, SignKey: "admin_key"}
adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true}
router := setupTestRouter(adminUser)
ctx := context.Background()
@@ -415,7 +415,7 @@ func TestRetryTaskMaxRetryExceeded(t *testing.T) {
_, _, cleanup := testhelper.SetupTestEnvironment(t)
defer cleanup()
adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true, SignKey: "admin_key"}
adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true}
router := setupTestRouter(adminUser)
ctx := context.Background()
+4
View File
@@ -20,4 +20,8 @@ const (
userNotFound = "用户不存在"
cannotDisable = "不能禁用管理员用户"
updateUserFailed = "更新用户状态失败"
usernameExists = "用户名已存在"
usernameRequired = "用户名不能为空"
passwordTooShort = "密码长度不能少于 8 位"
createUserFailed = "创建用户失败"
)
+103 -21
View File
@@ -23,9 +23,9 @@ import (
"github.com/gin-gonic/gin"
"github.com/linux-do/credit/internal/db"
"github.com/linux-do/credit/internal/db/idgen"
"github.com/linux-do/credit/internal/model"
"github.com/linux-do/credit/internal/util"
"github.com/shopspring/decimal"
"gorm.io/gorm"
)
@@ -38,23 +38,15 @@ type listUsersRequest struct {
}
type user struct {
ID uint64 `json:"id"`
Username string `json:"username"`
Nickname string `json:"nickname"`
AvatarUrl string `json:"avatar_url"`
TrustLevel model.TrustLevel `json:"trust_level"`
PayScore int64 `json:"pay_score"`
TotalReceive decimal.Decimal `json:"total_receive"`
TotalPayment decimal.Decimal `json:"total_payment"`
TotalTransfer decimal.Decimal `json:"total_transfer"`
TotalCommunity decimal.Decimal `json:"total_community"`
CommunityBalance decimal.Decimal `json:"community_balance"`
AvailableBalance decimal.Decimal `json:"available_balance"`
IsActive bool `json:"is_active"`
IsAdmin bool `json:"is_admin"`
LastLoginAt time.Time `json:"last_login_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ID uint64 `json:"id"`
Username string `json:"username"`
Nickname string `json:"nickname"`
AvatarUrl string `json:"avatar_url"`
IsActive bool `json:"is_active"`
IsAdmin bool `json:"is_admin"`
LastLoginAt time.Time `json:"last_login_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// listUsersResponse 用户列表响应
@@ -76,6 +68,7 @@ type listUsersResponse struct {
// @Failure 403 {object} util.ResponseAny "无管理员权限"
// @Failure 500 {object} util.ResponseAny "内部错误"
// @Router /api/v1/admin/users [get]
// ListUsers 获取用户列表
func ListUsers(c *gin.Context) {
var req listUsersRequest
if err := c.ShouldBindQuery(&req); err != nil {
@@ -105,9 +98,7 @@ func ListUsers(c *gin.Context) {
offset := (req.Page - 1) * req.PageSize
if err := query.
Select("id, username, nickname, avatar_url, trust_level, pay_score, " +
"total_receive, total_payment, total_transfer, total_community, " +
"community_balance, available_balance, is_active, is_admin, " +
Select("id, username, nickname, avatar_url, is_active, is_admin, " +
"last_login_at, created_at, updated_at").
Order("id DESC").
Offset(offset).
@@ -185,3 +176,94 @@ func UpdateUserStatus(c *gin.Context) {
c.JSON(http.StatusOK, util.OKNil())
}
// createUserRequest 创建用户请求
type createUserRequest struct {
Username string `json:"username" binding:"required,min=3,max=64"`
Password string `json:"password" binding:"required,min=8,max=64"`
Nickname string `json:"nickname" binding:"omitempty,max=64"`
IsActive bool `json:"is_active"`
IsAdmin bool `json:"is_admin"`
}
// CreateUser 创建用户
// @Summary 创建用户
// @Description 创建一个本地密码登录的新用户,需要管理员权限
// @Tags admin
// @Accept json
// @Produce json
// @Security SessionCookie
// @Param request body user.createUserRequest true "创建用户参数"
// @Success 200 {object} util.ResponseAny{data=user.user} "创建成功"
// @Failure 400 {object} util.ResponseAny "参数错误或用户名已存在"
// @Failure 401 {object} util.ResponseAny "未登录"
// @Failure 403 {object} util.ResponseAny "无管理员权限"
// @Failure 500 {object} util.ResponseAny "内部错误"
// @Router /api/v1/admin/users [post]
func CreateUser(c *gin.Context) {
var req createUserRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
return
}
req.Username = strings.TrimSpace(req.Username)
req.Nickname = strings.TrimSpace(req.Nickname)
req.Password = strings.TrimSpace(req.Password)
if req.Username == "" {
c.JSON(http.StatusBadRequest, util.Err(usernameRequired))
return
}
if len(req.Password) < 8 {
c.JSON(http.StatusBadRequest, util.Err(passwordTooShort))
return
}
ctx := c.Request.Context()
var count int64
if err := db.DB(ctx).Table("users").Where("username = ?", req.Username).Count(&count).Error; err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
if count > 0 {
c.JSON(http.StatusBadRequest, util.Err(usernameExists))
return
}
newUser := model.User{
ID: idgen.NextUint64ID(),
Username: req.Username,
Nickname: req.Nickname,
IsActive: req.IsActive,
IsAdmin: req.IsAdmin,
LastLoginAt: time.Time{},
}
if newUser.Nickname == "" {
newUser.Nickname = req.Username
}
if err := newUser.SetEncryptedPassword(req.Password); err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
if err := db.DB(ctx).Create(&newUser).Error; err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
res := user{
ID: newUser.ID,
Username: newUser.Username,
Nickname: newUser.Nickname,
AvatarUrl: newUser.AvatarUrl,
IsActive: newUser.IsActive,
IsAdmin: newUser.IsAdmin,
LastLoginAt: newUser.LastLoginAt,
CreatedAt: newUser.CreatedAt,
UpdatedAt: newUser.UpdatedAt,
}
c.JSON(http.StatusOK, util.OK(res))
}
+124 -28
View File
@@ -29,7 +29,6 @@ import (
"github.com/linux-do/credit/internal/model"
"github.com/linux-do/credit/internal/testhelper"
"github.com/linux-do/credit/internal/util"
"github.com/shopspring/decimal"
)
func setupTestRouter(authUser *model.User) *gin.Engine {
@@ -46,6 +45,7 @@ func setupTestRouter(authUser *model.User) *gin.Engine {
})
adminGroup.GET("/users", ListUsers)
adminGroup.POST("/users", CreateUser)
adminGroup.PUT("/users/:id/status", UpdateUserStatus)
return r
}
@@ -57,34 +57,28 @@ func TestListUsers(t *testing.T) {
// Seed users
users := []model.User{
{
ID: 1001,
Username: "alice",
Nickname: "Alice Nickname",
IsActive: true,
IsAdmin: false,
AvailableBalance: decimal.NewFromFloat(100.0),
LastLoginAt: time.Now(),
SignKey: "alice_sign_key",
ID: 1001,
Username: "alice",
Nickname: "Alice Nickname",
IsActive: true,
IsAdmin: false,
LastLoginAt: time.Now(),
},
{
ID: 1002,
Username: "bob",
Nickname: "Bob Nickname",
IsActive: true,
IsAdmin: false,
AvailableBalance: decimal.NewFromFloat(50.0),
LastLoginAt: time.Now(),
SignKey: "bob_sign_key",
ID: 1002,
Username: "bob",
Nickname: "Bob Nickname",
IsActive: true,
IsAdmin: false,
LastLoginAt: time.Now(),
},
{
ID: 1003,
Username: "charlie",
Nickname: "Charlie Nickname",
IsActive: false,
IsAdmin: true,
AvailableBalance: decimal.NewFromFloat(9999.0),
LastLoginAt: time.Now(),
SignKey: "charlie_sign_key",
ID: 1003,
Username: "charlie",
Nickname: "Charlie Nickname",
IsActive: false,
IsAdmin: true,
LastLoginAt: time.Now(),
},
}
@@ -94,7 +88,7 @@ func TestListUsers(t *testing.T) {
}
}
adminUser := &model.User{ID: 1003, Username: "charlie", IsAdmin: true, SignKey: "charlie_sign_key"}
adminUser := &model.User{ID: 1003, Username: "charlie", IsAdmin: true}
router := setupTestRouter(adminUser)
t.Run("basic pagination list", func(t *testing.T) {
@@ -185,14 +179,12 @@ func TestUpdateUserStatus(t *testing.T) {
Username: "alice",
IsActive: true,
IsAdmin: false,
SignKey: "alice_sign_key",
}
adminUser := model.User{
ID: 1002,
Username: "bob",
IsActive: true,
IsAdmin: true,
SignKey: "bob_sign_key",
}
dbConn.Create(&regularUser)
@@ -252,3 +244,107 @@ func TestUpdateUserStatus(t *testing.T) {
}
})
}
func TestCreateUser(t *testing.T) {
dbConn, _, cleanup := testhelper.SetupTestEnvironment(t)
defer cleanup()
adminUser := &model.User{ID: 1003, Username: "charlie", IsAdmin: true}
router := setupTestRouter(adminUser)
t.Run("create user successfully", func(t *testing.T) {
payload := createUserRequest{
Username: "newuser",
Password: "newpassword123",
Nickname: "New Nickname",
IsActive: true,
IsAdmin: false,
}
body, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST", "/api/v1/admin/users", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected 200 OK, got %d. Body: %s", w.Code, w.Body.String())
}
var resp util.ResponseAny
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if resp.ErrorMsg != "" {
t.Errorf("expected empty error message, got '%s'", resp.ErrorMsg)
}
dataBytes, _ := json.Marshal(resp.Data)
var resUser user
if err := json.Unmarshal(dataBytes, &resUser); err != nil {
t.Fatalf("failed to parse response data: %v", err)
}
if resUser.Username != "newuser" || resUser.Nickname != "New Nickname" || !resUser.IsActive || resUser.IsAdmin {
t.Errorf("unexpected user values: %+v", resUser)
}
// Verify in DB
var dbUser model.User
if err := dbConn.Where("username = ?", "newuser").First(&dbUser).Error; err != nil {
t.Fatalf("failed to find user in db: %v", err)
}
if !dbUser.CheckPassword("newpassword123") {
t.Error("password was not hashed correctly")
}
})
t.Run("create user with duplicate username", func(t *testing.T) {
// Create the first user
existing := model.User{
ID: 2001,
Username: "dupuser",
Nickname: "Dup User",
}
dbConn.Create(&existing)
payload := createUserRequest{
Username: "dupuser",
Password: "password123",
Nickname: "Another Nick",
IsActive: true,
}
body, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST", "/api/v1/admin/users", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400 Bad Request, got %d. Body: %s", w.Code, w.Body.String())
}
var resp util.ResponseAny
json.Unmarshal(w.Body.Bytes(), &resp)
if resp.ErrorMsg != usernameExists {
t.Errorf("expected error '%s', got '%s'", usernameExists, resp.ErrorMsg)
}
})
t.Run("validation error - password too short", func(t *testing.T) {
payload := createUserRequest{
Username: "shortpass",
Password: "123",
IsActive: true,
}
body, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST", "/api/v1/admin/users", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400 Bad Request, got %d. Body: %s", w.Code, w.Body.String())
}
})
}
-4
View File
@@ -712,7 +712,6 @@ func TestCallbackBind(t *testing.T) {
ID: 777,
Username: "existing_member",
Nickname: "Existing Member",
SignKey: "key",
IsActive: true,
LastLoginAt: time.Now(),
}
@@ -841,7 +840,6 @@ func TestCallbackBind(t *testing.T) {
ID: 888,
Username: "another_member",
Nickname: "Another Member",
SignKey: "key2",
IsActive: true,
LastLoginAt: time.Now(),
}
@@ -929,7 +927,6 @@ func TestExternalAccountsListAndDelete(t *testing.T) {
ID: 555,
Username: "account_holder",
IsActive: true,
SignKey: "key5",
})
dbConn.Create(&model.AuthSource{
@@ -1000,7 +997,6 @@ func TestLoginRequiredAndRiskChecks(t *testing.T) {
ID: 1122,
Username: "risk_tester",
IsActive: true,
SignKey: "keysign",
})
// Enable risk checks
+16 -37
View File
@@ -23,48 +23,25 @@ import (
"github.com/gin-gonic/gin"
"github.com/linux-do/credit/internal/model"
"github.com/linux-do/credit/internal/util"
"github.com/shopspring/decimal"
)
type BasicUserInfo struct {
ID uint64 `json:"id"`
Username string `json:"username"`
Nickname string `json:"nickname"`
TrustLevel model.TrustLevel `json:"trust_level"`
AvatarUrl string `json:"avatar_url"`
TotalReceive decimal.Decimal `json:"total_receive"`
TotalPayment decimal.Decimal `json:"total_payment"`
TotalTransfer decimal.Decimal `json:"total_transfer"`
TotalCommunity decimal.Decimal `json:"total_community"`
CommunityBalance decimal.Decimal `json:"community_balance"`
AvailableBalance decimal.Decimal `json:"available_balance"`
PendingBalance decimal.Decimal `json:"pending_balance"`
PayScore int64 `json:"pay_score"`
IsAdmin bool `json:"is_admin"`
RemainQuota decimal.Decimal `json:"remain_quota"`
PayLevel string `json:"pay_level"`
DailyLimit *int64 `json:"daily_limit"`
ID uint64 `json:"id"`
Username string `json:"username"`
Nickname string `json:"nickname"`
AvatarUrl string `json:"avatar_url"`
IsAdmin bool `json:"is_admin"`
NeedChangePassword bool `json:"need_change_password"`
}
func BuildBasicUserInfo(user *model.User) BasicUserInfo {
func BuildBasicUserInfo(user *model.User, needChange bool) BasicUserInfo {
return BasicUserInfo{
ID: user.ID,
Username: user.Username,
Nickname: user.Nickname,
TrustLevel: user.TrustLevel,
AvatarUrl: user.AvatarUrl,
TotalReceive: user.TotalReceive,
TotalPayment: user.TotalPayment,
TotalTransfer: user.TotalTransfer,
TotalCommunity: user.TotalCommunity,
CommunityBalance: user.CommunityBalance,
AvailableBalance: user.AvailableBalance,
PendingBalance: user.PendingBalance,
PayScore: user.PayScore,
IsAdmin: user.IsAdmin,
RemainQuota: decimal.NewFromInt(-1),
PayLevel: "Free",
DailyLimit: nil,
ID: user.ID,
Username: user.Username,
Nickname: user.Nickname,
AvatarUrl: user.AvatarUrl,
IsAdmin: user.IsAdmin,
NeedChangePassword: needChange,
}
}
@@ -79,10 +56,12 @@ func BuildBasicUserInfo(user *model.User) BasicUserInfo {
// @Router /api/v1/oauth/user-info [get]
func UserInfo(c *gin.Context) {
user, _ := util.GetFromContext[*model.User](c, UserObjKey)
session := sessions.Default(c)
needChange := session.Get("need_change_password") == true
c.JSON(
http.StatusOK,
util.OK(BuildBasicUserInfo(user)),
util.OK(BuildBasicUserInfo(user, needChange)),
)
}
+1 -1
View File
@@ -255,7 +255,7 @@ func normalizeOAuthUserInfo(userInfo *model.OAuthUserInfo) error {
func buildCallbackResult(user *model.User, status string) OAuthCallbackResult {
result := OAuthCallbackResult{Status: status}
if user != nil {
info := BuildBasicUserInfo(user)
info := BuildBasicUserInfo(user, false)
result.User = &info
}
return result
+29 -30
View File
@@ -35,7 +35,6 @@ import (
"github.com/gin-gonic/gin"
"github.com/linux-do/credit/internal/apps/oauth"
"github.com/linux-do/credit/internal/common/response"
"github.com/linux-do/credit/internal/config"
"github.com/linux-do/credit/internal/db"
"github.com/linux-do/credit/internal/db/idgen"
@@ -73,20 +72,20 @@ func UploadFile(c *gin.Context) {
header, err := c.FormFile("file")
if err != nil {
response.RespondFailure(c, ErrNoFileSelected)
c.JSON(http.StatusOK, util.Err(ErrNoFileSelected))
return
}
file, err := header.Open()
if err != nil {
response.RespondFailure(c, ErrOpenFileFailed)
c.JSON(http.StatusOK, util.Err(ErrOpenFileFailed))
return
}
defer file.Close()
// 校验大小
if header.Size > maxUploadSize {
response.RespondFailure(c, "文件大小不能超过 32MB")
c.JSON(http.StatusOK, util.Err("文件大小不能超过 32MB"))
return
}
@@ -109,7 +108,7 @@ func UploadFile(c *gin.Context) {
}
}
if !allowed {
response.RespondFailure(c, ErrUnsupportedFormat)
c.JSON(http.StatusOK, util.Err(ErrUnsupportedFormat))
return
}
}
@@ -119,7 +118,7 @@ func UploadFile(c *gin.Context) {
var buf bytes.Buffer
size, err := io.Copy(&buf, io.TeeReader(file, hashWriter))
if err != nil {
response.RespondFailure(c, ErrProcessFileFailed)
c.JSON(http.StatusOK, util.Err(ErrProcessFileFailed))
return
}
@@ -152,15 +151,15 @@ func UploadFile(c *gin.Context) {
}
if err := db.DB(ctx).Create(&newUpload).Error; err != nil {
response.RespondFailure(c, ErrSaveUploadRecordFailed)
c.JSON(http.StatusOK, util.Err(ErrSaveUploadRecordFailed))
return
}
logger.InfoF(ctx, "文件触发秒传成功! ID: %d, Path: %s", id, existing.FilePath)
response.RespondSuccess(c, newUpload)
c.JSON(http.StatusOK, util.OK(newUpload))
return
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
response.RespondFailure(c, "文件校验失败")
c.JSON(http.StatusOK, util.Err("文件校验失败"))
return
}
@@ -169,7 +168,7 @@ func UploadFile(c *gin.Context) {
var meta model.UploadMetadata
if metadataStr != "" {
if err := json.Unmarshal([]byte(metadataStr), &meta); err != nil {
response.RespondFailure(c, "元数据 JSON 格式不合法")
c.JSON(http.StatusOK, util.Err("元数据 JSON 格式不合法"))
return
}
}
@@ -192,7 +191,7 @@ func UploadFile(c *gin.Context) {
err = storage.PutObject(ctx, fullKey, bytes.NewReader(buf.Bytes()), size, mimeType)
if err != nil {
logger.ErrorF(ctx, "S3 存储上传失败: %v", err)
response.RespondFailure(c, ErrSaveFileFailed)
c.JSON(http.StatusOK, util.Err(ErrSaveFileFailed))
return
}
} else {
@@ -200,14 +199,14 @@ func UploadFile(c *gin.Context) {
localDir := filepath.Join("uploads", time.Now().Format("2006/01/02"))
if err := os.MkdirAll(localDir, 0755); err != nil {
logger.ErrorF(ctx, "创建本地上传目录失败: %v", err)
response.RespondFailure(c, ErrSaveFileFailed)
c.JSON(http.StatusOK, util.Err(ErrSaveFileFailed))
return
}
localPath := filepath.Join(localDir, fmt.Sprintf("%d.%s", id, ext))
if err := os.WriteFile(localPath, buf.Bytes(), 0644); err != nil {
logger.ErrorF(ctx, "本地磁盘写入文件失败: %v", err)
response.RespondFailure(c, ErrSaveFileFailed)
c.JSON(http.StatusOK, util.Err(ErrSaveFileFailed))
return
}
// 统一使用相对路径,方便将来环境移植或备份
@@ -235,11 +234,11 @@ func UploadFile(c *gin.Context) {
if storageDriver == "local" {
_ = os.Remove(subPath)
}
response.RespondFailure(c, ErrSaveUploadRecordFailed)
c.JSON(http.StatusOK, util.Err(ErrSaveUploadRecordFailed))
return
}
response.RespondSuccess(c, newUpload)
c.JSON(http.StatusOK, util.OK(newUpload))
}
// DownloadFile 通用单文件下载接口
@@ -259,7 +258,7 @@ func DownloadFile(c *gin.Context) {
idStr := c.Param("id")
uploadID, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
response.RespondFailure(c, "无效的文件 ID")
c.JSON(http.StatusOK, util.Err("无效的文件 ID"))
return
}
@@ -269,7 +268,7 @@ func DownloadFile(c *gin.Context) {
c.AbortWithStatus(http.StatusNotFound)
return
}
response.RespondFailure(c, "查询文件记录失败")
c.JSON(http.StatusOK, util.Err("查询文件记录失败"))
return
}
@@ -317,7 +316,7 @@ func BatchDownloadFiles(c *gin.Context) {
var req batchDownloadRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.RespondFailure(c, "参数绑定失败,请传入有效的文件 ID 数组")
c.JSON(http.StatusOK, util.Err("参数绑定失败,请传入有效的文件 ID 数组"))
return
}
@@ -326,7 +325,7 @@ func BatchDownloadFiles(c *gin.Context) {
for _, idStr := range req.IDs {
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
response.RespondFailure(c, fmt.Sprintf("无效的 ID 值: %s", idStr))
c.JSON(http.StatusOK, util.Err(fmt.Sprintf("无效的 ID 值: %s", idStr)))
return
}
ids = append(ids, id)
@@ -335,12 +334,12 @@ func BatchDownloadFiles(c *gin.Context) {
// 查库获取所有匹配且正常的文件记录
var uploads []model.Upload
if err := db.DB(ctx).Where("id IN ? AND status IN (?, ?)", ids, model.UploadStatusPending, model.UploadStatusUsed).Find(&uploads).Error; err != nil {
response.RespondFailure(c, "检索文件记录失败")
c.JSON(http.StatusOK, util.Err("检索文件记录失败"))
return
}
if len(uploads) == 0 {
response.RespondFailure(c, "没有找到任何有效的文件记录进行打包")
c.JSON(http.StatusOK, util.Err("没有找到任何有效的文件记录进行打包"))
return
}
@@ -436,7 +435,7 @@ func ListMyFiles(c *gin.Context) {
var req listMyFilesRequest
if err := c.ShouldBindQuery(&req); err != nil {
response.RespondFailure(c, "参数错误")
c.JSON(http.StatusOK, util.Err("参数错误"))
return
}
if req.Page <= 0 {
@@ -461,23 +460,23 @@ func ListMyFiles(c *gin.Context) {
var total int64
if err := query.Count(&total).Error; err != nil {
response.RespondFailure(c, "查询文件数量失败")
c.JSON(http.StatusOK, util.Err("查询文件数量失败"))
return
}
var items []model.Upload
offset := (req.Page - 1) * req.PageSize
if err := query.Order("created_at DESC").Offset(offset).Limit(req.PageSize).Find(&items).Error; err != nil {
response.RespondFailure(c, "查询文件列表失败")
c.JSON(http.StatusOK, util.Err("查询文件列表失败"))
return
}
response.RespondSuccess(c, listMyFilesResponse{
c.JSON(http.StatusOK, util.OK(listMyFilesResponse{
Total: total,
Page: req.Page,
PageSize: req.PageSize,
Items: items,
})
}))
}
// DeleteFile 软删除文件记录
@@ -498,7 +497,7 @@ func DeleteFile(c *gin.Context) {
idStr := c.Param("id")
uploadID, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
response.RespondFailure(c, "无效的文件 ID")
c.JSON(http.StatusOK, util.Err("无效的文件 ID"))
return
}
@@ -508,7 +507,7 @@ func DeleteFile(c *gin.Context) {
c.AbortWithStatus(http.StatusNotFound)
return
}
response.RespondFailure(c, "查询文件记录失败")
c.JSON(http.StatusOK, util.Err("查询文件记录失败"))
return
}
@@ -519,11 +518,11 @@ func DeleteFile(c *gin.Context) {
}
if err := db.DB(ctx).Model(&upload).Update("status", model.UploadStatusDeleted).Error; err != nil {
response.RespondFailure(c, "删除文件失败")
c.JSON(http.StatusOK, util.Err("删除文件失败"))
return
}
response.RespondSuccess(c, nil)
c.JSON(http.StatusOK, util.OKNil())
}
func min(a, b int) int {
+9 -10
View File
@@ -39,9 +39,8 @@ import (
)
type testResponse struct {
Success bool `json:"success"`
Message string `json:"message"`
Data json.RawMessage `json:"data"`
ErrorMsg string `json:"error_msg"`
Data json.RawMessage `json:"data"`
}
func setupTestRouter(authUser *model.User) *gin.Engine {
@@ -161,8 +160,8 @@ func TestUploadFile(t *testing.T) {
t.Fatalf("failed to unmarshal response: %v", err)
}
if !resp.Success {
t.Fatalf("expected success response, got failure: %s", resp.Message)
if resp.ErrorMsg != "" {
t.Fatalf("expected success response, got failure: %s", resp.ErrorMsg)
}
// Verify database record
@@ -212,7 +211,7 @@ func TestUploadFile(t *testing.T) {
var resp testResponse
json.Unmarshal(w.Body.Bytes(), &resp)
if resp.Success || !strings.Contains(resp.Message, ErrUnsupportedFormat) {
if resp.ErrorMsg == "" || !strings.Contains(resp.ErrorMsg, ErrUnsupportedFormat) {
t.Errorf("expected unsupported format error, got: %v", resp)
}
})
@@ -249,8 +248,8 @@ func TestUploadFile(t *testing.T) {
var resp2 testResponse
json.Unmarshal(w2.Body.Bytes(), &resp2)
if !resp2.Success {
t.Fatalf("second upload was unsuccessful: %s", resp2.Message)
if resp2.ErrorMsg != "" {
t.Fatalf("second upload was unsuccessful: %s", resp2.ErrorMsg)
}
var uploadRecord2 model.Upload
@@ -306,8 +305,8 @@ func TestUploadFile(t *testing.T) {
var resp testResponse
json.Unmarshal(w.Body.Bytes(), &resp)
if !resp.Success {
t.Fatalf("local upload failed: %s", resp.Message)
if resp.ErrorMsg != "" {
t.Fatalf("local upload failed: %s", resp.ErrorMsg)
}
var localRecord model.Upload
+22 -21
View File
@@ -17,12 +17,12 @@ limitations under the License.
package user
import (
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"github.com/linux-do/credit/internal/apps/oauth"
"github.com/linux-do/credit/internal/common/response"
"github.com/linux-do/credit/internal/db"
"github.com/linux-do/credit/internal/model"
"github.com/linux-do/credit/internal/util"
@@ -46,17 +46,18 @@ type tokenResponse struct {
// @Success 200 {object} util.ResponseAny{data=[]model.AccessToken} "令牌列表"
// @Failure 401 {object} util.ResponseAny "未登录"
// @Router /api/v1/user/access-tokens [get]
// ListAccessTokens 获取当前用户的 AccessToken 列表
func ListAccessTokens(c *gin.Context) {
currUser, _ := util.GetFromContext[*model.User](c, oauth.UserObjKey)
ctx := c.Request.Context()
var tokens []model.AccessToken
if err := db.DB(ctx).Where("user_id = ?", currUser.ID).Order("created_at desc").Find(&tokens).Error; err != nil {
response.RespondFailure(c, err.Error())
c.JSON(http.StatusOK, util.Err(err.Error()))
return
}
response.RespondSuccess(c, tokens)
c.JSON(http.StatusOK, util.OK(tokens))
}
// CreateAccessToken 创建一个新的 AccessToken
@@ -76,13 +77,13 @@ func CreateAccessToken(c *gin.Context) {
var req createTokenRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.RespondFailure(c, "参数绑定失败")
c.JSON(http.StatusOK, util.Err("参数绑定失败"))
return
}
req.Name = strings.TrimSpace(req.Name)
if req.Name == "" {
response.RespondFailure(c, "令牌名称不能为空")
c.JSON(http.StatusOK, util.Err("令牌名称不能为空"))
return
}
@@ -94,19 +95,19 @@ func CreateAccessToken(c *gin.Context) {
var count int64
if err := db.DB(ctx).Model(&model.AccessToken{}).Where("user_id = ?", currUser.ID).Count(&count).Error; err != nil {
response.RespondFailure(c, err.Error())
c.JSON(http.StatusOK, util.Err(err.Error()))
return
}
if int(count) >= maxLimit {
response.RespondFailure(c, "已达到访问令牌最大创建数量限制")
c.JSON(http.StatusOK, util.Err("已达到访问令牌最大创建数量限制"))
return
}
// 生成 Token
tokenStr, err := model.GenerateTokenString()
if err != nil {
response.RespondFailure(c, "生成令牌失败")
c.JSON(http.StatusOK, util.Err("生成令牌失败"))
return
}
@@ -121,14 +122,14 @@ func CreateAccessToken(c *gin.Context) {
}
if err := db.DB(ctx).Create(&tokenRecord).Error; err != nil {
response.RespondFailure(c, err.Error())
c.JSON(http.StatusOK, util.Err(err.Error()))
return
}
response.RespondSuccess(c, tokenResponse{
c.JSON(http.StatusOK, util.OK(tokenResponse{
Token: tokenStr,
Record: tokenRecord,
})
}))
}
// DeleteAccessToken 删除一个 AccessToken
@@ -148,22 +149,22 @@ func DeleteAccessToken(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
response.RespondFailure(c, "无效的令牌ID")
c.JSON(http.StatusOK, util.Err("无效的令牌ID"))
return
}
tx := db.DB(ctx).Where("id = ? AND user_id = ?", id, currUser.ID).Delete(&model.AccessToken{})
if tx.Error != nil {
response.RespondFailure(c, tx.Error.Error())
c.JSON(http.StatusOK, util.Err(tx.Error.Error()))
return
}
if tx.RowsAffected == 0 {
response.RespondFailure(c, "令牌不存在或无权操作")
c.JSON(http.StatusOK, util.Err("令牌不存在或无权操作"))
return
}
response.RespondSuccess(c, "删除成功")
c.JSON(http.StatusOK, util.OK("删除成功"))
}
// RotateAccessToken 轮换一个 AccessToken
@@ -183,20 +184,20 @@ func RotateAccessToken(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
response.RespondFailure(c, "无效的令牌ID")
c.JSON(http.StatusOK, util.Err("无效的令牌ID"))
return
}
var tokenRecord model.AccessToken
if err := db.DB(ctx).Where("id = ? AND user_id = ?", id, currUser.ID).First(&tokenRecord).Error; err != nil {
response.RespondFailure(c, "令牌不存在或无权操作")
c.JSON(http.StatusOK, util.Err("令牌不存在或无权操作"))
return
}
// 生成新的 Token
newTokenStr, err := model.GenerateTokenString()
if err != nil {
response.RespondFailure(c, "生成令牌失败")
c.JSON(http.StatusOK, util.Err("生成令牌失败"))
return
}
@@ -208,12 +209,12 @@ func RotateAccessToken(c *gin.Context) {
tokenRecord.LastUsedAt = nil // 轮换后重置使用时间
if err := db.DB(ctx).Save(&tokenRecord).Error; err != nil {
response.RespondFailure(c, err.Error())
c.JSON(http.StatusOK, util.Err(err.Error()))
return
}
response.RespondSuccess(c, tokenResponse{
c.JSON(http.StatusOK, util.OK(tokenResponse{
Token: newTokenStr,
Record: tokenRecord,
})
}))
}
+112 -23
View File
@@ -2,6 +2,7 @@ package user
import (
"context"
"net/http"
"strings"
"time"
@@ -10,7 +11,6 @@ import (
"github.com/linux-do/credit/internal/apps/oauth"
"github.com/linux-do/credit/internal/common"
"github.com/linux-do/credit/internal/common/bind"
"github.com/linux-do/credit/internal/common/response"
"github.com/linux-do/credit/internal/db"
"github.com/linux-do/credit/internal/model"
"github.com/linux-do/credit/internal/util"
@@ -75,7 +75,7 @@ func setLoginSession(c *gin.Context, user *model.User) error {
// @Router /api/v1/user/login [post]
func Login(c *gin.Context) {
if !isPasswordLoginEnabled() {
response.RespondFailure(c, "管理员关闭了密码登录")
c.JSON(http.StatusOK, util.Err("管理员关闭了密码登录"))
return
}
var req loginRequest
@@ -84,36 +84,56 @@ func Login(c *gin.Context) {
}
req.Username = strings.TrimSpace(req.Username)
if req.Username == "" || req.Password == "" {
response.RespondFailure(c, "无效的参数")
c.JSON(http.StatusOK, util.Err("无效的参数"))
return
}
var user model.User
ctx := c.Request.Context()
if err := db.DB(ctx).Where("username = ?", req.Username).First(&user).Error; err != nil {
response.RespondFailure(c, "用户名或密码错误")
c.JSON(http.StatusOK, util.Err("用户名或密码错误"))
return
}
if !user.IsActive {
response.RespondFailure(c, common.BannedAccount)
c.JSON(http.StatusOK, util.Err(common.BannedAccount))
return
}
// 判定是否是明文密码存储
isPlaintext := !(strings.HasPrefix(user.Password, "$2a$") || strings.HasPrefix(user.Password, "$2b$") || strings.HasPrefix(user.Password, "$2y$"))
if !user.CheckPassword(req.Password) {
response.RespondFailure(c, "用户名或密码错误")
c.JSON(http.StatusOK, util.Err("用户名或密码错误"))
return
}
session := sessions.Default(c)
needChangePassword := false
// 如果是以明文密码登录,在数据库中置换为加密密码
if isPlaintext {
if err := user.SetEncryptedPassword(req.Password); err == nil {
if err := db.DB(ctx).Model(&user).Update("password", user.Password).Error; err != nil {
c.JSON(http.StatusOK, util.Err("升级密码安全算法失败,请重试"))
return
}
needChangePassword = true
session.Set("need_change_password", true)
_ = session.Save()
}
}
user.LastLoginAt = time.Now()
if err := db.DB(ctx).Model(&user).Update("last_login_at", user.LastLoginAt).Error; err != nil {
response.RespondFailure(c, err.Error())
c.JSON(http.StatusOK, util.Err(err.Error()))
return
}
if err := setLoginSession(c, &user); err != nil {
response.RespondFailure(c, "无法保存会话信息,请重试")
c.JSON(http.StatusOK, util.Err("无法保存会话信息,请重试"))
return
}
response.RespondSuccess(c, oauth.BuildBasicUserInfo(&user))
c.JSON(http.StatusOK, util.OK(oauth.BuildBasicUserInfo(&user, needChangePassword)))
}
// Register 用户注册
@@ -129,7 +149,7 @@ func Login(c *gin.Context) {
// @Router /api/v1/user/register [post]
func Register(c *gin.Context) {
if !isRegistrationEnabled() || !isPasswordRegisterEnabled() {
response.RespondFailure(c, "管理员关闭了注册")
c.JSON(http.StatusOK, util.Err("管理员关闭了注册"))
return
}
@@ -144,22 +164,22 @@ func Register(c *gin.Context) {
req.DisplayName = strings.TrimSpace(req.DisplayName)
if req.Username == "" || req.Password == "" {
response.RespondFailure(c, "无效的参数")
c.JSON(http.StatusOK, util.Err("无效的参数"))
return
}
if len(req.Password) < 8 {
response.RespondFailure(c, "密码长度不能少于 8 位")
c.JSON(http.StatusOK, util.Err("密码长度不能少于 8 位"))
return
}
ctx := c.Request.Context()
var count int64
if err := db.DB(ctx).Model(&model.User{}).Where("username = ?", req.Username).Count(&count).Error; err != nil {
response.RespondFailure(c, err.Error())
c.JSON(http.StatusOK, util.Err(err.Error()))
return
}
if count > 0 {
response.RespondFailure(c, "用户名已存在")
c.JSON(http.StatusOK, util.Err("用户名已存在"))
return
}
@@ -167,9 +187,6 @@ func Register(c *gin.Context) {
Username: req.Username,
Nickname: req.Nickname,
AvatarUrl: "",
TrustLevel: model.TrustLevelNewUser,
PayScore: 0,
SignKey: util.GenerateUniqueIDSimple(),
IsActive: true,
IsAdmin: false,
LastLoginAt: time.Now(),
@@ -181,21 +198,21 @@ func Register(c *gin.Context) {
user.Nickname = req.Username
}
if err := user.SetPassword(req.Password); err != nil {
response.RespondFailure(c, err.Error())
c.JSON(http.StatusOK, util.Err(err.Error()))
return
}
if err := db.DB(ctx).Create(&user).Error; err != nil {
response.RespondFailure(c, err.Error())
c.JSON(http.StatusOK, util.Err(err.Error()))
return
}
if err := setLoginSession(c, &user); err != nil {
response.RespondFailure(c, "无法保存会话信息,请重试")
c.JSON(http.StatusOK, util.Err("无法保存会话信息,请重试"))
return
}
response.RespondSuccess(c, oauth.BuildBasicUserInfo(&user))
c.JSON(http.StatusOK, util.OK(oauth.BuildBasicUserInfo(&user, false)))
}
// Logout 用户退出登录
@@ -212,8 +229,80 @@ func Logout(c *gin.Context) {
session.Options(util.GetSessionOptions(-1))
session.Clear()
if err := session.Save(); err != nil {
response.RespondFailure(c, err.Error())
c.JSON(http.StatusOK, util.Err(err.Error()))
return
}
response.RespondSuccessMessage(c, "")
c.JSON(http.StatusOK, util.OK(""))
}
type changePasswordRequest struct {
OldPassword string `json:"old_password"`
NewPassword string `json:"new_password"`
}
// ChangePassword 修改用户密码
// @Summary 修改用户密码
// @Description 修改当前登录用户的密码。修改成功后,如果是首次明文登录的升级提示,则清除修改密码的提示状态。
// @Tags user
// @Accept json
// @Produce json
// @Param request body user.changePasswordRequest true "修改密码请求参数"
// @Success 200 {object} util.ResponseAny{data=string} "修改密码成功"
// @Failure 400 {object} util.ResponseAny "原密码错误或新密码不符合要求"
// @Failure 401 {object} util.ResponseAny "请先登录"
// @Router /api/v1/user/change-password [post]
func ChangePassword(c *gin.Context) {
var req changePasswordRequest
if !bind.JSON(c, &req) {
return
}
req.OldPassword = strings.TrimSpace(req.OldPassword)
req.NewPassword = strings.TrimSpace(req.NewPassword)
if req.OldPassword == "" || req.NewPassword == "" {
c.JSON(http.StatusOK, util.Err("无效的参数"))
return
}
if len(req.NewPassword) < 8 {
c.JSON(http.StatusOK, util.Err("新密码长度不能少于 8 位"))
return
}
userObj, _ := util.GetFromContext[*model.User](c, oauth.UserObjKey)
if userObj == nil {
c.JSON(http.StatusUnauthorized, util.Err("请先登录"))
return
}
ctx := c.Request.Context()
var dbUser model.User
if err := db.DB(ctx).Where("id = ?", userObj.ID).First(&dbUser).Error; err != nil {
c.JSON(http.StatusOK, util.Err("未找到该用户"))
return
}
// 校验旧密码
if !dbUser.CheckPassword(req.OldPassword) {
c.JSON(http.StatusOK, util.Err("原密码不正确"))
return
}
// 加密并更新为新密码
if err := dbUser.SetEncryptedPassword(req.NewPassword); err != nil {
c.JSON(http.StatusOK, util.Err("密码加密失败,请重试"))
return
}
if err := db.DB(ctx).Model(&dbUser).Update("password", dbUser.Password).Error; err != nil {
c.JSON(http.StatusOK, util.Err(err.Error()))
return
}
// 清除 Session 中修改密码提示状态
session := sessions.Default(c)
session.Delete("need_change_password")
_ = session.Save()
c.JSON(http.StatusOK, util.OK("密码修改成功"))
}
+4 -3
View File
@@ -4,10 +4,11 @@ import (
"encoding/json"
"errors"
"io"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/linux-do/credit/internal/common/response"
"github.com/linux-do/credit/internal/util"
)
// DecodeJSONBody decodes JSON reader to target
@@ -32,7 +33,7 @@ func IDParam(c *gin.Context) (uint, bool) {
func IDParamByName(c *gin.Context, name string) (uint, bool) {
id, err := strconv.ParseUint(c.Param(name), 10, 64)
if err != nil || id == 0 {
response.RespondBadRequest(c, "")
c.JSON(http.StatusBadRequest, util.Err("参数错误"))
return 0, false
}
return uint(id), true
@@ -41,7 +42,7 @@ func IDParamByName(c *gin.Context, name string) (uint, bool) {
// JSON binds JSON body of context request to target
func JSON(c *gin.Context, target any) bool {
if err := DecodeJSONBody(c.Request.Body, target); err != nil {
response.RespondBadRequest(c, "")
c.JSON(http.StatusBadRequest, util.Err("参数错误"))
return false
}
return true
-82
View File
@@ -1,82 +0,0 @@
package response
import (
"net/http"
"github.com/gin-gonic/gin"
)
const invalidParamsMessage = "参数错误"
// RespondSuccess sends a successful response with data
func RespondSuccess(c *gin.Context, data any) {
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": data,
})
}
// RespondSuccessWithExtras sends a successful response with data and extra fields
func RespondSuccessWithExtras(c *gin.Context, data any, extras gin.H) {
payload := gin.H{
"success": true,
"message": "",
"data": data,
}
for key, value := range extras {
payload[key] = value
}
c.JSON(http.StatusOK, payload)
}
// RespondSuccessMessage sends a successful response with a custom message
func RespondSuccessMessage(c *gin.Context, message string) {
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": message,
})
}
// RespondFailure sends a failed response with http.StatusOK and a failure message
func RespondFailure(c *gin.Context, message string) {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": message,
})
}
// RespondBadRequest sends a bad request response (400)
func RespondBadRequest(c *gin.Context, message string) {
if message == "" {
message = invalidParamsMessage
}
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": message,
})
}
// RespondUnauthorized sends an unauthorized response (401)
func RespondUnauthorized(c *gin.Context, message string) {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"message": message,
})
}
// RespondForbidden sends a forbidden response (403)
func RespondForbidden(c *gin.Context, message string) {
c.JSON(http.StatusForbidden, gin.H{
"success": false,
"message": message,
})
}
// RespondErrorWithStatus sends a response with target HTTP status code and a message
func RespondErrorWithStatus(c *gin.Context, code int, message string) {
c.JSON(code, gin.H{
"success": false,
"message": message,
})
}
+36
View File
@@ -19,11 +19,13 @@ package migrator
import (
"context"
"log"
"time"
"github.com/linux-do/credit/internal/model"
"github.com/linux-do/credit/internal/config"
"github.com/linux-do/credit/internal/db"
"github.com/linux-do/credit/internal/db/idgen"
)
func Migrate() {
@@ -46,6 +48,8 @@ func Migrate() {
// 初始化系统配置数据
initSystemConfigs()
// 初始化默认管理员用户
initDefaultAdmin()
}
// initSystemConfigs 初始化系统配置数据
@@ -113,3 +117,35 @@ func initSystemConfigs() {
log.Printf("[PostgreSQL] initialized %d default system configs\n", len(defaultConfigs))
}
}
// initDefaultAdmin 初始化默认管理员用户
func initDefaultAdmin() {
tx := db.DB(context.Background())
var count int64
if err := tx.Model(&model.User{}).Where("username = ?", "admin").Count(&count).Error; err != nil {
log.Printf("[PostgreSQL] failed to check default admin user: %v\n", err)
return
}
if count > 0 {
return
}
adminUser := model.User{
ID: idgen.NextUint64ID(),
Username: "admin",
Password: "12345678", // 密码使用明文存储
Nickname: "Administrator",
AvatarUrl: "",
IsActive: true,
IsAdmin: true,
LastLoginAt: time.Now(),
}
if err := tx.Create(&adminUser).Error; err != nil {
log.Printf("[PostgreSQL] failed to create default admin user: %v\n", err)
} else {
log.Printf("[PostgreSQL] default admin user created successfully (username: admin, password: 12345678)\n")
}
}
+30 -124
View File
@@ -17,41 +17,26 @@ limitations under the License.
package model
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"github.com/linux-do/credit/internal/common"
"github.com/linux-do/credit/internal/util"
"github.com/shopspring/decimal"
"gorm.io/gorm"
)
type TrustLevel uint8
const (
TrustLevelNewUser TrustLevel = iota
TrustLevelBasicUser
TrustLevelUser
TrustLevelActiveUser
TrustLevelLeader
)
// OAuthUserInfo 用户信息结构(同时支持 OIDC ID Token claims 和 UserEndpoint 响应)
type OAuthUserInfo struct {
Id uint64 `json:"id"`
Sub string `json:"sub"`
Username string `json:"username"`
PreferredUsername string `json:"preferred_username"`
Email string `json:"email"`
Name string `json:"name"`
Active bool `json:"active"`
AvatarUrl string `json:"avatar_url"`
TrustLevel TrustLevel `json:"trust_level"`
Id uint64 `json:"id"`
Sub string `json:"sub"`
Username string `json:"username"`
PreferredUsername string `json:"preferred_username"`
Email string `json:"email"`
Name string `json:"name"`
Active bool `json:"active"`
AvatarUrl string `json:"avatar_url"`
}
// GetID 获取用户 ID
@@ -68,49 +53,25 @@ func (u *OAuthUserInfo) GetID() uint64 {
return 0
}
// UserGamificationScoreResponse API响应
type UserGamificationScoreResponse struct {
User struct {
GamificationScore int64 `json:"gamification_score"`
} `json:"user"`
}
// LeaderboardResponse 排行榜 API 响应
type LeaderboardResponse struct {
Users []LeaderboardUser `json:"users"`
}
// LeaderboardUser 排行榜用户信息
type LeaderboardUser struct {
ID uint64 `json:"id"`
Username string `json:"username"`
TotalScore int64 `json:"total_score"`
}
type User struct {
ID uint64 `json:"id" gorm:"primaryKey;index:idx_users_active_bal_id,priority:3"`
Username string `json:"username" gorm:"size:64;uniqueIndex"`
Password string `json:"password,omitempty" gorm:"size:255"`
Nickname string `json:"nickname" gorm:"size:255"`
AvatarUrl string `json:"avatar_url" gorm:"size:255"`
TrustLevel TrustLevel `json:"trust_level" gorm:"index"`
PayScore int64 `json:"pay_score" gorm:"default:0;index"`
SignKey string `json:"sign_key" gorm:"size:64;uniqueIndex;not null"`
TotalReceive decimal.Decimal `json:"total_receive" gorm:"type:numeric(20,2);default:0"`
TotalPayment decimal.Decimal `json:"total_payment" gorm:"type:numeric(20,2);default:0"`
TotalTransfer decimal.Decimal `json:"total_transfer" gorm:"type:numeric(20,2);default:0"`
TotalCommunity decimal.Decimal `json:"total_community" gorm:"type:numeric(20,2);default:0"`
CommunityBalance decimal.Decimal `json:"community_balance" gorm:"type:numeric(20,2);default:0"`
AvailableBalance decimal.Decimal `json:"available_balance" gorm:"type:numeric(20,2);default:0;index:idx_users_active_bal_id,priority:2"`
PendingBalance decimal.Decimal `json:"pending_balance" gorm:"type:numeric(20,2);default:0"`
IsActive bool `json:"is_active" gorm:"default:true;index:idx_users_active_bal_id,priority:1"`
IsAdmin bool `json:"is_admin" gorm:"default:false"`
LastLoginAt time.Time `json:"last_login_at" gorm:"index"`
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime;index"`
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime;index"`
ID uint64 `json:"id" gorm:"primaryKey"`
Username string `json:"username" gorm:"size:64;uniqueIndex"`
Password string `json:"password,omitempty" gorm:"size:255"`
Nickname string `json:"nickname" gorm:"size:255"`
AvatarUrl string `json:"avatar_url" gorm:"size:255"`
IsActive bool `json:"is_active" gorm:"default:true;index"`
IsAdmin bool `json:"is_admin" gorm:"default:false"`
LastLoginAt time.Time `json:"last_login_at" gorm:"index"`
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime;index"`
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime;index"`
}
func (u *User) SetPassword(password string) error {
u.Password = password
return nil
}
func (u *User) SetEncryptedPassword(password string) error {
if password == "" {
u.Password = ""
return nil
@@ -127,7 +88,11 @@ func (u *User) CheckPassword(password string) bool {
if u.Password == "" || password == "" {
return false
}
return util.CheckPasswordHash(u.Password, password)
isBcrypt := strings.HasPrefix(u.Password, "$2a$") || strings.HasPrefix(u.Password, "$2b$") || strings.HasPrefix(u.Password, "$2y$")
if isBcrypt {
return util.CheckPasswordHash(u.Password, password)
}
return u.Password == password
}
func (u *User) GetByID(tx *gorm.DB, id uint64) error {
@@ -137,70 +102,13 @@ func (u *User) GetByID(tx *gorm.DB, id uint64) error {
return nil
}
// GetByIDs 批量查询用户
func GetByIDs(tx *gorm.DB, ids []uint64) ([]User, error) {
var users []User
if err := tx.Where("id IN ?", ids).Find(&users).Error; err != nil {
return nil, err
}
return users, nil
}
func (u *User) GetUserGamificationScore(ctx context.Context) (*UserGamificationScoreResponse, error) {
if u.Username == "dev_user" {
var response UserGamificationScoreResponse
response.User.GamificationScore = 12345
return &response, nil
}
url := fmt.Sprintf("https://linux.do/u/%s.json", u.Username)
resp, err := util.Request(ctx, http.MethodGet, url, nil, nil, nil)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("获取用户积分失败,状态码: %d", resp.StatusCode)
}
var response UserGamificationScoreResponse
if err = json.NewDecoder(resp.Body).Decode(&response); err != nil {
return nil, fmt.Errorf("解析用户积分响应失败: %w", err)
}
return &response, nil
}
// GetLeaderboard 获取排行榜数据
func GetLeaderboard(ctx context.Context, page int) (*LeaderboardResponse, error) {
url := fmt.Sprintf("https://linux.do/leaderboard/1.json?period=all_time&page=%d", page)
resp, err := util.Request(ctx, http.MethodGet, url, nil, nil, nil)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("获取排行榜失败,状态码: %d", resp.StatusCode)
}
var response LeaderboardResponse
if err = json.NewDecoder(resp.Body).Decode(&response); err != nil {
return nil, fmt.Errorf("解析排行榜响应失败: %w", err)
}
return &response, nil
}
// UpdateFromOAuthInfo 根据 OAuth 信息更新用户数据
func (u *User) UpdateFromOAuthInfo(oauthInfo *OAuthUserInfo) {
u.Username = oauthInfo.Username
u.Nickname = oauthInfo.Name
u.AvatarUrl = oauthInfo.AvatarUrl
u.IsActive = oauthInfo.Active
u.TrustLevel = oauthInfo.TrustLevel
u.LastLoginAt = time.Now()
if oauthInfo.Username == "dev_user" {
u.IsAdmin = true
}
}
// CheckActive 检查用户账户是否激活,未激活则返回错误
@@ -220,10 +128,8 @@ func (u *User) CreateUser(tx *gorm.DB, oauthInfo *OAuthUserInfo) error {
Nickname: oauthInfo.Name,
AvatarUrl: oauthInfo.AvatarUrl,
IsActive: oauthInfo.Active,
TrustLevel: oauthInfo.TrustLevel,
SignKey: util.GenerateUniqueIDSimple(),
LastLoginAt: now,
IsAdmin: oauthInfo.Username == "dev_user",
IsAdmin: false,
}
if err := tx.Create(&newUser).Error; err != nil {
return err
+3
View File
@@ -117,6 +117,7 @@ func Serve() {
apiV1Router.GET("/oauth/logout", oauth.Logout)
apiV1Router.POST("/oauth/callback", oauth.Callback)
apiV1Router.GET("/oauth/user-info", oauth.LoginRequired(), oauth.UserInfo)
apiV1Router.GET("/user-info", oauth.LoginRequired(), oauth.UserInfo)
apiV1Router.GET("/oauth/external-accounts", oauth.LoginRequired(), oauth.ListExternalAccounts)
apiV1Router.POST("/oauth/external-accounts/:id/delete", oauth.LoginRequired(), oauth.DeleteExternalAccount)
@@ -127,6 +128,7 @@ func Serve() {
userRouter.POST("/register", user.Register)
userRouter.GET("/logout", user.Logout)
userRouter.GET("/self", oauth.LoginRequired(), oauth.UserInfo)
userRouter.POST("/change-password", oauth.LoginRequired(), user.ChangePassword)
// Access Token
tokenRouter := userRouter.Group("/access-tokens")
@@ -171,6 +173,7 @@ func Serve() {
// Users
adminRouter.GET("/users", admin_user.ListUsers)
adminRouter.POST("/users", admin_user.CreateUser)
adminRouter.PUT("/users/:id/status", admin_user.UpdateUserStatus)
// System Config