239 lines
8.7 KiB
Go
239 lines
8.7 KiB
Go
package auth
|
||
|
||
import (
|
||
"context"
|
||
"database/sql"
|
||
|
||
"hyapp/pkg/appcode"
|
||
"hyapp/pkg/xerr"
|
||
authdomain "hyapp/services/user-service/internal/domain/auth"
|
||
invitedomain "hyapp/services/user-service/internal/domain/invite"
|
||
userdomain "hyapp/services/user-service/internal/domain/user"
|
||
invitestorage "hyapp/services/user-service/internal/storage/mysql/invite"
|
||
"hyapp/services/user-service/internal/storage/mysql/shared"
|
||
userstorage "hyapp/services/user-service/internal/storage/mysql/user"
|
||
)
|
||
|
||
func (r *Repository) FindThirdPartyIdentity(ctx context.Context, provider string, providerSubject string) (authdomain.ThirdPartyIdentity, error) {
|
||
var identity authdomain.ThirdPartyIdentity
|
||
err := r.db.QueryRowContext(ctx, `
|
||
SELECT app_code, user_id, provider, provider_subject, COALESCE(provider_union_id, ''), created_at_ms, updated_at_ms
|
||
FROM third_party_identities
|
||
WHERE app_code = ? AND provider = ? AND provider_subject = ?
|
||
`, appcode.FromContext(ctx), provider, providerSubject).Scan(&identity.AppCode, &identity.UserID, &identity.Provider, &identity.ProviderSubject, &identity.ProviderUnionID, &identity.CreatedAtMs, &identity.UpdatedAtMs)
|
||
if err == sql.ErrNoRows {
|
||
// 未绑定三方身份时由 service 转入注册路径。
|
||
return authdomain.ThirdPartyIdentity{}, xerr.New(xerr.NotFound, "third-party identity not found")
|
||
}
|
||
if err != nil {
|
||
return authdomain.ThirdPartyIdentity{}, err
|
||
}
|
||
|
||
return identity, nil
|
||
}
|
||
|
||
func (r *Repository) FindUserIDByRegisterDeviceID(ctx context.Context, appCode string, deviceID string) (int64, error) {
|
||
var userID int64
|
||
err := r.db.QueryRowContext(ctx, `
|
||
SELECT user_id
|
||
FROM users
|
||
WHERE app_code = ? AND register_device_id = ?
|
||
LIMIT 1
|
||
`, appcode.Normalize(appCode), deviceID).Scan(&userID)
|
||
if err == sql.ErrNoRows {
|
||
// 未查到占用者时,service 才允许新三方身份走注册事务。
|
||
return 0, xerr.New(xerr.NotFound, "registered device not found")
|
||
}
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
|
||
return userID, nil
|
||
}
|
||
|
||
func (r *Repository) CountUsersByRegisterDeviceID(ctx context.Context, appCode string, deviceID string) (int64, error) {
|
||
var count int64
|
||
if err := r.db.QueryRowContext(ctx, `
|
||
SELECT COUNT(*)
|
||
FROM users
|
||
WHERE app_code = ? AND register_device_id = ?
|
||
`, appcode.Normalize(appCode), deviceID).Scan(&count); err != nil {
|
||
return 0, err
|
||
}
|
||
|
||
return count, nil
|
||
}
|
||
|
||
func (r *Repository) ListDisplayUserIDsByRegisterDeviceID(ctx context.Context, appCode string, deviceID string) ([]string, error) {
|
||
rows, err := r.db.QueryContext(ctx, `
|
||
SELECT current_display_user_id
|
||
FROM users
|
||
WHERE app_code = ? AND register_device_id = ?
|
||
ORDER BY user_id ASC
|
||
`, appcode.Normalize(appCode), deviceID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
|
||
displayUserIDs := make([]string, 0)
|
||
for rows.Next() {
|
||
var displayUserID string
|
||
if err := rows.Scan(&displayUserID); err != nil {
|
||
return nil, err
|
||
}
|
||
displayUserIDs = append(displayUserIDs, displayUserID)
|
||
}
|
||
if err := rows.Err(); err != nil {
|
||
return nil, err
|
||
}
|
||
return displayUserIDs, nil
|
||
}
|
||
|
||
// CreateThirdPartyUser 原子创建用户、默认短号、三方身份和首个 session。
|
||
|
||
func (r *Repository) CreateThirdPartyUser(ctx context.Context, user userdomain.User, identity userdomain.Identity, thirdParty authdomain.ThirdPartyIdentity, session authdomain.Session, inviteBind invitedomain.BindCommand, maxAccountsPerDevice int32) error {
|
||
// 三方首次登录注册必须同时写用户、默认短号、三方绑定和首个 session。
|
||
tx, err := r.db.BeginTx(ctx, nil)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer tx.Rollback()
|
||
|
||
if err := ensureRegisterDeviceQuotaTx(ctx, tx, user.AppCode, user.RegisterDeviceID, maxAccountsPerDevice); err != nil {
|
||
// 事务内再次校验设备上限,避免两个并发注册都在事务外读到旧计数后同时插入。
|
||
return err
|
||
}
|
||
if err := userstorage.InsertUserIdentity(ctx, tx, user, identity); err != nil {
|
||
// 默认短号冲突由 service 重试。
|
||
return mapAuthDuplicateError(err)
|
||
}
|
||
if _, err := invitestorage.EnsurePrimaryCodeForUser(ctx, tx, user.AppCode, user.UserID, user.CreatedAtMs); err != nil {
|
||
return err
|
||
}
|
||
if _, err := invitestorage.BindRelation(ctx, tx, inviteBind); err != nil {
|
||
return err
|
||
}
|
||
|
||
_, err = tx.ExecContext(ctx, `
|
||
INSERT INTO third_party_identities (app_code, user_id, provider, provider_subject, provider_union_id, created_at_ms, updated_at_ms)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||
`, appcode.Normalize(thirdParty.AppCode), thirdParty.UserID, thirdParty.Provider, thirdParty.ProviderSubject, shared.NullableString(thirdParty.ProviderUnionID), thirdParty.CreatedAtMs, thirdParty.UpdatedAtMs)
|
||
if err != nil {
|
||
return mapAuthDuplicateError(err)
|
||
}
|
||
|
||
if err := insertSession(ctx, tx, session); err != nil {
|
||
// session 插入失败会回滚用户和三方绑定。
|
||
return err
|
||
}
|
||
if err := userstorage.InsertUserRegisteredOutbox(ctx, tx, user); err != nil {
|
||
// 完整注册入口会传入 profile_completed=true;旧三方登录仍是 pending 用户,这里会按用户状态自动跳过。
|
||
return err
|
||
}
|
||
return tx.Commit()
|
||
}
|
||
|
||
// CompleteThirdPartyRegistration 原子补齐旧三方 pending 用户资料并写入可续期 session。
|
||
// 它只服务 provider subject 已绑定但尚未 completed 的兼容路径,避免 Flutter 完成资料时再创建第二个用户。
|
||
func (r *Repository) CompleteThirdPartyRegistration(ctx context.Context, command userdomain.CompleteOnboardingCommand, session authdomain.Session) (userdomain.User, error) {
|
||
tx, err := r.db.BeginTx(ctx, nil)
|
||
if err != nil {
|
||
return userdomain.User{}, err
|
||
}
|
||
defer tx.Rollback()
|
||
|
||
user, err := userstorage.QueryUser(ctx, tx, "WHERE user_id = ? FOR UPDATE", command.UserID)
|
||
if err == sql.ErrNoRows {
|
||
return userdomain.User{}, xerr.New(xerr.NotFound, "user not found")
|
||
}
|
||
if err != nil {
|
||
return userdomain.User{}, err
|
||
}
|
||
if !user.CanLogin() {
|
||
// pending 用户可能已被后台禁用;注册完成入口不能绕过用户状态限制创建新 session。
|
||
return userdomain.User{}, xerr.New(xerr.UserDisabled, "user is disabled")
|
||
}
|
||
if user.ProfileCompleted {
|
||
// 幂等兼容:已完成用户只补 session,不重写用户资料,避免重复提交覆盖用户后续修改。
|
||
if err := insertSession(ctx, tx, session); err != nil {
|
||
return userdomain.User{}, err
|
||
}
|
||
if err := tx.Commit(); err != nil {
|
||
return userdomain.User{}, err
|
||
}
|
||
return userstorage.New(r.db).GetUser(ctx, command.UserID)
|
||
}
|
||
|
||
inviteSnapshot := command.InviteCode
|
||
if command.InviteCode != "" {
|
||
binding, err := invitestorage.BindRelation(ctx, tx, invitedomain.BindCommand{
|
||
AppCode: command.AppCode,
|
||
InvitedUserID: command.UserID,
|
||
InvitedRegionID: command.RegionID,
|
||
InviteCode: command.InviteCode,
|
||
Source: "third_party_register",
|
||
RequestID: command.RequestID,
|
||
BoundAtMs: command.CompletedAtMs,
|
||
})
|
||
if err != nil {
|
||
return userdomain.User{}, err
|
||
}
|
||
if binding.InviteCode != "" {
|
||
inviteSnapshot = binding.InviteCode
|
||
user.InviteBinding = binding
|
||
}
|
||
}
|
||
|
||
_, err = tx.ExecContext(ctx, `
|
||
UPDATE users
|
||
SET username = ?,
|
||
avatar = ?,
|
||
gender = ?,
|
||
country = ?,
|
||
region_id = ?,
|
||
invite_code = CASE WHEN ? = '' THEN invite_code ELSE ? END,
|
||
profile_completed = ?,
|
||
profile_completed_at_ms = ?,
|
||
onboarding_status = ?,
|
||
updated_at_ms = ?
|
||
WHERE user_id = ?
|
||
AND app_code = ?
|
||
`, command.Username, command.Avatar, command.Gender, command.Country, shared.NullableRegionID(command.RegionID), inviteSnapshot, inviteSnapshot, true, command.CompletedAtMs, string(userdomain.OnboardingStatusCompleted), command.CompletedAtMs, command.UserID, appcode.Normalize(command.AppCode))
|
||
if err != nil {
|
||
return userdomain.User{}, err
|
||
}
|
||
user.Username = command.Username
|
||
user.Avatar = command.Avatar
|
||
user.Gender = command.Gender
|
||
user.Country = command.Country
|
||
user.RegionID = command.RegionID
|
||
if inviteSnapshot != "" {
|
||
user.InviteCode = inviteSnapshot
|
||
}
|
||
user.ProfileCompleted = true
|
||
user.ProfileCompletedAtMs = command.CompletedAtMs
|
||
user.OnboardingStatus = userdomain.OnboardingStatusCompleted
|
||
user.UpdatedAtMs = command.CompletedAtMs
|
||
if err := insertSession(ctx, tx, session); err != nil {
|
||
// session 和资料完成必须同事务提交,否则 Flutter 会拿不到可落 AppAuthSession 的 refresh token。
|
||
return userdomain.User{}, err
|
||
}
|
||
if err := userstorage.InsertUserRegisteredOutbox(ctx, tx, user); err != nil {
|
||
// UserRegistered 是统计和注册完成事实,必须和首次资料完成一起提交。
|
||
return userdomain.User{}, err
|
||
}
|
||
if err := tx.Commit(); err != nil {
|
||
return userdomain.User{}, err
|
||
}
|
||
|
||
updated, err := userstorage.New(r.db).GetUser(ctx, command.UserID)
|
||
if err != nil {
|
||
return userdomain.User{}, err
|
||
}
|
||
updated.InviteBinding = user.InviteBinding
|
||
return updated, nil
|
||
}
|
||
|
||
// RecordLoginAudit 写登录审计;调用方不会让审计失败影响主链路。
|