收敛邀请码标签元数据校验

将邀请码 metadata.tags 写入时收敛为 userTags。

拒绝非数组或非字符串 tags 元数据。

补充链接邀请码自动兑换失败时的前端测试。
This commit is contained in:
2026-07-01 18:03:15 +08:00
parent 71a6ed5945
commit 69b4d5f090
2 changed files with 95 additions and 1 deletions
@@ -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<String, RuntimeProfil
fn normalize_profile_task_id(value: String) -> Result<String, RuntimeProfileFieldError> {
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::<Value>(&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::<Value>(&normalized).expect("normalized metadata should parse"),
serde_json::json!({
"userTags": ["正式"]
})
);
}
}
+42
View File
@@ -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(
<AuthGate>
<ProtectedActionButton onAuthenticated={vi.fn()} />
</AuthGate>,
);
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({