diff --git a/backend/base.api b/backend/base.api index a5760b6..7e73022 100644 --- a/backend/base.api +++ b/backend/base.api @@ -29,13 +29,13 @@ type ( // 注册请求 RegisterRequest { Username string `json:"username" validate:"required,min=3,max=32"` // 用户名 - Email string `json:"email" validate:"required,email"` // 邮箱 Password string `json:"password" validate:"required,min=6,max=32"` // 密码 - Phone string `json:"phone,optional"` // 手机号 + Phone string `json:"phone" validate:"required"` // 手机号(必填) + Email string `json:"email,optional"` // 邮箱(可选) } // 登录请求 LoginRequest { - Email string `json:"email" validate:"required,email"` // 邮箱 + Account string `json:"account" validate:"required"` // 手机号或用户名 Password string `json:"password" validate:"required,min=6,max=32"` // 密码 } // 刷新Token请求 @@ -71,7 +71,7 @@ service base-api { @server ( prefix: /api/v1 group: user - middleware: Cors,Log,Auth + middleware: Cors,Log,Auth,Authz ) service base-api { // ========== 用户管理接口 ========== @@ -104,7 +104,7 @@ service base-api { @server ( prefix: /api/v1 group: profile - middleware: Cors,Log,Auth + middleware: Cors,Log,Auth,Authz ) service base-api { // ========== 个人中心接口 ========== @@ -127,7 +127,7 @@ service base-api { @server ( prefix: /api/v1 group: dashboard - middleware: Cors,Log,Auth + middleware: Cors,Log,Auth,Authz ) service base-api { // ========== 仪表盘接口 ========== diff --git a/backend/internal/logic/auth/loginlogic.go b/backend/internal/logic/auth/loginlogic.go index eee513d..d529956 100644 --- a/backend/internal/logic/auth/loginlogic.go +++ b/backend/internal/logic/auth/loginlogic.go @@ -4,6 +4,7 @@ import ( "context" "crypto/md5" "fmt" + "regexp" "github.com/youruser/base/internal/svc" "github.com/youruser/base/internal/types" @@ -13,13 +14,14 @@ import ( "github.com/zeromicro/go-zero/core/logx" ) +var phoneRegex = regexp.MustCompile(`^\d{11}$`) + type LoginLogic struct { logx.Logger ctx context.Context svcCtx *svc.ServiceContext } -// 用户登录 func NewLoginLogic(ctx context.Context, svcCtx *svc.ServiceContext) *LoginLogic { return &LoginLogic{ Logger: logx.WithContext(ctx), @@ -29,8 +31,13 @@ func NewLoginLogic(ctx context.Context, svcCtx *svc.ServiceContext) *LoginLogic } func (l *LoginLogic) Login(req *types.LoginRequest) (resp *types.LoginResponse, err error) { - // 查询用户 - user, err := model.FindOneByEmail(l.ctx, l.svcCtx.DB, req.Email) + var user *model.User + if phoneRegex.MatchString(req.Account) { + user, err = model.FindOneByPhone(l.ctx, l.svcCtx.DB, req.Account) + } else { + user, err = model.FindOneByUsername(l.ctx, l.svcCtx.DB, req.Account) + } + if err != nil { if err == model.ErrNotFound { return &types.LoginResponse{ @@ -42,7 +49,14 @@ func (l *LoginLogic) Login(req *types.LoginRequest) (resp *types.LoginResponse, return nil, fmt.Errorf("查询用户失败: %v", err) } - // 加密输入的密码并与数据库密码对比 + if user.UserType == "casdoor" { + return &types.LoginResponse{ + Code: 400, + Message: "该账号已绑定 SSO,请使用 SSO 方式登录", + Success: false, + }, nil + } + inputPassword := fmt.Sprintf("%x", md5.Sum([]byte(req.Password))) if user.Password != inputPassword { return &types.LoginResponse{ @@ -52,8 +66,7 @@ func (l *LoginLogic) Login(req *types.LoginRequest) (resp *types.LoginResponse, }, nil } - // 生成 Token - token, err := jwt.GenerateToken(user.Id, user.Username, user.Email) + token, err := jwt.GenerateToken(user.Id, user.Username, user.Role) if err != nil { return nil, fmt.Errorf("生成Token失败: %v", err) } diff --git a/backend/internal/logic/auth/refreshtokenlogic.go b/backend/internal/logic/auth/refreshtokenlogic.go index 4bd570d..1c0ad92 100644 --- a/backend/internal/logic/auth/refreshtokenlogic.go +++ b/backend/internal/logic/auth/refreshtokenlogic.go @@ -52,7 +52,7 @@ func (l *RefreshTokenLogic) RefreshToken(req *types.RefreshTokenRequest) (resp * } // 生成新 Token - newToken, err := jwt.GenerateToken(user.Id, user.Username, user.Email) + newToken, err := jwt.GenerateToken(user.Id, user.Username, user.Role) if err != nil { return nil, fmt.Errorf("生成Token失败: %v", err) } diff --git a/backend/internal/logic/auth/registerlogic.go b/backend/internal/logic/auth/registerlogic.go index b999fc0..681ec40 100644 --- a/backend/internal/logic/auth/registerlogic.go +++ b/backend/internal/logic/auth/registerlogic.go @@ -18,7 +18,6 @@ type RegisterLogic struct { svcCtx *svc.ServiceContext } -// 用户注册 func NewRegisterLogic(ctx context.Context, svcCtx *svc.ServiceContext) *RegisterLogic { return &RegisterLogic{ Logger: logx.WithContext(ctx), @@ -28,50 +27,55 @@ func NewRegisterLogic(ctx context.Context, svcCtx *svc.ServiceContext) *Register } func (l *RegisterLogic) Register(req *types.RegisterRequest) (resp *types.UserInfo, err error) { - // 检查邮箱是否已存在 - _, err = model.FindOneByEmail(l.ctx, l.svcCtx.DB, req.Email) + _, err = model.FindOneByUsername(l.ctx, l.svcCtx.DB, req.Username) if err == nil { - return nil, fmt.Errorf("邮箱已被注册") + return nil, fmt.Errorf("用户名已被注册") } if err != model.ErrNotFound { - return nil, fmt.Errorf("检查邮箱失败: %v", err) + return nil, fmt.Errorf("检查用户名失败: %v", err) + } + + _, err = model.FindOneByPhone(l.ctx, l.svcCtx.DB, req.Phone) + if err == nil { + return nil, fmt.Errorf("手机号已被注册") + } + if err != model.ErrNotFound { + return nil, fmt.Errorf("检查手机号失败: %v", err) } - // 创建用户模型 user := &model.User{ Username: req.Username, Email: req.Email, - Password: fmt.Sprintf("%x", md5.Sum([]byte(req.Password))), // 密码加密 + Password: fmt.Sprintf("%x", md5.Sum([]byte(req.Password))), Phone: req.Phone, - Status: 1, // 默认正常状态 + Role: model.RoleUser, + Source: model.SourceRegister, + Status: 1, } - // 插入数据库 id, err := model.Insert(l.ctx, l.svcCtx.DB, user) if err != nil { return nil, fmt.Errorf("创建用户失败: %v", err) } - // 查询创建的用户 user, err = model.FindOne(l.ctx, l.svcCtx.DB, id) if err != nil { return nil, fmt.Errorf("查询用户失败: %v", err) } - // 返回用户信息(不返回密码) resp = &types.UserInfo{ Id: user.Id, Username: user.Username, Email: user.Email, Phone: user.Phone, + Role: user.Role, + Source: user.Source, + Remark: user.Remark, Status: int(user.Status), CreatedAt: user.CreatedAt.Format("2006-01-02 15:04:05"), UpdatedAt: user.UpdatedAt.Format("2006-01-02 15:04:05"), } - // 返回 Token 在响应头中(通过中间件处理) - // 临时方案:将 token 放入响应 Data 中 l.Infof("注册成功,userId=%d", user.Id) - return resp, nil } diff --git a/backend/internal/logic/auth/ssologic.go b/backend/internal/logic/auth/ssologic.go new file mode 100644 index 0000000..a5a96a9 --- /dev/null +++ b/backend/internal/logic/auth/ssologic.go @@ -0,0 +1,304 @@ +package auth + +import ( + "context" + "crypto/rand" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/youruser/base/internal/svc" + "github.com/youruser/base/internal/util/jwt" + "github.com/youruser/base/model" + + "github.com/zeromicro/go-zero/core/logx" +) + +// casdoorHttpClient 用于与 Casdoor 通信的 HTTP 客户端(带超时) +var casdoorHttpClient = &http.Client{Timeout: 10 * time.Second} + +type SSOLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewSSOLogic(ctx context.Context, svcCtx *svc.ServiceContext) *SSOLogic { + return &SSOLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +// GetLoginUrl 生成 Casdoor SSO 登录链接 +func (l *SSOLogic) GetLoginUrl() (map[string]string, error) { + c := l.svcCtx.Config.Casdoor + + state, err := generateState() + if err != nil { + return nil, fmt.Errorf("生成 state 失败: %v", err) + } + + loginUrl := fmt.Sprintf("%s/login/oauth/authorize?client_id=%s&response_type=code&redirect_uri=%s&scope=read&state=%s", + c.Endpoint, + url.QueryEscape(c.ClientId), + url.QueryEscape(c.RedirectUrl), + url.QueryEscape(state), + ) + + return map[string]string{ + "login_url": loginUrl, + }, nil +} + +// casdoorTokenResponse Casdoor token 响应 +type casdoorTokenResponse struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in"` + Scope string `json:"scope"` +} + +// casdoorUserInfo Casdoor 用户信息 +type casdoorUserInfo struct { + Sub string `json:"sub"` + Name string `json:"name"` + PreferredUsername string `json:"preferred_username"` + Email string `json:"email"` + Phone string `json:"phone"` + Avatar string `json:"avatar"` +} + +// HandleCallback 处理 SSO 回调 +func (l *SSOLogic) HandleCallback(code, state string) (string, error) { + if code == "" { + return "", fmt.Errorf("缺少授权码") + } + + c := l.svcCtx.Config.Casdoor + + // 1. 用 code 换取 access_token + accessToken, err := l.exchangeToken(code) + if err != nil { + l.Errorf("SSO token 交换失败: %v", err) + return "", fmt.Errorf("token 交换失败: %v", err) + } + + // 2. 获取用户信息 + userInfo, err := l.getUserInfo(accessToken) + if err != nil { + l.Errorf("SSO 获取用户信息失败: %v", err) + return "", fmt.Errorf("获取用户信息失败: %v", err) + } + + // 3. 查找或创建本地用户 + casdoorId := userInfo.Sub + if casdoorId == "" { + casdoorId = userInfo.Name + } + + // 从 Casdoor 信息中提取用户名和邮箱 + username := userInfo.PreferredUsername + if username == "" { + username = userInfo.Name + } + email := userInfo.Email + if email == "" { + email = username + "@sso.local" + } + + localUser, err := model.FindOneByCasdoorId(l.ctx, l.svcCtx.DB, casdoorId) + if err != nil { + if err == model.ErrNotFound { + // 用户不存在,尝试通过邮箱关联已有本地用户 + existingUser, findErr := model.FindOneByEmail(l.ctx, l.svcCtx.DB, email) + if findErr == nil { + existingUser.CasdoorId = casdoorId + existingUser.UserType = "casdoor" + if updateErr := model.Update(l.ctx, l.svcCtx.DB, existingUser); updateErr != nil { + return "", fmt.Errorf("关联用户失败: %v", updateErr) + } + localUser = existingUser + l.Infof("SSO 关联已有用户: userId=%d, casdoorId=%s", existingUser.Id, casdoorId) + } else { + // 创建新用户 + newUser := &model.User{ + Username: username, + Email: email, + Password: "SSO_NO_PASSWORD", // SSO 用户不使用密码登录 + Phone: userInfo.Phone, + CasdoorId: casdoorId, + UserType: "casdoor", + Role: model.RoleUser, + Source: model.SourceCasdoor, + Status: 1, + } + + _, insertErr := model.Insert(l.ctx, l.svcCtx.DB, newUser) + if insertErr != nil { + l.Errorf("SSO 创建用户失败: %v", insertErr) + return "", fmt.Errorf("创建用户失败: %v", insertErr) + } + + localUser = newUser + l.Infof("SSO 新用户创建成功: username=%s, casdoorId=%s", username, casdoorId) + } + } else { + return "", fmt.Errorf("查询用户失败: %v", err) + } + } else { + // 已有用户,同步更新 Casdoor 端的最新信息 + updated := false + if username != "" && localUser.Username != username { + localUser.Username = username + updated = true + } + if email != "" && localUser.Email != email { + localUser.Email = email + updated = true + } + if userInfo.Phone != "" && localUser.Phone != userInfo.Phone { + localUser.Phone = userInfo.Phone + updated = true + } + if updated { + if updateErr := model.Update(l.ctx, l.svcCtx.DB, localUser); updateErr != nil { + l.Errorf("SSO 同步用户信息失败: %v", updateErr) + } + } + } + + // 4. 生成本地 JWT Token + token, err := jwt.GenerateToken(localUser.Id, localUser.Username, localUser.Role) + if err != nil { + return "", fmt.Errorf("生成 Token 失败: %v", err) + } + + l.Infof("SSO 登录成功: userId=%d, username=%s", localUser.Id, localUser.Username) + + // 5. 构建前端回调 URL + redirectUrl := fmt.Sprintf("%s/sso/callback?token=%s", + c.FrontendUrl, + url.QueryEscape(token), + ) + + return redirectUrl, nil +} + +// exchangeToken 用授权码换取 access_token +func (l *SSOLogic) exchangeToken(code string) (string, error) { + c := l.svcCtx.Config.Casdoor + + tokenUrl := fmt.Sprintf("%s/api/login/oauth/access_token", c.Endpoint) + + data := url.Values{} + data.Set("grant_type", "authorization_code") + data.Set("client_id", c.ClientId) + data.Set("client_secret", c.ClientSecret) + data.Set("code", code) + data.Set("redirect_uri", c.RedirectUrl) + + req, err := http.NewRequestWithContext(l.ctx, http.MethodPost, tokenUrl, + strings.NewReader(data.Encode())) + if err != nil { + return "", fmt.Errorf("创建请求失败: %v", err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := casdoorHttpClient.Do(req) + if err != nil { + return "", fmt.Errorf("请求 token 失败: %v", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("读取响应失败: %v", err) + } + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("token 请求返回 %d: %s", resp.StatusCode, string(body)) + } + + var tokenResp casdoorTokenResponse + if err := json.Unmarshal(body, &tokenResp); err != nil { + return "", fmt.Errorf("解析 token 响应失败: %v", err) + } + + if tokenResp.AccessToken == "" { + return "", fmt.Errorf("未获取到 access_token, 响应: %s", string(body)) + } + + return tokenResp.AccessToken, nil +} + +// getUserInfo 从 access_token JWT 中解析用户信息 +// Casdoor 的 access_token 本身是一个 JWT,包含完整的用户 claims +func (l *SSOLogic) getUserInfo(accessToken string) (*casdoorUserInfo, error) { + // 解析 JWT payload(不验证签名,因为 token 刚从 Casdoor 获取) + parts := strings.Split(accessToken, ".") + if len(parts) != 3 { + return nil, fmt.Errorf("access_token 不是有效的 JWT 格式") + } + + // Base64 解码 payload + payload := parts[1] + // 补齐 base64 padding + switch len(payload) % 4 { + case 2: + payload += "==" + case 3: + payload += "=" + } + + decoded, err := base64.URLEncoding.DecodeString(payload) + if err != nil { + return nil, fmt.Errorf("解码 JWT payload 失败: %v", err) + } + + // 解析 JWT claims + var claims map[string]interface{} + if err := json.Unmarshal(decoded, &claims); err != nil { + return nil, fmt.Errorf("解析 JWT claims 失败: %v", err) + } + + // 从 claims 中提取用户信息(Casdoor JWT 字段名) + userInfo := &casdoorUserInfo{ + Sub: getStringClaim(claims, "sub"), + } + + // Casdoor JWT 中用户名可能在 name 或 preferred_username 字段 + userInfo.Name = getStringClaim(claims, "name") + userInfo.PreferredUsername = getStringClaim(claims, "preferred_username") + userInfo.Email = getStringClaim(claims, "email") + userInfo.Phone = getStringClaim(claims, "phone") + userInfo.Avatar = getStringClaim(claims, "avatar") + + return userInfo, nil +} + +// getStringClaim 从 claims map 中安全获取字符串值 +func getStringClaim(claims map[string]interface{}, key string) string { + if v, ok := claims[key]; ok { + if s, ok := v.(string); ok { + return s + } + } + return "" +} + +// generateState 生成随机 state 参数(CSRF 防护) +func generateState() (string, error) { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} diff --git a/backend/internal/svc/servicecontext.go b/backend/internal/svc/servicecontext.go index 3e7ab36..5979251 100644 --- a/backend/internal/svc/servicecontext.go +++ b/backend/internal/svc/servicecontext.go @@ -4,6 +4,15 @@ package svc import ( + "context" + "crypto/md5" + "fmt" + "log" + + "github.com/casbin/casbin/v2" + casbinmodel "github.com/casbin/casbin/v2/model" + gormadapter "github.com/casbin/gorm-adapter/v3" + "github.com/youruser/base/internal/config" "github.com/youruser/base/internal/middleware" "github.com/youruser/base/model" @@ -14,13 +23,33 @@ import ( "github.com/zeromicro/go-zero/rest" ) +const casbinModelText = ` +[request_definition] +r = sub, obj, act + +[policy_definition] +p = sub, obj, act + +[role_definition] +g = _, _ + +[policy_effect] +e = some(where (p.eft == allow)) + +[matchers] +m = g(r.sub, p.sub) && keyMatch2(r.obj, p.obj) && r.act == p.act +` + type ServiceContext struct { Config config.Config Cors rest.Middleware Log rest.Middleware Auth rest.Middleware + Authz rest.Middleware // 数据库连接 DB *gorm.DB + // Casbin enforcer + Enforcer *casbin.Enforcer } func NewServiceContext(c config.Config) *ServiceContext { @@ -37,12 +66,23 @@ func NewServiceContext(c config.Config) *ServiceContext { panic("Failed to migrate database: " + err.Error()) } + // 初始化 Casbin + enforcer := initCasbin(db) + + // 种子超级管理员 + seedSuperAdmin(db) + + // 种子 Casbin 策略 + seedCasbinPolicies(enforcer) + return &ServiceContext{ - Config: c, - Cors: middleware.NewCorsMiddleware().Handle, - Log: middleware.NewLogMiddleware().Handle, - Auth: middleware.NewAuthMiddleware().Handle, - DB: db, + Config: c, + Cors: middleware.NewCorsMiddleware().Handle, + Log: middleware.NewLogMiddleware().Handle, + Auth: middleware.NewAuthMiddleware().Handle, + Authz: middleware.NewAuthzMiddleware(enforcer).Handle, + DB: db, + Enforcer: enforcer, } } @@ -57,3 +97,110 @@ func (s *ServiceContext) Close() error { } return nil } + +// initCasbin 初始化 Casbin enforcer +func initCasbin(db *gorm.DB) *casbin.Enforcer { + // 使用 GORM adapter(自动创建 casbin_rule 表) + adapter, err := gormadapter.NewAdapterByDB(db) + if err != nil { + panic("Failed to create Casbin adapter: " + err.Error()) + } + + // 从字符串加载 model + m, err := casbinmodel.NewModelFromString(casbinModelText) + if err != nil { + panic("Failed to create Casbin model: " + err.Error()) + } + + enforcer, err := casbin.NewEnforcer(m, adapter) + if err != nil { + panic("Failed to create Casbin enforcer: " + err.Error()) + } + + // 加载策略 + if err := enforcer.LoadPolicy(); err != nil { + panic("Failed to load Casbin policy: " + err.Error()) + } + + log.Println("[Casbin] Enforcer initialized successfully") + return enforcer +} + +// seedSuperAdmin 首次启动创建超级管理员 +func seedSuperAdmin(db *gorm.DB) { + ctx := context.Background() + + existing, err := model.FindOneByUsername(ctx, db, "admin") + if err == nil { + if existing.Role != model.RoleSuperAdmin { + existing.Role = model.RoleSuperAdmin + existing.Source = model.SourceSystem + model.Update(ctx, db, existing) + log.Println("[Seed] Updated admin to super_admin role") + } + return + } + + password := fmt.Sprintf("%x", md5.Sum([]byte("admin123"))) + admin := &model.User{ + Username: "admin", + Phone: "13800000000", + Email: "", + Password: password, + Role: model.RoleSuperAdmin, + Source: model.SourceSystem, + Remark: "系统自动创建的超级管理员", + Status: 1, + } + + _, err = model.Insert(ctx, db, admin) + if err != nil { + log.Printf("[Seed] Failed to create super admin: %v", err) + return + } + log.Println("[Seed] Super admin created: admin / admin123") +} + +// seedCasbinPolicies 种子 Casbin 策略(幂等) +func seedCasbinPolicies(enforcer *casbin.Enforcer) { + // 角色层级: super_admin > admin > user > guest + roleHierarchy := [][]string{ + {"super_admin", "admin"}, + {"admin", "user"}, + {"user", "guest"}, + } + for _, g := range roleHierarchy { + if has, _ := enforcer.HasGroupingPolicy(g[0], g[1]); !has { + enforcer.AddGroupingPolicy(g[0], g[1]) + } + } + + // 默认策略 + policies := [][]string{ + // guest: 仪表盘只读 + {"guest", "/api/v1/dashboard/*", "GET"}, + + // user: 个人中心 + {"user", "/api/v1/profile/*", "GET"}, + {"user", "/api/v1/profile/*", "PUT"}, + {"user", "/api/v1/profile/*", "POST"}, + + // admin: 用户管理(增查改) + {"admin", "/api/v1/users", "GET"}, + {"admin", "/api/v1/user", "POST"}, + {"admin", "/api/v1/user/:id", "GET"}, + {"admin", "/api/v1/user/:id", "PUT"}, + + // super_admin: 用户删除 + {"super_admin", "/api/v1/user/:id", "DELETE"}, + } + + for _, p := range policies { + if has, _ := enforcer.HasPolicy(p[0], p[1], p[2]); !has { + enforcer.AddPolicy(p[0], p[1], p[2]) + } + } + + enforcer.SavePolicy() + log.Println("[Casbin] Policies seeded successfully") +} diff --git a/backend/internal/types/types.go b/backend/internal/types/types.go index 88d7600..0ed975e 100644 --- a/backend/internal/types/types.go +++ b/backend/internal/types/types.go @@ -21,6 +21,8 @@ type CreateUserRequest struct { Email string `json:"email" validate:"required,email"` // 邮箱 Password string `json:"password" validate:"required,min=6,max=32"` // 密码 Phone string `json:"phone,optional"` // 手机号 + Role string `json:"role,optional"` // 角色 + Remark string `json:"remark,optional"` // 备注 } type DashboardStatsResponse struct { @@ -52,7 +54,7 @@ type GetUserRequest struct { } type LoginRequest struct { - Email string `json:"email" validate:"required,email"` // 邮箱 + Account string `json:"account" validate:"required"` // 手机号或用户名 Password string `json:"password" validate:"required,min=6,max=32"` // 密码 } @@ -77,9 +79,9 @@ type RefreshTokenRequest struct { type RegisterRequest struct { Username string `json:"username" validate:"required,min=3,max=32"` // 用户名 - Email string `json:"email" validate:"required,email"` // 邮箱 Password string `json:"password" validate:"required,min=6,max=32"` // 密码 - Phone string `json:"phone,optional"` // 手机号 + Phone string `json:"phone" validate:"required"` // 手机号(必填) + Email string `json:"email,optional"` // 邮箱(可选) } type Response struct { @@ -102,6 +104,8 @@ type UpdateUserRequest struct { Email string `json:"email,optional"` // 邮箱 Phone string `json:"phone,optional"` // 手机号 Status int `json:"status,optional"` // 状态 + Role string `json:"role,optional"` // 角色 + Remark string `json:"remark,optional"` // 备注 } type UserInfo struct { @@ -109,6 +113,9 @@ type UserInfo struct { Username string `json:"username"` // 用户名 Email string `json:"email"` // 邮箱 Phone string `json:"phone"` // 手机号 + Role string `json:"role"` // 角色 + Source string `json:"source"` // 来源 + Remark string `json:"remark"` // 备注 Status int `json:"status"` // 状态 1-正常 2-禁用 CreatedAt string `json:"createdAt"` // 创建时间 UpdatedAt string `json:"updatedAt"` // 更新时间