From f14703b0508482f3a0b8bd6d5312f362621dc87a Mon Sep 17 00:00:00 2001 From: yyg-max <175597134+yyg-max@users.noreply.github.com> Date: Wed, 14 Oct 2026 10:44:48 +0800 Subject: [PATCH] feat: introduce public account authorization and switching --- docs/docs.go | 171 ++++++++++++++- docs/swagger.json | 171 ++++++++++++++- docs/swagger.yaml | 110 +++++++++- frontend/components/common/admin/credit.tsx | 5 +- frontend/components/common/admin/users.tsx | 88 ++++---- .../components/common/general/table-data.tsx | 1 + .../public-account/admin-switch-button.tsx | 93 +++++++++ .../common/public-account/bind-button.tsx | 157 ++++++++++++++ .../common/public-account/switch-button.tsx | 103 +++++++++ frontend/components/layout/header.tsx | 3 + frontend/contexts/user-context.tsx | 25 ++- frontend/lib/services/admin/admin.service.ts | 40 ++++ frontend/lib/services/admin/types.ts | 32 ++- frontend/lib/services/user/types.ts | 22 ++ frontend/lib/services/user/user.service.ts | 23 +++ internal/apps/admin/order/logics.go | 2 +- internal/apps/admin/public_account/errs.go | 24 +++ internal/apps/admin/public_account/routers.go | 195 ++++++++++++++++++ internal/apps/admin/task/routers.go | 2 +- internal/apps/admin/user/errs.go | 2 +- internal/apps/admin/user/routers.go | 20 +- internal/apps/dashboard/logic.go | 2 +- internal/apps/dashboard/routers.go | 2 +- internal/apps/leaderboard/logic.go | 6 +- internal/apps/leaderboard/routers.go | 6 +- internal/apps/leaderboard/utils.go | 2 +- internal/apps/oauth/errs.go | 1 + internal/apps/oauth/logics.go | 17 +- internal/apps/oauth/middlewares.go | 19 +- internal/apps/oauth/risk.go | 4 +- internal/apps/oauth/routers.go | 2 +- internal/apps/payment/routers.go | 6 +- internal/apps/payment/utils.go | 6 +- internal/apps/public_account/errs.go | 24 +++ internal/apps/public_account/routers.go | 137 ++++++++++++ internal/apps/user/tasks.go | 6 +- internal/db/migrator/migrator.go | 3 +- internal/model/disputes.go | 4 +- internal/model/merchant_api_keys.go | 2 +- internal/model/order_transfers.go | 2 +- internal/model/orders.go | 4 +- internal/model/public_account_bindings.go | 28 +++ internal/model/red_envelopes.go | 4 +- internal/model/uploads.go | 2 +- internal/model/users.go | 14 +- internal/router/router.go | 9 + internal/service/payment.go | 13 +- internal/util/custom_types.go | 9 +- support-files/sql/create_clickhouse.sql | 8 +- 49 files changed, 1518 insertions(+), 113 deletions(-) create mode 100644 frontend/components/common/public-account/admin-switch-button.tsx create mode 100644 frontend/components/common/public-account/bind-button.tsx create mode 100644 frontend/components/common/public-account/switch-button.tsx create mode 100644 internal/apps/admin/public_account/errs.go create mode 100644 internal/apps/admin/public_account/routers.go create mode 100644 internal/apps/public_account/errs.go create mode 100644 internal/apps/public_account/routers.go create mode 100644 internal/model/public_account_bindings.go diff --git a/docs/docs.go b/docs/docs.go index cbb8e673..f1aa707f 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -163,6 +163,39 @@ const docTemplate = `{ } } }, + "/api/v1/admin/public-accounts": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "parameters": [ + { + "minimum": 1, + "type": "integer", + "name": "page", + "in": "query" + }, + { + "maximum": 100, + "minimum": 1, + "type": "integer", + "name": "page_size", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, "/api/v1/admin/system-configs": { "get": { "produces": [ @@ -530,6 +563,43 @@ const docTemplate = `{ } } }, + "/api/v1/admin/users/{id}/bind-public": { + "post": { + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "目标用户ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "绑定请求", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/public_account.bindPublicAccountsRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, "/api/v1/admin/users/{id}/status": { "put": { "produces": [ @@ -566,6 +636,34 @@ const docTemplate = `{ } } }, + "/api/v1/admin/users/{id}/switch": { + "post": { + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "目标账号ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, "/api/v1/config/public": { "get": { "consumes": [ @@ -1756,6 +1854,53 @@ const docTemplate = `{ } } }, + "/api/v1/user/public-accounts": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/api/v1/user/public-accounts/switch": { + "post": { + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "parameters": [ + { + "description": "切换请求", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/public_account.switchPublicAccountRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, "/f/{id}": { "get": { "produces": [ @@ -2475,6 +2620,29 @@ const docTemplate = `{ } } }, + "public_account.bindPublicAccountsRequest": { + "type": "object", + "properties": { + "public_user_ids": { + "type": "array", + "items": { + "type": "integer" + } + } + } + }, + "public_account.switchPublicAccountRequest": { + "type": "object", + "required": [ + "public_user_id" + ], + "properties": { + "public_user_id": { + "type": "string", + "example": "0" + } + } + }, "redenvelope.ClaimRequest": { "type": "object", "required": [ @@ -2610,7 +2778,8 @@ const docTemplate = `{ "type": "string" }, "user_id": { - "type": "integer" + "type": "string", + "example": "0" } } }, diff --git a/docs/swagger.json b/docs/swagger.json index 003dc0fb..0c72c9c4 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -154,6 +154,39 @@ } } }, + "/api/v1/admin/public-accounts": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "parameters": [ + { + "minimum": 1, + "type": "integer", + "name": "page", + "in": "query" + }, + { + "maximum": 100, + "minimum": 1, + "type": "integer", + "name": "page_size", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, "/api/v1/admin/system-configs": { "get": { "produces": [ @@ -521,6 +554,43 @@ } } }, + "/api/v1/admin/users/{id}/bind-public": { + "post": { + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "目标用户ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "绑定请求", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/public_account.bindPublicAccountsRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, "/api/v1/admin/users/{id}/status": { "put": { "produces": [ @@ -557,6 +627,34 @@ } } }, + "/api/v1/admin/users/{id}/switch": { + "post": { + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "目标账号ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, "/api/v1/config/public": { "get": { "consumes": [ @@ -1747,6 +1845,53 @@ } } }, + "/api/v1/user/public-accounts": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/api/v1/user/public-accounts/switch": { + "post": { + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "parameters": [ + { + "description": "切换请求", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/public_account.switchPublicAccountRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, "/f/{id}": { "get": { "produces": [ @@ -2466,6 +2611,29 @@ } } }, + "public_account.bindPublicAccountsRequest": { + "type": "object", + "properties": { + "public_user_ids": { + "type": "array", + "items": { + "type": "integer" + } + } + } + }, + "public_account.switchPublicAccountRequest": { + "type": "object", + "required": [ + "public_user_id" + ], + "properties": { + "public_user_id": { + "type": "string", + "example": "0" + } + } + }, "redenvelope.ClaimRequest": { "type": "object", "required": [ @@ -2601,7 +2769,8 @@ "type": "string" }, "user_id": { - "type": "integer" + "type": "string", + "example": "0" } } }, diff --git a/docs/swagger.yaml b/docs/swagger.yaml index f42a3d17..94f6d846 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -418,6 +418,21 @@ definitions: - recipient_id - recipient_username type: object + public_account.bindPublicAccountsRequest: + properties: + public_user_ids: + items: + type: integer + type: array + type: object + public_account.switchPublicAccountRequest: + properties: + public_user_id: + example: "0" + type: string + required: + - public_user_id + type: object redenvelope.ClaimRequest: properties: id: @@ -510,7 +525,8 @@ definitions: task_type: type: string user_id: - type: integer + example: "0" + type: string required: - task_type type: object @@ -673,6 +689,27 @@ paths: $ref: '#/definitions/util.ResponseAny' tags: - admin + /api/v1/admin/public-accounts: + get: + parameters: + - in: query + minimum: 1 + name: page + type: integer + - in: query + maximum: 100 + minimum: 1 + name: page_size + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/util.ResponseAny' + tags: + - admin /api/v1/admin/system-configs: get: produces: @@ -905,6 +942,30 @@ paths: $ref: '#/definitions/util.ResponseAny' tags: - admin + /api/v1/admin/users/{id}/bind-public: + post: + parameters: + - description: 目标用户ID + format: int64 + in: path + name: id + required: true + type: integer + - description: 绑定请求 + in: body + name: request + required: true + schema: + $ref: '#/definitions/public_account.bindPublicAccountsRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/util.ResponseAny' + tags: + - admin /api/v1/admin/users/{id}/status: put: parameters: @@ -928,6 +989,24 @@ paths: $ref: '#/definitions/util.ResponseAny' tags: - admin + /api/v1/admin/users/{id}/switch: + post: + parameters: + - description: 目标账号ID + format: int64 + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/util.ResponseAny' + tags: + - admin /api/v1/config/public: get: consumes: @@ -1681,6 +1760,35 @@ paths: $ref: '#/definitions/util.ResponseAny' tags: - user + /api/v1/user/public-accounts: + get: + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/util.ResponseAny' + tags: + - user + /api/v1/user/public-accounts/switch: + post: + parameters: + - description: 切换请求 + in: body + name: request + required: true + schema: + $ref: '#/definitions/public_account.switchPublicAccountRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/util.ResponseAny' + tags: + - user /f/{id}: get: parameters: diff --git a/frontend/components/common/admin/credit.tsx b/frontend/components/common/admin/credit.tsx index 8e441f83..9445bc3c 100644 --- a/frontend/components/common/admin/credit.tsx +++ b/frontend/components/common/admin/credit.tsx @@ -91,7 +91,6 @@ function PayConfigDetailPanel({ { @@ -106,9 +105,7 @@ function PayConfigDetailPanel({ return } - if (numValue >= 0) { - onEditDataChange('min_score', numValue) - } + onEditDataChange('min_score', numValue) }} className="text-xs text-right h-4 w-16 px-0 rounded-none border-none shadow-none focus-visible:ring-0 focus-visible:ring-offset-0 placeholder:text-[12px]" /> diff --git a/frontend/components/common/admin/users.tsx b/frontend/components/common/admin/users.tsx index 478748a0..41ac0a61 100644 --- a/frontend/components/common/admin/users.tsx +++ b/frontend/components/common/admin/users.tsx @@ -20,6 +20,9 @@ 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 { useUser } from "@/contexts/user-context" +import { SwitchAccountButton } from "@/components/common/public-account/admin-switch-button" +import { BindPublicAccountButton } from "@/components/common/public-account/bind-button" export function UsersManager() { const { @@ -41,6 +44,8 @@ export function UsersManager() { updateUserStatus } = useAdminUsers() + const { user: currentUser } = useUser() + const [selectedUser, setSelectedUser] = useState(null) const [detailOpen, setDetailOpen] = useState(false) const [updatingUserId, setUpdatingUserId] = useState(null) @@ -387,41 +392,54 @@ export function UsersManager() { e.stopPropagation()}>
- - - -
- handleStatusToggle(user)} - disabled={user.is_admin} - className="scale-75 data-[state=checked]:bg-green-600 h-4 w-7" - /> -
-
- - {user.is_admin ? '管理员账户' : user.is_active ? '禁用账户' : '启用账户'} - -
-
- - - - - - - 更新积分 - - - + {!user.is_admin && ( + + + +
+ handleStatusToggle(user)} + className="scale-75 data-[state=checked]:bg-green-600 h-4 w-7" + /> +
+
+ + {user.is_active ? '禁用账户' : '启用账户'} + +
+
+ )} + {Number(user.id) >= 0 && ( + + + + + + + 更新积分 + + + + )} + {Number(user.id) > 0 && ( + fetchUsers(true)} + /> + )} + {Number(user.id) < 0 && currentUser?.id !== user.id && ( + + )} diff --git a/frontend/components/common/general/table-data.tsx b/frontend/components/common/general/table-data.tsx index adc4f7a5..ad92ff82 100644 --- a/frontend/components/common/general/table-data.tsx +++ b/frontend/components/common/general/table-data.tsx @@ -174,6 +174,7 @@ const TransactionTableRow = React.memo(React.forwardRef { + setSwitching(true) + try { + await AdminService.switchAccount(accountId) + // 切换成功后重新加载页面,清除残留数据并以目标账号身份重新加载 + window.location.reload() + } catch (err) { + toast.error("切换失败", { + description: err instanceof Error ? err.message : "未知错误", + }) + setSwitching(false) + } + } + + return ( + + + + +
+ + + +
+
+ + 切换到该公共账号 + +
+
+ + + 确认切换到公共账号? + + 切换后当前会话身份将变为公共账号 @{username}(ID: {accountId}) + + + + 取消 + + {switching && } + 确认切换 + + + +
+ ) +} diff --git a/frontend/components/common/public-account/bind-button.tsx b/frontend/components/common/public-account/bind-button.tsx new file mode 100644 index 00000000..e234a720 --- /dev/null +++ b/frontend/components/common/public-account/bind-button.tsx @@ -0,0 +1,157 @@ +"use client" + +import { useEffect, useState } from "react" + +import { Button } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip" +import { AdminService } from "@/lib/services" +import type { PublicAccount } from "@/lib/services/admin/types" +import { Link2, Loader2 } from "lucide-react" +import { toast } from "sonner" + +/** + * 公共账号绑定按钮(展示在用户列表每行的操作栏,仅真实用户) + * + * @param userId - 目标真实用户 ID + * @param boundPublicAccountIds - 当前已绑定的公共账号 ID 列表 + * @param onChanged - 绑定保存成功后的回调(刷新列表) + * + * @remarks + * 多对多绑定:保存时全量同步(先清空该用户全部绑定再按勾选重建)。 + */ +export function BindPublicAccountButton({ + userId, + boundPublicAccountIds, + onChanged, +}: { + userId: string + boundPublicAccountIds: string[] + onChanged: () => void +}) { + const [open, setOpen] = useState(false) + const [publicAccounts, setPublicAccounts] = useState([]) + const [loadingList, setLoadingList] = useState(false) + const [selectedIds, setSelectedIds] = useState([]) + const [pending, setPending] = useState(false) + + // 打开对话框时加载公共账号列表并初始化勾选状态 + useEffect(() => { + if (!open) return + setLoadingList(true) + setSelectedIds(boundPublicAccountIds) + AdminService.listPublicAccounts() + .then((res) => setPublicAccounts(res.users)) + .catch((err) => { + toast.error("加载公共账号列表失败", { + description: err instanceof Error ? err.message : "未知错误", + }) + }) + .finally(() => setLoadingList(false)) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]) + + const toggleAccount = (id: string, checked: boolean) => { + setSelectedIds((prev) => (checked ? [...prev, id] : prev.filter((item) => item !== id))) + } + + const handleSave = async () => { + setPending(true) + try { + await AdminService.bindPublicAccounts(userId, selectedIds) + toast.success("已保存公共账号授权") + setOpen(false) + onChanged() + } catch (err) { + toast.error("保存失败", { + description: err instanceof Error ? err.message : "未知错误", + }) + } finally { + setPending(false) + } + } + + const boundCount = boundPublicAccountIds.length + + return ( + + + + +
+ + + + {boundCount > 0 && ( + + {boundCount} + + )} +
+
+ + {boundCount > 0 ? `已授权 ${boundCount} 个公共账号` : "授权公共账号"} + +
+
+ + + 授权公共账号 + + 勾选该用户可切换的公共账号,保存后全量生效。一个公共账号可同时授权给多个用户。 + + + +
+ {loadingList ? ( +
+ + 加载中... +
+ ) : publicAccounts.length === 0 ? ( +
暂无公共账号
+ ) : ( + publicAccounts.map((account) => ( + + )) + )} +
+ + + + +
+
+ ) +} diff --git a/frontend/components/common/public-account/switch-button.tsx b/frontend/components/common/public-account/switch-button.tsx new file mode 100644 index 00000000..4b1f041b --- /dev/null +++ b/frontend/components/common/public-account/switch-button.tsx @@ -0,0 +1,103 @@ +"use client" + +import { useState } from "react" + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog" +import { Button } from "@/components/ui/button" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { useUser } from "@/contexts/user-context" +import type { PublicAccountInfo } from "@/lib/services/user/types" +import { Loader2, UserRoundCog } from "lucide-react" +import { toast } from "sonner" + +/** + * 公共账号切换按钮(展示在顶部 header) + * + * 仅真实身份且绑定了公共账号时显示:下拉列出所有绑定的公共账号供切换。 + * 切换后恢复原身份需退出登录后重新登录(与管理端切换一致)。 + * + * @remarks + * 切换成功后重新加载页面,以新身份重新加载数据。 + */ +export function PublicAccountSwitchButton() { + const { user, publicAccounts, switchPublicAccount } = useUser() + const [pending, setPending] = useState(false) + const [switchTarget, setSwitchTarget] = useState(null) + + const accounts = publicAccounts?.public_accounts ?? [] + const canSwitch = !!user && Number(user.id) > 0 && accounts.length > 0 + + if (!canSwitch) return null + + const handleSwitch = async () => { + if (!switchTarget) return + setPending(true) + try { + await switchPublicAccount(switchTarget.id) + } catch (err) { + toast.error("切换失败", { + description: err instanceof Error ? err.message : "未知错误", + }) + setPending(false) + } + // 成功后 context 内部会刷新页面,无需重置状态 + } + + return ( + <> + + + + + + {accounts.map((account) => ( + setSwitchTarget(account)}> + {account.id} + @{account.username} + + ))} + + + + !open && setSwitchTarget(null)}> + + + 切换到公共账号? + + 切换后当前会话身份将变为公共账号 @{switchTarget?.username}(ID: {switchTarget?.id}) + + + + 取消 + + {pending && } + 确认 + + + + + + ) +} diff --git a/frontend/components/layout/header.tsx b/frontend/components/layout/header.tsx index e8c532e3..819fe5d5 100644 --- a/frontend/components/layout/header.tsx +++ b/frontend/components/layout/header.tsx @@ -12,6 +12,7 @@ import { useTheme } from "next-themes" import { useRouter } from "next/navigation" import { SearchDialog } from "@/components/layout/search-dialog" import { Kbd } from "@/components/ui/kbd" +import { PublicAccountSwitchButton } from "@/components/common/public-account/switch-button" /** @@ -89,6 +90,7 @@ export function SiteHeader({ isFullWidth = false, onToggleFullWidth }: { isFullW 搜索 +