feat: require invitation code for registration

This commit is contained in:
richjohnrambo
2026-10-15 17:31:42 +08:00
parent 05d6ec26ad
commit b6a0f819b8
6 changed files with 32 additions and 9 deletions
+1 -1
View File
@@ -155,7 +155,7 @@ func (s *Service) Register(ctx context.Context, input RegisterInput) (*Result, e
if len([]rune(input.Password)) < 6 || len([]byte(input.Password)) > 72 { if len([]rune(input.Password)) < 6 || len([]byte(input.Password)) > 72 {
return nil, ErrPasswordInvalid return nil, ErrPasswordInvalid
} }
if input.ReferralCode != "" && !referralPattern.MatchString(input.ReferralCode) { if !referralPattern.MatchString(input.ReferralCode) {
return nil, ErrReferralInvalid return nil, ErrReferralInvalid
} }
if !input.PrivacyAccepted { if !input.PrivacyAccepted {
+2 -1
View File
@@ -46,8 +46,9 @@ func TestRegisterValidation(t *testing.T) {
{name: "username", input: RegisterInput{Username: "张 三", Phone: "13800138000", Password: "secret1", PrivacyAccepted: true}, want: ErrUsernameInvalid}, {name: "username", input: RegisterInput{Username: "张 三", Phone: "13800138000", Password: "secret1", PrivacyAccepted: true}, want: ErrUsernameInvalid},
{name: "phone", input: RegisterInput{Username: "alice", Phone: "12800138000", Password: "secret1", PrivacyAccepted: true}, want: ErrPhoneInvalid}, {name: "phone", input: RegisterInput{Username: "alice", Phone: "12800138000", Password: "secret1", PrivacyAccepted: true}, want: ErrPhoneInvalid},
{name: "password", input: RegisterInput{Username: "alice", Phone: "13800138000", Password: "123", PrivacyAccepted: true}, want: ErrPasswordInvalid}, {name: "password", input: RegisterInput{Username: "alice", Phone: "13800138000", Password: "123", PrivacyAccepted: true}, want: ErrPasswordInvalid},
{name: "referral required", input: RegisterInput{Username: "alice", Phone: "13800138000", Password: "secret1", PrivacyAccepted: true}, want: ErrReferralInvalid},
{name: "referral", input: RegisterInput{Username: "alice", Phone: "13800138000", Password: "secret1", ReferralCode: "bad!", PrivacyAccepted: true}, want: ErrReferralInvalid}, {name: "referral", input: RegisterInput{Username: "alice", Phone: "13800138000", Password: "secret1", ReferralCode: "bad!", PrivacyAccepted: true}, want: ErrReferralInvalid},
{name: "privacy", input: RegisterInput{Username: "alice", Phone: "13800138000", Password: "secret1"}, want: ErrPrivacyNotAccepted}, {name: "privacy", input: RegisterInput{Username: "alice", Phone: "13800138000", Password: "secret1", ReferralCode: "ABC123"}, want: ErrPrivacyNotAccepted},
} }
for _, test := range tests { for _, test := range tests {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
+1 -1
View File
@@ -129,7 +129,7 @@ func accountByMobile(ctx context.Context, db queryer, tenantID int64, mobile str
func referralParent(ctx context.Context, tx *sqlx.Tx, tenantID int64, code string) (int64, int, string, error) { func referralParent(ctx context.Context, tx *sqlx.Tx, tenantID int64, code string) (int64, int, string, error) {
code = strings.TrimSpace(code) code = strings.TrimSpace(code)
if code == "" { if code == "" {
return 0, 1, "", nil return 0, 0, "", ErrReferralInvalid
} }
var parent struct { var parent struct {
ID int64 `db:"id"` ID int64 `db:"id"`
+1
View File
@@ -9,6 +9,7 @@ const reasonMessages: Record<string, string> = {
FORBIDDEN: '当前账户无权执行此操作', FORBIDDEN: '当前账户无权执行此操作',
INVALID_CREDENTIALS: '手机号或密码错误', INVALID_CREDENTIALS: '手机号或密码错误',
ACCOUNT_ALREADY_REGISTERED: '该账户或手机号已经注册', ACCOUNT_ALREADY_REGISTERED: '该账户或手机号已经注册',
REFERRAL_CODE_INVALID: '邀请码无效,请检查后重新输入',
RATE_LIMITED: '操作过于频繁,请稍后再试', RATE_LIMITED: '操作过于频繁,请稍后再试',
TASK_DISABLED: '领取功能暂未开放', TASK_DISABLED: '领取功能暂未开放',
OUTSIDE_BUSINESS_HOURS: '当前不在营业时间内', OUTSIDE_BUSINESS_HOURS: '当前不在营业时间内',
+24 -3
View File
@@ -48,7 +48,8 @@
<input <input
v-model.trim="form.referral_code" v-model.trim="form.referral_code"
maxlength="32" maxlength="32"
placeholder="请输入推荐码(选填)" autocomplete="off"
placeholder="请输入邀请码(必填)"
/> />
</label> </label>
<router-link v-if="mode === 'login'" class="m-forgot-link" to="/app/password/verify" <router-link v-if="mode === 'login'" class="m-forgot-link" to="/app/password/verify"
@@ -99,15 +100,32 @@
const error = ref(''); const error = ref('');
const form = reactive({ username: '', phone: '', password: '', referral_code: '' }); const form = reactive({ username: '', phone: '', password: '', referral_code: '' });
watch(mode, () => { function referralCodeFromLink() {
const value =
route.query.inviteCode ?? route.query.invite_code ?? route.query.referral_code;
return typeof value === 'string' ? value.trim() : '';
}
watch(mode, (value) => {
form.username = ''; form.username = '';
form.phone = ''; form.phone = '';
form.password = ''; form.password = '';
form.referral_code = ''; form.referral_code = value === 'register' ? referralCodeFromLink() : '';
accepted.value = true; accepted.value = true;
error.value = ''; error.value = '';
}); });
watch(
() => [route.query.inviteCode, route.query.invite_code, route.query.referral_code],
() => {
const referralCode = referralCodeFromLink();
if (!referralCode) return;
mode.value = 'register';
form.referral_code = referralCode;
},
{ immediate: true }
);
async function submit() { async function submit() {
error.value = ''; error.value = '';
if (!/^1\d{10}$/.test(form.phone)) return void (error.value = '请输入正确的手机号码'); if (!/^1\d{10}$/.test(form.phone)) return void (error.value = '请输入正确的手机号码');
@@ -116,6 +134,9 @@
if (mode.value === 'register' && !/^[\p{L}\p{M}·]{1,10}$/u.test(form.username)) { if (mode.value === 'register' && !/^[\p{L}\p{M}·]{1,10}$/u.test(form.username)) {
return void (error.value = '名字须为 1 至 10 个字符,支持汉字'); return void (error.value = '名字须为 1 至 10 个字符,支持汉字');
} }
if (mode.value === 'register' && !/^[A-Za-z0-9]{1,32}$/.test(form.referral_code)) {
return void (error.value = '请输入有效的邀请码');
}
submitting.value = true; submitting.value = true;
try { try {
const data = await request<AuthResponse>({ const data = await request<AuthResponse>({
+3 -3
View File
@@ -45,7 +45,7 @@
<n-modal v-model:show="showResult" :mask-closable="false"> <n-modal v-model:show="showResult" :mask-closable="false">
<section class="m-claim-result" :class="`is-${resultState}`"> <section class="m-claim-result" :class="`is-${resultState}`">
<template v-if="resultState === 'matching'"> <template v-if="resultState === 'matching'">
<h2>正在匹配任务</h2> <h2>正在匹配订单</h2>
<p>{{ matchingHint }}</p> <p>{{ matchingHint }}</p>
<div class="m-match-visual"> <div class="m-match-visual">
<img class="orbit" :src="mobileAssets.claimSpinner" alt="" /> <img class="orbit" :src="mobileAssets.claimSpinner" alt="" />
@@ -54,7 +54,7 @@
<div v-if="isAutoMatching" class="m-match-progress"><i :style="{ width: `${matchProgress}%` }"></i></div> <div v-if="isAutoMatching" class="m-match-progress"><i :style="{ width: `${matchProgress}%` }"></i></div>
<strong v-if="isAutoMatching" class="m-match-percent">{{ matchProgress }}%</strong> <strong v-if="isAutoMatching" class="m-match-percent">{{ matchProgress }}%</strong>
<ol class="m-match-steps m-match-steps--single"> <ol class="m-match-steps m-match-steps--single">
<li class="active"><span>✓</span>正在匹配任务</li> <li class="active"><span>✓</span>正在匹配订单</li>
</ol> </ol>
<button class="m-result-secondary" @click="openOrders">稍后去记录查看</button> <button class="m-result-secondary" @click="openOrders">稍后去记录查看</button>
</template> </template>
@@ -141,7 +141,7 @@
const matchingHint = computed(() => { const matchingHint = computed(() => {
if (order.value?.dispatch_mode === 'manual') return '订单已领取,正在等待后台匹配...'; if (order.value?.dispatch_mode === 'manual') return '订单已领取,正在等待后台匹配...';
if (matchRemainingSeconds.value > 0) return `系统正在派单... ${formatCountdown(matchRemainingSeconds.value)}`; if (matchRemainingSeconds.value > 0) return `系统正在派单... ${formatCountdown(matchRemainingSeconds.value)}`;
return pollFinished.value ? '任务仍在匹配,可稍后前往记录查看' : '系统正在为您匹配任务,请稍等...'; return pollFinished.value ? '订单仍在匹配,可稍后前往记录查看' : '系统正在为您匹配订单,请稍等...';
}); });
const resultIcon = computed(() => resultState.value === 'success' ? mobileAssets.claimSuccess : resultState.value === 'failed' ? mobileAssets.claimFailed : mobileAssets.claimCancelled); const resultIcon = computed(() => resultState.value === 'success' ? mobileAssets.claimSuccess : resultState.value === 'failed' ? mobileAssets.claimFailed : mobileAssets.claimCancelled);
const resultTitle = computed(() => resultState.value === 'success' ? '任务获取成功' : resultState.value === 'failed' ? '领取失败' : '已取消任务'); const resultTitle = computed(() => resultState.value === 'success' ? '任务获取成功' : resultState.value === 'failed' ? '领取失败' : '已取消任务');