diff --git a/backend/baseService/go.mod b/backend/baseService/go.mod index 579e92c..4989db3 100644 --- a/backend/baseService/go.mod +++ b/backend/baseService/go.mod @@ -4,6 +4,7 @@ go 1.22.2 require ( buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.34.2-20240508200655-46a4cf4ba109.2 + github.com/TremblingV5/box v0.0.7 github.com/bufbuild/protovalidate-go v0.6.3 github.com/bwmarrin/snowflake v0.3.0 github.com/bytedance/sonic v1.12.3 @@ -29,7 +30,6 @@ require ( require ( dario.cat/mergo v1.0.0 // indirect - github.com/TremblingV5/box v0.0.7 // indirect github.com/antlr4-go/antlr/v4 v4.13.0 // indirect github.com/apache/rocketmq-client-go/v2 v2.1.2 // indirect github.com/armon/go-metrics v0.4.1 // indirect diff --git a/backend/baseService/internal/domain/service/accountservice/service.go b/backend/baseService/internal/domain/service/accountservice/service.go index cf0e7f4..ffe92fe 100644 --- a/backend/baseService/internal/domain/service/accountservice/service.go +++ b/backend/baseService/internal/domain/service/accountservice/service.go @@ -49,6 +49,10 @@ func (s *Service) checkAccountUnique(ctx context.Context, account *account.Accou } func (s *Service) Create(ctx context.Context, mobile, email, password string) (int64, error) { + if mobile == "" && email == "" { + return 0, errors.New("mobile or email is required") + } + account := account.NewAccount( account.WithMobile(mobile), account.WithEmail(email), @@ -177,12 +181,30 @@ func (s *Service) Bind(ctx context.Context, id int64, voucherType api.VoucherTyp return errors.New("账户id不能为空") } + if voucher == "" { + return errors.New("绑定值不能为空") + } + var column field.Expr switch voucherType { - case api.VoucherType_VOUCHER_EMAIL: - column = query.Q.Account.Email case api.VoucherType_VOUCHER_PHONE: + exist, err := s.account.IsMobileExist(ctx, voucher) + if err != nil { + return err + } + if exist { + return errors.New("该手机号已被其他账户绑定") + } column = query.Q.Account.Mobile + case api.VoucherType_VOUCHER_EMAIL: + exist, err := s.account.IsEmailExist(ctx, voucher) + if err != nil { + return err + } + if exist { + return errors.New("该邮箱已被其他账户绑定") + } + column = query.Q.Account.Email default: return errors.New("不支持的类型") } diff --git a/backend/baseService/internal/infrastructure/dal/models/account.gen.go b/backend/baseService/internal/infrastructure/dal/models/account.gen.go index e5f9492..feee63e 100644 --- a/backend/baseService/internal/infrastructure/dal/models/account.gen.go +++ b/backend/baseService/internal/infrastructure/dal/models/account.gen.go @@ -8,13 +8,15 @@ const TableNameAccount = "account" // Account mapped from table type Account struct { - ID int64 `gorm:"column:id;type:bigint;primaryKey" json:"id"` - Mobile string `gorm:"column:mobile;type:varchar(20);not null;index:account_mobile_idx,priority:1" json:"mobile"` - Email string `gorm:"column:email;type:varchar(100);not null;index:account_email_idx,priority:1" json:"email"` - Password string `gorm:"column:password;type:varchar(64);not null" json:"password"` - Salt string `gorm:"column:salt;type:varchar(64);not null" json:"salt"` - IsDeleted bool `gorm:"column:is_deleted;type:tinyint(1);not null" json:"is_deleted"` - Number string `gorm:"column:number;type:varchar(15);not null;comment:doutok号" json:"number"` // doutok号 + ID int64 `gorm:"column:id;type:bigint;primaryKey" json:"id"` + Mobile string `gorm:"column:mobile;type:varchar(20);not null;index:account_mobile_idx,priority:1" json:"mobile"` + MobileUnique *string `gorm:"column:mobile_unique;type:varchar(20);->;uniqueIndex:uk_account_mobile" json:"-"` + Email string `gorm:"column:email;type:varchar(100);not null;index:account_email_idx,priority:1" json:"email"` + EmailUnique *string `gorm:"column:email_unique;type:varchar(100);->;uniqueIndex:uk_account_email" json:"-"` + Password string `gorm:"column:password;type:varchar(64);not null" json:"password"` + Salt string `gorm:"column:salt;type:varchar(64);not null" json:"salt"` + IsDeleted bool `gorm:"column:is_deleted;type:tinyint(1);not null" json:"is_deleted"` + Number string `gorm:"column:number;type:varchar(15);not null;comment:doutok号" json:"number"` // doutok号 } // TableName Account's table name diff --git a/backend/baseService/internal/infrastructure/repositories/accountrepo/repository.go b/backend/baseService/internal/infrastructure/repositories/accountrepo/repository.go index 67d0263..30e3d46 100644 --- a/backend/baseService/internal/infrastructure/repositories/accountrepo/repository.go +++ b/backend/baseService/internal/infrastructure/repositories/accountrepo/repository.go @@ -75,7 +75,7 @@ func (r *PersistRepository) IsEmailExist(ctx context.Context, email string) (boo func (r *PersistRepository) ClearColumn(ctx context.Context, column field.Expr) error { return dbtx.TxDo(ctx, func(tx *query.QueryTx) error { - _, err := tx.WithContext(ctx).Account.Update(column, nil) + _, err := tx.WithContext(ctx).Account.Update(column, "") return err }) } diff --git a/backend/shortVideoApiService/api/svapi/user.proto b/backend/shortVideoApiService/api/svapi/user.proto index 6e72e81..b49839c 100644 --- a/backend/shortVideoApiService/api/svapi/user.proto +++ b/backend/shortVideoApiService/api/svapi/user.proto @@ -97,7 +97,7 @@ message RegisterRequest { string mobile = 1; string email = 2; string password = 3 [ - (buf.validate.field).string.min_len = 6, + (buf.validate.field).string.min_len = 8, (buf.validate.field).string.max_len = 50 ]; // @gotags: json:"code_id,omitempty,string" @@ -111,8 +111,8 @@ message RegisterResponse { } message LoginRequest { - string mobile = 1 [(buf.validate.field).string.pattern = "^\\+?[1-9]\\d{1,14}$"]; - string email = 2 [(buf.validate.field).string.email = true]; + string mobile = 1; + string email = 2; string password = 3 [ (buf.validate.field).string.min_len = 8, (buf.validate.field).string.max_len = 50 diff --git a/backend/shortVideoApiService/go.mod b/backend/shortVideoApiService/go.mod index bbc2e3a..62082ca 100644 --- a/backend/shortVideoApiService/go.mod +++ b/backend/shortVideoApiService/go.mod @@ -75,7 +75,6 @@ require ( github.com/patrickmn/go-cache v2.1.0+incompatible // indirect github.com/pkg/errors v0.9.1 // indirect github.com/redis/go-redis/v9 v9.6.1 // indirect - github.com/rogpeppe/go-internal v1.13.1 // indirect github.com/rs/xid v1.5.0 // indirect github.com/samber/lo v1.46.0 // indirect github.com/sirupsen/logrus v1.9.3 // indirect @@ -111,3 +110,5 @@ require ( ) replace github.com/cloudzenith/DouTok/backend/gopkgs v0.0.9 => github.com/cloudzenith/DouTok/backend/gopkgs v0.0.0-20241103032449-fe0152ac484a + +replace github.com/cloudzenith/DouTok/backend/baseService v0.0.1 => ../baseService diff --git a/backend/shortVideoApiService/internal/applications/userapp/application.go b/backend/shortVideoApiService/internal/applications/userapp/application.go index d402b44..09d5225 100644 --- a/backend/shortVideoApiService/internal/applications/userapp/application.go +++ b/backend/shortVideoApiService/internal/applications/userapp/application.go @@ -3,6 +3,7 @@ package userapp import ( "context" "fmt" + baseapi "github.com/cloudzenith/DouTok/backend/baseService/api" "github.com/cloudzenith/DouTok/backend/shortVideoApiService/api/svapi" "github.com/cloudzenith/DouTok/backend/shortVideoApiService/internal/infrastructure/adapter/baseadapter" "github.com/cloudzenith/DouTok/backend/shortVideoApiService/internal/infrastructure/adapter/baseadapter/accountoptions" @@ -114,6 +115,16 @@ func (a *Application) setToken2Header(ctx context.Context, claim *claims.Claims) } func (a *Application) Login(ctx context.Context, request *svapi.LoginRequest) (*svapi.LoginResponse, error) { + if request.GetMobile() == "" && request.GetEmail() == "" { + return nil, errorx.New(1, "mobile or email is required") + } + if request.GetMobile() != "" && !isValidMobile(request.GetMobile()) { + return nil, errorx.New(1, "invalid mobile format") + } + if request.GetEmail() != "" && !isValidEmail(request.GetEmail()) { + return nil, errorx.New(1, "invalid email format") + } + accountId, err := a.base.CheckAccount( ctx, accountoptions.CheckAccountWithMobile(request.GetMobile()), @@ -130,7 +141,7 @@ func (a *Application) Login(ctx context.Context, request *svapi.LoginRequest) (* return nil, errorx.New(1, "failed to get user info") } - token, err := a.setToken2Header(ctx, claims.New(user.Id)) + token, err := a.setToken2Header(ctx, claims.NewWithAccount(user.Id, accountId)) if err != nil { log.Context(ctx).Error("failed to generate token: %v", err) return nil, errorx.New(1, "failed to generate token") @@ -141,6 +152,16 @@ func (a *Application) Login(ctx context.Context, request *svapi.LoginRequest) (* } func (a *Application) Register(ctx context.Context, request *svapi.RegisterRequest) (*svapi.RegisterResponse, error) { + if request.Mobile == "" && request.Email == "" { + return nil, errorx.New(1, "mobile or email is required") + } + if request.Mobile != "" && !isValidMobile(request.Mobile) { + return nil, errorx.New(1, "invalid mobile format") + } + if request.Email != "" && !isValidEmail(request.Email) { + return nil, errorx.New(1, "invalid email format") + } + if err := a.base.ValidateVerificationCode(ctx, request.CodeId, request.Code); err != nil { return nil, errorx.New(1, "invalid verification code") } @@ -198,13 +219,59 @@ func (a *Application) UpdateUserInfo(ctx context.Context, request *svapi.UpdateU } func (a *Application) BindUserVoucher(ctx context.Context, request *svapi.BindUserVoucherRequest) (*svapi.BindUserVoucherResponse, error) { - //TODO implement me - panic("implement me") + accountId, err := claims.GetAccountId(ctx) + if err != nil { + return nil, errorx.New(1, "failed to get account id from token") + } + + if request.Voucher == "" { + return nil, errorx.New(1, "voucher value is required") + } + + var vt baseapi.VoucherType + switch request.VoucherType { + case svapi.VoucherType_PHONE: + if !isValidMobile(request.Voucher) { + return nil, errorx.New(1, "invalid mobile format") + } + vt = baseapi.VoucherType_VOUCHER_PHONE + case svapi.VoucherType_EMAIL: + if !isValidEmail(request.Voucher) { + return nil, errorx.New(1, "invalid email format") + } + vt = baseapi.VoucherType_VOUCHER_EMAIL + default: + return nil, errorx.New(1, "unsupported voucher type") + } + + if err := a.base.Bind(ctx, accountId, vt, request.Voucher); err != nil { + log.Context(ctx).Errorf("failed to bind voucher: %v", err) + return nil, errorx.New(1, "failed to bind voucher") + } + return &svapi.BindUserVoucherResponse{}, nil } func (a *Application) UnbindUserVoucher(ctx context.Context, request *svapi.UnbindUserVoucherRequest) (*svapi.UnbindUserVoucherResponse, error) { - //TODO implement me - panic("implement me") + accountId, err := claims.GetAccountId(ctx) + if err != nil { + return nil, errorx.New(1, "failed to get account id from token") + } + + var vt baseapi.VoucherType + switch request.VoucherType { + case svapi.VoucherType_PHONE: + vt = baseapi.VoucherType_VOUCHER_PHONE + case svapi.VoucherType_EMAIL: + vt = baseapi.VoucherType_VOUCHER_EMAIL + default: + return nil, errorx.New(1, "unsupported voucher type") + } + + if err := a.base.Unbind(ctx, accountId, vt); err != nil { + log.Context(ctx).Errorf("failed to unbind voucher: %v", err) + return nil, errorx.New(1, "failed to unbind voucher") + } + return &svapi.UnbindUserVoucherResponse{}, nil } var _ svapi.UserServiceHTTPServer = (*Application)(nil) diff --git a/backend/shortVideoApiService/internal/applications/userapp/validation.go b/backend/shortVideoApiService/internal/applications/userapp/validation.go new file mode 100644 index 0000000..eefdd42 --- /dev/null +++ b/backend/shortVideoApiService/internal/applications/userapp/validation.go @@ -0,0 +1,17 @@ +package userapp + +import ( + "net/mail" + "regexp" +) + +var mobilePattern = regexp.MustCompile(`^\+?[1-9]\d{1,14}$`) + +func isValidMobile(mobile string) bool { + return mobilePattern.MatchString(mobile) +} + +func isValidEmail(email string) bool { + _, err := mail.ParseAddress(email) + return err == nil +} diff --git a/backend/shortVideoApiService/internal/infrastructure/adapter/baseadapter/account.go b/backend/shortVideoApiService/internal/infrastructure/adapter/baseadapter/account.go index fbfc6e3..4340552 100644 --- a/backend/shortVideoApiService/internal/infrastructure/adapter/baseadapter/account.go +++ b/backend/shortVideoApiService/internal/infrastructure/adapter/baseadapter/account.go @@ -36,3 +36,20 @@ func (a *Adapter) CheckAccount(ctx context.Context, options ...accountoptions.Ch }, ) } + +func (a *Adapter) Bind(ctx context.Context, accountId int64, voucherType api.VoucherType, voucher string) error { + resp, err := a.account.Bind(ctx, &api.BindRequest{ + AccountId: accountId, + VoucherType: voucherType, + Voucher: voucher, + }) + return respcheck.Check[*api.Metadata](resp, err) +} + +func (a *Adapter) Unbind(ctx context.Context, accountId int64, voucherType api.VoucherType) error { + resp, err := a.account.Unbind(ctx, &api.UnbindRequest{ + AccountId: accountId, + VoucherType: voucherType, + }) + return respcheck.Check[*api.Metadata](resp, err) +} diff --git a/backend/shortVideoApiService/internal/infrastructure/utils/claims/claims.go b/backend/shortVideoApiService/internal/infrastructure/utils/claims/claims.go index 55f1cc8..b46c462 100644 --- a/backend/shortVideoApiService/internal/infrastructure/utils/claims/claims.go +++ b/backend/shortVideoApiService/internal/infrastructure/utils/claims/claims.go @@ -10,7 +10,8 @@ import ( type Claims struct { jwt5.RegisteredClaims - UserId int64 `json:"user_id"` + UserId int64 `json:"user_id"` + AccountId int64 `json:"account_id,omitempty"` } func New(userId int64) *Claims { @@ -19,6 +20,13 @@ func New(userId int64) *Claims { } } +func NewWithAccount(userId, accountId int64) *Claims { + return &Claims{ + UserId: userId, + AccountId: accountId, + } +} + func GetUserId(ctx context.Context) (int64, error) { anyClaims, ok := jwt.FromContext(ctx) if !ok { @@ -33,6 +41,24 @@ func GetUserId(ctx context.Context) (int64, error) { return claims.UserId, nil } +func GetAccountId(ctx context.Context) (int64, error) { + anyClaims, ok := jwt.FromContext(ctx) + if !ok { + return 0, errors.New("no claims in context") + } + + claims, ok := anyClaims.(*Claims) + if !ok { + return 0, errors.New("claims type error") + } + + if claims.AccountId == 0 { + return 0, errors.New("account_id not found in token, please re-login") + } + + return claims.AccountId, nil +} + func GenerateToken(claim *Claims) (string, error) { token := jwt5.NewWithClaims(jwt5.SigningMethodHS256, claim) tokenString, err := token.SignedString([]byte("token")) diff --git a/sql/20260527000001_v0_3_0_auth_upgrade.sql b/sql/20260527000001_v0_3_0_auth_upgrade.sql new file mode 100644 index 0000000..b88721e --- /dev/null +++ b/sql/20260527000001_v0_3_0_auth_upgrade.sql @@ -0,0 +1,41 @@ +-- +goose Up + +-- ============================================================ +-- v0.3.0 Auth Upgrade: 支持仅手机号 / 仅邮箱 / 混合注册 +-- ============================================================ +-- +-- 迁移前请先执行以下检查 SQL,确认无重复数据: +-- +-- SELECT mobile, COUNT(*) AS cnt +-- FROM account +-- WHERE mobile != '' AND is_deleted = 0 +-- GROUP BY mobile HAVING cnt > 1; +-- +-- SELECT email, COUNT(*) AS cnt +-- FROM account +-- WHERE email != '' AND is_deleted = 0 +-- GROUP BY email HAVING cnt > 1; +-- +-- 如有重复行,请先手动处理后再执行迁移。 +-- ============================================================ + +-- 使用 GENERATED COLUMN + UNIQUE INDEX 实现条件唯一约束: +-- mobile/email 为空串时 → 生成列值为 NULL → UNIQUE 允许多个 NULL +-- mobile/email 非空时 → 生成列保留原值 → UNIQUE 约束生效 + +-- +goose StatementBegin +ALTER TABLE account + ADD COLUMN `mobile_unique` VARCHAR(20) GENERATED ALWAYS AS (NULLIF(mobile, '')) STORED AFTER `mobile`, + ADD COLUMN `email_unique` VARCHAR(100) GENERATED ALWAYS AS (NULLIF(email, '')) STORED AFTER `email`, + ADD UNIQUE INDEX `uk_account_mobile` (`mobile_unique`), + ADD UNIQUE INDEX `uk_account_email` (`email_unique`); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +ALTER TABLE account + DROP INDEX `uk_account_email`, + DROP INDEX `uk_account_mobile`, + DROP COLUMN `email_unique`, + DROP COLUMN `mobile_unique`; +-- +goose StatementEnd