feat(user): implement OTP login, user creation, and profile management

- Added SendOTP method for simulating OTP sending.
- Implemented LoginWithOTP method for user login/registration via OTP, including user creation if not found.
- Added Me method to retrieve current user information.
- Implemented Update method for updating user profile details.
- Added RealName method for real-name verification.
- Implemented GetNotifications method to fetch user notifications.
- Created user_test.go for comprehensive unit tests covering login, profile retrieval, updates, real-name verification, and notifications.
- Updated database models to use appropriate consts for fields like gender, status, and roles.
This commit is contained in:
2025-12-29 10:55:13 +08:00
parent bc2064639f
commit b78f1e1c84
17 changed files with 497 additions and 120 deletions

View File

@@ -113,4 +113,4 @@ func (c *Content) LikeComment(ctx fiber.Ctx, id string) error {
// @Success 200 {array} dto.Topic
func (c *Content) ListTopics(ctx fiber.Ctx) ([]dto.Topic, error) {
return services.Content.ListTopics(ctx.Context())
}
}

View File

@@ -61,4 +61,4 @@ func (s *content) RemoveLike(ctx context.Context, contentId string) error {
func (s *content) ListTopics(ctx context.Context) ([]content_dto.Topic, error) {
return []content_dto.Topic{}, nil
}
}

View File

@@ -1,6 +1,8 @@
package services
import (
"quyun/v2/providers/jwt"
"go.ipao.vip/atom"
"go.ipao.vip/atom/container"
"go.ipao.vip/atom/contracts"
@@ -81,8 +83,12 @@ func Provide(opts ...opt.Option) error {
}); err != nil {
return err
}
if err := container.Container.Provide(func() (*user, error) {
obj := &user{}
if err := container.Container.Provide(func(
jwt *jwt.JWT,
) (*user, error) {
obj := &user{
jwt: jwt,
}
return obj, nil
}); err != nil {

View File

@@ -2,34 +2,190 @@ package services
import (
"context"
"errors"
"time"
"quyun/v2/app/errorx"
auth_dto "quyun/v2/app/http/v1/dto"
user_dto "quyun/v2/app/http/v1/dto"
"quyun/v2/database/models"
"quyun/v2/pkg/consts"
"quyun/v2/providers/jwt"
"github.com/spf13/cast"
"go.ipao.vip/gen/types"
"gorm.io/gorm"
)
// @provider
type user struct{}
type user struct {
jwt *jwt.JWT
}
// SendOTP 发送验证码
// 当前仅模拟发送,实际应对接短信服务
func (s *user) SendOTP(ctx context.Context, phone string) error {
// TODO: 对接短信服务
// 模拟发送成功
return nil
}
// LoginWithOTP 手机号验证码登录/注册
func (s *user) LoginWithOTP(ctx context.Context, phone, otp string) (*auth_dto.LoginResponse, error) {
return &auth_dto.LoginResponse{}, nil
// 1. 校验验证码 (模拟:固定 123456)
if otp != "123456" {
return nil, errorx.ErrInvalidCredentials.WithMsg("验证码错误")
}
// 2. 查询或创建用户
tbl, query := models.UserQuery.QueryContext(ctx)
u, err := query.Where(tbl.Phone.Eq(phone)).First()
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
// 创建新用户
u = &models.User{
Phone: phone,
Username: phone, // 默认用户名 = 手机号
Password: "", // 免密登录
Nickname: "User_" + phone[len(phone)-4:],
Status: string(consts.UserStatusVerified), // 默认已审核?需确认业务逻辑
Roles: types.Array[consts.Role]{consts.RoleUser},
}
if err := query.Create(u); err != nil {
return nil, errorx.ErrDatabaseError.WithMsg("创建用户失败")
}
} else {
return nil, errorx.ErrDatabaseError.WithMsg("查询用户失败")
}
}
// 3. 检查状态
if u.Status == string(consts.UserStatusBanned) {
return nil, errorx.ErrAccountDisabled
}
// 4. 生成 Token
token, err := s.jwt.CreateToken(s.jwt.CreateClaims(jwt.BaseClaims{
UserID: u.ID,
// TenantID: 0, // 初始登录无租户上下文
}))
if err != nil {
return nil, errorx.ErrInternalError.WithMsg("生成令牌失败")
}
return &auth_dto.LoginResponse{
Token: token,
User: s.toAuthUserDTO(u),
}, nil
}
// Me 获取当前用户信息
func (s *user) Me(ctx context.Context) (*auth_dto.User, error) {
return &auth_dto.User{}, nil
userID := ctx.Value(consts.CtxKeyUser)
if userID == nil {
return nil, errorx.ErrUnauthorized
}
uid := cast.ToInt64(userID)
tbl, query := models.UserQuery.QueryContext(ctx)
u, err := query.Where(tbl.ID.Eq(uid)).First()
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errorx.ErrRecordNotFound
}
return nil, errorx.ErrDatabaseError
}
return s.toAuthUserDTO(u), nil
}
// Update 更新用户信息
func (s *user) Update(ctx context.Context, form *user_dto.UserUpdate) error {
userID := ctx.Value(consts.CtxKeyUser)
if userID == nil {
return errorx.ErrUnauthorized
}
uid := cast.ToInt64(userID)
tbl, query := models.UserQuery.QueryContext(ctx)
_, err := query.Where(tbl.ID.Eq(uid)).Updates(&models.User{
Nickname: form.Nickname,
Avatar: form.Avatar,
Gender: form.Gender,
Bio: form.Bio,
// Birthday: form.Birthday, // 类型转换需处理
})
if err != nil {
return errorx.ErrDatabaseError
}
return nil
}
// RealName 实名认证
func (s *user) RealName(ctx context.Context, form *user_dto.RealNameForm) error {
userID := ctx.Value(consts.CtxKeyUser)
if userID == nil {
return errorx.ErrUnauthorized
}
uid := cast.ToInt64(userID)
// TODO: 调用实名认证接口校验
tbl, query := models.UserQuery.QueryContext(ctx)
_, err := query.Where(tbl.ID.Eq(uid)).Updates(&models.User{
IsRealNameVerified: true,
// RealName: form.Realname, // 需在 user 表添加字段? payout_accounts 有 realname
})
if err != nil {
return errorx.ErrDatabaseError
}
return nil
}
// GetNotifications 获取通知
func (s *user) GetNotifications(ctx context.Context, typeArg string) ([]user_dto.Notification, error) {
return []user_dto.Notification{}, nil
userID := ctx.Value(consts.CtxKeyUser)
if userID == nil {
return nil, errorx.ErrUnauthorized
}
uid := cast.ToInt64(userID)
tbl, query := models.NotificationQuery.QueryContext(ctx)
query = query.Where(tbl.UserID.Eq(uid))
if typeArg != "" && typeArg != "all" {
query = query.Where(tbl.Type.Eq(typeArg))
}
list, err := query.Order(tbl.CreatedAt.Desc()).Find()
if err != nil {
return nil, errorx.ErrDatabaseError
}
result := make([]user_dto.Notification, len(list))
for i, v := range list {
result[i] = user_dto.Notification{
ID: cast.ToString(v.ID),
Type: v.Type,
Title: v.Title,
Content: v.Content,
Read: v.IsRead,
Time: v.CreatedAt.Format(time.RFC3339),
}
}
return result, nil
}
func (s *user) toAuthUserDTO(u *models.User) *auth_dto.User {
return &auth_dto.User{
ID: cast.ToString(u.ID),
Phone: u.Phone,
Nickname: u.Nickname,
Avatar: u.Avatar,
Gender: consts.Gender(u.Gender),
Bio: u.Bio,
Balance: float64(u.Balance) / 100.0,
Points: u.Points,
IsRealNameVerified: u.IsRealNameVerified,
}
}

View File

@@ -0,0 +1,185 @@
package services
import (
"context"
"database/sql"
"testing"
"quyun/v2/app/commands/testx"
user_dto "quyun/v2/app/http/v1/dto"
"quyun/v2/database"
"quyun/v2/database/models"
"quyun/v2/pkg/consts"
. "github.com/smartystreets/goconvey/convey"
"github.com/spf13/cast"
"github.com/stretchr/testify/suite"
"go.ipao.vip/atom/contracts"
"go.uber.org/dig"
)
type UserTestSuiteInjectParams struct {
dig.In
DB *sql.DB
Initials []contracts.Initial `group:"initials"`
}
type UserTestSuite struct {
suite.Suite
UserTestSuiteInjectParams
}
func Test_User(t *testing.T) {
providers := testx.Default().With(Provide)
testx.Serve(providers, t, func(p UserTestSuiteInjectParams) {
suite.Run(t, &UserTestSuite{UserTestSuiteInjectParams: p})
})
}
func (s *UserTestSuite) Test_LoginWithOTP() {
Convey("LoginWithOTP", s.T(), func() {
ctx := s.T().Context()
database.Truncate(ctx, s.DB, models.TableNameUser)
Convey("should create user and login success with correct OTP", func() {
phone := "13800138000"
resp, err := User.LoginWithOTP(ctx, phone, "123456")
So(err, ShouldBeNil)
So(resp, ShouldNotBeNil)
So(resp.Token, ShouldNotBeEmpty)
So(resp.User.Phone, ShouldEqual, phone)
So(resp.User.Nickname, ShouldStartWith, "User_")
})
Convey("should login existing user", func() {
phone := "13800138001"
// Pre-create user
_, err := User.LoginWithOTP(ctx, phone, "123456")
So(err, ShouldBeNil)
// Login again
resp, err := User.LoginWithOTP(ctx, phone, "123456")
So(err, ShouldBeNil)
So(resp.User.Phone, ShouldEqual, phone)
})
Convey("should fail with incorrect OTP", func() {
resp, err := User.LoginWithOTP(ctx, "13800138002", "000000")
So(err, ShouldNotBeNil)
So(resp, ShouldBeNil)
})
})
}
func (s *UserTestSuite) Test_Me() {
Convey("Me", s.T(), func() {
ctx := s.T().Context()
database.Truncate(ctx, s.DB, models.TableNameUser)
// Create user
phone := "13800138003"
resp, _ := User.LoginWithOTP(ctx, phone, "123456")
userID := cast.ToInt64(resp.User.ID)
Convey("should return user profile", func() {
// Mock context with user ID
ctx = context.WithValue(ctx, consts.CtxKeyUser, userID)
user, err := User.Me(ctx)
So(err, ShouldBeNil)
So(user.ID, ShouldEqual, resp.User.ID)
So(user.Phone, ShouldEqual, phone)
})
Convey("should fail without auth", func() {
// No user ID in context
user, err := User.Me(ctx)
So(err, ShouldNotBeNil)
So(user, ShouldBeNil)
})
})
}
func (s *UserTestSuite) Test_Update() {
Convey("Update", s.T(), func() {
ctx := s.T().Context()
database.Truncate(ctx, s.DB, models.TableNameUser)
phone := "13800138004"
resp, _ := User.LoginWithOTP(ctx, phone, "123456")
userID := cast.ToInt64(resp.User.ID)
ctx = context.WithValue(ctx, consts.CtxKeyUser, userID)
Convey("should update nickname and bio", func() {
form := &user_dto.UserUpdate{
Nickname: "NewName",
Bio: "New Bio",
Gender: consts.GenderMale,
}
err := User.Update(ctx, form)
So(err, ShouldBeNil)
// Verify
u, _ := User.Me(ctx)
So(u.Nickname, ShouldEqual, "NewName")
So(u.Bio, ShouldEqual, "New Bio")
So(u.Gender, ShouldEqual, consts.GenderMale)
})
})
}
func (s *UserTestSuite) Test_RealName() {
Convey("RealName", s.T(), func() {
ctx := s.T().Context()
database.Truncate(ctx, s.DB, models.TableNameUser)
phone := "13800138005"
resp, _ := User.LoginWithOTP(ctx, phone, "123456")
userID := cast.ToInt64(resp.User.ID)
ctx = context.WithValue(ctx, consts.CtxKeyUser, userID)
Convey("should update realname status", func() {
form := &user_dto.RealNameForm{
Realname: "张三",
IDCard: "123456789012345678",
}
err := User.RealName(ctx, form)
So(err, ShouldBeNil)
// Verify
u, _ := User.Me(ctx)
So(u.IsRealNameVerified, ShouldBeTrue)
})
})
}
func (s *UserTestSuite) Test_GetNotifications() {
Convey("GetNotifications", s.T(), func() {
ctx := s.T().Context()
database.Truncate(ctx, s.DB, models.TableNameUser, models.TableNameNotification)
phone := "13800138006"
resp, _ := User.LoginWithOTP(ctx, phone, "123456")
userID := cast.ToInt64(resp.User.ID)
ctx = context.WithValue(ctx, consts.CtxKeyUser, userID)
// Mock notifications
_ = models.Q.Notification.WithContext(ctx).Create(&models.Notification{
UserID: userID,
Type: "system",
Title: "Welcome",
Content: "Hello World",
IsRead: false,
})
Convey("should return notifications", func() {
list, err := User.GetNotifications(ctx, "all")
So(err, ShouldBeNil)
So(len(list), ShouldEqual, 1)
So(list[0].Title, ShouldEqual, "Welcome")
So(list[0].Type, ShouldEqual, "system")
})
})
}