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 ? '禁用账户' : '启用账户'} - - - - - - - handleUpdateCredits(user)} - disabled={updatingUserId === user.id} - > - - - - - 更新积分 - - - + {!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 && ( + + + + handleUpdateCredits(user)} + disabled={updatingUserId === user.id} + > + + + + + 更新积分 + + + + )} + {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 ( + + + + + + + + {switching ? ( + + ) : ( + + )} + + + + + + 切换到该公共账号 + + + + + + 确认切换到公共账号? + + 切换后当前会话身份将变为公共账号 @{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 ( + + + + + + + 0 ? "text-primary hover:text-primary" : ""}`} + > + + + + {boundCount > 0 && ( + + {boundCount} + + )} + + + + {boundCount > 0 ? `已授权 ${boundCount} 个公共账号` : "授权公共账号"} + + + + + + 授权公共账号 + + 勾选该用户可切换的公共账号,保存后全量生效。一个公共账号可同时授权给多个用户。 + + + + + {loadingList ? ( + + + 加载中... + + ) : publicAccounts.length === 0 ? ( + 暂无公共账号 + ) : ( + publicAccounts.map((account) => ( + + toggleAccount(account.id, checked === true)} + disabled={pending} + /> + @{account.username} + + ID: {account.id} + + + )) + )} + + + + + {pending && } + 保存 + + + + + ) +} 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 搜索 + router.push('/settings')}> 设置 @@ -110,6 +112,7 @@ export function SiteHeader({ isFullWidth = false, onToggleFullWidth }: { isFullW + router.push('/settings')}> 设置 diff --git a/frontend/contexts/user-context.tsx b/frontend/contexts/user-context.tsx index d3e9a6b8..f914606e 100644 --- a/frontend/contexts/user-context.tsx +++ b/frontend/contexts/user-context.tsx @@ -4,11 +4,13 @@ import { createContext, useContext, useState, useEffect, useRef, useCallback, Re import services from '@/lib/services' import { User, TrustLevel, PayLevel } from '@/lib/services/auth/types' +import type { PublicAccountsResponse } from '@/lib/services/user/types' /** 用户状态接口 */ interface UserState { user: User | null + publicAccounts: PublicAccountsResponse | null loading: boolean error: string | null } @@ -17,6 +19,7 @@ interface UserState { interface UserContextValue extends UserState { refetch: () => Promise updatePayKey: (payKey: string) => Promise + switchPublicAccount: (publicUserId: string) => Promise getTrustLevelLabel: (trustLevel: TrustLevel) => string getPayLevelLabel: (payLevel: PayLevel) => string logout: () => Promise @@ -59,6 +62,7 @@ const UserContext = createContext(undefined) export function UserProvider({ children }: { children: ReactNode }) { const [state, setState] = useState({ user: null, + publicAccounts: null, loading: true, error: null, }) @@ -81,14 +85,23 @@ export function UserProvider({ children }: { children: ReactNode }) { setState(prev => ({ ...prev, loading: true, error: null })) const user = await services.auth.getUserInfo() + // 公共账号绑定状态(失败不阻断用户信息展示) + let publicAccounts: PublicAccountsResponse | null = null + try { + publicAccounts = await services.user.getPublicAccounts() + } catch { + // 忽略绑定状态获取失败 + } + if (!isMountedRef.current) return - setState({ user, loading: false, error: null }) + setState({ user, publicAccounts, loading: false, error: null }) } catch (error) { if (!isMountedRef.current) return setState({ user: null, + publicAccounts: null, loading: false, error: error instanceof Error ? error.message : '获取用户信息失败', }) @@ -106,6 +119,13 @@ export function UserProvider({ children }: { children: ReactNode }) { await fetchUser() }, [fetchUser]) + /** 切换到已绑定的公共账号身份 */ + const switchPublicAccount = useCallback(async (publicUserId: string) => { + await services.user.switchPublicAccount(publicUserId) + // 切换成功后重新加载页面,以公共账号身份重新加载数据 + window.location.reload() + }, []) + /** 用户登出 */ const logout = useCallback(async () => { try { @@ -113,7 +133,7 @@ export function UserProvider({ children }: { children: ReactNode }) { if (!isMountedRef.current) return - setState({ user: null, loading: false, error: null }) + setState({ user: null, publicAccounts: null, loading: false, error: null }) window.location.href = '/login' } catch (error) { if (!isMountedRef.current) { @@ -145,6 +165,7 @@ export function UserProvider({ children }: { children: ReactNode }) { ...state, refetch, updatePayKey, + switchPublicAccount, getTrustLevelLabel, getPayLevelLabel, logout, diff --git a/frontend/lib/services/admin/admin.service.ts b/frontend/lib/services/admin/admin.service.ts index ed5904e5..eac5f85b 100644 --- a/frontend/lib/services/admin/admin.service.ts +++ b/frontend/lib/services/admin/admin.service.ts @@ -11,6 +11,7 @@ import type { DispatchTaskRequest, ListUsersRequest, ListUsersResponse, + ListPublicAccountsResponse, UpdateUserStatusRequest, ListAdminOrdersRequest, ListAdminOrdersResponse, @@ -345,6 +346,15 @@ export class AdminService extends BaseService { return this.get('/users', request as unknown as Record); } + /** + * 获取公共账号列表(负数 ID) + * @returns 公共账号列表及总数 + * @throws {ForbiddenError} 当无管理员权限时 + */ + static async listPublicAccounts(): Promise { + return this.get('/public-accounts', { page: 1, page_size: 100 }); + } + /** * 更新用户状态 * @param id - 用户 ID @@ -373,6 +383,36 @@ export class AdminService extends BaseService { return this.put(`/users/${ id }/status`, request); } + // ==================== 账号切换 ==================== + + /** + * 切换到公共账号身份(模拟登录) + * @param id - 目标公共账号 ID(负数) + * @returns void + * @throws {UnauthorizedError} 当未登录时 + * @throws {ForbiddenError} 当无管理员权限时 + * @throws {NotFoundError} 当目标公共账号不存在或不可用时 + * + * @remarks + * - 切换后 session 绑定用户被替换为目标账号,不影响目标账号自身的登录会话 + * - 只能退出登录后重新登录恢复原账号 + */ + static async switchAccount(id: string): Promise { + return this.post(`/users/${ id }/switch`); + } + + /** + * 设置用户的公共账号授权(全量同步,空数组即清空授权) + * @param id - 目标用户 ID + * @param publicUserIds - 公共账号 ID 列表(负数) + * @returns void + * @throws {ForbiddenError} 当无管理员权限时 + * @throws {NotFoundError} 当公共账号不存在时 + */ + static async bindPublicAccounts(id: string, publicUserIds: string[]): Promise { + return this.post(`/users/${ id }/bind-public`, { public_user_ids: publicUserIds.map(Number) }); + } + // ==================== 订单管理 ==================== /** diff --git a/frontend/lib/services/admin/types.ts b/frontend/lib/services/admin/types.ts index 8d0ef673..6ffd84cb 100644 --- a/frontend/lib/services/admin/types.ts +++ b/frontend/lib/services/admin/types.ts @@ -190,6 +190,8 @@ export interface AdminUser { is_active: boolean; /** 是否管理员 */ is_admin: boolean; + /** 绑定的公共账号 ID 列表 */ + public_account_ids: string[]; /** 最后登录时间 */ last_login_at: string; /** 创建时间 */ @@ -222,6 +224,32 @@ export interface ListUsersResponse { total: number; } +/** + * 公共账号信息(管理端列表) + */ +export interface PublicAccount { + /** 公共账号 ID */ + id: string; + /** 用户名 */ + username: string; + /** 昵称 */ + nickname: string; + /** 头像 URL */ + avatar_url: string; + /** 是否激活 */ + is_active: boolean; +} + +/** + * 公共账号列表响应 + */ +export interface ListPublicAccountsResponse { + /** 公共账号列表 */ + users: PublicAccount[]; + /** 总数 */ + total: number; +} + /** * 更新用户状态请求参数 */ @@ -256,8 +284,8 @@ export interface AdminOrder { order_name: string; merchant_order_no: string | null; client_id: string; - payer_user_id: number; - payee_user_id: number; + payer_user_id: string; + payee_user_id: string; payer_username: string; payee_username: string; payer_avatar_url?: string; diff --git a/frontend/lib/services/user/types.ts b/frontend/lib/services/user/types.ts index 1c626da4..730434cc 100644 --- a/frontend/lib/services/user/types.ts +++ b/frontend/lib/services/user/types.ts @@ -6,3 +6,25 @@ export interface UpdatePayKeyRequest { pay_key: string; } +/** + * 公共账号信息 + */ +export interface PublicAccountInfo { + /** 公共账号 ID */ + id: string; + /** 用户名 */ + username: string; + /** 昵称 */ + nickname: string; + /** 头像 URL */ + avatar_url: string; +} + +/** + * 公共账号绑定列表响应 + */ +export interface PublicAccountsResponse { + /** 可切换的公共账号列表(真实身份下有值) */ + public_accounts: PublicAccountInfo[]; +} + diff --git a/frontend/lib/services/user/user.service.ts b/frontend/lib/services/user/user.service.ts index aeed82de..a774abe6 100644 --- a/frontend/lib/services/user/user.service.ts +++ b/frontend/lib/services/user/user.service.ts @@ -1,4 +1,5 @@ import { BaseService } from '../core/base.service'; +import type { PublicAccountsResponse } from './types'; /** * 用户服务 @@ -26,5 +27,27 @@ export class UserService extends BaseService { static async updatePayKey(payKey: string): Promise { return this.put('/pay-key', { pay_key: payKey }); } + + /** + * 获取当前用户的公共账号绑定状态 + * @returns 公共账号绑定状态 + * @throws {UnauthorizedError} 当未登录时 + */ + static async getPublicAccounts(): Promise { + return this.get('/public-accounts'); + } + + /** + * 切换到已绑定的公共账号身份 + * @param publicUserId - 目标公共账号 ID + * @returns void + * @throws {UnauthorizedError} 当未登录时 + * + * @remarks + * - 切换后 session 身份变为公共账号,恢复原身份需退出登录后重新登录 + */ + static async switchPublicAccount(publicUserId: string): Promise { + return this.post('/public-accounts/switch', { public_user_id: publicUserId }); + } } diff --git a/internal/apps/admin/order/logics.go b/internal/apps/admin/order/logics.go index 54b6123e..831976eb 100644 --- a/internal/apps/admin/order/logics.go +++ b/internal/apps/admin/order/logics.go @@ -93,7 +93,7 @@ func listOrders(ctx context.Context, req *listOrdersRequest) (*listOrdersRespons } // refundOrder 执行管理员退款;有争议时更新争议,无争议时只按需追加订单备注。 -func refundOrder(ctx context.Context, id uint64, req *refundOrderRequest, adminUserID uint64) error { +func refundOrder(ctx context.Context, id uint64, req *refundOrderRequest, adminUserID int64) error { return db.DB(ctx).Transaction(func(tx *gorm.DB) error { var order model.Order if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). diff --git a/internal/apps/admin/public_account/errs.go b/internal/apps/admin/public_account/errs.go new file mode 100644 index 00000000..ebe5dc7b --- /dev/null +++ b/internal/apps/admin/public_account/errs.go @@ -0,0 +1,24 @@ +/* +Copyright 2025 linux.do + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package public_account + +const ( + invalidTargetAccount = "无效的目标账号ID" + invalidBindRequest = "无效的绑定请求" + bindTargetNotFound = "目标用户或公共账号不存在" + targetAccountNotFound = "目标账号不存在或不可用" +) diff --git a/internal/apps/admin/public_account/routers.go b/internal/apps/admin/public_account/routers.go new file mode 100644 index 00000000..136684eb --- /dev/null +++ b/internal/apps/admin/public_account/routers.go @@ -0,0 +1,195 @@ +/* +Copyright 2025 linux.do + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package public_account + +import ( + "net/http" + "slices" + "strconv" + + "github.com/gin-contrib/sessions" + "github.com/gin-gonic/gin" + "github.com/linux-do/credit/internal/apps/oauth" + "github.com/linux-do/credit/internal/db" + "github.com/linux-do/credit/internal/model" + "github.com/linux-do/credit/internal/util" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// listPublicAccountsRequest 公共账号列表查询请求 +type listPublicAccountsRequest struct { + Page int `form:"page" binding:"min=1"` + PageSize int `form:"page_size" binding:"min=1,max=100"` +} + +// publicAccount 公共账号信息 +type publicAccount struct { + ID int64 `json:"id,string"` + Username string `json:"username"` + Nickname string `json:"nickname"` + AvatarUrl string `json:"avatar_url"` + IsActive bool `json:"is_active"` +} + +// listPublicAccountsResponse 公共账号列表响应 +type listPublicAccountsResponse struct { + Users []publicAccount `json:"users"` + Total int64 `json:"total"` +} + +// ListPublicAccounts 列出公共账号(负数 ID) +// @Tags admin +// @Produce json +// @Param request query listPublicAccountsRequest true "查询参数" +// @Success 200 {object} util.ResponseAny +// @Router /api/v1/admin/public-accounts [get] +func ListPublicAccounts(c *gin.Context) { + var req listPublicAccountsRequest + if err := c.ShouldBindQuery(&req); err != nil { + c.JSON(http.StatusBadRequest, util.Err(err.Error())) + return + } + + var accounts []publicAccount + var total int64 + + query := db.DB(c.Request.Context()).Table("users").Where("id < ?", 0) + + if err := query.Count(&total).Error; err != nil { + c.JSON(http.StatusInternalServerError, util.Err(err.Error())) + return + } + + offset := (req.Page - 1) * req.PageSize + if err := query. + Select("id, username, nickname, avatar_url, is_active"). + Order("id DESC"). + Offset(offset). + Limit(req.PageSize). + Find(&accounts).Error; err != nil { + c.JSON(http.StatusInternalServerError, util.Err(err.Error())) + return + } + + c.JSON(http.StatusOK, util.OK(listPublicAccountsResponse{ + Users: accounts, + Total: total, + })) +} + +// bindPublicAccountsRequest 绑定公共账号请求(全量同步,空数组即清空授权) +type bindPublicAccountsRequest struct { + PublicUserIDs []int64 `json:"public_user_ids" binding:"dive,lt=0"` +} + +// BindPublicAccounts 设置用户的公共账号授权(全量同步:先清空再按请求重建;空数组即清空授权) +// @Tags admin +// @Produce json +// @Param id path int64 true "目标用户ID" +// @Param request body bindPublicAccountsRequest true "绑定请求" +// @Success 200 {object} util.ResponseAny +// @Router /api/v1/admin/users/{id}/bind-public [post] +func BindPublicAccounts(c *gin.Context) { + userID, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil || userID <= 0 { + c.JSON(http.StatusBadRequest, util.Err(invalidTargetAccount)) + return + } + + // gin 校验:每个元素为负数(dive,lt=0) + var req bindPublicAccountsRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, util.Err(invalidBindRequest)) + return + } + + // 排序 + 去重 + publicIDs := slices.Compact(slices.Sorted(slices.Values(req.PublicUserIDs))) + + var count int64 + if err := db.DB(c.Request.Context()).Model(&model.User{}). + Where("id IN ?", append(slices.Clone(publicIDs), userID)). + Count(&count).Error; err != nil { + c.JSON(http.StatusInternalServerError, util.Err(err.Error())) + return + } + if count != int64(len(publicIDs)+1) { + c.JSON(http.StatusNotFound, util.Err(bindTargetNotFound)) + return + } + + // 删除本次未保留的绑定;插入期望绑定,已存在的由唯一索引跳过 + if err := db.DB(c.Request.Context()).Transaction(func(tx *gorm.DB) error { + query := tx.Where("user_id = ?", userID) + if len(publicIDs) > 0 { + query = query.Where("public_account_id NOT IN ?", publicIDs) + } + if err := query.Delete(&model.PublicAccountBinding{}).Error; err != nil { + return err + } + + if len(publicIDs) == 0 { + return nil + } + bindings := make([]model.PublicAccountBinding, 0, len(publicIDs)) + for _, id := range publicIDs { + bindings = append(bindings, model.PublicAccountBinding{PublicAccountID: id, UserID: userID}) + } + return tx.Clauses(clause.OnConflict{DoNothing: true}).Create(bindings).Error + }); err != nil { + c.JSON(http.StatusInternalServerError, util.Err(err.Error())) + return + } + + c.JSON(http.StatusOK, util.OKNil()) +} + +// SwitchAccount 管理员切换到公共账号身份 +// @Tags admin +// @Produce json +// @Param id path int64 true "目标账号ID" +// @Success 200 {object} util.ResponseAny +// @Router /api/v1/admin/users/{id}/switch [post] +func SwitchAccount(c *gin.Context) { + accountID, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, util.Err(invalidTargetAccount)) + return + } + + // 查询条件限定 id < 0 且与路径参数一致:非公共账号会命中 NotFound 分支 + var targetAccount model.User + if err := db.DB(c.Request.Context()).Where("id = ? AND id < ? AND is_active = ?", accountID, 0, true).First(&targetAccount).Error; err != nil { + if err == gorm.ErrRecordNotFound { + c.JSON(http.StatusNotFound, util.Err(targetAccountNotFound)) + return + } + c.JSON(http.StatusInternalServerError, util.Err(err.Error())) + return + } + + session := sessions.Default(c) + session.Set(oauth.UserIDKey, targetAccount.ID) + session.Set(oauth.UserNameKey, targetAccount.Username) + if err := session.Save(); err != nil { + c.JSON(http.StatusInternalServerError, util.Err(err.Error())) + return + } + + c.JSON(http.StatusOK, util.OKNil()) +} diff --git a/internal/apps/admin/task/routers.go b/internal/apps/admin/task/routers.go index f3e36975..c9ede1c8 100644 --- a/internal/apps/admin/task/routers.go +++ b/internal/apps/admin/task/routers.go @@ -43,7 +43,7 @@ type DispatchTaskRequest struct { TaskType string `json:"task_type" binding:"required"` StartTime *time.Time `json:"start_time"` EndTime *time.Time `json:"end_time"` - UserID *uint64 `json:"user_id"` + UserID *int64 `json:"user_id,string"` } // DispatchTask 下发任务 diff --git a/internal/apps/admin/user/errs.go b/internal/apps/admin/user/errs.go index acfcaec2..4d98d292 100644 --- a/internal/apps/admin/user/errs.go +++ b/internal/apps/admin/user/errs.go @@ -17,7 +17,7 @@ limitations under the License. package user const ( - userNotFound = "用户不存在" cannotDisable = "不能禁用管理员用户" updateUserFailed = "更新用户状态失败" + userNotFound = "用户不存在" ) diff --git a/internal/apps/admin/user/routers.go b/internal/apps/admin/user/routers.go index 25238e52..5959c074 100644 --- a/internal/apps/admin/user/routers.go +++ b/internal/apps/admin/user/routers.go @@ -31,14 +31,14 @@ import ( // listUsersRequest 用户列表查询请求 type listUsersRequest struct { - Page int `form:"page" binding:"min=1"` - PageSize int `form:"page_size" binding:"min=1,max=100"` - UserID *uint64 `form:"user_id" binding:"omitempty,gt=0"` - Username string `form:"username"` + Page int `form:"page" binding:"min=1"` + PageSize int `form:"page_size" binding:"min=1,max=100"` + UserID *int64 `form:"user_id"` + Username string `form:"username"` } type user struct { - ID uint64 `json:"id"` + ID int64 `json:"id,string"` Username string `json:"username"` Nickname string `json:"nickname"` AvatarUrl string `json:"avatar_url"` @@ -52,6 +52,8 @@ type user struct { AvailableBalance decimal.Decimal `json:"available_balance"` IsActive bool `json:"is_active"` IsAdmin bool `json:"is_admin"` + // PublicAccountIDs 该用户绑定的公共账号 ID 列表 + PublicAccountIDs util.StringArray `json:"public_account_ids"` LastLoginAt time.Time `json:"last_login_at"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` @@ -101,7 +103,9 @@ func ListUsers(c *gin.Context) { 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, " + - "last_login_at, created_at, updated_at"). + "last_login_at, created_at, updated_at, " + + "COALESCE((SELECT array_to_json(array_agg(b.public_account_id::text)) " + + "FROM public_account_bindings b WHERE b.user_id = users.id), '[]') AS public_account_ids"). Order("id DESC"). Offset(offset). Limit(req.PageSize). @@ -138,8 +142,8 @@ func UpdateUserStatus(c *gin.Context) { id := c.Param("id") var targetUser struct { - ID uint64 `gorm:"column:id"` - IsAdmin bool `gorm:"column:is_admin"` + ID int64 `gorm:"column:id"` + IsAdmin bool `gorm:"column:is_admin"` } if err := db.DB(c.Request.Context()). Table("users"). diff --git a/internal/apps/dashboard/logic.go b/internal/apps/dashboard/logic.go index eb814eb8..6929e754 100644 --- a/internal/apps/dashboard/logic.go +++ b/internal/apps/dashboard/logic.go @@ -40,7 +40,7 @@ type dailyAmountResult struct { // queryDailyAmounts 查询每日金额 // isIncome: true=收入(payee), false=支出(payer) -func queryDailyAmounts(ctx context.Context, userID uint64, isIncome bool, startDate, endDate time.Time) (map[string]decimal.Decimal, error) { +func queryDailyAmounts(ctx context.Context, userID int64, isIncome bool, startDate, endDate time.Time) (map[string]decimal.Decimal, error) { var results []dailyAmountResult var err error diff --git a/internal/apps/dashboard/routers.go b/internal/apps/dashboard/routers.go index 5ce3d457..93faea12 100644 --- a/internal/apps/dashboard/routers.go +++ b/internal/apps/dashboard/routers.go @@ -48,7 +48,7 @@ type TopCustomersRequest struct { // TopCustomer Top客户项 type TopCustomer struct { - UserID uint64 `json:"user_id"` + UserID int64 `json:"user_id"` Username string `json:"username"` TotalAmount decimal.Decimal `json:"total_amount"` OrderCount int64 `json:"order_count"` diff --git a/internal/apps/leaderboard/logic.go b/internal/apps/leaderboard/logic.go index ba292a82..1a055c65 100644 --- a/internal/apps/leaderboard/logic.go +++ b/internal/apps/leaderboard/logic.go @@ -56,7 +56,7 @@ func getList(ctx context.Context, req *ListRequest) (*ListResponse, error) { return response, nil } -func getUserRank(ctx context.Context, userID uint64) (*UserRankResponse, error) { +func getUserRank(ctx context.Context, userID int64) (*UserRankResponse, error) { // 检查缓存 cacheKey := fmt.Sprintf("%suser:%d", cacheKeyPrefix, userID) if data, err := db.Redis.Get(ctx, db.PrefixedKey(cacheKey)).Bytes(); err == nil { @@ -77,8 +77,8 @@ func getUserRank(ctx context.Context, userID uint64) (*UserRankResponse, error) // 即:余额更高,或余额相同但 id 更小 var rank int64 if err := db.DB(ctx).Model(&model.User{}). - Where("is_active = ? AND ((available_balance > ?) OR (available_balance = ? AND id < ?))", - true, user.AvailableBalance, user.AvailableBalance, userID). + Where("id > ? AND is_active = ? AND ((available_balance > ?) OR (available_balance = ? AND id < ?))", + 0, true, user.AvailableBalance, user.AvailableBalance, userID). Count(&rank).Error; err != nil { return nil, err } diff --git a/internal/apps/leaderboard/routers.go b/internal/apps/leaderboard/routers.go index 63d653ac..54bb6297 100644 --- a/internal/apps/leaderboard/routers.go +++ b/internal/apps/leaderboard/routers.go @@ -35,7 +35,7 @@ type ListRequest struct { // LeaderboardEntry 排行榜条目(rank 由前端根据 offset + index + 1 计算) type LeaderboardEntry struct { - UserID uint64 `json:"user_id"` + UserID int64 `json:"user_id"` Username string `json:"username"` AvatarURL string `json:"avatar_url"` AvailableBalance decimal.Decimal `json:"available_balance"` @@ -58,7 +58,7 @@ type UserRankResponse struct { // UserRankInfo 用户排名信息 type UserRankInfo struct { - UserID uint64 `json:"user_id"` + UserID int64 `json:"user_id"` Rank int `json:"rank"` AvailableBalance decimal.Decimal `json:"available_balance"` } @@ -118,7 +118,7 @@ func GetMyRank(c *gin.Context) { // @Router /api/v1/leaderboard/users/{id} [get] func GetUserRankByID(c *gin.Context) { userIDStr := c.Param("id") - userID, err := strconv.ParseUint(userIDStr, 10, 64) + userID, err := strconv.ParseInt(userIDStr, 10, 64) if err != nil { c.JSON(http.StatusBadRequest, util.Err("invalid user id")) return diff --git a/internal/apps/leaderboard/utils.go b/internal/apps/leaderboard/utils.go index 541fb34e..d9215594 100644 --- a/internal/apps/leaderboard/utils.go +++ b/internal/apps/leaderboard/utils.go @@ -35,7 +35,7 @@ func getCacheTTL(ctx context.Context) time.Duration { func queryLeaderboard(ctx context.Context, req *ListRequest) ([]LeaderboardEntry, int64, error) { offset := (req.Page - 1) * req.PageSize - baseQuery := db.DB(ctx).Model(&model.User{}).Where("is_active = ?", true) + baseQuery := db.DB(ctx).Model(&model.User{}).Where("id > ? AND is_active = ?", 0, true) var total int64 if err := baseQuery.Count(&total).Error; err != nil { diff --git a/internal/apps/oauth/errs.go b/internal/apps/oauth/errs.go index 0414ef7b..dea7e0fe 100644 --- a/internal/apps/oauth/errs.go +++ b/internal/apps/oauth/errs.go @@ -20,4 +20,5 @@ const ( InvalidState = "非法登录请求" IDTokenVerifyFailed = "ID Token 验证失败" NonceMismatch = "nonce 不匹配,可能存在重放攻击" + InvalidOAuthUserID = "OAuth 用户 ID 非法" ) diff --git a/internal/apps/oauth/logics.go b/internal/apps/oauth/logics.go index b2e6030e..378314d4 100644 --- a/internal/apps/oauth/logics.go +++ b/internal/apps/oauth/logics.go @@ -35,15 +35,15 @@ import ( "gorm.io/gorm" ) -func GetUserIDFromSession(s sessions.Session) uint64 { - userID, ok := s.Get(UserIDKey).(uint64) +func GetUserIDFromSession(s sessions.Session) int64 { + userID, ok := s.Get(UserIDKey).(int64) if !ok { return 0 } return userID } -func GetUserIDFromContext(c *gin.Context) uint64 { +func GetUserIDFromContext(c *gin.Context) int64 { session := sessions.Default(c) return GetUserIDFromSession(session) } @@ -101,6 +101,13 @@ func doOAuth(ctx context.Context, code string, nonce string) (*model.User, error } } + // 负数 ID 为系统保留区间(公共账号),外部 OAuth 流程不允许产生 + if userInfo.GetID() < 0 { + err = errors.New(InvalidOAuthUserID) + span.SetStatus(codes.Error, err.Error()) + return nil, err + } + if !userInfo.Active { err = errors.New(common.BannedAccount) span.SetStatus(codes.Error, err.Error()) @@ -111,6 +118,10 @@ func doOAuth(ctx context.Context, code string, nonce string) (*model.User, error err = db.DB(ctx).Transaction(func(tx *gorm.DB) error { var holder model.User if conflictErr := tx.Where("username = ? AND id != ?", userInfo.Username, userInfo.GetID()).First(&holder).Error; conflictErr == nil { + // 负数 ID 均为公共账号,用户名由系统控制,不允许被释放 + if holder.ID < 0 { + return fmt.Errorf("用户名 %s 已被公共账号占用", holder.Username) + } // 用户名来自 OAuth 且可能被改名复用;冲突时只释放旧用户名,不禁用原账号。 newUsername := fmt.Sprintf("__released_username__:%d:%s", holder.ID, uuid.NewString()) if updateErr := tx.Model(&holder).Update("username", newUsername).Error; updateErr != nil { diff --git a/internal/apps/oauth/middlewares.go b/internal/apps/oauth/middlewares.go index 9985ac80..a68691e9 100644 --- a/internal/apps/oauth/middlewares.go +++ b/internal/apps/oauth/middlewares.go @@ -17,6 +17,7 @@ limitations under the License. package oauth import ( + "errors" "net/http" "github.com/gin-gonic/gin" @@ -26,10 +27,11 @@ import ( "github.com/linux-do/credit/internal/model" "github.com/linux-do/credit/internal/otel_trace" "github.com/linux-do/credit/internal/util" + "gorm.io/gorm" ) type loginRequiredAuditLog struct { - UserID uint64 `json:"user_id"` + UserID int64 `json:"user_id"` Username string `json:"username"` ClientIP string `json:"client_ip"` Method string `json:"method"` @@ -47,7 +49,7 @@ func LoginRequired() gin.HandlerFunc { // load user userId := GetUserIDFromContext(c) - if userId <= 0 { + if userId == 0 { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error_msg": common.UnAuthorized, "data": nil}) return } @@ -56,6 +58,10 @@ func LoginRequired() gin.HandlerFunc { var user model.User tx := db.DB(ctx).Where("id = ? AND is_active = ?", userId, true).First(&user) if tx.Error != nil { + if errors.Is(tx.Error, gorm.ErrRecordNotFound) { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error_msg": common.UnAuthorized, "data": nil}) + return + } c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error_msg": tx.Error.Error(), "data": nil}) return } @@ -71,9 +77,12 @@ func LoginRequired() gin.HandlerFunc { // set user info util.SetToContext(c, UserObjKey, &user) - if risk, ok := checkOpenAPIUserRisk(ctx, user.ID); ok { - if blocked := applyOpenAPIUserRisk(c, risk); blocked { - return + // 负数 ID 的公共账号不参与 OpenAPI 风险检查 + if user.ID > 0 { + if risk, ok := checkOpenAPIUserRisk(ctx, user.ID); ok { + if blocked := applyOpenAPIUserRisk(c, risk); blocked { + return + } } } diff --git a/internal/apps/oauth/risk.go b/internal/apps/oauth/risk.go index 5358a151..f7209a17 100644 --- a/internal/apps/oauth/risk.go +++ b/internal/apps/oauth/risk.go @@ -65,7 +65,7 @@ type riskBlockDetails struct { Risks []openAPIUserRiskItem `json:"risks"` } -func checkOpenAPIUserRisk(ctx context.Context, userID uint64) (*openAPIUserRiskResponse, bool) { +func checkOpenAPIUserRisk(ctx context.Context, userID int64) (*openAPIUserRiskResponse, bool) { cfg := config.Config.OpenAPIRisk if !cfg.Enabled || strings.TrimSpace(cfg.BaseURL) == "" { return nil, false @@ -98,7 +98,7 @@ func checkOpenAPIUserRisk(ctx context.Context, userID uint64) (*openAPIUserRiskR return risk, true } -func fetchOpenAPIUserRisk(ctx context.Context, userID uint64) (*openAPIUserRiskResponse, error) { +func fetchOpenAPIUserRisk(ctx context.Context, userID int64) (*openAPIUserRiskResponse, error) { cfg := config.Config.OpenAPIRisk endpoint := fmt.Sprintf( "%s/api/open/v1/risk/users/%d", diff --git a/internal/apps/oauth/routers.go b/internal/apps/oauth/routers.go index 44e71e48..59801b83 100644 --- a/internal/apps/oauth/routers.go +++ b/internal/apps/oauth/routers.go @@ -109,7 +109,7 @@ func Callback(c *gin.Context) { } type BasicUserInfo struct { - ID uint64 `json:"id"` + ID int64 `json:"id,string"` Username string `json:"username"` Nickname string `json:"nickname"` TrustLevel model.TrustLevel `json:"trust_level"` diff --git a/internal/apps/payment/routers.go b/internal/apps/payment/routers.go index 32f25b63..f96c2cd0 100644 --- a/internal/apps/payment/routers.go +++ b/internal/apps/payment/routers.go @@ -66,7 +66,7 @@ type GetOrderResponse struct { // TransferRequest 转账请求 type TransferRequest struct { - RecipientID uint64 `json:"recipient_id,string" binding:"required"` + RecipientID int64 `json:"recipient_id,string" binding:"required"` RecipientUsername string `json:"recipient_username" binding:"required"` Amount decimal.Decimal `json:"amount" binding:"required"` PayKey string `json:"pay_key" binding:"required,max=6"` @@ -135,7 +135,7 @@ func CreateMerchantOrder(c *gin.Context) { return err } - merchantIDStr := strconv.FormatUint(merchantUser.ID, 10) + merchantIDStr := strconv.FormatInt(merchantUser.ID, 10) if errSet := db.Redis.Set(c.Request.Context(), db.PrefixedKey(fmt.Sprintf(OrderMerchantIDCacheKeyFormat, encryptString)), merchantIDStr, remainingTTL).Err(); errSet != nil { return fmt.Errorf("failed to set redis key: %w", errSet) } @@ -298,7 +298,7 @@ func RefundMerchantOrder(c *gin.Context) { // MerchantDistributeRequest 商户分发请求 type MerchantDistributeRequest struct { - RecipientID uint64 `json:"user_id" binding:"required"` + RecipientID int64 `json:"user_id" binding:"required"` RecipientUsername string `json:"username" binding:"required"` Amount decimal.Decimal `json:"amount" binding:"required"` MerchantOrderNo *string `json:"out_trade_no" binding:"omitempty,min=1,max=64"` diff --git a/internal/apps/payment/utils.go b/internal/apps/payment/utils.go index 74f0edd1..c59cb5d1 100644 --- a/internal/apps/payment/utils.go +++ b/internal/apps/payment/utils.go @@ -72,7 +72,7 @@ type OrderContext struct { } // validateExpectedPayer 校验当前用户是否为订单绑定的预期付款人。 -func validateExpectedPayer(expectedPayerUserID, currentUserID uint64) error { +func validateExpectedPayer(expectedPayerUserID, currentUserID int64) error { if expectedPayerUserID == 0 || expectedPayerUserID != currentUserID { return errors.New(OrderPayerMismatch) } @@ -80,7 +80,7 @@ func validateExpectedPayer(expectedPayerUserID, currentUserID uint64) error { } // createOrReuseMerchantOrder 创建商户订单;幂等键冲突时复用原待支付订单。 -func createOrReuseMerchantOrder(tx *gorm.DB, req *CreateOrderRequest, apiKey *model.MerchantAPIKey, merchantUserID uint64, expiresAt time.Time) (*model.Order, error) { +func createOrReuseMerchantOrder(tx *gorm.DB, req *CreateOrderRequest, apiKey *model.MerchantAPIKey, merchantUserID int64, expiresAt time.Time) (*model.Order, error) { order := model.Order{ OrderName: req.OrderName, ClientID: apiKey.ClientID, @@ -149,7 +149,7 @@ func ParseOrderNo(c *gin.Context, orderNo string) (*OrderContext, error) { return nil, errGet } - merchantID, errParse := strconv.ParseUint(merchantIDStr, 10, 64) + merchantID, errParse := strconv.ParseInt(merchantIDStr, 10, 64) if errParse != nil { return nil, errors.New(OrderNoFormatError) } diff --git a/internal/apps/public_account/errs.go b/internal/apps/public_account/errs.go new file mode 100644 index 00000000..e260bcfe --- /dev/null +++ b/internal/apps/public_account/errs.go @@ -0,0 +1,24 @@ +/* +Copyright 2025 linux.do + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package public_account + +const ( + invalidPublicRequest = "无效的请求参数" + publicAccountNotBound = "未绑定该公共账号" + publicAccountUnavailable = "公共账号不可用" + switchPublicNotAllowed = "当前身份不允许此操作" +) diff --git a/internal/apps/public_account/routers.go b/internal/apps/public_account/routers.go new file mode 100644 index 00000000..4b355000 --- /dev/null +++ b/internal/apps/public_account/routers.go @@ -0,0 +1,137 @@ +/* +Copyright 2025 linux.do + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package public_account + +import ( + "net/http" + + "github.com/gin-contrib/sessions" + "github.com/gin-gonic/gin" + "github.com/linux-do/credit/internal/apps/oauth" + "github.com/linux-do/credit/internal/db" + "github.com/linux-do/credit/internal/model" + "github.com/linux-do/credit/internal/util" + "gorm.io/gorm" +) + +// PublicAccountInfo 公共账号信息 +type PublicAccountInfo struct { + ID int64 `json:"id,string"` + Username string `json:"username"` + Nickname string `json:"nickname"` + AvatarUrl string `json:"avatar_url"` +} + +// PublicAccountsResponse 公共账号绑定列表响应 +type PublicAccountsResponse struct { + // 可切换的公共账号列表(仅真实身份下有值) + PublicAccounts []PublicAccountInfo `json:"public_accounts"` +} + +// GetPublicAccounts 获取当前用户绑定的公共账号列表 +// @Tags user +// @Produce json +// @Success 200 {object} util.ResponseAny +// @Router /api/v1/user/public-accounts [get] +func GetPublicAccounts(c *gin.Context) { + currentUser, _ := util.GetFromContext[*model.User](c, oauth.UserObjKey) + response := PublicAccountsResponse{} + + // 仅真实身份返回绑定列表;公共账号身份无切换能力(退出重登恢复) + if currentUser.ID < 0 { + c.JSON(http.StatusOK, util.OK(response)) + return + } + + var accounts []PublicAccountInfo + if err := db.DB(c.Request.Context()). + Table("public_account_bindings"). + Select("users.id AS id, users.username AS username, users.nickname AS nickname, users.avatar_url AS avatar_url"). + Joins("JOIN users ON users.id = public_account_bindings.public_account_id"). + Where("public_account_bindings.user_id = ?", currentUser.ID). + Scan(&accounts).Error; err != nil { + c.JSON(http.StatusInternalServerError, util.Err(err.Error())) + return + } + response.PublicAccounts = accounts + + c.JSON(http.StatusOK, util.OK(response)) +} + +// switchPublicAccountRequest 切换公共账号请求 +type switchPublicAccountRequest struct { + PublicUserID int64 `json:"public_user_id,string" binding:"required"` +} + +// SwitchPublicAccount 切换到已绑定的公共账号身份 +// @Tags user +// @Produce json +// @Param request body switchPublicAccountRequest true "切换请求" +// @Success 200 {object} util.ResponseAny +// @Router /api/v1/user/public-accounts/switch [post] +func SwitchPublicAccount(c *gin.Context) { + currentUser, _ := util.GetFromContext[*model.User](c, oauth.UserObjKey) + if currentUser.ID < 0 { + c.JSON(http.StatusBadRequest, util.Err(switchPublicNotAllowed)) + return + } + + var req switchPublicAccountRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, util.Err(invalidPublicRequest)) + return + } + if req.PublicUserID >= 0 { + c.JSON(http.StatusBadRequest, util.Err(invalidPublicRequest)) + return + } + + tx := db.DB(c.Request.Context()) + + // 校验绑定关系存在 + var binding model.PublicAccountBinding + if err := tx.Where("user_id = ? AND public_account_id = ?", currentUser.ID, req.PublicUserID).First(&binding).Error; err != nil { + if err == gorm.ErrRecordNotFound { + c.JSON(http.StatusBadRequest, util.Err(publicAccountNotBound)) + return + } + c.JSON(http.StatusInternalServerError, util.Err(err.Error())) + return + } + + // 公共账号必须可用 + var publicAccount model.User + if err := tx.Where("id = ? AND is_active = ?", req.PublicUserID, true).First(&publicAccount).Error; err != nil { + if err == gorm.ErrRecordNotFound { + c.JSON(http.StatusBadRequest, util.Err(publicAccountUnavailable)) + return + } + c.JSON(http.StatusInternalServerError, util.Err(err.Error())) + return + } + + // 切换后 session 身份变为公共账号,恢复原身份需退出重新登录 + session := sessions.Default(c) + session.Set(oauth.UserIDKey, publicAccount.ID) + session.Set(oauth.UserNameKey, publicAccount.Username) + if err := session.Save(); err != nil { + c.JSON(http.StatusInternalServerError, util.Err(err.Error())) + return + } + + c.JSON(http.StatusOK, util.OKNil()) +} diff --git a/internal/apps/user/tasks.go b/internal/apps/user/tasks.go index 66f4e246..990cc354 100644 --- a/internal/apps/user/tasks.go +++ b/internal/apps/user/tasks.go @@ -134,8 +134,8 @@ func batchUpdateUserScores(ctx context.Context, userScores []model.LeaderboardUs return nil } - userIDs := make([]uint64, len(userScores)) - scoreMap := make(map[uint64]int64, len(userScores)) + userIDs := make([]int64, len(userScores)) + scoreMap := make(map[int64]int64, len(userScores)) for i, u := range userScores { userIDs[i] = u.ID scoreMap[u.ID] = u.TotalScore @@ -243,7 +243,7 @@ func batchUpdateUserScores(ctx context.Context, userScores []model.LeaderboardUs // HandleUpdateSingleUserGamificationScore 处理用户积分更新任务 func HandleUpdateSingleUserGamificationScore(ctx context.Context, t *asynq.Task) error { var payload struct { - UserID uint64 `json:"user_id"` + UserID int64 `json:"user_id"` UserScores []model.LeaderboardUser `json:"user_scores"` } if err := json.Unmarshal(t.Payload(), &payload); err != nil { diff --git a/internal/db/migrator/migrator.go b/internal/db/migrator/migrator.go index 0991c26e..54d6f909 100644 --- a/internal/db/migrator/migrator.go +++ b/internal/db/migrator/migrator.go @@ -44,6 +44,7 @@ func Migrate() { &model.RedEnvelope{}, &model.RedEnvelopeClaim{}, &model.Upload{}, + &model.PublicAccountBinding{}, ); err != nil { log.Fatalf("[PostgreSQL] auto migrate failed: %v\n", err) } @@ -177,7 +178,7 @@ func initUserPayConfigs() { defaultConfigs := []model.UserPayConfig{ { Level: model.PayLevelFree, - MinScore: 0, + MinScore: -999999, MaxScore: int64Ptr(2000), DailyLimit: int64Ptr(1000), FeeRate: decimal.Zero, diff --git a/internal/model/disputes.go b/internal/model/disputes.go index 0da32ed8..38a11c41 100644 --- a/internal/model/disputes.go +++ b/internal/model/disputes.go @@ -34,10 +34,10 @@ const ( type Dispute struct { ID uint64 `json:"id,string" gorm:"primaryKey"` OrderID uint64 `json:"order_id,string" gorm:"uniqueIndex:idx_dispute_order;index:idx_dispute_order_status,priority:1;not null"` - InitiatorUserID uint64 `json:"initiator_user_id" gorm:"not null;index:idx_initiator_status_created,priority:1"` + InitiatorUserID int64 `json:"initiator_user_id" gorm:"not null;index:idx_initiator_status_created,priority:1"` Reason string `json:"reason" gorm:"size:500;not null"` Status DisputeStatus `json:"status" gorm:"type:varchar(20);index;index:idx_dispute_order_status,priority:2;index:idx_initiator_status_created,priority:2;not null;default:'disputing'"` - HandlerUserID *uint64 `json:"handler_user_id" gorm:"index"` + HandlerUserID *int64 `json:"handler_user_id" gorm:"index"` InitiatorUsername string `json:"initiator_username" gorm:"-:migration;->"` HandlerUsername string `json:"handler_username" gorm:"-:migration;->"` CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime;index:idx_initiator_status_created,priority:3"` diff --git a/internal/model/merchant_api_keys.go b/internal/model/merchant_api_keys.go index f54406af..3ab3da22 100644 --- a/internal/model/merchant_api_keys.go +++ b/internal/model/merchant_api_keys.go @@ -25,7 +25,7 @@ import ( type MerchantAPIKey struct { ID uint64 `json:"id,string" gorm:"primaryKey"` - UserID uint64 `json:"user_id" gorm:"not null;index:idx_merchant_api_keys_user_created,priority:1"` + UserID int64 `json:"user_id" gorm:"not null;index:idx_merchant_api_keys_user_created,priority:1"` ClientID string `json:"client_id" gorm:"size:64;uniqueIndex;index:idx_client_credentials,priority:2;not null"` ClientSecret string `json:"client_secret" gorm:"size:64;index:idx_client_credentials,priority:1;not null"` AppName string `json:"app_name" gorm:"size:20;not null"` diff --git a/internal/model/order_transfers.go b/internal/model/order_transfers.go index 08f240b5..3bbc332f 100644 --- a/internal/model/order_transfers.go +++ b/internal/model/order_transfers.go @@ -34,7 +34,7 @@ const ( type OrderTransfer struct { ID uint64 `json:"id,string" gorm:"primaryKey;autoIncrement"` OrderID uint64 `json:"order_id,string" gorm:"index;uniqueIndex:uk_order_id;index:idx_order_status,priority:1"` - PayeeUserID uint64 `json:"payee_user_id" gorm:"index"` + PayeeUserID int64 `json:"payee_user_id" gorm:"index"` Amount decimal.Decimal `json:"amount" gorm:"type:numeric(20,2);not null"` Status OrderTransferStatus `json:"status" gorm:"type:varchar(20);not null;index:idx_status_transfer_at,priority:1;index:idx_order_status,priority:2"` TransferAt time.Time `json:"transfer_at" gorm:"not null;index:idx_status_transfer_at,priority:2"` diff --git a/internal/model/orders.go b/internal/model/orders.go index 8cd064fc..34be1d65 100644 --- a/internal/model/orders.go +++ b/internal/model/orders.go @@ -62,8 +62,8 @@ type Order struct { OrderName string `json:"order_name" gorm:"size:64;not null;index"` MerchantOrderNo *string `json:"merchant_order_no" gorm:"size:64;index:idx_orders_merchant_order_no;uniqueIndex:idx_orders_client_merchant_order,priority:2"` ClientID string `json:"client_id" gorm:"size:64;index:idx_orders_client_status_created,priority:1;index:idx_orders_client_payee,priority:1;index:idx_orders_client_payer,priority:1;uniqueIndex:idx_orders_client_merchant_order,priority:1"` - PayerUserID uint64 `json:"payer_user_id" gorm:"index:idx_orders_payer_status_type_created,priority:1;index:idx_orders_payer_status_type_trade,priority:1;index:idx_orders_client_payer,priority:2"` - PayeeUserID uint64 `json:"payee_user_id" gorm:"index:idx_orders_payee_status_type_created,priority:1;index:idx_orders_client_payee,priority:2"` + PayerUserID int64 `json:"payer_user_id,string" gorm:"index:idx_orders_payer_status_type_created,priority:1;index:idx_orders_payer_status_type_trade,priority:1;index:idx_orders_client_payer,priority:2"` + PayeeUserID int64 `json:"payee_user_id,string" gorm:"index:idx_orders_payee_status_type_created,priority:1;index:idx_orders_client_payee,priority:2"` PayerUsername string `json:"payer_username" gorm:"-:migration;->"` PayeeUsername string `json:"payee_username" gorm:"-:migration;->"` Amount decimal.Decimal `json:"amount" gorm:"type:numeric(20,2);not null;index"` diff --git a/internal/model/public_account_bindings.go b/internal/model/public_account_bindings.go new file mode 100644 index 00000000..28846cd0 --- /dev/null +++ b/internal/model/public_account_bindings.go @@ -0,0 +1,28 @@ +/* +Copyright 2025 linux.do + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package model + +import "time" + +// PublicAccountBinding 公共账号与真实用户的绑定关系(多对多) +type PublicAccountBinding struct { + ID uint64 `json:"id,string" gorm:"primaryKey;autoIncrement"` + PublicAccountID int64 `json:"public_account_id,string" gorm:"uniqueIndex:uk_binding_pa_user,priority:1;not null"` + UserID int64 `json:"user_id,string" gorm:"uniqueIndex:uk_binding_pa_user,priority:2;index:idx_binding_user_pa,priority:1;not null"` + CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"` + UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"` +} diff --git a/internal/model/red_envelopes.go b/internal/model/red_envelopes.go index d6b7d876..5b4faaec 100644 --- a/internal/model/red_envelopes.go +++ b/internal/model/red_envelopes.go @@ -40,7 +40,7 @@ const ( // RedEnvelope 红包 type RedEnvelope struct { ID uint64 `json:"id,string" gorm:"primaryKey"` - CreatorID uint64 `json:"creator_id,string" gorm:"index;not null"` + CreatorID int64 `json:"creator_id,string" gorm:"index;not null"` CreatorUsername string `json:"creator_username" gorm:"-:migration;->"` CreatorAvatarURL string `json:"creator_avatar_url" gorm:"-:migration;->"` Type RedEnvelopeType `json:"type" gorm:"type:varchar(20);not null"` @@ -61,7 +61,7 @@ type RedEnvelope struct { type RedEnvelopeClaim struct { ID uint64 `json:"id,string" gorm:"primaryKey"` RedEnvelopeID uint64 `json:"red_envelope_id,string" gorm:"uniqueIndex:idx_red_envelope_user,priority:2;not null"` - UserID uint64 `json:"user_id,string" gorm:"uniqueIndex:idx_red_envelope_user,priority:1;not null"` + UserID int64 `json:"user_id,string" gorm:"uniqueIndex:idx_red_envelope_user,priority:1;not null"` Username string `json:"username" gorm:"-:migration;->"` AvatarURL string `json:"avatar_url" gorm:"-:migration;->"` Amount decimal.Decimal `json:"amount" gorm:"type:numeric(20,2);not null"` diff --git a/internal/model/uploads.go b/internal/model/uploads.go index 206a310d..b04c4002 100644 --- a/internal/model/uploads.go +++ b/internal/model/uploads.go @@ -38,7 +38,7 @@ const ( // Upload 上传文件记录 type Upload struct { ID uint64 `json:"id,string" gorm:"primaryKey"` - UserID uint64 `json:"user_id,string" gorm:"index;not null"` + UserID int64 `json:"user_id,string" gorm:"index;not null"` FilePath string `json:"file_path" gorm:"size:500;not null;uniqueIndex"` // 文件路径 FileSize int64 `json:"file_size" gorm:"not null"` // 文件大小(字节) Type string `json:"type" gorm:"column:type;size:50;not null;index"` // 类型 (cover, heterotypic) diff --git a/internal/model/users.go b/internal/model/users.go index bec0daa7..2316ed91 100644 --- a/internal/model/users.go +++ b/internal/model/users.go @@ -48,7 +48,7 @@ const ( // OAuthUserInfo 用户信息结构(同时支持 OIDC ID Token claims 和 UserEndpoint 响应) type OAuthUserInfo struct { - Id uint64 `json:"id"` + Id int64 `json:"id"` Sub string `json:"sub"` Username string `json:"username"` Name string `json:"name"` @@ -58,13 +58,13 @@ type OAuthUserInfo struct { } // GetID 获取用户 ID -func (u *OAuthUserInfo) GetID() uint64 { +func (u *OAuthUserInfo) GetID() int64 { if u.Id != 0 { return u.Id } // 从 sub 解析(OIDC 格式) if u.Sub != "" { - if id, err := strconv.ParseUint(u.Sub, 10, 64); err == nil { + if id, err := strconv.ParseInt(u.Sub, 10, 64); err == nil { return id } } @@ -85,13 +85,13 @@ type LeaderboardResponse struct { // LeaderboardUser 排行榜用户信息 type LeaderboardUser struct { - ID uint64 `json:"id"` + ID int64 `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"` + ID int64 `json:"id" gorm:"primaryKey;index:idx_users_active_bal_id,priority:3"` Username string `json:"username" gorm:"size:255;uniqueIndex"` Nickname string `json:"nickname" gorm:"size:255"` AvatarUrl string `json:"avatar_url" gorm:"size:255"` @@ -113,7 +113,7 @@ type User struct { UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime;index"` } -func (u *User) GetByID(tx *gorm.DB, id uint64) error { +func (u *User) GetByID(tx *gorm.DB, id int64) error { if err := tx.Where("id = ?", id).First(u).Error; err != nil { return err } @@ -121,7 +121,7 @@ func (u *User) GetByID(tx *gorm.DB, id uint64) error { } // GetByIDs 批量查询用户 -func GetByIDs(tx *gorm.DB, ids []uint64) ([]User, error) { +func GetByIDs(tx *gorm.DB, ids []int64) ([]User, error) { var users []User if err := tx.Where("id IN ?", ids).Find(&users).Error; err != nil { return nil, err diff --git a/internal/router/router.go b/internal/router/router.go index 6ba5c0af..9568f81d 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -29,6 +29,7 @@ import ( "github.com/linux-do/credit/internal/apps/admin" admin_order "github.com/linux-do/credit/internal/apps/admin/order" + admin_public_account "github.com/linux-do/credit/internal/apps/admin/public_account" admin_task "github.com/linux-do/credit/internal/apps/admin/task" admin_user "github.com/linux-do/credit/internal/apps/admin/user" publicconfig "github.com/linux-do/credit/internal/apps/config" @@ -53,6 +54,7 @@ import ( "github.com/linux-do/credit/internal/apps/leaderboard" "github.com/linux-do/credit/internal/apps/oauth" "github.com/linux-do/credit/internal/apps/order" + "github.com/linux-do/credit/internal/apps/public_account" "github.com/linux-do/credit/internal/apps/user" "github.com/linux-do/credit/internal/config" "github.com/linux-do/credit/internal/otel_trace" @@ -143,6 +145,8 @@ func Serve() { userRouter.Use(oauth.LoginRequired()) { userRouter.PUT("/pay-key", user.UpdatePayKey) + userRouter.GET("/public-accounts", public_account.GetPublicAccounts) + userRouter.POST("/public-accounts/switch", public_account.SwitchPublicAccount) } // Dashboard @@ -253,6 +257,11 @@ func Serve() { adminRouter.GET("/users", admin_user.ListUsers) adminRouter.PUT("/users/:id/status", admin_user.UpdateUserStatus) + // Public accounts + adminRouter.GET("/public-accounts", admin_public_account.ListPublicAccounts) + adminRouter.POST("/users/:id/bind-public", admin_public_account.BindPublicAccounts) + adminRouter.POST("/users/:id/switch", admin_public_account.SwitchAccount) + // Orders adminRouter.POST("/orders", admin_order.ListOrders) adminRouter.POST("/orders/:id/refund", admin_order.RefundOrder) diff --git a/internal/service/payment.go b/internal/service/payment.go index 0b8178e6..9f15a91e 100644 --- a/internal/service/payment.go +++ b/internal/service/payment.go @@ -48,7 +48,7 @@ const ( // BalanceUpdateOptions 余额更新选项 type BalanceUpdateOptions struct { - UserID uint64 + UserID int64 Amount decimal.Decimal Operation BalanceOperation ScoreChange int64 @@ -97,7 +97,7 @@ func UpdateBalance(tx *gorm.DB, opts BalanceUpdateOptions) error { } // SettlePendingToAvailable 将资金从 PendingBalance 转入 AvailableBalance -func SettlePendingToAvailable(tx *gorm.DB, userID uint64, amount decimal.Decimal) error { +func SettlePendingToAvailable(tx *gorm.DB, userID int64, amount decimal.Decimal) error { result := tx.Model(&model.User{}). Where("id = ? AND pending_balance >= ?", userID, amount). UpdateColumns(map[string]interface{}{ @@ -115,14 +115,14 @@ func SettlePendingToAvailable(tx *gorm.DB, userID uint64, amount decimal.Decimal // CheckDailyLimit 检查用户每日支付限额 // 返回 nil 表示未超限额,返回 error 表示超限或查询失败 -func CheckDailyLimit(tx *gorm.DB, userID uint64, amount decimal.Decimal, dailyLimit *int64) error { +func CheckDailyLimit(tx *gorm.DB, userID int64, amount decimal.Decimal, dailyLimit *int64) error { if dailyLimit == nil || *dailyLimit <= 0 { return nil } now := time.Now() datePart := int64(now.Year()*10000 + int(now.Month())*100 + now.Day()) - lockID := int64(userID)*100000000 + datePart + lockID := userID*100000000 + datePart if err := tx.Exec("SELECT pg_advisory_xact_lock(?)", lockID).Error; err != nil { return err } @@ -140,7 +140,7 @@ func CheckDailyLimit(tx *gorm.DB, userID uint64, amount decimal.Decimal, dailyLi } // GetTodayUsedAmount 获取用户当日已使用的支付额度 -func GetTodayUsedAmount(db *gorm.DB, userID uint64) (decimal.Decimal, error) { +func GetTodayUsedAmount(db *gorm.DB, userID int64) (decimal.Decimal, error) { now := time.Now() todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) todayEnd := todayStart.Add(24 * time.Hour) @@ -194,6 +194,7 @@ func RefundOrder(tx *gorm.DB, order *model.Order, merchantPayConfig *model.UserP return errors.New(RefundOrderPayerNotFound) } + // 商家按订单全额退回(手续费由商家承担) merchantScoreDecrease := order.Amount.Mul(merchantPayConfig.ScoreRate).Round(0).IntPart() merchantResult := tx.Model(&model.User{}). Where("id = ?", order.PayeeUserID). @@ -230,7 +231,7 @@ func RefundOrder(tx *gorm.DB, order *model.Order, merchantPayConfig *model.UserP // ValidateTestModePayment 验证测试模式下的支付权限 // 返回 error:nil 表示允许支付,非 nil 表示拒绝支付 -func ValidateTestModePayment(currentUserID, merchantUserID uint64, isTestMode bool) error { +func ValidateTestModePayment(currentUserID, merchantUserID int64, isTestMode bool) error { if currentUserID == merchantUserID { if !isTestMode { return errors.New(common.CannotPaySelf) diff --git a/internal/util/custom_types.go b/internal/util/custom_types.go index aa0ce08d..490c6796 100644 --- a/internal/util/custom_types.go +++ b/internal/util/custom_types.go @@ -26,8 +26,13 @@ import ( type StringArray []string func (sa *StringArray) Scan(value interface{}) error { - bytesValue, ok := value.([]byte) - if !ok { + var bytesValue []byte + switch v := value.(type) { + case []byte: + bytesValue = v + case string: + bytesValue = []byte(v) + default: return fmt.Errorf("invalid value: %v", value) } return json.Unmarshal(bytesValue, sa) diff --git a/support-files/sql/create_clickhouse.sql b/support-files/sql/create_clickhouse.sql index dd0eb9b6..74ac9d30 100644 --- a/support-files/sql/create_clickhouse.sql +++ b/support-files/sql/create_clickhouse.sql @@ -2,14 +2,18 @@ CREATE DATABASE IF NOT EXISTS linux_do_credit; USE linux_do_credit; +-- 注意:payer_user_id / payee_user_id 使用 Int64(支持负数用户 ID,如中央银行账号 id=-1)。 +-- 已有环境升级时需先执行: +-- ALTER TABLE linux_do_credit.orders MODIFY COLUMN payer_user_id Int64; +-- ALTER TABLE linux_do_credit.orders MODIFY COLUMN payee_user_id Int64; CREATE TABLE IF NOT EXISTS orders ( id UInt64, order_name String, merchant_order_no String, client_id String, - payer_user_id UInt64, - payee_user_id UInt64, + payer_user_id Int64, + payee_user_id Int64, amount Decimal(20, 2), status LowCardinality(String), type LowCardinality(String),