package repository import ( "context" "errors" "time" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" ) // AuthRepo persists account credentials and sessions. type AuthRepo struct { Pool *pgxpool.Pool } // AccountRow is a registered user snapshot. type AccountRow struct { ID uuid.UUID Phone string PasswordHash string Nickname string AvatarURL string Status string WxOpenID string WxUnionID string JavaPlatformUserID int64 } // GetByPhone loads a registered user by phone. func (r *AuthRepo) GetByPhone(ctx context.Context, phone string) (*AccountRow, error) { row := &AccountRow{} err := r.Pool.QueryRow(ctx, ` SELECT id, phone, password_hash, COALESCE(nickname,''), COALESCE(avatar_url,''), status, COALESCE(wx_openid,''), COALESCE(wx_unionid,''), COALESCE(java_platform_user_id,0) FROM users WHERE phone=$1 AND deleted_at IS NULL`, phone, ).Scan(&row.ID, &row.Phone, &row.PasswordHash, &row.Nickname, &row.AvatarURL, &row.Status, &row.WxOpenID, &row.WxUnionID, &row.JavaPlatformUserID) if errors.Is(err, pgx.ErrNoRows) { return nil, err } if err != nil { return nil, err } return row, nil } // GetAccount loads account fields for a user id. func (r *AuthRepo) GetAccount(ctx context.Context, userID uuid.UUID) (*AccountRow, error) { row := &AccountRow{} var phone, hash *string err := r.Pool.QueryRow(ctx, ` SELECT id, phone, password_hash, COALESCE(nickname,''), COALESCE(avatar_url,''), status, COALESCE(wx_openid,''), COALESCE(wx_unionid,''), COALESCE(java_platform_user_id,0) FROM users WHERE id=$1 AND deleted_at IS NULL`, userID, ).Scan(&row.ID, &phone, &hash, &row.Nickname, &row.AvatarURL, &row.Status, &row.WxOpenID, &row.WxUnionID, &row.JavaPlatformUserID) if err != nil { return nil, err } if phone != nil { row.Phone = *phone } if hash != nil { row.PasswordHash = *hash } return row, nil } // RegisterOnUser upgrades an anonymous user with phone credentials. func (r *AuthRepo) RegisterOnUser(ctx context.Context, userID uuid.UUID, phone, hash, nickname string) error { tag, err := r.Pool.Exec(ctx, ` UPDATE users SET phone=$2, password_hash=$3, nickname=NULLIF($4,''), updated_at=now() WHERE id=$1 AND deleted_at IS NULL AND phone IS NULL`, userID, phone, hash, nickname, ) if err != nil { return err } if tag.RowsAffected() == 0 { return errString("register conflict") } return nil } // CreateUserWithPhone inserts a new registered user. func (r *AuthRepo) CreateUserWithPhone(ctx context.Context, phone, hash, nickname string) (uuid.UUID, error) { var id uuid.UUID err := r.Pool.QueryRow(ctx, ` INSERT INTO users(phone, password_hash, nickname) VALUES ($1,$2,NULLIF($3,'')) RETURNING id`, phone, hash, nickname, ).Scan(&id) return id, err } // UpdateNickname sets users.nickname (non-empty). func (r *AuthRepo) UpdateNickname(ctx context.Context, userID uuid.UUID, nickname string) error { _, err := r.Pool.Exec(ctx, ` UPDATE users SET nickname=$2, updated_at=now() WHERE id=$1 AND deleted_at IS NULL`, userID, nickname) return err } // UpdateAvatarURL sets users.avatar_url. func (r *AuthRepo) UpdateAvatarURL(ctx context.Context, userID uuid.UUID, url string) error { _, err := r.Pool.Exec(ctx, ` UPDATE users SET avatar_url=$2, updated_at=now() WHERE id=$1 AND deleted_at IS NULL`, userID, url) return err } // TouchPassword updates stored password hash (open-login record). func (r *AuthRepo) TouchPassword(ctx context.Context, userID uuid.UUID, hash string) error { _, err := r.Pool.Exec(ctx, ` UPDATE users SET password_hash=$2, updated_at=now() WHERE id=$1 AND deleted_at IS NULL`, userID, hash) return err } // BindDevice sets device_identities.user_id to account. func (r *AuthRepo) BindDevice(ctx context.Context, deviceKey string, userID uuid.UUID) error { _, err := r.Pool.Exec(ctx, ` INSERT INTO device_identities(device_key, user_id) VALUES ($1,$2) ON CONFLICT (device_key) DO UPDATE SET user_id=$2, updated_at=now(), deleted_at=NULL`, deviceKey, userID, ) return err } // RebindDeviceAnonymous creates a fresh anonymous user and points the device at it. // Used on logout so the device is no longer tied to the registered account (Spec R5). func (r *AuthRepo) RebindDeviceAnonymous(ctx context.Context, deviceKey string) error { if deviceKey == "" { return nil } tx, err := r.Pool.Begin(ctx) if err != nil { return err } defer tx.Rollback(ctx) var uid uuid.UUID if err := tx.QueryRow(ctx, `INSERT INTO users DEFAULT VALUES RETURNING id`).Scan(&uid); err != nil { return err } if _, err := tx.Exec(ctx, ` INSERT INTO device_identities(device_key, user_id) VALUES ($1,$2) ON CONFLICT (device_key) DO UPDATE SET user_id=$2, updated_at=now(), deleted_at=NULL`, deviceKey, uid, ); err != nil { return err } return tx.Commit(ctx) } // CreateSession inserts a session token. func (r *AuthRepo) CreateSession(ctx context.Context, userID uuid.UUID, token string, expires time.Time) error { _, err := r.Pool.Exec(ctx, ` INSERT INTO user_sessions(user_id, token, expires_at) VALUES ($1,$2,$3)`, userID, token, expires, ) return err } // UserIDByToken resolves a live session. func (r *AuthRepo) UserIDByToken(ctx context.Context, token string) (uuid.UUID, error) { var id uuid.UUID err := r.Pool.QueryRow(ctx, ` SELECT user_id FROM user_sessions WHERE token=$1 AND revoked_at IS NULL AND expires_at > now()`, token, ).Scan(&id) return id, err } // RevokeSession marks token revoked. func (r *AuthRepo) RevokeSession(ctx context.Context, token string) error { _, err := r.Pool.Exec(ctx, ` UPDATE user_sessions SET revoked_at=now() WHERE token=$1 AND revoked_at IS NULL`, token) return err } // GetByWxOpenid loads a user by WeChat openid. func (r *AuthRepo) GetByWxOpenid(ctx context.Context, openid string) (*AccountRow, error) { row := &AccountRow{} err := r.Pool.QueryRow(ctx, ` SELECT id, phone, password_hash, COALESCE(nickname,''), COALESCE(avatar_url,''), status, COALESCE(wx_openid,''), COALESCE(wx_unionid,''), COALESCE(java_platform_user_id,0) FROM users WHERE wx_openid=$1 AND deleted_at IS NULL`, openid, ).Scan(&row.ID, &row.Phone, &row.PasswordHash, &row.Nickname, &row.AvatarURL, &row.Status, &row.WxOpenID, &row.WxUnionID, &row.JavaPlatformUserID) if errors.Is(err, pgx.ErrNoRows) { return nil, err } if err != nil { return nil, err } return row, nil } // BindWeChat writes openid/unionid (and optional phone) onto a user. func (r *AuthRepo) BindWeChat(ctx context.Context, userID uuid.UUID, openid, unionID, phone string) error { _, err := r.Pool.Exec(ctx, ` UPDATE users SET wx_openid=$2, wx_unionid=COALESCE(NULLIF($3,''), wx_unionid), phone=COALESCE(NULLIF($4,''), phone), updated_at=now() WHERE id=$1 AND deleted_at IS NULL`, userID, openid, unionID, phone, ) return err } // SetJavaPlatformUserID stores the Java t_platform_user.id. func (r *AuthRepo) SetJavaPlatformUserID(ctx context.Context, userID uuid.UUID, javaID int64) error { _, err := r.Pool.Exec(ctx, ` UPDATE users SET java_platform_user_id=$2, updated_at=now() WHERE id=$1 AND deleted_at IS NULL`, userID, javaID) return err } // SetSessionJavaToken stores the Java Redis token on a Go session. func (r *AuthRepo) SetSessionJavaToken(ctx context.Context, goToken, javaToken string) error { _, err := r.Pool.Exec(ctx, ` UPDATE user_sessions SET java_access_token=$2 WHERE token=$1 AND revoked_at IS NULL`, goToken, javaToken) return err } // JavaTokenByGoToken returns the cached Java token for a live Go session. func (r *AuthRepo) JavaTokenByGoToken(ctx context.Context, goToken string) (string, error) { var tok *string err := r.Pool.QueryRow(ctx, ` SELECT java_access_token FROM user_sessions WHERE token=$1 AND revoked_at IS NULL AND expires_at > now()`, goToken, ).Scan(&tok) if err != nil { return "", err } if tok == nil { return "", nil } return *tok, nil } // CreateUserWithWeChat inserts a registered user bound to WeChat. func (r *AuthRepo) CreateUserWithWeChat(ctx context.Context, phone, hash, nickname, openid, unionID string) (uuid.UUID, error) { var id uuid.UUID err := r.Pool.QueryRow(ctx, ` INSERT INTO users(phone, password_hash, nickname, wx_openid, wx_unionid) VALUES ($1,$2,NULLIF($3,''),$4,NULLIF($5,'')) RETURNING id`, phone, hash, nickname, openid, unionID, ).Scan(&id) return id, err } // IsRegistered reports whether user has phone. func (r *AuthRepo) IsRegistered(ctx context.Context, userID uuid.UUID) (bool, error) { var ok bool err := r.Pool.QueryRow(ctx, ` SELECT EXISTS( SELECT 1 FROM users WHERE id=$1 AND phone IS NOT NULL AND deleted_at IS NULL )`, userID).Scan(&ok) return ok, err }