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