From 69b4d5f0901ebf3e5f63be645f63993a38cf5050 Mon Sep 17 00:00:00 2001 From: kdletters Date: Wed, 1 Jul 2026 18:03:15 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=B6=E6=95=9B=E9=82=80=E8=AF=B7=E7=A0=81?= =?UTF-8?q?=E6=A0=87=E7=AD=BE=E5=85=83=E6=95=B0=E6=8D=AE=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将邀请码 metadata.tags 写入时收敛为 userTags。 拒绝非数组或非字符串 tags 元数据。 补充链接邀请码自动兑换失败时的前端测试。 --- .../crates/module-runtime/src/commands.rs | 54 ++++++++++++++++++- src/components/auth/AuthGate.test.tsx | 42 +++++++++++++++ 2 files changed, 95 insertions(+), 1 deletion(-) diff --git a/server-rs/crates/module-runtime/src/commands.rs b/server-rs/crates/module-runtime/src/commands.rs index 0db69e337..496a246ca 100644 --- a/server-rs/crates/module-runtime/src/commands.rs +++ b/server-rs/crates/module-runtime/src/commands.rs @@ -920,8 +920,10 @@ fn normalize_invite_code_metadata_user_tags( // 中文注释:邀请码授予标签复用 metadata,保存时统一收敛成 camelCase 字段。 let raw = object .remove("userTags") - .or_else(|| object.remove("user_tags")); + .or_else(|| object.remove("user_tags")) + .or_else(|| object.remove("tags")); object.remove("user_tags"); + object.remove("tags"); let Some(raw) = raw else { return Ok(()); @@ -1034,3 +1036,53 @@ fn normalize_wallet_metadata_json(value: String) -> Result Result { normalize_required_string(value).ok_or(RuntimeProfileFieldError::MissingTaskId) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn invite_code_metadata_tags_alias_is_saved_as_user_tags() { + let normalized = normalize_invite_code_metadata_json( + r#"{"tags":["系统"," beta ","系统"],"channel":"spring"}"#.to_string(), + ) + .expect("metadata tags should normalize"); + + assert_eq!( + serde_json::from_str::(&normalized).expect("normalized metadata should parse"), + serde_json::json!({ + "channel": "spring", + "userTags": ["系统", "beta"] + }) + ); + } + + #[test] + fn invite_code_metadata_rejects_invalid_tags_alias_shape() { + assert_eq!( + normalize_invite_code_metadata_json(r#"{"tags":"北科"}"#.to_string()) + .expect_err("string tags should reject"), + RuntimeProfileFieldError::InvalidUserTag + ); + assert_eq!( + normalize_invite_code_metadata_json(r#"{"tags":[123,"北科"]}"#.to_string()) + .expect_err("mixed tags should reject"), + RuntimeProfileFieldError::InvalidUserTag + ); + } + + #[test] + fn invite_code_metadata_user_tags_takes_precedence_over_tags_alias() { + let normalized = normalize_invite_code_metadata_json( + r#"{"userTags":["正式"],"user_tags":["旧"],"tags":["别名"]}"#.to_string(), + ) + .expect("metadata userTags should normalize"); + + assert_eq!( + serde_json::from_str::(&normalized).expect("normalized metadata should parse"), + serde_json::json!({ + "userTags": ["正式"] + }) + ); + } +} diff --git a/src/components/auth/AuthGate.test.tsx b/src/components/auth/AuthGate.test.tsx index 92cbe75e8..9715780b5 100644 --- a/src/components/auth/AuthGate.test.tsx +++ b/src/components/auth/AuthGate.test.tsx @@ -689,6 +689,48 @@ test('auth gate auto redeems link invite code for new sms account', async () => expect(authMocks.redeemRegistrationInviteCode).not.toHaveBeenCalled(); }); +test('auth gate keeps invite modal open when link invite auto redeem fails', async () => { + const user = userEvent.setup(); + window.history.replaceState(null, '', '/?inviteCode=spring-2026'); + authMocks.getAuthLoginOptions.mockResolvedValue({ + availableLoginMethods: ['phone'], + }); + authMocks.loginWithPhoneCode.mockResolvedValueOnce({ + token: 'jwt-phone-new', + user: mockUser, + created: true, + referral: { + ok: false, + message: '邀请码无效,已继续注册', + inviteeRewardGranted: false, + inviterRewardGranted: false, + inviteeBalanceAfter: null, + inviterBalanceAfter: null, + }, + }); + + render( + + + , + ); + + await user.click(await screen.findByRole('button', { name: '进入作品' })); + const dialog = await screen.findByRole('dialog', { name: '账号入口' }); + await user.type(within(dialog).getByLabelText('手机号'), '13800000000'); + await user.type(within(dialog).getByLabelText('验证码'), '123456'); + await acceptLegalConsent(user, dialog); + await user.click(within(dialog).getByRole('button', { name: '登录' })); + + const inviteDialog = await screen.findByRole('dialog', { + name: '请填写邀请码', + }); + expect( + (within(inviteDialog).getByLabelText('邀请码') as HTMLInputElement).value, + ).toBe('SPRING2026'); + expect(within(inviteDialog).getByText('邀请码无效,已继续注册')).toBeTruthy(); +}); + test('registration invite modal can skip when invite code is empty', async () => { const user = userEvent.setup(); authMocks.getAuthLoginOptions.mockResolvedValue({