商城c端前端

This commit is contained in:
richjohnrambo
2026-10-12 20:29:27 +08:00
parent 2d411e1b44
commit 8f3a97a54d
550 changed files with 304 additions and 68927 deletions
+3 -3
View File
@@ -19,7 +19,7 @@ type profileRepository struct {
func (r *profileRepository) RegisterAccount(context.Context, store.AccountRegistration) (*store.Account, error) {
return nil, errors.New("not implemented")
}
func (r *profileRepository) AccountByMobileHash(context.Context, int64, string) (*store.Account, error) {
func (r *profileRepository) AccountByMobile(context.Context, int64, string) (*store.Account, error) {
return nil, errors.New("not implemented")
}
func (r *profileRepository) RecordLogin(context.Context, int64, int64) error { return nil }
@@ -44,14 +44,14 @@ func (r *profileRepository) UpdateTradePassword(_ context.Context, _, _ int64, _
func TestProfileMapsMemberCategory(t *testing.T) {
repository := &profileRepository{overview: &store.MemberOverview{
MemberID: 7, MemberNo: "M0007", Username: "robot", MemberStatus: 1, MemberCategory: 2,
Currency: "CNY", WalletStatus: 1,
Mobile: "13800138000", Currency: "CNY", WalletStatus: 1,
}}
service := &Service{repository: repository, ready: true}
profile, err := service.Profile(context.Background(), 0, 7)
if err != nil {
t.Fatal(err)
}
if profile.MemberCategory != "fake" || profile.MemberStatus != "active" || profile.Wallet.Status != "active" {
if profile.Phone != "13800138000" || profile.MemberCategory != "fake" || profile.MemberStatus != "active" || profile.Wallet.Status != "active" {
t.Fatalf("unexpected profile states: %+v", profile)
}
}
+14 -69
View File
@@ -2,9 +2,6 @@ package accounts
import (
"context"
"crypto/aes"
"crypto/cipher"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
@@ -47,7 +44,7 @@ var (
type Repository interface {
RegisterAccount(context.Context, store.AccountRegistration) (*store.Account, error)
AccountByMobileHash(context.Context, int64, string) (*store.Account, error)
AccountByMobile(context.Context, int64, string) (*store.Account, error)
RecordLogin(context.Context, int64, int64) error
MemberOverview(context.Context, int64, int64) (*store.MemberOverview, error)
UpdateAvatar(context.Context, int64, int64, int64) (*store.Avatar, error)
@@ -81,7 +78,7 @@ type Profile struct {
MemberID int64 `json:"member_id"`
MemberNo string `json:"member_no"`
Username string `json:"username"`
PhoneMask string `json:"phone_mask"`
Phone string `json:"phone"`
AvatarURL string `json:"avatar_url"`
InviteCode string `json:"invite_code"`
Level ProfileLevel `json:"level"`
@@ -129,30 +126,16 @@ type Service struct {
repository Repository
redis *redis.Client
cfg config.AuthConfig
aead cipher.AEAD
lookupKey []byte
ready bool
}
func New(repository Repository, redisClient *redis.Client, cfg config.AuthConfig) *Service {
service := &Service{repository: repository, redis: redisClient, cfg: cfg}
encryptionKey, encryptionErr := decodeKey(cfg.DataEncryptionKey)
lookupKey, lookupErr := decodeKey(cfg.LookupHMACKey)
if encryptionErr != nil || len(encryptionKey) != 32 || lookupErr != nil || len(lookupKey) < 32 {
return service
return &Service{
repository: repository,
redis: redisClient,
cfg: cfg,
ready: repository != nil && redisClient != nil,
}
block, err := aes.NewCipher(encryptionKey)
if err != nil {
return service
}
aead, err := cipher.NewGCM(block)
if err != nil {
return service
}
service.aead = aead
service.lookupKey = lookupKey
service.ready = repository != nil && redisClient != nil
return service
}
func (s *Service) Register(ctx context.Context, input RegisterInput) (*Result, error) {
@@ -178,12 +161,6 @@ func (s *Service) Register(ctx context.Context, input RegisterInput) (*Result, e
return nil, ErrPrivacyNotAccepted
}
normalizedPhone := "+86" + input.Phone
mobileCipher, err := s.encrypt(normalizedPhone)
if err != nil {
return nil, err
}
mobileHash := s.lookupHash(normalizedPhone)
passwordHash, err := bcrypt.GenerateFromPassword([]byte(input.Password), bcrypt.DefaultCost)
if err != nil {
return nil, err
@@ -200,9 +177,7 @@ func (s *Service) Register(ctx context.Context, input RegisterInput) (*Result, e
TenantID: s.cfg.DefaultTenantID,
MemberNo: memberNo,
Username: input.Username,
MobileCipher: mobileCipher,
MobileHash: mobileHash,
MobileMask: maskPhone(input.Phone),
Mobile: input.Phone,
PasswordHash: string(passwordHash),
ReferralCode: input.ReferralCode,
InviteCode: inviteCode,
@@ -227,12 +202,11 @@ func (s *Service) Login(ctx context.Context, input LoginInput) (*Result, error)
if input.Password == "" || len([]byte(input.Password)) > 72 {
return nil, store.ErrInvalidCredentials
}
mobileHash := s.lookupHash("+86" + phone)
rateKey := s.loginRateKey(mobileHash)
rateKey := s.loginRateKey(phone)
if attempts, err := s.redis.Get(ctx, rateKey).Int(); err == nil && attempts >= 10 {
return nil, ErrRateLimited
}
account, err := s.repository.AccountByMobileHash(ctx, s.cfg.DefaultTenantID, mobileHash)
account, err := s.repository.AccountByMobile(ctx, s.cfg.DefaultTenantID, phone)
if err != nil {
if errors.Is(err, store.ErrInvalidCredentials) {
s.recordLoginFailure(ctx, rateKey)
@@ -263,7 +237,7 @@ func (s *Service) Profile(ctx context.Context, tenantID, memberID int64) (*Profi
}
return &Profile{
MemberID: row.MemberID, MemberNo: row.MemberNo, Username: row.Username,
PhoneMask: row.MobileMask, AvatarURL: row.AvatarURL, InviteCode: row.InviteCode,
Phone: row.Mobile, AvatarURL: row.AvatarURL, InviteCode: row.InviteCode,
Level: ProfileLevel{ID: row.LevelID, Code: row.LevelCode, Name: row.LevelName,
IconURL: row.LevelIconURL, CommissionRate: row.CommissionRate},
MemberStatus: memberStatusName(row.MemberStatus), MemberCategory: memberCategoryName(row.MemberCategory),
@@ -379,23 +353,9 @@ func (s *Service) issueSession(ctx context.Context, account *store.Account, devi
return &Result{Account: account, Token: token, ExpiresAt: expiresAt}, nil
}
func (s *Service) encrypt(value string) (string, error) {
nonce := make([]byte, s.aead.NonceSize())
if _, err := rand.Read(nonce); err != nil {
return "", err
}
sealed := s.aead.Seal(nil, nonce, []byte(value), nil)
return base64.RawStdEncoding.EncodeToString(append(nonce, sealed...)), nil
}
func (s *Service) lookupHash(value string) string {
mac := hmac.New(sha256.New, s.lookupKey)
_, _ = mac.Write([]byte(value))
return hex.EncodeToString(mac.Sum(nil))
}
func (s *Service) loginRateKey(mobileHash string) string {
return "workbench:auth:login-rate:" + strconv.FormatInt(s.cfg.DefaultTenantID, 10) + ":" + mobileHash
func (s *Service) loginRateKey(phone string) string {
digest := sha256.Sum256([]byte(phone))
return "workbench:auth:login-rate:" + strconv.FormatInt(s.cfg.DefaultTenantID, 10) + ":" + hex.EncodeToString(digest[:])
}
func (s *Service) recordLoginFailure(ctx context.Context, key string) {
@@ -405,26 +365,11 @@ func (s *Service) recordLoginFailure(ctx context.Context, key string) {
_, _ = pipe.Exec(ctx)
}
func decodeKey(value string) ([]byte, error) {
value = strings.TrimSpace(value)
if decoded, err := base64.StdEncoding.DecodeString(value); err == nil {
return decoded, nil
}
return base64.RawStdEncoding.DecodeString(value)
}
func sessionKey(pattern string, rawToken []byte) string {
digest := sha256.Sum256(rawToken)
return strings.Replace(pattern, "%s", hex.EncodeToString(digest[:]), 1)
}
func maskPhone(phone string) string {
if len(phone) != 11 {
return ""
}
return phone[:3] + "****" + phone[7:]
}
func newPublicID(prefix string, length int) (string, error) {
const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
raw := make([]byte, length-len(prefix))
+7 -29
View File
@@ -2,10 +2,8 @@ package accounts
import (
"context"
"crypto/aes"
"crypto/cipher"
"encoding/base64"
"errors"
"strings"
"testing"
"git.118im.com/im_team/im_workbench/internal/store"
@@ -16,7 +14,7 @@ type fakeRepository struct{}
func (fakeRepository) RegisterAccount(context.Context, store.AccountRegistration) (*store.Account, error) {
return nil, errors.New("should not be called")
}
func (fakeRepository) AccountByMobileHash(context.Context, int64, string) (*store.Account, error) {
func (fakeRepository) AccountByMobile(context.Context, int64, string) (*store.Account, error) {
return nil, errors.New("should not be called")
}
func (fakeRepository) RecordLogin(context.Context, int64, int64) error {
@@ -61,31 +59,11 @@ func TestRegisterValidation(t *testing.T) {
}
}
func TestMobileEncryptionRoundTrip(t *testing.T) {
key := make([]byte, 32)
block, err := aes.NewCipher(key)
if err != nil {
t.Fatal(err)
}
aead, err := cipher.NewGCM(block)
if err != nil {
t.Fatal(err)
}
s := &Service{aead: aead, lookupKey: []byte("01234567890123456789012345678901")}
encrypted, err := s.encrypt("+8613800138000")
if err != nil {
t.Fatal(err)
}
raw, err := base64.RawStdEncoding.DecodeString(encrypted)
if err != nil {
t.Fatal(err)
}
plain, err := aead.Open(nil, raw[:aead.NonceSize()], raw[aead.NonceSize():], nil)
if err != nil || string(plain) != "+8613800138000" {
t.Fatalf("decrypt = %q, err=%v", plain, err)
}
if len(s.lookupHash("+8613800138000")) != 64 {
t.Fatal("mobile lookup hash must be hex sha256")
func TestLoginRateKeyDoesNotExposePhone(t *testing.T) {
s := &Service{}
key := s.loginRateKey("13800138000")
if strings.Contains(key, "13800138000") {
t.Fatal("login rate key must not expose the phone number")
}
}
+1 -1
View File
@@ -110,7 +110,7 @@ func accountResultPayload(result *accountsvc.Result) map[string]any {
"member_id": result.Account.MemberID,
"member_no": result.Account.MemberNo,
"username": result.Account.Username,
"phone_mask": result.Account.MobileMask,
"phone": result.Account.Mobile,
"invite_code": result.Account.InviteCode,
"token": result.Token,
"token_type": "Bearer",
+13 -16
View File
@@ -21,9 +21,7 @@ type AccountRegistration struct {
TenantID int64
MemberNo string
Username string
MobileCipher string
MobileHash string
MobileMask string
Mobile string
PasswordHash string
ReferralCode string
InviteCode string
@@ -34,8 +32,7 @@ type Account struct {
MemberID int64 `db:"member_id"`
MemberNo string `db:"member_no"`
Username string `db:"username"`
MobileMask string `db:"mobile_mask"`
MobileHash string `db:"mobile_hash"`
Mobile string `db:"mobile"`
PasswordHash string `db:"password_hash"`
InviteCode string `db:"invite_code"`
MemberStatus int `db:"member_status"`
@@ -62,12 +59,12 @@ func (s *Store) RegisterAccount(ctx context.Context, input AccountRegistration)
}
result, err := tx.ExecContext(ctx, `INSERT INTO hg_app_member
(tenant_id,member_no,username,mobile_cipher,mobile_hash,mobile_mask,password_hash,
(tenant_id,member_no,username,mobile,password_hash,
level_id,parent_id,level,tree,invite_code,member_status,risk_status,auto_dispatch,
credit_score,is_test_account,created_by,updated_by)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,1,1,2,100,2,0,0)`,
input.TenantID, input.MemberNo, input.Username, input.MobileCipher, input.MobileHash,
input.MobileMask, input.PasswordHash, levelID, parentID, teamLevel, tree, input.InviteCode)
VALUES(?,?,?,?,?,?,?,?,?,?,1,1,2,100,2,0,0)`,
input.TenantID, input.MemberNo, input.Username, input.Mobile, input.PasswordHash,
levelID, parentID, teamLevel, tree, input.InviteCode)
if err != nil {
if isDuplicate(err) {
return nil, ErrAccountExists
@@ -91,15 +88,15 @@ func (s *Store) RegisterAccount(ctx context.Context, input AccountRegistration)
return nil, err
}
return &Account{TenantID: input.TenantID, MemberID: memberID, MemberNo: input.MemberNo,
Username: input.Username, MobileMask: input.MobileMask, MobileHash: input.MobileHash,
Username: input.Username, Mobile: input.Mobile,
InviteCode: input.InviteCode, MemberStatus: memberStatusActive, MemberCategory: memberCategoryReal}, nil
}
func (s *Store) AccountByMobileHash(ctx context.Context, tenantID int64, mobileHash string) (*Account, error) {
func (s *Store) AccountByMobile(ctx context.Context, tenantID int64, mobile string) (*Account, error) {
if s == nil || s.db == nil {
return nil, errors.New("store is unavailable")
}
return accountByMobileHash(ctx, s.db, tenantID, mobileHash)
return accountByMobile(ctx, s.db, tenantID, mobile)
}
func (s *Store) RecordLogin(ctx context.Context, tenantID, memberID int64) error {
@@ -112,11 +109,11 @@ type queryer interface {
GetContext(context.Context, any, string, ...any) error
}
func accountByMobileHash(ctx context.Context, db queryer, tenantID int64, mobileHash string) (*Account, error) {
func accountByMobile(ctx context.Context, db queryer, tenantID int64, mobile string) (*Account, error) {
var row Account
err := db.GetContext(ctx, &row, `SELECT tenant_id, id AS member_id, member_no, username, mobile_mask,
mobile_hash, password_hash, invite_code, member_status, risk_status AS member_category
FROM hg_app_member WHERE tenant_id=? AND mobile_hash=? AND deleted_at IS NULL LIMIT 1`, tenantID, mobileHash)
err := db.GetContext(ctx, &row, `SELECT tenant_id, id AS member_id, member_no, username, mobile,
password_hash, invite_code, member_status, risk_status AS member_category
FROM hg_app_member WHERE tenant_id=? AND mobile=? AND deleted_at IS NULL LIMIT 1`, tenantID, mobile)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrInvalidCredentials
}
+2 -2
View File
@@ -11,7 +11,7 @@ type MemberOverview struct {
MemberID int64 `db:"member_id"`
MemberNo string `db:"member_no"`
Username string `db:"username"`
MobileMask string `db:"mobile_mask"`
Mobile string `db:"mobile"`
InviteCode string `db:"invite_code"`
MemberStatus int `db:"member_status"`
MemberCategory int `db:"member_category"`
@@ -48,7 +48,7 @@ func (s *Store) MemberOverview(ctx context.Context, tenantID, memberID int64) (*
return nil, errors.New("store is unavailable")
}
var row MemberOverview
err := s.db.GetContext(ctx, &row, `SELECT m.tenant_id,m.id AS member_id,m.member_no,m.username,m.mobile_mask,
err := s.db.GetContext(ctx, &row, `SELECT m.tenant_id,m.id AS member_id,m.member_no,m.username,m.mobile,
m.invite_code,m.member_status,m.risk_status AS member_category,m.credit_score,
COALESCE(p.avatar_attachment_id,0) AS avatar_attachment_id,COALESCE(a.file_url,'') AS avatar_url,
COALESCE(l.id,0) AS level_id,COALESCE(l.level_code,'') AS level_code,