From ee22a661e80ba88e0dc5208121784628f7443707 Mon Sep 17 00:00:00 2001 From: Aether_254 <67494671+Aether-254@users.noreply.github.com> Date: Sat, 26 Sep 2026 19:53:16 +0800 Subject: [PATCH 1/2] fix(user): verify current pay key before update --- docs/docs.go | 6 +- docs/swagger.json | 6 +- docs/swagger.yaml | 3 +- .../components/common/settings/security.tsx | 81 +++++++++++--- frontend/contexts/user-context.tsx | 6 +- frontend/lib/services/user/index.ts | 2 +- frontend/lib/services/user/types.ts | 3 +- frontend/lib/services/user/user.service.ts | 11 +- internal/apps/user/errs.go | 4 +- internal/apps/user/pay_key_test.go | 104 ++++++++++++++++++ internal/apps/user/routers.go | 34 +++++- 11 files changed, 229 insertions(+), 31 deletions(-) create mode 100644 internal/apps/user/pay_key_test.go diff --git a/docs/docs.go b/docs/docs.go index cbb8e673..6ccbf093 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -2620,9 +2620,11 @@ const docTemplate = `{ "pay_key" ], "properties": { + "current_pay_key": { + "type": "string" + }, "pay_key": { - "type": "string", - "maxLength": 6 + "type": "string" } } }, diff --git a/docs/swagger.json b/docs/swagger.json index 003dc0fb..04f5469a 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -2611,9 +2611,11 @@ "pay_key" ], "properties": { + "current_pay_key": { + "type": "string" + }, "pay_key": { - "type": "string", - "maxLength": 6 + "type": "string" } } }, diff --git a/docs/swagger.yaml b/docs/swagger.yaml index f42a3d17..90949f96 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -516,8 +516,9 @@ definitions: type: object user.UpdatePayKeyRequest: properties: + current_pay_key: + type: string pay_key: - maxLength: 6 type: string required: - pay_key diff --git a/frontend/components/common/settings/security.tsx b/frontend/components/common/settings/security.tsx index c13f6801..c8c2ab60 100644 --- a/frontend/components/common/settings/security.tsx +++ b/frontend/components/common/settings/security.tsx @@ -11,19 +11,35 @@ import { Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbP import { Button } from "@/components/ui/button" import { InputOTP, InputOTPGroup, InputOTPSlot } from "@/components/ui/input-otp" import { Spinner } from "@/components/ui/spinner" +import { useUser } from "@/contexts/user-context" import { UserService } from "@/lib/services/user" export function SecurityMain() { const router = useRouter() - const [setupStep, setSetupStep] = React.useState<'password' | 'confirm'>('password') + const { user } = useUser() + const hasPayKey = user?.is_pay_key ?? true + const [setupStep, setSetupStep] = React.useState<'current' | 'password' | 'confirm'>('current') + const [currentPayKey, setCurrentPayKey] = React.useState("") const [payKey, setPayKey] = React.useState("") const [confirmPayKey, setConfirmPayKey] = React.useState("") const [isSubmitting, setIsSubmitting] = React.useState(false) + const isCurrentPayKeyValid = currentPayKey.length === 6 && /^\d{6}$/.test(currentPayKey) const isPayKeyValid = payKey.length === 6 && /^\d{6}$/.test(payKey) const isConfirmValid = confirmPayKey.length === 6 && /^\d{6}$/.test(confirmPayKey) const passwordsMatch = payKey === confirmPayKey + React.useEffect(() => { + if (user && !user.is_pay_key) { + setSetupStep('password') + } + }, [user]) + + /* 原安全密码输入 */ + const handleCurrentPayKeyChange = (value: string) => { + setCurrentPayKey(value.replaceAll(/\D/g, '')) + } + /* 安全密码输入 */ const handlePayKeyChange = (value: string) => { const numericValue = value.replaceAll(/\D/g, '') @@ -39,7 +55,13 @@ export function SecurityMain() { const handlePayKeySubmit = async (e: React.FormEvent) => { e.preventDefault() - if (setupStep === 'password') { + if (setupStep === 'current') { + if (!isCurrentPayKeyValid) { + toast.error("原安全密码必须为6位数字") + return + } + setSetupStep('password') + } else if (setupStep === 'password') { if (!isPayKeyValid) { toast.error("安全密码必须为6位数字") return @@ -60,7 +82,7 @@ export function SecurityMain() { setIsSubmitting(true) try { - await UserService.updatePayKey(payKey) + await UserService.updatePayKey(payKey, hasPayKey ? currentPayKey : undefined) toast.success("修改成功", { description: "您的安全密码已成功更新", }) @@ -71,7 +93,9 @@ export function SecurityMain() { toast.error("修改失败", { description: error instanceof Error ? error.message : "更新密码时发生错误,请稍后重试", }) - setSetupStep('password') + setSetupStep(hasPayKey ? 'current' : 'password') + setCurrentPayKey("") + setPayKey("") setConfirmPayKey("") } finally { setIsSubmitting(false) @@ -121,18 +145,38 @@ export function SecurityMain() { >

- {setupStep === 'password' ? '设置新密码' : '确认新密码'} + {setupStep === 'current' + ? '验证原密码' + : setupStep === 'password' + ? '设置新密码' + : '确认新密码'}

- {setupStep === 'password' - ? '请输入新的6位数字安全密码' - : '请再次输入密码进行确认'} + {setupStep === 'current' + ? '请输入当前的6位数字安全密码' + : setupStep === 'password' + ? '请输入新的6位数字安全密码' + : '请再次输入密码进行确认'}

- {setupStep === 'password' ? ( + {setupStep === 'current' ? ( + + + {[0, 1, 2, 3, 4, 5].map((i) => ( + + ))} + + + ) : setupStep === 'password' ? (
- {setupStep === 'confirm' && ( + {setupStep !== 'current' && hasPayKey && (
diff --git a/frontend/contexts/user-context.tsx b/frontend/contexts/user-context.tsx index d3e9a6b8..304e9d36 100644 --- a/frontend/contexts/user-context.tsx +++ b/frontend/contexts/user-context.tsx @@ -16,7 +16,7 @@ interface UserState { /** 用户上下文接口 */ interface UserContextValue extends UserState { refetch: () => Promise - updatePayKey: (payKey: string) => Promise + updatePayKey: (payKey: string, currentPayKey?: string) => Promise getTrustLevelLabel: (trustLevel: TrustLevel) => string getPayLevelLabel: (payLevel: PayLevel) => string logout: () => Promise @@ -101,8 +101,8 @@ export function UserProvider({ children }: { children: ReactNode }) { }, [fetchUser]) /** 更新支付密码 */ - const updatePayKey = useCallback(async (payKey: string) => { - await services.user.updatePayKey(payKey) + const updatePayKey = useCallback(async (payKey: string, currentPayKey?: string) => { + await services.user.updatePayKey(payKey, currentPayKey) await fetchUser() }, [fetchUser]) diff --git a/frontend/lib/services/user/index.ts b/frontend/lib/services/user/index.ts index 09462dd2..a2bf2581 100644 --- a/frontend/lib/services/user/index.ts +++ b/frontend/lib/services/user/index.ts @@ -10,7 +10,7 @@ * import { UserService } from '@/lib/services'; * * // 更新支付密钥 - * await UserService.updatePayKey('123456'); + * await UserService.updatePayKey('654321', '123456'); * ``` */ diff --git a/frontend/lib/services/user/types.ts b/frontend/lib/services/user/types.ts index 1c626da4..9f4cb010 100644 --- a/frontend/lib/services/user/types.ts +++ b/frontend/lib/services/user/types.ts @@ -2,7 +2,8 @@ * 更新支付密钥请求 */ export interface UpdatePayKeyRequest { + /** 当前支付密钥(已设置支付密钥时必填) */ + current_pay_key?: string; /** 新的支付密钥(6位数字) */ pay_key: string; } - diff --git a/frontend/lib/services/user/user.service.ts b/frontend/lib/services/user/user.service.ts index aeed82de..c93ef02c 100644 --- a/frontend/lib/services/user/user.service.ts +++ b/frontend/lib/services/user/user.service.ts @@ -10,21 +10,24 @@ export class UserService extends BaseService { /** * 更新用户支付密钥 * @param payKey - 新的支付密钥 + * @param currentPayKey - 当前支付密钥(已设置支付密钥时必填) * @returns void * @throws {UnauthorizedError} 当用户未登录时 * @throws {ValidationError} 当支付密钥格式无效时 * * @example * ```typescript - * await UserService.updatePayKey('123456'); + * await UserService.updatePayKey('654321', '123456'); * ``` * * @remarks * - 支付密钥必须为6位数字 * - 只能更新当前登录用户的支付密钥 */ - static async updatePayKey(payKey: string): Promise { - return this.put('/pay-key', { pay_key: payKey }); + static async updatePayKey(payKey: string, currentPayKey?: string): Promise { + return this.put('/pay-key', { + pay_key: payKey, + ...(currentPayKey ? { current_pay_key: currentPayKey } : {}), + }); } } - diff --git a/internal/apps/user/errs.go b/internal/apps/user/errs.go index 9e0747a7..61bddc8e 100644 --- a/internal/apps/user/errs.go +++ b/internal/apps/user/errs.go @@ -17,5 +17,7 @@ limitations under the License. package user const ( - EncryptPayKeyFailed = "加密支付密码失败" + EncryptPayKeyFailed = "加密支付密码失败" + InvalidCurrentPayKey = "原安全密码错误" + InvalidPayKeyFormat = "安全密码必须为6位数字" ) diff --git a/internal/apps/user/pay_key_test.go b/internal/apps/user/pay_key_test.go new file mode 100644 index 00000000..00353361 --- /dev/null +++ b/internal/apps/user/pay_key_test.go @@ -0,0 +1,104 @@ +/* +Copyright 2025-2026 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 user + +import ( + "testing" + + "github.com/linux-do/credit/internal/model" + "github.com/linux-do/credit/internal/util" +) + +func TestValidatePayKeyUpdate(t *testing.T) { + const ( + signKey = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + currentPayKey = "123456" + ) + + encryptedPayKey, err := util.Encrypt(signKey, currentPayKey) + if err != nil { + t.Fatalf("encrypt current pay key: %v", err) + } + + userWithPayKey := &model.User{ + SignKey: signKey, + PayKey: encryptedPayKey, + } + userWithoutPayKey := &model.User{SignKey: signKey} + + tests := []struct { + name string + user *model.User + currentPayKey string + newPayKey string + wantErr string + }{ + { + name: "rejects missing current pay key", + user: userWithPayKey, + newPayKey: "654321", + wantErr: InvalidCurrentPayKey, + }, + { + name: "rejects incorrect current pay key", + user: userWithPayKey, + currentPayKey: "000000", + newPayKey: "654321", + wantErr: InvalidCurrentPayKey, + }, + { + name: "accepts correct current pay key", + user: userWithPayKey, + currentPayKey: currentPayKey, + newPayKey: "654321", + }, + { + name: "allows initial setup without current pay key", + user: userWithoutPayKey, + newPayKey: "654321", + }, + { + name: "rejects non-numeric current pay key", + user: userWithPayKey, + currentPayKey: "abcdef", + newPayKey: "654321", + wantErr: InvalidPayKeyFormat, + }, + { + name: "rejects invalid new pay key length", + user: userWithPayKey, + currentPayKey: currentPayKey, + newPayKey: "12345", + wantErr: InvalidPayKeyFormat, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validatePayKeyUpdate(tt.user, tt.currentPayKey, tt.newPayKey) + if tt.wantErr == "" { + if err != nil { + t.Fatalf("validatePayKeyUpdate() error = %v", err) + } + return + } + if err == nil || err.Error() != tt.wantErr { + t.Fatalf("validatePayKeyUpdate() error = %v, want %q", err, tt.wantErr) + } + }) + } +} diff --git a/internal/apps/user/routers.go b/internal/apps/user/routers.go index 73692aa0..6b7e32fd 100644 --- a/internal/apps/user/routers.go +++ b/internal/apps/user/routers.go @@ -17,7 +17,9 @@ limitations under the License. package user import ( + "errors" "net/http" + "regexp" "github.com/gin-gonic/gin" "github.com/linux-do/credit/internal/apps/oauth" @@ -28,7 +30,29 @@ import ( // UpdatePayKeyRequest 更新支付密钥请求 type UpdatePayKeyRequest struct { - PayKey string `json:"pay_key" binding:"required,max=6"` + CurrentPayKey string `json:"current_pay_key" binding:"omitempty,len=6,numeric"` + PayKey string `json:"pay_key" binding:"required,len=6,numeric"` +} + +var payKeyPattern = regexp.MustCompile(`^\d{6}$`) + +func validatePayKeyUpdate(user *model.User, currentPayKey, newPayKey string) error { + if !payKeyPattern.MatchString(newPayKey) { + return errors.New(InvalidPayKeyFormat) + } + if user.PayKey == "" { + return nil + } + if !payKeyPattern.MatchString(currentPayKey) { + if currentPayKey == "" { + return errors.New(InvalidCurrentPayKey) + } + return errors.New(InvalidPayKeyFormat) + } + if !user.VerifyPayKey(currentPayKey) { + return errors.New(InvalidCurrentPayKey) + } + return nil } // UpdatePayKey 更新用户支付密钥 @@ -46,6 +70,14 @@ func UpdatePayKey(c *gin.Context) { } user, _ := util.GetFromContext[*model.User](c, oauth.UserObjKey) + if user == nil { + c.JSON(http.StatusUnauthorized, util.Err("未授权")) + return + } + if err := validatePayKeyUpdate(user, req.CurrentPayKey, req.PayKey); err != nil { + c.JSON(http.StatusBadRequest, util.Err(err.Error())) + return + } encryptedPayKey, err := util.Encrypt(user.SignKey, req.PayKey) if err != nil { From 8ba50f84569a8b0c61e9ccf7f50537d5b28f4a3e Mon Sep 17 00:00:00 2001 From: Aether_254 <67494671+Aether-254@users.noreply.github.com> Date: Tue, 6 Oct 2026 02:54:42 +0000 Subject: [PATCH 2/2] fix(user): validate and atomically update pay key --- docs/docs.go | 34 ++++- docs/swagger.json | 34 ++++- docs/swagger.yaml | 23 +++ .../components/common/settings/security.tsx | 8 +- frontend/lib/services/core/api-client.ts | 1 + frontend/lib/services/core/errors.ts | 8 +- internal/apps/user/errs.go | 11 ++ internal/apps/user/pay_key_test.go | 126 ++++++++++++++-- internal/apps/user/routers.go | 136 +++++++++++++++--- internal/util/response.go | 19 ++- 10 files changed, 357 insertions(+), 43 deletions(-) diff --git a/docs/docs.go b/docs/docs.go index 6ccbf093..bf4cde18 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -1752,6 +1752,24 @@ const docTemplate = `{ "schema": { "$ref": "#/definitions/util.ResponseAny" } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } } } } @@ -2621,10 +2639,18 @@ const docTemplate = `{ ], "properties": { "current_pay_key": { - "type": "string" + "description": "CurrentPayKey 当前安全密码;已设置安全密码的用户修改时必须提供", + "type": "string", + "maxLength": 6, + "minLength": 6, + "example": "123456" }, "pay_key": { - "type": "string" + "description": "PayKey 新安全密码,必须为6位数字", + "type": "string", + "maxLength": 6, + "minLength": 6, + "example": "654321" } } }, @@ -2699,6 +2725,10 @@ const docTemplate = `{ "type": "object", "properties": { "data": {}, + "error_code": { + "type": "string", + "example": "" + }, "error_msg": { "type": "string", "example": "" diff --git a/docs/swagger.json b/docs/swagger.json index 04f5469a..278751bb 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -1743,6 +1743,24 @@ "schema": { "$ref": "#/definitions/util.ResponseAny" } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } } } } @@ -2612,10 +2630,18 @@ ], "properties": { "current_pay_key": { - "type": "string" + "description": "CurrentPayKey 当前安全密码;已设置安全密码的用户修改时必须提供", + "type": "string", + "maxLength": 6, + "minLength": 6, + "example": "123456" }, "pay_key": { - "type": "string" + "description": "PayKey 新安全密码,必须为6位数字", + "type": "string", + "maxLength": 6, + "minLength": 6, + "example": "654321" } } }, @@ -2690,6 +2716,10 @@ "type": "object", "properties": { "data": {}, + "error_code": { + "type": "string", + "example": "" + }, "error_msg": { "type": "string", "example": "" diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 90949f96..d4ea6a1e 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -517,8 +517,16 @@ definitions: user.UpdatePayKeyRequest: properties: current_pay_key: + description: CurrentPayKey 当前安全密码;已设置安全密码的用户修改时必须提供 + example: "123456" + maxLength: 6 + minLength: 6 type: string pay_key: + description: PayKey 新安全密码,必须为6位数字 + example: "654321" + maxLength: 6 + minLength: 6 type: string required: - pay_key @@ -571,6 +579,9 @@ definitions: util.ResponseAny: properties: data: {} + error_code: + example: "" + type: string error_msg: example: "" type: string @@ -1680,6 +1691,18 @@ paths: description: OK schema: $ref: '#/definitions/util.ResponseAny' + "400": + description: Bad Request + schema: + $ref: '#/definitions/util.ResponseAny' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/util.ResponseAny' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/util.ResponseAny' tags: - user /f/{id}: diff --git a/frontend/components/common/settings/security.tsx b/frontend/components/common/settings/security.tsx index c8c2ab60..db359f4d 100644 --- a/frontend/components/common/settings/security.tsx +++ b/frontend/components/common/settings/security.tsx @@ -12,11 +12,10 @@ import { Button } from "@/components/ui/button" import { InputOTP, InputOTPGroup, InputOTPSlot } from "@/components/ui/input-otp" import { Spinner } from "@/components/ui/spinner" import { useUser } from "@/contexts/user-context" -import { UserService } from "@/lib/services/user" export function SecurityMain() { const router = useRouter() - const { user } = useUser() + const { user, updatePayKey } = useUser() const hasPayKey = user?.is_pay_key ?? true const [setupStep, setSetupStep] = React.useState<'current' | 'password' | 'confirm'>('current') const [currentPayKey, setCurrentPayKey] = React.useState("") @@ -82,7 +81,10 @@ export function SecurityMain() { setIsSubmitting(true) try { - await UserService.updatePayKey(payKey, hasPayKey ? currentPayKey : undefined) + await updatePayKey( + payKey, + hasPayKey ? currentPayKey : undefined, + ) toast.success("修改成功", { description: "您的安全密码已成功更新", }) diff --git a/frontend/lib/services/core/api-client.ts b/frontend/lib/services/core/api-client.ts index 8fa5ec23..e3f046a8 100644 --- a/frontend/lib/services/core/api-client.ts +++ b/frontend/lib/services/core/api-client.ts @@ -262,6 +262,7 @@ apiClient.interceptors.response.use( new ValidationError( error.response.data?.error_msg || '请求参数验证失败', error.response.data?.details, + error.response.data?.error_code || 'VALIDATION_ERROR', ), ); } diff --git a/frontend/lib/services/core/errors.ts b/frontend/lib/services/core/errors.ts index 83c2b3c0..51b47235 100644 --- a/frontend/lib/services/core/errors.ts +++ b/frontend/lib/services/core/errors.ts @@ -88,8 +88,12 @@ export class ServerError extends ApiErrorBase { * 验证错误 (400) */ export class ValidationError extends ApiErrorBase { - constructor(message = '请求参数验证失败', details?: unknown) { - super(message, 'VALIDATION_ERROR', 400, details); + constructor( + message = '请求参数验证失败', + details?: unknown, + code = 'VALIDATION_ERROR', + ) { + super(message, code, 400, details); this.name = 'ValidationError'; Object.setPrototypeOf(this, ValidationError.prototype); } diff --git a/internal/apps/user/errs.go b/internal/apps/user/errs.go index 61bddc8e..bd8d80e7 100644 --- a/internal/apps/user/errs.go +++ b/internal/apps/user/errs.go @@ -16,8 +16,19 @@ limitations under the License. package user +import "errors" + const ( EncryptPayKeyFailed = "加密支付密码失败" InvalidCurrentPayKey = "原安全密码错误" InvalidPayKeyFormat = "安全密码必须为6位数字" + + ErrorCodeInvalidCurrentPayKey = "INVALID_CURRENT_PAY_KEY" + ErrorCodeInvalidPayKeyFormat = "INVALID_PAY_KEY_FORMAT" +) + +var ( + errEncryptPayKeyFailed = errors.New(EncryptPayKeyFailed) + errInvalidCurrentPayKey = errors.New(InvalidCurrentPayKey) + errInvalidPayKeyFormat = errors.New(InvalidPayKeyFormat) ) diff --git a/internal/apps/user/pay_key_test.go b/internal/apps/user/pay_key_test.go index 00353361..590a96c0 100644 --- a/internal/apps/user/pay_key_test.go +++ b/internal/apps/user/pay_key_test.go @@ -17,8 +17,15 @@ limitations under the License. package user import ( + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" "testing" + "github.com/gin-gonic/gin" + "github.com/linux-do/credit/internal/apps/oauth" "github.com/linux-do/credit/internal/model" "github.com/linux-do/credit/internal/util" ) @@ -45,20 +52,20 @@ func TestValidatePayKeyUpdate(t *testing.T) { user *model.User currentPayKey string newPayKey string - wantErr string + wantErr error }{ { name: "rejects missing current pay key", user: userWithPayKey, newPayKey: "654321", - wantErr: InvalidCurrentPayKey, + wantErr: errInvalidCurrentPayKey, }, { name: "rejects incorrect current pay key", user: userWithPayKey, currentPayKey: "000000", newPayKey: "654321", - wantErr: InvalidCurrentPayKey, + wantErr: errInvalidCurrentPayKey, }, { name: "accepts correct current pay key", @@ -76,28 +83,131 @@ func TestValidatePayKeyUpdate(t *testing.T) { user: userWithPayKey, currentPayKey: "abcdef", newPayKey: "654321", - wantErr: InvalidPayKeyFormat, + wantErr: errInvalidPayKeyFormat, }, { name: "rejects invalid new pay key length", user: userWithPayKey, currentPayKey: currentPayKey, newPayKey: "12345", - wantErr: InvalidPayKeyFormat, + wantErr: errInvalidPayKeyFormat, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { err := validatePayKeyUpdate(tt.user, tt.currentPayKey, tt.newPayKey) - if tt.wantErr == "" { + if tt.wantErr == nil { if err != nil { t.Fatalf("validatePayKeyUpdate() error = %v", err) } return } - if err == nil || err.Error() != tt.wantErr { - t.Fatalf("validatePayKeyUpdate() error = %v, want %q", err, tt.wantErr) + + if !errors.Is(err, tt.wantErr) { + t.Fatalf( + "validatePayKeyUpdate() error = %v, want %v", + err, + tt.wantErr, + ) + } + }) + } +} + +func TestUpdatePayKeyRejectsInvalidFormatAtHTTPBoundary(t *testing.T) { + gin.SetMode(gin.TestMode) + + tests := []struct { + name string + body string + }{ + { + name: "rejects missing new pay key", + body: `{}`, + }, + { + name: "rejects short new pay key", + body: `{"pay_key":"12345"}`, + }, + { + name: "rejects non-numeric new pay key", + body: `{"pay_key":"abcdef"}`, + }, + { + name: "rejects malformed current pay key", + body: `{"current_pay_key":"abcdef","pay_key":"654321"}`, + }, + { + name: "rejects non-string pay key", + body: `{"pay_key":123456}`, + }, + { + name: "rejects malformed json", + body: `{`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + + c.Request = httptest.NewRequest( + http.MethodPut, + "/api/v1/user/pay-key", + strings.NewReader(tt.body), + ) + c.Request.Header.Set("Content-Type", "application/json") + + /* + 所有这些 case 都应该在数据库操作之前被拒绝。 + 设置用户 context 是为了确保测试覆盖真实 handler 路径, + 同时不依赖测试数据库。 + */ + util.SetToContext( + c, + oauth.UserObjKey, + &model.User{ID: 1}, + ) + + UpdatePayKey(c) + + if recorder.Code != http.StatusBadRequest { + t.Fatalf( + "status = %d, want %d; body=%s", + recorder.Code, + http.StatusBadRequest, + recorder.Body.String(), + ) + } + + var response struct { + ErrorMsg string `json:"error_msg"` + ErrorCode string `json:"error_code"` + } + + if err := json.Unmarshal( + recorder.Body.Bytes(), + &response, + ); err != nil { + t.Fatalf("decode response: %v", err) + } + + if response.ErrorMsg != InvalidPayKeyFormat { + t.Fatalf( + "error_msg = %q, want %q", + response.ErrorMsg, + InvalidPayKeyFormat, + ) + } + + if response.ErrorCode != ErrorCodeInvalidPayKeyFormat { + t.Fatalf( + "error_code = %q, want %q", + response.ErrorCode, + ErrorCodeInvalidPayKeyFormat, + ) } }) } diff --git a/internal/apps/user/routers.go b/internal/apps/user/routers.go index 6b7e32fd..584f75e8 100644 --- a/internal/apps/user/routers.go +++ b/internal/apps/user/routers.go @@ -26,31 +26,46 @@ import ( "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" ) // UpdatePayKeyRequest 更新支付密钥请求 type UpdatePayKeyRequest struct { - CurrentPayKey string `json:"current_pay_key" binding:"omitempty,len=6,numeric"` - PayKey string `json:"pay_key" binding:"required,len=6,numeric"` + // CurrentPayKey 当前安全密码;已设置安全密码的用户修改时必须提供 + CurrentPayKey string `json:"current_pay_key,omitempty" minLength:"6" maxLength:"6" example:"123456"` + // PayKey 新安全密码,必须为6位数字 + PayKey string `json:"pay_key" validate:"required" minLength:"6" maxLength:"6" example:"654321"` } var payKeyPattern = regexp.MustCompile(`^\d{6}$`) -func validatePayKeyUpdate(user *model.User, currentPayKey, newPayKey string) error { +func validatePayKeyFormat(currentPayKey, newPayKey string) error { if !payKeyPattern.MatchString(newPayKey) { - return errors.New(InvalidPayKeyFormat) + return errInvalidPayKeyFormat + } + + if currentPayKey != "" && !payKeyPattern.MatchString(currentPayKey) { + return errInvalidPayKeyFormat + } + + return nil +} + +func validatePayKeyUpdate(user *model.User, currentPayKey, newPayKey string) error { + if err := validatePayKeyFormat(currentPayKey, newPayKey); err != nil { + return err } if user.PayKey == "" { return nil } - if !payKeyPattern.MatchString(currentPayKey) { - if currentPayKey == "" { - return errors.New(InvalidCurrentPayKey) - } - return errors.New(InvalidPayKeyFormat) + + if currentPayKey == "" { + return errInvalidCurrentPayKey } + if !user.VerifyPayKey(currentPayKey) { - return errors.New(InvalidCurrentPayKey) + return errInvalidCurrentPayKey } return nil } @@ -61,11 +76,33 @@ func validatePayKeyUpdate(user *model.User, currentPayKey, newPayKey string) err // @Produce json // @Param request body UpdatePayKeyRequest true "request body" // @Success 200 {object} util.ResponseAny +// @Failure 400 {object} util.ResponseAny +// @Failure 401 {object} util.ResponseAny +// @Failure 500 {object} util.ResponseAny // @Router /api/v1/user/pay-key [put] func UpdatePayKey(c *gin.Context) { var req UpdatePayKeyRequest if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, util.Err(err.Error())) + c.JSON( + http.StatusBadRequest, + util.ErrCode( + ErrorCodeInvalidPayKeyFormat, + InvalidPayKeyFormat, + ), + ) + return + } + + // 先做与用户状态无关的格式校验。 + // 这样长度、数字格式等错误不会进入数据库事务。 + if err := validatePayKeyFormat(req.CurrentPayKey, req.PayKey); err != nil { + c.JSON( + http.StatusBadRequest, + util.ErrCode( + ErrorCodeInvalidPayKeyFormat, + InvalidPayKeyFormat, + ), + ) return } @@ -74,24 +111,79 @@ func UpdatePayKey(c *gin.Context) { c.JSON(http.StatusUnauthorized, util.Err("未授权")) return } - if err := validatePayKeyUpdate(user, req.CurrentPayKey, req.PayKey); err != nil { - c.JSON(http.StatusBadRequest, util.Err(err.Error())) + /* + 必须在同一事务内重新读取并锁定用户行。 + + 不能直接使用 LoginRequired 中加载进 context 的 user.PayKey: + 两个并发修改请求可能同时拿旧密码通过校验, + 导致后一个请求覆盖前一个请求。 + */ + err := db.DB(c.Request.Context()).Transaction(func(tx *gorm.DB) error { + var currentUser model.User + + if err := tx. + Clauses(clause.Locking{Strength: "UPDATE"}). + Where("id = ?", user.ID). + First(¤tUser).Error; err != nil { + return err + } + + if err := validatePayKeyUpdate( + ¤tUser, + req.CurrentPayKey, + req.PayKey, + ); err != nil { + return err + } + + encryptedPayKey, err := util.Encrypt( + currentUser.SignKey, + req.PayKey, + ) + if err != nil { + return errEncryptPayKeyFailed + } + + return tx. + Model(¤tUser). + Update("pay_key", encryptedPayKey). + Error + }) + + if err == nil { + c.JSON(http.StatusOK, util.OKNil()) + return + } + + if errors.Is(err, errInvalidCurrentPayKey) { + c.JSON( + http.StatusBadRequest, + util.ErrCode( + ErrorCodeInvalidCurrentPayKey, + InvalidCurrentPayKey, + ), + ) return } - encryptedPayKey, err := util.Encrypt(user.SignKey, req.PayKey) - if err != nil { - c.JSON(http.StatusInternalServerError, util.Err(EncryptPayKeyFailed)) + if errors.Is(err, errInvalidPayKeyFormat) { + c.JSON( + http.StatusBadRequest, + util.ErrCode( + ErrorCodeInvalidPayKeyFormat, + InvalidPayKeyFormat, + ), + ) return } - if err := db.DB(c.Request.Context()). - Model(&model.User{}). - Where("id = ?", user.ID). - Update("pay_key", encryptedPayKey).Error; err != nil { - c.JSON(http.StatusInternalServerError, util.Err(err.Error())) + if errors.Is(err, errEncryptPayKeyFailed) { + c.JSON( + http.StatusInternalServerError, + util.Err(EncryptPayKeyFailed), + ) return } - c.JSON(http.StatusOK, util.OKNil()) + c.JSON(http.StatusInternalServerError, util.Err(err.Error())) } diff --git a/internal/util/response.go b/internal/util/response.go index be36d31b..cccc7d59 100644 --- a/internal/util/response.go +++ b/internal/util/response.go @@ -18,15 +18,17 @@ package util // Response 通用响应体 type Response[T any] struct { - ErrorMsg string `json:"error_msg"` - Data T `json:"data"` + ErrorMsg string `json:"error_msg"` + ErrorCode string `json:"error_code,omitempty"` + Data T `json:"data"` } // ResponseAny 用于 Swagger 文档的响应类型(非泛型) // swag 不支持泛型,使用此类型替代 Response[T] type ResponseAny struct { - ErrorMsg string `json:"error_msg" example:""` - Data interface{} `json:"data"` + ErrorMsg string `json:"error_msg" example:""` + ErrorCode string `json:"error_code,omitempty" example:""` + Data interface{} `json:"data"` } // OK 构造成功响应 @@ -43,3 +45,12 @@ func OKNil() Response[any] { func Err(msg string) Response[any] { return Response[any]{ErrorMsg: msg, Data: nil} } + +// ErrCode 构造带稳定错误码的错误响应 +func ErrCode(code, msg string) Response[any] { + return Response[any]{ + ErrorMsg: msg, + ErrorCode: code, + Data: nil, + } +}