- 修复返利不到账的根因:tryClaimAffiliateRebateAudit 中 PostgreSQL 参数类型推断冲突 - 补全 OAuth 注册路径(LinuxDo/OIDC/WeChat/Pending Flow)的邀请码绑定 - 前端 OAuth 注册页面传递 aff_code 参数 - 新增返利冻结期机制:可配置冻结时间,到期后自动解冻(懒解冻) - 新增返利有效期:绑定后 N 天内有效,过期不再产生返利 - 新增单人返利上限:超出上限部分精确截断 - 增强返利流程 slog 结构化日志,便于排查问题 - 已邀请用户列表增加返利明细列
388 lines
11 KiB
Go
388 lines
11 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net/mail"
|
|
"strings"
|
|
"time"
|
|
|
|
dbent "github.com/Wei-Shaw/sub2api/ent"
|
|
"github.com/Wei-Shaw/sub2api/ent/redeemcode"
|
|
)
|
|
|
|
func normalizeOAuthSignupSource(signupSource string) string {
|
|
signupSource = strings.TrimSpace(strings.ToLower(signupSource))
|
|
switch signupSource {
|
|
case "", "email":
|
|
return "email"
|
|
case "linuxdo", "wechat", "oidc":
|
|
return signupSource
|
|
default:
|
|
return "email"
|
|
}
|
|
}
|
|
|
|
// SendPendingOAuthVerifyCode sends a local verification code for pending OAuth
|
|
// account-creation flows without relying on the public registration gate.
|
|
func (s *AuthService) SendPendingOAuthVerifyCode(ctx context.Context, email string) (*SendVerifyCodeResult, error) {
|
|
email = strings.TrimSpace(strings.ToLower(email))
|
|
if email == "" {
|
|
return nil, ErrEmailVerifyRequired
|
|
}
|
|
if _, err := mail.ParseAddress(email); err != nil {
|
|
return nil, ErrEmailVerifyRequired
|
|
}
|
|
if isReservedEmail(email) {
|
|
return nil, ErrEmailReserved
|
|
}
|
|
if s == nil || s.emailService == nil {
|
|
return nil, ErrServiceUnavailable
|
|
}
|
|
|
|
siteName := "Sub2API"
|
|
if s.settingService != nil {
|
|
siteName = s.settingService.GetSiteName(ctx)
|
|
}
|
|
if err := s.emailService.SendVerifyCode(ctx, email, siteName); err != nil {
|
|
return nil, err
|
|
}
|
|
return &SendVerifyCodeResult{
|
|
Countdown: int(verifyCodeCooldown / time.Second),
|
|
}, nil
|
|
}
|
|
|
|
func (s *AuthService) validateOAuthRegistrationInvitation(ctx context.Context, invitationCode string) (*RedeemCode, error) {
|
|
if s == nil || s.settingService == nil || !s.settingService.IsInvitationCodeEnabled(ctx) {
|
|
return nil, nil
|
|
}
|
|
if s.redeemRepo == nil && s.oauthEmailFlowClient(ctx) == nil {
|
|
return nil, ErrServiceUnavailable
|
|
}
|
|
|
|
invitationCode = strings.TrimSpace(invitationCode)
|
|
if invitationCode == "" {
|
|
return nil, ErrInvitationCodeRequired
|
|
}
|
|
|
|
redeemCode, err := s.loadOAuthRegistrationInvitation(ctx, invitationCode)
|
|
if err != nil {
|
|
return nil, ErrInvitationCodeInvalid
|
|
}
|
|
if redeemCode.Type != RedeemTypeInvitation || redeemCode.Status != StatusUnused {
|
|
return nil, ErrInvitationCodeInvalid
|
|
}
|
|
return redeemCode, nil
|
|
}
|
|
|
|
// VerifyOAuthEmailCode verifies the locally entered email verification code for
|
|
// third-party signup and binding flows. This is intentionally independent from
|
|
// the global registration email verification toggle.
|
|
func (s *AuthService) VerifyOAuthEmailCode(ctx context.Context, email, verifyCode string) error {
|
|
email = strings.TrimSpace(strings.ToLower(email))
|
|
verifyCode = strings.TrimSpace(verifyCode)
|
|
|
|
if email == "" {
|
|
return ErrEmailVerifyRequired
|
|
}
|
|
if verifyCode == "" {
|
|
return ErrEmailVerifyRequired
|
|
}
|
|
if s == nil || s.emailService == nil {
|
|
return ErrServiceUnavailable
|
|
}
|
|
return s.emailService.VerifyCode(ctx, email, verifyCode)
|
|
}
|
|
|
|
// RegisterOAuthEmailAccount creates a local account from a third-party first
|
|
// login after the user has verified a local email address.
|
|
func (s *AuthService) RegisterOAuthEmailAccount(
|
|
ctx context.Context,
|
|
email string,
|
|
password string,
|
|
verifyCode string,
|
|
invitationCode string,
|
|
signupSource string,
|
|
) (*TokenPair, *User, error) {
|
|
if s == nil {
|
|
return nil, nil, ErrServiceUnavailable
|
|
}
|
|
if s.settingService == nil || !s.settingService.IsRegistrationEnabled(ctx) {
|
|
return nil, nil, ErrRegDisabled
|
|
}
|
|
|
|
email = strings.TrimSpace(strings.ToLower(email))
|
|
if isReservedEmail(email) {
|
|
return nil, nil, ErrEmailReserved
|
|
}
|
|
if err := s.validateRegistrationEmailPolicy(ctx, email); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
if err := s.VerifyOAuthEmailCode(ctx, email, verifyCode); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
if _, err := s.validateOAuthRegistrationInvitation(ctx, invitationCode); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
existsEmail, err := s.userRepo.ExistsByEmail(ctx, email)
|
|
if err != nil {
|
|
return nil, nil, ErrServiceUnavailable
|
|
}
|
|
if existsEmail {
|
|
return nil, nil, ErrEmailExists
|
|
}
|
|
|
|
hashedPassword, err := s.HashPassword(password)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("hash password: %w", err)
|
|
}
|
|
|
|
signupSource = normalizeOAuthSignupSource(signupSource)
|
|
grantPlan := s.resolveSignupGrantPlan(ctx, signupSource)
|
|
|
|
user := &User{
|
|
Email: email,
|
|
PasswordHash: hashedPassword,
|
|
Role: RoleUser,
|
|
Balance: grantPlan.Balance,
|
|
Concurrency: grantPlan.Concurrency,
|
|
Status: StatusActive,
|
|
SignupSource: signupSource,
|
|
}
|
|
|
|
if err := s.userRepo.Create(ctx, user); err != nil {
|
|
if errors.Is(err, ErrEmailExists) {
|
|
return nil, nil, ErrEmailExists
|
|
}
|
|
return nil, nil, ErrServiceUnavailable
|
|
}
|
|
|
|
tokenPair, err := s.GenerateTokenPair(ctx, user, "")
|
|
if err != nil {
|
|
_ = s.RollbackOAuthEmailAccountCreation(ctx, user.ID, "")
|
|
return nil, nil, fmt.Errorf("generate token pair: %w", err)
|
|
}
|
|
return tokenPair, user, nil
|
|
}
|
|
|
|
// FinalizeOAuthEmailAccount applies invitation usage and normal signup bootstrap
|
|
// only after the pending OAuth flow has fully reached its last reversible step.
|
|
func (s *AuthService) FinalizeOAuthEmailAccount(
|
|
ctx context.Context,
|
|
user *User,
|
|
invitationCode string,
|
|
signupSource string,
|
|
affiliateCode string,
|
|
) error {
|
|
if s == nil || user == nil || user.ID <= 0 {
|
|
return ErrServiceUnavailable
|
|
}
|
|
|
|
signupSource = normalizeOAuthSignupSource(signupSource)
|
|
invitationRedeemCode, err := s.validateOAuthRegistrationInvitation(ctx, invitationCode)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if invitationRedeemCode != nil {
|
|
if err := s.useOAuthRegistrationInvitation(ctx, invitationRedeemCode.ID, user.ID); err != nil {
|
|
return ErrInvitationCodeInvalid
|
|
}
|
|
}
|
|
|
|
s.updateOAuthSignupSource(ctx, user.ID, signupSource)
|
|
grantPlan := s.resolveSignupGrantPlan(ctx, signupSource)
|
|
s.assignSubscriptions(ctx, user.ID, grantPlan.Subscriptions, "auto assigned by signup defaults")
|
|
s.bindOAuthAffiliate(ctx, user.ID, affiliateCode)
|
|
return nil
|
|
}
|
|
|
|
// RollbackOAuthEmailAccountCreation removes a partially-created local account
|
|
// and restores any invitation code already consumed by that account.
|
|
func (s *AuthService) RollbackOAuthEmailAccountCreation(ctx context.Context, userID int64, invitationCode string) error {
|
|
if s == nil || s.userRepo == nil || userID <= 0 {
|
|
return ErrServiceUnavailable
|
|
}
|
|
if err := s.restoreOAuthRegistrationInvitation(ctx, invitationCode, userID); err != nil {
|
|
return err
|
|
}
|
|
if err := s.userRepo.Delete(ctx, userID); err != nil {
|
|
return fmt.Errorf("delete created oauth user: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *AuthService) restoreOAuthRegistrationInvitation(ctx context.Context, invitationCode string, userID int64) error {
|
|
if s == nil || s.settingService == nil || !s.settingService.IsInvitationCodeEnabled(ctx) {
|
|
return nil
|
|
}
|
|
if s.redeemRepo == nil && s.oauthEmailFlowClient(ctx) == nil {
|
|
return ErrServiceUnavailable
|
|
}
|
|
|
|
invitationCode = strings.TrimSpace(invitationCode)
|
|
if invitationCode == "" || userID <= 0 {
|
|
return nil
|
|
}
|
|
|
|
redeemCode, err := s.loadOAuthRegistrationInvitation(ctx, invitationCode)
|
|
if err != nil {
|
|
if errors.Is(err, ErrRedeemCodeNotFound) {
|
|
return nil
|
|
}
|
|
return fmt.Errorf("load invitation code: %w", err)
|
|
}
|
|
if redeemCode.Type != RedeemTypeInvitation || redeemCode.Status != StatusUsed || redeemCode.UsedBy == nil || *redeemCode.UsedBy != userID {
|
|
return nil
|
|
}
|
|
|
|
redeemCode.Status = StatusUnused
|
|
redeemCode.UsedBy = nil
|
|
redeemCode.UsedAt = nil
|
|
if err := s.updateOAuthRegistrationInvitation(ctx, redeemCode); err != nil {
|
|
return fmt.Errorf("restore invitation code: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *AuthService) oauthEmailFlowClient(ctx context.Context) *dbent.Client {
|
|
if s == nil || s.entClient == nil {
|
|
return nil
|
|
}
|
|
if tx := dbent.TxFromContext(ctx); tx != nil {
|
|
return tx.Client()
|
|
}
|
|
return s.entClient
|
|
}
|
|
|
|
func (s *AuthService) loadOAuthRegistrationInvitation(ctx context.Context, invitationCode string) (*RedeemCode, error) {
|
|
if client := s.oauthEmailFlowClient(ctx); client != nil {
|
|
entity, err := client.RedeemCode.Query().Where(redeemcode.CodeEQ(invitationCode)).Only(ctx)
|
|
if err != nil {
|
|
if dbent.IsNotFound(err) {
|
|
return nil, ErrRedeemCodeNotFound
|
|
}
|
|
return nil, err
|
|
}
|
|
return &RedeemCode{
|
|
ID: entity.ID,
|
|
Code: entity.Code,
|
|
Type: entity.Type,
|
|
Value: entity.Value,
|
|
Status: entity.Status,
|
|
UsedBy: entity.UsedBy,
|
|
UsedAt: entity.UsedAt,
|
|
Notes: oauthEmailFlowStringValue(entity.Notes),
|
|
CreatedAt: entity.CreatedAt,
|
|
GroupID: entity.GroupID,
|
|
ValidityDays: entity.ValidityDays,
|
|
}, nil
|
|
}
|
|
return s.redeemRepo.GetByCode(ctx, invitationCode)
|
|
}
|
|
|
|
func (s *AuthService) useOAuthRegistrationInvitation(ctx context.Context, invitationID, userID int64) error {
|
|
if client := s.oauthEmailFlowClient(ctx); client != nil {
|
|
affected, err := client.RedeemCode.Update().
|
|
Where(redeemcode.IDEQ(invitationID), redeemcode.StatusEQ(StatusUnused)).
|
|
SetStatus(StatusUsed).
|
|
SetUsedBy(userID).
|
|
SetUsedAt(time.Now().UTC()).
|
|
Save(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if affected == 0 {
|
|
return ErrRedeemCodeUsed
|
|
}
|
|
return nil
|
|
}
|
|
return s.redeemRepo.Use(ctx, invitationID, userID)
|
|
}
|
|
|
|
func (s *AuthService) updateOAuthRegistrationInvitation(ctx context.Context, code *RedeemCode) error {
|
|
if code == nil {
|
|
return nil
|
|
}
|
|
if client := s.oauthEmailFlowClient(ctx); client != nil {
|
|
update := client.RedeemCode.UpdateOneID(code.ID).
|
|
SetCode(code.Code).
|
|
SetType(code.Type).
|
|
SetValue(code.Value).
|
|
SetStatus(code.Status).
|
|
SetNotes(code.Notes).
|
|
SetValidityDays(code.ValidityDays)
|
|
if code.UsedBy != nil {
|
|
update = update.SetUsedBy(*code.UsedBy)
|
|
} else {
|
|
update = update.ClearUsedBy()
|
|
}
|
|
if code.UsedAt != nil {
|
|
update = update.SetUsedAt(*code.UsedAt)
|
|
} else {
|
|
update = update.ClearUsedAt()
|
|
}
|
|
if code.GroupID != nil {
|
|
update = update.SetGroupID(*code.GroupID)
|
|
} else {
|
|
update = update.ClearGroupID()
|
|
}
|
|
_, err := update.Save(ctx)
|
|
return err
|
|
}
|
|
return s.redeemRepo.Update(ctx, code)
|
|
}
|
|
|
|
func (s *AuthService) updateOAuthSignupSource(ctx context.Context, userID int64, signupSource string) {
|
|
client := s.oauthEmailFlowClient(ctx)
|
|
if client == nil || userID <= 0 || strings.TrimSpace(signupSource) == "" {
|
|
return
|
|
}
|
|
_ = client.User.UpdateOneID(userID).SetSignupSource(signupSource).Exec(ctx)
|
|
}
|
|
|
|
func oauthEmailFlowStringValue(value *string) string {
|
|
if value == nil {
|
|
return ""
|
|
}
|
|
return *value
|
|
}
|
|
|
|
// ValidatePasswordCredentials checks the local password without completing the
|
|
// login flow. This is used by pending third-party account adoption flows before
|
|
// the external identity has been bound.
|
|
func (s *AuthService) ValidatePasswordCredentials(ctx context.Context, email, password string) (*User, error) {
|
|
if s == nil {
|
|
return nil, ErrServiceUnavailable
|
|
}
|
|
|
|
user, err := s.userRepo.GetByEmail(ctx, strings.TrimSpace(strings.ToLower(email)))
|
|
if err != nil {
|
|
if errors.Is(err, ErrUserNotFound) {
|
|
return nil, ErrInvalidCredentials
|
|
}
|
|
return nil, ErrServiceUnavailable
|
|
}
|
|
if !user.IsActive() {
|
|
return nil, ErrUserNotActive
|
|
}
|
|
if !s.CheckPassword(password, user.PasswordHash) {
|
|
return nil, ErrInvalidCredentials
|
|
}
|
|
return user, nil
|
|
}
|
|
|
|
// RecordSuccessfulLogin updates last-login activity after a non-standard login
|
|
// flow finishes with a real session.
|
|
func (s *AuthService) RecordSuccessfulLogin(ctx context.Context, userID int64) {
|
|
if s != nil && s.userRepo != nil && userID > 0 {
|
|
user, err := s.userRepo.GetByID(ctx, userID)
|
|
if err == nil && user != nil && !isReservedEmail(user.Email) {
|
|
s.backfillEmailIdentityOnSuccessfulLogin(ctx, user)
|
|
}
|
|
}
|
|
s.touchUserLogin(ctx, userID)
|
|
}
|