diff --git a/docs/docs.go b/docs/docs.go index a256fba5..02938521 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -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" }, diff --git a/docs/swagger.json b/docs/swagger.json index 4ccaea83..ab433e34 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -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" }, diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 5a001adf..4f715890 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -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: diff --git a/frontend/app/(main)/settings/files/page.tsx b/frontend/app/(main)/admin/files/page.tsx similarity index 51% rename from frontend/app/(main)/settings/files/page.tsx rename to frontend/app/(main)/admin/files/page.tsx index a91770c4..fbfcef22 100644 --- a/frontend/app/(main)/settings/files/page.tsx +++ b/frontend/app/(main)/admin/files/page.tsx @@ -1,4 +1,4 @@ -import {FilesMain} from "@/components/common/settings/files" +import {FilesMain} from "@/components/common/admin/files" export default function FilesPage() { return diff --git a/frontend/components/auth/login-form.tsx b/frontend/components/auth/login-form.tsx index 2264d35f..92df2bcd 100644 --- a/frontend/components/auth/login-form.tsx +++ b/frontend/components/auth/login-form.tsx @@ -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() {

账号登录

-

- 使用账号密码或第三方 OIDC 认证源登录 -

setMode(value as "login" | "register")}> @@ -165,6 +162,7 @@ export function LoginForm() { @@ -216,6 +214,7 @@ export function LoginForm() { + + + + + + ) +} diff --git a/frontend/components/common/settings/files.tsx b/frontend/components/common/admin/files.tsx similarity index 99% rename from frontend/components/common/settings/files.tsx rename to frontend/components/common/admin/files.tsx index 6ab6d15b..aab75b93 100644 --- a/frontend/components/common/settings/files.tsx +++ b/frontend/components/common/admin/files.tsx @@ -147,7 +147,7 @@ export function FilesMain() { 文件管理

- 管理您上传的所有文件,支持下载和批量操作 + 管理您上传的所有文件,支持下载 and 批量操作

diff --git a/frontend/components/common/admin/users.tsx b/frontend/components/common/admin/users.tsx index ddf7955c..85fe32d3 100644 --- a/frontend/components/common/admin/users.tsx +++ b/frontend/components/common/admin/users.tsx @@ -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(null) const [detailOpen, setDetailOpen] = useState(false) + const [createModalOpen, setCreateModalOpen] = useState(false) useEffect(() => { fetchUsers() @@ -277,6 +291,10 @@ export function UsersManager() {
用户管理
+ {renderFilterBar()} @@ -409,9 +427,6 @@ export function UsersManager() { UID: {selectedUser.id} - - Lv.{selectedUser.trust_level} - {selectedUser.is_admin && ( Admin @@ -482,6 +497,8 @@ export function UsersManager() { )} + + setCreateModalOpen(false)} /> ) } diff --git a/frontend/components/common/docs/terms.tsx b/frontend/components/common/docs/terms.tsx index b57fc3f6..ebb0a98d 100644 --- a/frontend/components/common/docs/terms.tsx +++ b/frontend/components/common/docs/terms.tsx @@ -24,11 +24,7 @@ export const termsSections: PolicySection[] = [ title: "2. 服务定义与性质界定", content: (
-

2.1 纯技术研发脚手架:本平台是一个开源/闭源授权的技术二次开发底座。我们仅提供用户注册、API 调用、安全配置维护等纯软件技术服务。

-

2.2 非金融机构与无承兑申明:

    -
  • 非持牌金融或支付机构:本系统不是银行、商户收单或清算结算机构。
  • -
  • 不提供资金管理:系统无真实法币、加密货币或商业代金券充值、存储与兑现功能。若在二开中加入了积分等属性,其最终性质也应限制于虚拟软件积分。
  • 责任自担:关于用户对本系统进行二次开发并应用于其他生产环境产生的任何业务行为,由二开部署运营主体承担全部合规责任。
diff --git a/frontend/components/common/home/home-main.tsx b/frontend/components/common/home/home-main.tsx index 9b99e250..f577de96 100644 --- a/frontend/components/common/home/home-main.tsx +++ b/frontend/components/common/home/home-main.tsx @@ -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 = [ { diff --git a/frontend/components/common/settings/auth-source-modal.tsx b/frontend/components/common/settings/auth-source-modal.tsx index d4a00b81..ec418508 100644 --- a/frontend/components/common/settings/auth-source-modal.tsx +++ b/frontend/components/common/settings/auth-source-modal.tsx @@ -90,56 +90,56 @@ export function AuthSourceModal({
- setForm((prev) => ({ ...prev, name: e.target.value }))} + onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))} placeholder="例如: github" />
- setForm((prev) => ({ ...prev, display_name: e.target.value }))} + setForm((prev) => ({ ...prev, display_name: e.target.value }))} placeholder="例如: GitHub 登录" />
- setForm((prev) => ({ ...prev, client_id: e.target.value }))} + setForm((prev) => ({ ...prev, client_id: e.target.value }))} />
- setForm((prev) => ({ ...prev, client_secret: e.target.value }))} - placeholder={source ? "留空则保留原值" : ""} + setForm((prev) => ({ ...prev, client_secret: e.target.value }))} + placeholder={source ? "留空则保留原值" : ""} />
- setForm((prev) => ({ ...prev, openid_discovery_url: e.target.value }))} + setForm((prev) => ({ ...prev, openid_discovery_url: e.target.value }))} placeholder="https://..." />
- setForm((prev) => ({ ...prev, scopes: e.target.value }))} + setForm((prev) => ({ ...prev, scopes: e.target.value }))} />
- setForm((prev) => ({ ...prev, icon_url: e.target.value }))} + setForm((prev) => ({ ...prev, icon_url: e.target.value }))} placeholder="https://... 或留空" />
@@ -154,7 +154,7 @@ export function AuthSourceModal({ -
diff --git a/frontend/components/common/settings/profile.tsx b/frontend/components/common/settings/profile.tsx index f3e14b1b..76e2af91 100644 --- a/frontend/components/common/settings/profile.tsx +++ b/frontend/components/common/settings/profile.tsx @@ -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() {
{user.id}
-
-
信任等级
-
{getTrustLevelLabel(user.trust_level)}
-
+
管理员身份
@@ -158,6 +190,72 @@ export function ProfileMain() {
+ {/* 修改密码面板 */} +
+
+
+ +
+
+

修改密码

+

更改您的账号密码以确保安全。密码长度不能少于 8 位。

+
+
+ + {user.need_change_password && ( +
+ +
+

密码风险提示

+

+ 为了账号安全,您必须修改初始密码。 +

+
+
+ )} + +
+
+ + setOldPassword(e.target.value)} + required + /> +
+
+ + setNewPassword(e.target.value)} + required + /> +
+
+ + setConfirmPassword(e.target.value)} + required + /> +
+ + +
+
+ {/* 账号绑定面板 */}
diff --git a/frontend/components/layout/header.tsx b/frontend/components/layout/header.tsx index 94058e3f..b7a99995 100644 --- a/frontend/components/layout/header.tsx +++ b/frontend/components/layout/header.tsx @@ -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 (
+ {user?.need_change_password && ( +
+
+ + 为了您的账号安全,请立即修改密码! +
+ +
+ )}
diff --git a/frontend/components/layout/sidebar.tsx b/frontend/components/layout/sidebar.tsx index c3a3340f..f92b2dcf 100644 --- a/frontend/components/layout/sidebar.tsx +++ b/frontend/components/layout/sidebar.tsx @@ -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) { 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) { {user?.nickname || user?.username || "Unknown User"} - {user ? getTrustLevelLabel(user.trust_level) : "Trust Level Unknown"} + {user?.is_admin ? "系统管理员" : "普通用户"}
@@ -226,7 +226,7 @@ export function AppSidebar({ ...props }: React.ComponentProps) { {user?.nickname || user?.username || "Unknown User"} - {user ? getTrustLevelLabel(user.trust_level) : "Trust Level Unknown"} + {user?.is_admin ? "系统管理员" : "普通用户"}
diff --git a/frontend/contexts/admin-users-context.tsx b/frontend/contexts/admin-users-context.tsx index cf6f9e5b..444a2779 100644 --- a/frontend/contexts/admin-users-context.tsx +++ b/frontend/contexts/admin-users-context.tsx @@ -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 refresh: () => Promise updateUserStatus: (user: AdminUser) => Promise + createUser: (req: CreateUserRequest) => Promise } 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 ( diff --git a/frontend/contexts/user-context.tsx b/frontend/contexts/user-context.tsx index cec9510f..d9b9cce4 100644 --- a/frontend/contexts/user-context.tsx +++ b/frontend/contexts/user-context.tsx @@ -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 - getTrustLevelLabel: (trustLevel: TrustLevel) => string - getPayLevelLabel: (payLevel: PayLevel) => string logout: () => Promise } -/** 信任等级映射 */ -const TRUST_LEVEL_LABELS: Record = { - [TrustLevel.New]: '新用户', - [TrustLevel.Basic]: '基本用户', - [TrustLevel.Member]: '成员', - [TrustLevel.Regular]: '活跃用户', - [TrustLevel.Leader]: '领导者', -} -/** 支付等级映射 */ -const PAY_LEVEL_LABELS: Record = { - [PayLevel.BlackGold]: '黑金', - [PayLevel.WhiteGold]: '白金', - [PayLevel.Gold]: '黄金', - [PayLevel.Ordinary]: '普通', -} /** 用户上下文 */ const UserContext = createContext(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, }} > diff --git a/frontend/lib/services/admin/admin.service.ts b/frontend/lib/services/admin/admin.service.ts index e7ce3267..4a091437 100644 --- a/frontend/lib/services/admin/admin.service.ts +++ b/frontend/lib/services/admin/admin.service.ts @@ -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 { return this.put(`/users/${ id }/status`, request); } + + /** + * 创建用户 + * @param request - 创建用户请求参数 + * @returns 创建成功的用户信息 + */ + static async createUser(request: CreateUserRequest): Promise { + return this.post('/users', request); + } } diff --git a/frontend/lib/services/admin/index.ts b/frontend/lib/services/admin/index.ts index d92d7d0d..ddc2cac8 100644 --- a/frontend/lib/services/admin/index.ts +++ b/frontend/lib/services/admin/index.ts @@ -23,6 +23,7 @@ export { AdminService } from './admin.service'; export type { SystemConfig, CreateSystemConfigRequest, + CreateUserRequest, UpdateSystemConfigRequest, AuthSource, AuthSourceRequest, diff --git a/frontend/lib/services/admin/types.ts b/frontend/lib/services/admin/types.ts index 9c21994e..e3ab3de8 100644 --- a/frontend/lib/services/admin/types.ts +++ b/frontend/lib/services/admin/types.ts @@ -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; +} + /** * 认证源信息 */ diff --git a/frontend/lib/services/auth/auth.service.ts b/frontend/lib/services/auth/auth.service.ts index 9a5f60a0..f87127a8 100644 --- a/frontend/lib/services/auth/auth.service.ts +++ b/frontend/lib/services/auth/auth.service.ts @@ -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/register', request); } + static async changePassword(request: ChangePasswordRequest): Promise { + return this.post('/user/change-password', request); + } + static async getExternalAccountBindings(): Promise { return this.get('/oauth/external-accounts'); } diff --git a/frontend/lib/services/auth/index.ts b/frontend/lib/services/auth/index.ts index 7488ea0e..ef9afd13 100644 --- a/frontend/lib/services/auth/index.ts +++ b/frontend/lib/services/auth/index.ts @@ -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'; diff --git a/frontend/lib/services/auth/types.ts b/frontend/lib/services/auth/types.ts index 196305ac..47c468ba 100644 --- a/frontend/lib/services/auth/types.ts +++ b/frontend/lib/services/auth/types.ts @@ -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; +} diff --git a/frontend/lib/services/index.ts b/frontend/lib/services/index.ts index d777f2bd..98b1af8e 100644 --- a/frontend/lib/services/index.ts +++ b/frontend/lib/services/index.ts @@ -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, diff --git a/internal/apps/admin/auth_source/routers_test.go b/internal/apps/admin/auth_source/routers_test.go index 8df2fe76..d6bcc0b0 100644 --- a/internal/apps/admin/auth_source/routers_test.go +++ b/internal/apps/admin/auth_source/routers_test.go @@ -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) diff --git a/internal/apps/admin/system_config/routers_test.go b/internal/apps/admin/system_config/routers_test.go index 8e39db8a..1cf40912 100644 --- a/internal/apps/admin/system_config/routers_test.go +++ b/internal/apps/admin/system_config/routers_test.go @@ -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) { diff --git a/internal/apps/admin/task/routers_test.go b/internal/apps/admin/task/routers_test.go index e5a9a3fc..80c8c4e5 100644 --- a/internal/apps/admin/task/routers_test.go +++ b/internal/apps/admin/task/routers_test.go @@ -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() diff --git a/internal/apps/admin/user/errs.go b/internal/apps/admin/user/errs.go index acfcaec2..5e345c62 100644 --- a/internal/apps/admin/user/errs.go +++ b/internal/apps/admin/user/errs.go @@ -20,4 +20,8 @@ const ( userNotFound = "用户不存在" cannotDisable = "不能禁用管理员用户" updateUserFailed = "更新用户状态失败" + usernameExists = "用户名已存在" + usernameRequired = "用户名不能为空" + passwordTooShort = "密码长度不能少于 8 位" + createUserFailed = "创建用户失败" ) diff --git a/internal/apps/admin/user/routers.go b/internal/apps/admin/user/routers.go index 40c7dc26..c6f9073d 100644 --- a/internal/apps/admin/user/routers.go +++ b/internal/apps/admin/user/routers.go @@ -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)) +} diff --git a/internal/apps/admin/user/routers_test.go b/internal/apps/admin/user/routers_test.go index 6f658f34..99b8504c 100644 --- a/internal/apps/admin/user/routers_test.go +++ b/internal/apps/admin/user/routers_test.go @@ -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(®ularUser) @@ -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()) + } + }) +} diff --git a/internal/apps/oauth/oauth_test.go b/internal/apps/oauth/oauth_test.go index df87f603..ac2a121f 100644 --- a/internal/apps/oauth/oauth_test.go +++ b/internal/apps/oauth/oauth_test.go @@ -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 diff --git a/internal/apps/oauth/routers.go b/internal/apps/oauth/routers.go index 233e753b..52783271 100644 --- a/internal/apps/oauth/routers.go +++ b/internal/apps/oauth/routers.go @@ -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)), ) } diff --git a/internal/apps/oauth/sources.go b/internal/apps/oauth/sources.go index 8c50577a..f5368d2a 100644 --- a/internal/apps/oauth/sources.go +++ b/internal/apps/oauth/sources.go @@ -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 diff --git a/internal/apps/upload/routers.go b/internal/apps/upload/routers.go index a80d3357..b34e71fc 100644 --- a/internal/apps/upload/routers.go +++ b/internal/apps/upload/routers.go @@ -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 { diff --git a/internal/apps/upload/routers_test.go b/internal/apps/upload/routers_test.go index 0440bd9c..b2cb0db8 100644 --- a/internal/apps/upload/routers_test.go +++ b/internal/apps/upload/routers_test.go @@ -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 diff --git a/internal/apps/user/access_tokens.go b/internal/apps/user/access_tokens.go index ff7d95e4..fd29faf5 100644 --- a/internal/apps/user/access_tokens.go +++ b/internal/apps/user/access_tokens.go @@ -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, - }) + })) } diff --git a/internal/apps/user/controllers.go b/internal/apps/user/controllers.go index ffbfbcbd..7e32095e 100644 --- a/internal/apps/user/controllers.go +++ b/internal/apps/user/controllers.go @@ -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("密码修改成功")) } diff --git a/internal/common/bind/bind.go b/internal/common/bind/bind.go index e832f40a..433f33b5 100644 --- a/internal/common/bind/bind.go +++ b/internal/common/bind/bind.go @@ -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 diff --git a/internal/common/response/response.go b/internal/common/response/response.go deleted file mode 100644 index dcae0d23..00000000 --- a/internal/common/response/response.go +++ /dev/null @@ -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, - }) -} diff --git a/internal/db/migrator/migrator.go b/internal/db/migrator/migrator.go index 2df21ecd..a11573a0 100644 --- a/internal/db/migrator/migrator.go +++ b/internal/db/migrator/migrator.go @@ -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") + } +} diff --git a/internal/model/users.go b/internal/model/users.go index 0cf13d0d..0e171f34 100644 --- a/internal/model/users.go +++ b/internal/model/users.go @@ -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 diff --git a/internal/router/router.go b/internal/router/router.go index 1ccbb4ec..17f09b3d 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -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