feat: use six-digit numeric member invite codes

This commit is contained in:
richjohnrambo
2026-10-15 18:41:35 +08:00
parent b6a0f819b8
commit eb96b7ac1f
7 changed files with 61 additions and 30 deletions
+4 -4
View File
@@ -31,12 +31,12 @@ Content-Type: application/json
"username": "alice01",
"phone": "13800138000",
"password": "Secret123",
"referral_code": "ABCD2345",
"referral_code": "123456",
"privacy_accepted": true
}
```
`referral_code` 可选;传入时必须匹配当前租户下正常会员的 `invite_code`。
`referral_code` 必填,必须是 6 位数字,且匹配当前租户下正常会员的 `invite_code`。邀请链接可通过 `inviteCode`、`invite_code` 或 `referral_code` 参数预填注册表单。
## 4 登录
@@ -64,7 +64,7 @@ Content-Type: application/json
"member_no": "MABCDEFGHIJKLMNOPQRST",
"username": "alice01",
"phone": "13800138000",
"invite_code": "EFGH6789",
"invite_code": "654321",
"token": "...",
"token_type": "Bearer",
"expires_at": "2026-10-23T10:00:00Z"
@@ -92,7 +92,7 @@ Authorization: Bearer <token>
| `USERNAME_INVALID_FORMAT` | 400 | 账户名格式错误 |
| `PHONE_INVALID_FORMAT` | 400 | 手机号格式错误 |
| `PASSWORD_INVALID_FORMAT` | 400 | 密码格式错误 |
| `REFERRAL_CODE_INVALID` | 400 | 推荐码无效 |
| `REFERRAL_CODE_INVALID` | 400 | 邀请码为空、格式错误或不存在 |
| `PRIVACY_POLICY_NOT_ACCEPTED` | 400 | 未同意隐私政策 |
| `ACCOUNT_ALREADY_REGISTERED` | 409 | 账户名或手机号已注册 |
| `INVALID_CREDENTIALS` | 401 | 手机号或密码错误 |
@@ -167,7 +167,7 @@ Content-Type: application/json
"username": "alice01",
"phone": "13800138000",
"password": "Secret123",
"referral_code": "ABCD2345",
"referral_code": "123456",
"privacy_accepted": true
}
```
@@ -177,7 +177,7 @@ Content-Type: application/json
| `username` | string | 是 | 1–10 位小写字母或数字;后端去空格并转小写 |
| `phone` | string | 是 | 中国大陆 11 位手机号,不带 `+86` |
| `password` | string | 是 | 6–72 字节,不做 MD5 |
| `referral_code` | string | 否 | 1–32 位字母或数字,必须匹配正常会员邀请码 |
| `referral_code` | string | 是 | 6 位数字,必须匹配正常会员邀请码 |
| `privacy_accepted` | boolean | 是 | 必须为 `true` |
注册事务同时写入:
@@ -196,7 +196,7 @@ Content-Type: application/json
"member_no": "MABCDEFGHIJKLMNOPQRST",
"username": "alice01",
"phone": "13800138000",
"invite_code": "EFGH6789",
"invite_code": "654321",
"token": "random-base64url-token",
"token_type": "Bearer",
"expires_at": "2026-10-23T10:00:00Z"
@@ -241,7 +241,7 @@ Authorization: Bearer <token>
| 400 | `USERNAME_INVALID_FORMAT` | 账户名格式错误 |
| 400 | `PHONE_INVALID_FORMAT` | 手机号格式错误 |
| 400 | `PASSWORD_INVALID_FORMAT` | 密码格式错误 |
| 400 | `REFERRAL_CODE_INVALID` | 推荐码无效 |
| 400 | `REFERRAL_CODE_INVALID` | 邀请码为空、格式错误或不存在 |
| 400 | `PRIVACY_POLICY_NOT_ACCEPTED` | 未同意隐私政策 |
| 401 | `INVALID_CREDENTIALS` | 手机号或密码错误 |
| 409 | `ACCOUNT_ALREADY_REGISTERED` | 账户名、手机号或其他唯一字段冲突 |
@@ -265,7 +265,7 @@ GET /v1/me
"username": "alice01",
"phone": "13800138000",
"avatar_url": "",
"invite_code": "EFGH6789",
"invite_code": "654321",
"level": {
"id": 1,
"code": "normal",
+33 -17
View File
@@ -8,6 +8,8 @@ import (
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"math/big"
"regexp"
"strconv"
"strings"
@@ -39,7 +41,7 @@ var (
var (
phonePattern = regexp.MustCompile(`^1[3-9][0-9]{9}$`)
referralPattern = regexp.MustCompile(`^[A-Za-z0-9]{1,32}$`)
referralPattern = regexp.MustCompile(`^[0-9]{6}$`)
tradePasswordPattern = regexp.MustCompile(`^[0-9]{6}$`)
)
@@ -170,26 +172,40 @@ func (s *Service) Register(ctx context.Context, input RegisterInput) (*Result, e
if err != nil {
return nil, err
}
inviteCode, err := newPublicID("", 8)
if err != nil {
return nil, err
}
account, err := s.repository.RegisterAccount(ctx, store.AccountRegistration{
TenantID: s.cfg.DefaultTenantID,
MemberNo: memberNo,
Username: input.Username,
Mobile: input.Phone,
PasswordHash: string(passwordHash),
ReferralCode: input.ReferralCode,
InviteCode: inviteCode,
})
if err != nil {
for attempt := 0; attempt < 10; attempt++ {
inviteCode, err := newInviteCode()
if err != nil {
return nil, err
}
account, err := s.repository.RegisterAccount(ctx, store.AccountRegistration{
TenantID: s.cfg.DefaultTenantID,
MemberNo: memberNo,
Username: input.Username,
Mobile: input.Phone,
PasswordHash: string(passwordHash),
ReferralCode: input.ReferralCode,
InviteCode: inviteCode,
})
if errors.Is(err, store.ErrInviteCodeExists) {
continue
}
if errors.Is(err, store.ErrReferralInvalid) {
return nil, ErrReferralInvalid
}
return nil, err
if err != nil {
return nil, err
}
return s.issueSession(ctx, account, input.DeviceID)
}
return s.issueSession(ctx, account, input.DeviceID)
return nil, ErrUnavailable
}
func newInviteCode() (string, error) {
number, err := rand.Int(rand.Reader, big.NewInt(1000000))
if err != nil {
return "", err
}
return fmt.Sprintf("%06d", number.Int64()), nil
}
func validMemberName(value string) bool {
+11 -1
View File
@@ -48,7 +48,8 @@ func TestRegisterValidation(t *testing.T) {
{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: "privacy", input: RegisterInput{Username: "alice", Phone: "13800138000", Password: "secret1", ReferralCode: "ABC123"}, want: ErrPrivacyNotAccepted},
{name: "referral length", input: RegisterInput{Username: "alice", Phone: "13800138000", Password: "secret1", ReferralCode: "12345", PrivacyAccepted: true}, want: ErrReferralInvalid},
{name: "privacy", input: RegisterInput{Username: "alice", Phone: "13800138000", Password: "secret1", ReferralCode: "123456"}, want: ErrPrivacyNotAccepted},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
@@ -60,6 +61,15 @@ func TestRegisterValidation(t *testing.T) {
}
}
func TestNewInviteCodeIsSixDigits(t *testing.T) {
for i := 0; i < 100; i++ {
code, err := newInviteCode()
if err != nil || !referralPattern.MatchString(code) {
t.Fatalf("newInviteCode() = %q, %v", code, err)
}
}
}
func TestValidMemberNameSupportsChinese(t *testing.T) {
for _, name := range []string{"杨情", "欧阳娜娜", "Alicia", "阿依古丽·买买提"} {
if !validMemberName(name) {
+3
View File
@@ -67,6 +67,9 @@ func (s *Store) RegisterAccount(ctx context.Context, input AccountRegistration)
levelID, parentID, teamLevel, tree, input.InviteCode)
if err != nil {
if isDuplicate(err) {
if strings.Contains(err.Error(), "uk_app_member_invite_code") {
return nil, ErrInviteCodeExists
}
return nil, ErrAccountExists
}
return nil, err
+1
View File
@@ -4,6 +4,7 @@ import "errors"
var (
ErrAccountExists = errors.New("account already exists")
ErrInviteCodeExists = errors.New("invite code already exists")
ErrAccountNotFound = errors.New("account not found")
ErrAccountRestricted = errors.New("account restricted")
ErrAttachmentInvalid = errors.New("attachment invalid")
+4 -3
View File
@@ -47,7 +47,8 @@
<n-icon :component="MailOpenOutline" />
<input
v-model.trim="form.referral_code"
maxlength="32"
maxlength="6"
inputmode="numeric"
autocomplete="off"
placeholder="请输入邀请码(必填)"
/>
@@ -134,8 +135,8 @@
if (mode.value === 'register' && !/^[\p{L}\p{M}·]{1,10}$/u.test(form.username)) {
return void (error.value = '名字须为 1 至 10 个字符,支持汉字');
}
if (mode.value === 'register' && !/^[A-Za-z0-9]{1,32}$/.test(form.referral_code)) {
return void (error.value = '请输入有效的邀请码');
if (mode.value === 'register' && !/^\d{6}$/.test(form.referral_code)) {
return void (error.value = '请输入 6 位数字邀请码');
}
submitting.value = true;
try {