feat: use six-digit numeric member invite codes
This commit is contained in:
+4
-4
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user