mirror of https://github.com/veypi/OneAuth.git
upgrade
parent
a3923ebcdb
commit
792f3ba707
@ -0,0 +1,50 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"github.com/veypi/vbase/internal/api"
|
||||
"github.com/veypi/vbase/internal/cache"
|
||||
"github.com/veypi/vbase/internal/config"
|
||||
"github.com/veypi/vbase/internal/model"
|
||||
"github.com/veypi/vigo"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// 初始化数据库
|
||||
if err := model.InitDB(); err != nil {
|
||||
log.Fatalf("Failed to init database: %v", err)
|
||||
}
|
||||
|
||||
// 自动迁移
|
||||
if err := model.AutoMigrate(); err != nil {
|
||||
log.Fatalf("Failed to migrate database: %v", err)
|
||||
}
|
||||
|
||||
// 初始化系统数据
|
||||
if err := model.InitSystemData(); err != nil {
|
||||
log.Fatalf("Failed to init system data: %v", err)
|
||||
}
|
||||
|
||||
// 初始化Redis
|
||||
if err := cache.Init(); err != nil {
|
||||
log.Printf("Warning: Failed to init redis: %v", err)
|
||||
} else {
|
||||
log.Println("Redis connected")
|
||||
}
|
||||
|
||||
// 创建路由
|
||||
router := api.NewRouter()
|
||||
|
||||
// 创建服务器
|
||||
server, err := vigo.New(vigo.WithHost(config.C.Server.Host), vigo.WithPort(config.C.Server.Port))
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to create server: %v", err)
|
||||
}
|
||||
server.Router().Extend("api", router)
|
||||
|
||||
log.Printf("Server starting on %s:%d", config.C.Server.Host, config.C.Server.Port)
|
||||
if err := server.Run(); err != nil {
|
||||
log.Fatalf("Server error: %v", err)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,513 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/veypi/vbase/internal/cache"
|
||||
"github.com/veypi/vbase/internal/config"
|
||||
"github.com/veypi/vbase/internal/model"
|
||||
"github.com/veypi/vbase/internal/pkg/crypto"
|
||||
"github.com/veypi/vbase/internal/pkg/jwt"
|
||||
"github.com/veypi/vigo"
|
||||
)
|
||||
|
||||
// LoginRequest 登录请求
|
||||
type LoginRequest struct {
|
||||
Username string `json:"username" src:"json" desc:"用户名/邮箱/手机号"`
|
||||
Password string `json:"password" src:"json" desc:"密码"`
|
||||
CaptchaID string `json:"captcha_id,omitempty" src:"json" desc:"验证码ID"`
|
||||
CaptchaCode string `json:"captcha_code,omitempty" src:"json" desc:"验证码"`
|
||||
Remember bool `json:"remember,omitempty" src:"json" desc:"记住登录"`
|
||||
}
|
||||
|
||||
// RegisterRequest 注册请求
|
||||
type RegisterRequest struct {
|
||||
Username string `json:"username" src:"json" desc:"用户名"`
|
||||
Password string `json:"password" src:"json" desc:"密码"`
|
||||
Email string `json:"email,omitempty" src:"json" desc:"邮箱"`
|
||||
Phone string `json:"phone,omitempty" src:"json" desc:"手机号"`
|
||||
Nickname string `json:"nickname,omitempty" src:"json" desc:"昵称"`
|
||||
}
|
||||
|
||||
// AuthResponse 认证响应
|
||||
type AuthResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
User *UserInfo `json:"user"`
|
||||
}
|
||||
|
||||
// UserInfo 用户信息
|
||||
type UserInfo struct {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Nickname string `json:"nickname"`
|
||||
Email string `json:"email"`
|
||||
Avatar string `json:"avatar"`
|
||||
}
|
||||
|
||||
// Login 用户登录
|
||||
func Login(x *vigo.X, req *LoginRequest) (*AuthResponse, error) {
|
||||
// 查找用户
|
||||
var user model.User
|
||||
query := model.DB.Where("username = ? OR email = ? OR phone = ?", req.Username, req.Username, req.Username)
|
||||
if err := query.First(&user).Error; err != nil {
|
||||
return nil, vigo.ErrNotAuthorized.WithString("invalid username or password")
|
||||
}
|
||||
|
||||
// 检查用户状态
|
||||
if user.Status != model.UserStatusActive {
|
||||
return nil, vigo.ErrForbidden.WithString("user is disabled")
|
||||
}
|
||||
|
||||
// 验证密码
|
||||
if !crypto.VerifyPassword(req.Password, user.Password) {
|
||||
return nil, vigo.ErrNotAuthorized.WithString("invalid username or password")
|
||||
}
|
||||
|
||||
// 获取用户的组织信息
|
||||
orgs, err := getUserOrgs(user.ID)
|
||||
if err != nil {
|
||||
return nil, vigo.ErrInternalServer.WithError(err)
|
||||
}
|
||||
|
||||
// 生成token
|
||||
orgClaims := make([]jwt.OrgClaim, 0, len(orgs))
|
||||
for _, org := range orgs {
|
||||
orgClaims = append(orgClaims, jwt.OrgClaim{
|
||||
OrgID: org.OrgID,
|
||||
Code: org.Code,
|
||||
Name: org.Name,
|
||||
Roles: org.Roles,
|
||||
Status: org.Status,
|
||||
})
|
||||
}
|
||||
|
||||
tokenPair, err := jwt.GenerateTokenPair(
|
||||
user.ID,
|
||||
user.Username,
|
||||
user.Nickname,
|
||||
user.Avatar,
|
||||
user.Email,
|
||||
orgClaims,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, vigo.ErrInternalServer.WithError(err)
|
||||
}
|
||||
|
||||
// 保存session
|
||||
session := &model.Session{
|
||||
UserID: user.ID,
|
||||
TokenID: getJTI(tokenPair.AccessToken),
|
||||
Type: "access",
|
||||
DeviceInfo: x.Request.UserAgent(),
|
||||
IP: x.GetRemoteIP(),
|
||||
ExpiresAt: time.Now().Add(config.C.JWT.AccessExpiry),
|
||||
}
|
||||
model.DB.Create(session)
|
||||
|
||||
// 更新最后登录时间
|
||||
now := time.Now()
|
||||
model.DB.Model(&user).Update("last_login_at", now)
|
||||
|
||||
return &AuthResponse{
|
||||
AccessToken: tokenPair.AccessToken,
|
||||
RefreshToken: tokenPair.RefreshToken,
|
||||
TokenType: tokenPair.TokenType,
|
||||
ExpiresIn: tokenPair.ExpiresIn,
|
||||
User: &UserInfo{
|
||||
ID: user.ID,
|
||||
Username: user.Username,
|
||||
Nickname: user.Nickname,
|
||||
Email: user.Email,
|
||||
Avatar: user.Avatar,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Register 用户注册
|
||||
func Register(x *vigo.X, req *RegisterRequest) (*AuthResponse, error) {
|
||||
// 检查用户名是否已存在
|
||||
var count int64
|
||||
model.DB.Model(&model.User{}).Where("username = ?", req.Username).Count(&count)
|
||||
if count > 0 {
|
||||
return nil, vigo.ErrArgInvalid.WithString("username already exists")
|
||||
}
|
||||
|
||||
// 检查邮箱是否已存在
|
||||
if req.Email != "" {
|
||||
model.DB.Model(&model.User{}).Where("email = ?", req.Email).Count(&count)
|
||||
if count > 0 {
|
||||
return nil, vigo.ErrArgInvalid.WithString("email already exists")
|
||||
}
|
||||
}
|
||||
|
||||
// 哈希密码
|
||||
hashedPassword, err := crypto.HashPassword(req.Password, config.C.Security.BcryptCost)
|
||||
if err != nil {
|
||||
return nil, vigo.ErrInternalServer.WithError(err)
|
||||
}
|
||||
|
||||
// 创建用户
|
||||
user := &model.User{
|
||||
Username: req.Username,
|
||||
Password: hashedPassword,
|
||||
Email: req.Email,
|
||||
Phone: req.Phone,
|
||||
Nickname: req.Nickname,
|
||||
Status: model.UserStatusActive,
|
||||
}
|
||||
|
||||
if user.Nickname == "" {
|
||||
user.Nickname = user.Username
|
||||
}
|
||||
|
||||
if err := model.DB.Create(user).Error; err != nil {
|
||||
return nil, vigo.ErrInternalServer.WithError(err)
|
||||
}
|
||||
|
||||
// 生成token
|
||||
tokenPair, err := jwt.GenerateTokenPair(
|
||||
user.ID,
|
||||
user.Username,
|
||||
user.Nickname,
|
||||
user.Avatar,
|
||||
user.Email,
|
||||
nil, // 新用户无组织
|
||||
)
|
||||
if err != nil {
|
||||
return nil, vigo.ErrInternalServer.WithError(err)
|
||||
}
|
||||
|
||||
// 保存session
|
||||
session := &model.Session{
|
||||
UserID: user.ID,
|
||||
TokenID: getJTI(tokenPair.AccessToken),
|
||||
Type: "access",
|
||||
DeviceInfo: x.Request.UserAgent(),
|
||||
IP: x.GetRemoteIP(),
|
||||
ExpiresAt: time.Now().Add(config.C.JWT.AccessExpiry),
|
||||
}
|
||||
model.DB.Create(session)
|
||||
|
||||
return &AuthResponse{
|
||||
AccessToken: tokenPair.AccessToken,
|
||||
RefreshToken: tokenPair.RefreshToken,
|
||||
TokenType: tokenPair.TokenType,
|
||||
ExpiresIn: tokenPair.ExpiresIn,
|
||||
User: &UserInfo{
|
||||
ID: user.ID,
|
||||
Username: user.Username,
|
||||
Nickname: user.Nickname,
|
||||
Email: user.Email,
|
||||
Avatar: user.Avatar,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// RefreshRequest 刷新请求
|
||||
type RefreshRequest struct {
|
||||
RefreshToken string `json:"refresh_token" src:"json" desc:"刷新令牌"`
|
||||
}
|
||||
|
||||
// Refresh 刷新Token
|
||||
func Refresh(x *vigo.X, req *RefreshRequest) (*AuthResponse, error) {
|
||||
// 解析refresh token
|
||||
claims, err := jwt.ParseToken(req.RefreshToken)
|
||||
if err != nil {
|
||||
if err == jwt.ErrExpiredToken {
|
||||
return nil, vigo.ErrNotAuthorized.WithString("refresh token expired")
|
||||
}
|
||||
return nil, vigo.ErrNotAuthorized.WithString("invalid refresh token")
|
||||
}
|
||||
|
||||
if !jwt.IsRefreshToken(claims) {
|
||||
return nil, vigo.ErrNotAuthorized.WithString("invalid token type")
|
||||
}
|
||||
|
||||
// 查找用户
|
||||
var user model.User
|
||||
if err := model.DB.First(&user, "id = ?", claims.UserID).Error; err != nil {
|
||||
return nil, vigo.ErrNotAuthorized.WithString("user not found")
|
||||
}
|
||||
|
||||
if user.Status != model.UserStatusActive {
|
||||
return nil, vigo.ErrForbidden.WithString("user is disabled")
|
||||
}
|
||||
|
||||
// 获取用户的组织信息
|
||||
orgs, err := getUserOrgs(user.ID)
|
||||
if err != nil {
|
||||
return nil, vigo.ErrInternalServer.WithError(err)
|
||||
}
|
||||
|
||||
orgClaims := make([]jwt.OrgClaim, 0, len(orgs))
|
||||
for _, org := range orgs {
|
||||
orgClaims = append(orgClaims, jwt.OrgClaim{
|
||||
OrgID: org.OrgID,
|
||||
Code: org.Code,
|
||||
Name: org.Name,
|
||||
Roles: org.Roles,
|
||||
Status: org.Status,
|
||||
})
|
||||
}
|
||||
|
||||
// 生成新token
|
||||
tokenPair, err := jwt.GenerateTokenPair(
|
||||
user.ID,
|
||||
user.Username,
|
||||
user.Nickname,
|
||||
user.Avatar,
|
||||
user.Email,
|
||||
orgClaims,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, vigo.ErrInternalServer.WithError(err)
|
||||
}
|
||||
|
||||
// 保存新session
|
||||
session := &model.Session{
|
||||
UserID: user.ID,
|
||||
TokenID: getJTI(tokenPair.AccessToken),
|
||||
Type: "access",
|
||||
DeviceInfo: x.Request.UserAgent(),
|
||||
IP: x.GetRemoteIP(),
|
||||
ExpiresAt: time.Now().Add(config.C.JWT.AccessExpiry),
|
||||
}
|
||||
model.DB.Create(session)
|
||||
|
||||
return &AuthResponse{
|
||||
AccessToken: tokenPair.AccessToken,
|
||||
RefreshToken: tokenPair.RefreshToken,
|
||||
TokenType: tokenPair.TokenType,
|
||||
ExpiresIn: tokenPair.ExpiresIn,
|
||||
User: &UserInfo{
|
||||
ID: user.ID,
|
||||
Username: user.Username,
|
||||
Nickname: user.Nickname,
|
||||
Email: user.Email,
|
||||
Avatar: user.Avatar,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Logout 用户登出
|
||||
func Logout(x *vigo.X) error {
|
||||
tokenString := extractTokenFromRequest(x.Request)
|
||||
if tokenString == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
jti, err := jwt.GetJTI(tokenString)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 加入黑名单
|
||||
expiration, _ := jwt.GetExpiration(tokenString)
|
||||
if cache.IsEnabled() {
|
||||
ttl := time.Until(expiration)
|
||||
if ttl > 0 {
|
||||
cache.BlacklistToken(jti, ttl)
|
||||
}
|
||||
}
|
||||
|
||||
// 标记session为撤销
|
||||
model.DB.Model(&model.Session{}).Where("token_id = ?", jti).Updates(map[string]interface{}{
|
||||
"revoked": true,
|
||||
"revoked_at": time.Now(),
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Me 获取当前用户信息
|
||||
func Me(x *vigo.X) (*UserInfo, error) {
|
||||
userID := getCurrentUserID(x)
|
||||
if userID == "" {
|
||||
return nil, vigo.ErrNotAuthorized
|
||||
}
|
||||
|
||||
var user model.User
|
||||
if err := model.DB.First(&user, "id = ?", userID).Error; err != nil {
|
||||
return nil, vigo.ErrNotFound
|
||||
}
|
||||
|
||||
return &UserInfo{
|
||||
ID: user.ID,
|
||||
Username: user.Username,
|
||||
Nickname: user.Nickname,
|
||||
Email: user.Email,
|
||||
Avatar: user.Avatar,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// UpdateMeRequest 更新自己请求
|
||||
type UpdateMeRequest struct {
|
||||
Nickname *string `json:"nickname,omitempty" src:"json" desc:"昵称"`
|
||||
Avatar *string `json:"avatar,omitempty" src:"json" desc:"头像"`
|
||||
Email *string `json:"email,omitempty" src:"json" desc:"邮箱"`
|
||||
}
|
||||
|
||||
// UpdateMe 更新当前用户信息
|
||||
func UpdateMe(x *vigo.X, req *UpdateMeRequest) (*UserInfo, error) {
|
||||
userID := getCurrentUserID(x)
|
||||
if userID == "" {
|
||||
return nil, vigo.ErrNotAuthorized
|
||||
}
|
||||
|
||||
updates := make(map[string]interface{})
|
||||
if req.Nickname != nil {
|
||||
updates["nickname"] = *req.Nickname
|
||||
}
|
||||
if req.Avatar != nil {
|
||||
updates["avatar"] = *req.Avatar
|
||||
}
|
||||
if req.Email != nil {
|
||||
// 检查邮箱是否被其他用户使用
|
||||
var count int64
|
||||
model.DB.Model(&model.User{}).Where("email = ? AND id != ?", *req.Email, userID).Count(&count)
|
||||
if count > 0 {
|
||||
return nil, vigo.ErrArgInvalid.WithString("email already exists")
|
||||
}
|
||||
updates["email"] = *req.Email
|
||||
}
|
||||
|
||||
if err := model.DB.Model(&model.User{}).Where("id = ?", userID).Updates(updates).Error; err != nil {
|
||||
return nil, vigo.ErrInternalServer.WithError(err)
|
||||
}
|
||||
|
||||
return Me(x)
|
||||
}
|
||||
|
||||
// ChangePasswordRequest 修改密码请求
|
||||
type ChangePasswordRequest struct {
|
||||
OldPassword string `json:"old_password" src:"json" desc:"旧密码"`
|
||||
NewPassword string `json:"new_password" src:"json" desc:"新密码"`
|
||||
}
|
||||
|
||||
// ChangePassword 修改密码
|
||||
func ChangePassword(x *vigo.X, req *ChangePasswordRequest) error {
|
||||
userID := getCurrentUserID(x)
|
||||
if userID == "" {
|
||||
return vigo.ErrNotAuthorized
|
||||
}
|
||||
|
||||
var user model.User
|
||||
if err := model.DB.First(&user, "id = ?", userID).Error; err != nil {
|
||||
return vigo.ErrNotFound
|
||||
}
|
||||
|
||||
// 验证旧密码
|
||||
if !crypto.VerifyPassword(req.OldPassword, user.Password) {
|
||||
return vigo.ErrArgInvalid.WithString("old password is incorrect")
|
||||
}
|
||||
|
||||
// 哈希新密码
|
||||
hashedPassword, err := crypto.HashPassword(req.NewPassword, config.C.Security.BcryptCost)
|
||||
if err != nil {
|
||||
return vigo.ErrInternalServer.WithError(err)
|
||||
}
|
||||
|
||||
// 更新密码
|
||||
if err := model.DB.Model(&user).Update("password", hashedPassword).Error; err != nil {
|
||||
return vigo.ErrInternalServer.WithError(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// helper functions
|
||||
|
||||
func getCurrentUserID(x *vigo.X) string {
|
||||
if uid, ok := x.Get("current_user").(string); ok {
|
||||
return uid
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func extractToken(r *vigo.X) string {
|
||||
// 从Header获取
|
||||
auth := r.Request.Header.Get("Authorization")
|
||||
if auth != "" {
|
||||
parts := make([]string, 0)
|
||||
for _, p := range []string{auth} {
|
||||
parts = append(parts, p)
|
||||
}
|
||||
// Simple check
|
||||
if len(auth) > 7 && auth[:7] == "Bearer " {
|
||||
return auth[7:]
|
||||
}
|
||||
}
|
||||
|
||||
// 从Query获取
|
||||
return r.Request.URL.Query().Get("access_token")
|
||||
}
|
||||
|
||||
func extractTokenFromRequest(r *http.Request) string {
|
||||
// 从Header获取
|
||||
auth := r.Header.Get("Authorization")
|
||||
if auth != "" {
|
||||
if len(auth) > 7 && auth[:7] == "Bearer " {
|
||||
return auth[7:]
|
||||
}
|
||||
}
|
||||
|
||||
// 从Query获取
|
||||
return r.URL.Query().Get("access_token")
|
||||
}
|
||||
|
||||
func getJTI(token string) string {
|
||||
jti, _ := jwt.GetJTI(token)
|
||||
return jti
|
||||
}
|
||||
|
||||
type userOrgInfo struct {
|
||||
OrgID string
|
||||
Code string
|
||||
Name string
|
||||
Roles []string
|
||||
Status int
|
||||
}
|
||||
|
||||
func getUserOrgs(userID string) ([]userOrgInfo, error) {
|
||||
var members []model.OrgMember
|
||||
if err := model.DB.Where("user_id = ? AND status = ?", userID, model.MemberStatusActive).Find(&members).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(members) == 0 {
|
||||
return []userOrgInfo{}, nil
|
||||
}
|
||||
|
||||
result := make([]userOrgInfo, 0, len(members))
|
||||
for _, m := range members {
|
||||
var org model.Org
|
||||
if err := model.DB.First(&org, "id = ?", m.OrgID).Error; err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// 解析角色ID
|
||||
roles := parseRoles(m.RoleIDs)
|
||||
|
||||
result = append(result, userOrgInfo{
|
||||
OrgID: m.OrgID,
|
||||
Code: org.Code,
|
||||
Name: org.Name,
|
||||
Roles: roles,
|
||||
Status: m.Status,
|
||||
})
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func parseRoles(roleIDs string) []string {
|
||||
if roleIDs == "" {
|
||||
return []string{}
|
||||
}
|
||||
// 简单解析,实际可能需要更复杂的逻辑
|
||||
return []string{}
|
||||
}
|
||||
@ -0,0 +1,141 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/veypi/vbase/internal/cache"
|
||||
"github.com/veypi/vbase/internal/pkg/jwt"
|
||||
"github.com/veypi/vigo"
|
||||
)
|
||||
|
||||
const (
|
||||
ContextKeyUser = "current_user"
|
||||
ContextKeyClaims = "jwt_claims"
|
||||
ContextKeyOrgID = "org_id"
|
||||
ContextKeyIsAdmin = "is_admin"
|
||||
)
|
||||
|
||||
// AuthRequired 认证中间件
|
||||
func AuthRequired(skips ...string) func(*vigo.X) (any, error) {
|
||||
skipMap := make(map[string]bool)
|
||||
for _, s := range skips {
|
||||
skipMap[s] = true
|
||||
}
|
||||
|
||||
return func(x *vigo.X) (any, error) {
|
||||
// 检查是否跳过
|
||||
if skipMap[x.Request.URL.Path] {
|
||||
x.Next()
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// 提取token
|
||||
tokenString := extractToken(x.Request)
|
||||
if tokenString == "" {
|
||||
return nil, vigo.ErrNotAuthorized.WithString("missing token")
|
||||
}
|
||||
|
||||
// 解析token
|
||||
claims, err := jwt.ParseToken(tokenString)
|
||||
if err != nil {
|
||||
if err == jwt.ErrExpiredToken {
|
||||
return nil, vigo.ErrNotAuthorized.WithString("token expired")
|
||||
}
|
||||
return nil, vigo.ErrNotAuthorized.WithString("invalid token")
|
||||
}
|
||||
|
||||
// 必须是access token
|
||||
if !jwt.IsAccessToken(claims) {
|
||||
return nil, vigo.ErrNotAuthorized.WithString("invalid token type")
|
||||
}
|
||||
|
||||
// 检查黑名单
|
||||
if cache.IsEnabled() {
|
||||
isRevoked, err := cache.IsTokenBlacklisted(claims.ID)
|
||||
if err != nil {
|
||||
return nil, vigo.ErrInternalServer.WithError(err)
|
||||
}
|
||||
if isRevoked {
|
||||
return nil, vigo.ErrNotAuthorized.WithString("token revoked")
|
||||
}
|
||||
}
|
||||
|
||||
// 设置上下文
|
||||
x.Set(ContextKeyClaims, claims)
|
||||
x.Set(ContextKeyUser, claims.UserID)
|
||||
|
||||
x.Next()
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
// OptionalAuth 可选认证
|
||||
func OptionalAuth() func(*vigo.X) (any, error) {
|
||||
return func(x *vigo.X) (any, error) {
|
||||
tokenString := extractToken(x.Request)
|
||||
if tokenString != "" {
|
||||
claims, err := jwt.ParseToken(tokenString)
|
||||
if err == nil && jwt.IsAccessToken(claims) {
|
||||
x.Set(ContextKeyClaims, claims)
|
||||
x.Set(ContextKeyUser, claims.UserID)
|
||||
}
|
||||
}
|
||||
x.Next()
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
// OrgContext 组织上下文中间件
|
||||
func OrgContext() func(*vigo.X) (any, error) {
|
||||
return func(x *vigo.X) (any, error) {
|
||||
orgID := x.Request.Header.Get("X-Org-ID")
|
||||
if orgID == "" {
|
||||
orgID = x.Request.URL.Query().Get("org_id")
|
||||
}
|
||||
if orgID != "" {
|
||||
x.Set(ContextKeyOrgID, orgID)
|
||||
}
|
||||
x.Next()
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
// extractToken 从请求中提取token
|
||||
func extractToken(r *http.Request) string {
|
||||
// 从Header获取
|
||||
auth := r.Header.Get("Authorization")
|
||||
if auth != "" {
|
||||
parts := strings.SplitN(auth, " ", 2)
|
||||
if len(parts) == 2 && strings.EqualFold(parts[0], "Bearer") {
|
||||
return parts[1]
|
||||
}
|
||||
}
|
||||
|
||||
// 从Query获取
|
||||
return r.URL.Query().Get("access_token")
|
||||
}
|
||||
|
||||
// CurrentUser 获取当前用户ID
|
||||
func CurrentUser(x *vigo.X) string {
|
||||
if uid, ok := x.Get(ContextKeyUser).(string); ok {
|
||||
return uid
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// CurrentClaims 获取当前JWT Claims
|
||||
func CurrentClaims(x *vigo.X) *jwt.Claims {
|
||||
if claims, ok := x.Get(ContextKeyClaims).(*jwt.Claims); ok {
|
||||
return claims
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CurrentOrgID 获取当前组织ID
|
||||
func CurrentOrgID(x *vigo.X) string {
|
||||
if orgID, ok := x.Get(ContextKeyOrgID).(string); ok {
|
||||
return orgID
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@ -0,0 +1,399 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"strings"
|
||||
|
||||
"github.com/veypi/vbase/internal/api/middleware"
|
||||
"github.com/veypi/vbase/internal/model"
|
||||
"github.com/veypi/vigo"
|
||||
)
|
||||
|
||||
// ClientRequest 客户端请求
|
||||
type ClientRequest struct {
|
||||
Name string `json:"name" src:"json" desc:"应用名称"`
|
||||
Description string `json:"description" src:"json" desc:"应用描述"`
|
||||
RedirectURIs []string `json:"redirect_uris" src:"json" desc:"回调地址列表"`
|
||||
GrantTypes []string `json:"grant_types" src:"json" desc:"授权类型"`
|
||||
ResponseTypes []string `json:"response_types" src:"json" desc:"响应类型"`
|
||||
AllowedScopes []string `json:"allowed_scopes" src:"json" desc:"允许的权限范围"`
|
||||
TokenExpiry int `json:"token_expiry" src:"json" desc:"Token有效期(秒)"`
|
||||
RefreshExpiry int `json:"refresh_expiry" src:"json" desc:"RefreshToken有效期(秒)"`
|
||||
OrgID string `json:"org_id" src:"json" desc:"所属组织ID(可选)"`
|
||||
}
|
||||
|
||||
// ClientResponse 客户端响应
|
||||
type ClientResponse struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
ClientID string `json:"client_id"`
|
||||
ClientSecret string `json:"client_secret,omitempty"`
|
||||
RedirectURIs []string `json:"redirect_uris"`
|
||||
GrantTypes []string `json:"grant_types"`
|
||||
ResponseTypes []string `json:"response_types"`
|
||||
AllowedScopes []string `json:"allowed_scopes"`
|
||||
TokenExpiry int `json:"token_expiry"`
|
||||
RefreshExpiry int `json:"refresh_expiry"`
|
||||
OwnerID string `json:"owner_id"`
|
||||
OrgID string `json:"org_id,omitempty"`
|
||||
Status int `json:"status"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// generateClientID 生成客户端ID
|
||||
func generateClientID() string {
|
||||
b := make([]byte, 16)
|
||||
rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// generateClientSecret 生成客户端密钥
|
||||
func generateClientSecret() string {
|
||||
b := make([]byte, 32)
|
||||
rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// CreateClient 创建OAuth客户端
|
||||
func CreateClient(x *vigo.X, req *ClientRequest) (*ClientResponse, error) {
|
||||
userID := middleware.CurrentUser(x)
|
||||
if userID == "" {
|
||||
return nil, vigo.ErrNotAuthorized
|
||||
}
|
||||
|
||||
// 默认值
|
||||
grantTypes := req.GrantTypes
|
||||
if len(grantTypes) == 0 {
|
||||
grantTypes = []string{model.GrantTypeAuthorizationCode, model.GrantTypeRefreshToken}
|
||||
}
|
||||
responseTypes := req.ResponseTypes
|
||||
if len(responseTypes) == 0 {
|
||||
responseTypes = []string{model.ResponseTypeCode}
|
||||
}
|
||||
allowedScopes := req.AllowedScopes
|
||||
if len(allowedScopes) == 0 {
|
||||
allowedScopes = []string{model.ScopeOpenID, model.ScopeProfile, model.ScopeEmail}
|
||||
}
|
||||
tokenExpiry := req.TokenExpiry
|
||||
if tokenExpiry == 0 {
|
||||
tokenExpiry = 3600
|
||||
}
|
||||
refreshExpiry := req.RefreshExpiry
|
||||
if refreshExpiry == 0 {
|
||||
refreshExpiry = 2592000 // 30天
|
||||
}
|
||||
|
||||
client := &model.OAuthClient{
|
||||
Name: req.Name,
|
||||
Description: req.Description,
|
||||
ClientID: generateClientID(),
|
||||
ClientSecret: generateClientSecret(),
|
||||
RedirectURIs: strings.Join(req.RedirectURIs, ","),
|
||||
GrantTypes: strings.Join(grantTypes, ","),
|
||||
ResponseTypes: strings.Join(responseTypes, ","),
|
||||
AllowedScopes: strings.Join(allowedScopes, ","),
|
||||
TokenExpiry: tokenExpiry,
|
||||
RefreshExpiry: refreshExpiry,
|
||||
OwnerID: userID,
|
||||
Status: 1,
|
||||
}
|
||||
|
||||
if req.OrgID != "" {
|
||||
// 检查用户是否是组织成员
|
||||
var member model.OrgMember
|
||||
if err := model.DB.Where("org_id = ? AND user_id = ? AND status = ?", req.OrgID, userID, model.MemberStatusActive).First(&member).Error; err != nil {
|
||||
return nil, vigo.ErrForbidden.WithString("you are not a member of this organization")
|
||||
}
|
||||
client.OrgID = req.OrgID
|
||||
}
|
||||
|
||||
if err := model.DB.Create(client).Error; err != nil {
|
||||
return nil, vigo.ErrInternalServer.WithError(err)
|
||||
}
|
||||
|
||||
return &ClientResponse{
|
||||
ID: client.ID,
|
||||
Name: client.Name,
|
||||
Description: client.Description,
|
||||
ClientID: client.ClientID,
|
||||
ClientSecret: client.ClientSecret,
|
||||
RedirectURIs: req.RedirectURIs,
|
||||
GrantTypes: grantTypes,
|
||||
ResponseTypes: responseTypes,
|
||||
AllowedScopes: allowedScopes,
|
||||
TokenExpiry: client.TokenExpiry,
|
||||
RefreshExpiry: client.RefreshExpiry,
|
||||
OwnerID: client.OwnerID,
|
||||
OrgID: client.OrgID,
|
||||
Status: client.Status,
|
||||
CreatedAt: client.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ListClientsRequest 客户端列表请求
|
||||
type ListClientsRequest struct {
|
||||
Page int `json:"page" src:"query" default:"1" desc:"页码"`
|
||||
PageSize int `json:"page_size" src:"query" default:"10" desc:"每页数量"`
|
||||
OrgID string `json:"org_id" src:"query" desc:"组织ID筛选"`
|
||||
}
|
||||
|
||||
// ListClientsResponse 客户端列表响应
|
||||
type ListClientsResponse struct {
|
||||
Items []ClientResponse `json:"items"`
|
||||
Total int64 `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
}
|
||||
|
||||
// ListClients 获取OAuth客户端列表
|
||||
func ListClients(x *vigo.X, req *ListClientsRequest) (*ListClientsResponse, error) {
|
||||
userID := middleware.CurrentUser(x)
|
||||
if userID == "" {
|
||||
return nil, vigo.ErrNotAuthorized
|
||||
}
|
||||
|
||||
if req.Page < 1 {
|
||||
req.Page = 1
|
||||
}
|
||||
if req.PageSize < 1 || req.PageSize > 100 {
|
||||
req.PageSize = 10
|
||||
}
|
||||
|
||||
query := model.DB.Model(&model.OAuthClient{}).Where("owner_id = ? OR org_id IN (SELECT org_id FROM org_members WHERE user_id = ? AND status = ?)", userID, userID, model.MemberStatusActive)
|
||||
if req.OrgID != "" {
|
||||
query = query.Where("org_id = ?", req.OrgID)
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return nil, vigo.ErrInternalServer.WithError(err)
|
||||
}
|
||||
|
||||
var clients []model.OAuthClient
|
||||
offset := (req.Page - 1) * req.PageSize
|
||||
if err := query.Offset(offset).Limit(req.PageSize).Order("created_at DESC").Find(&clients).Error; err != nil {
|
||||
return nil, vigo.ErrInternalServer.WithError(err)
|
||||
}
|
||||
|
||||
items := make([]ClientResponse, 0, len(clients))
|
||||
for _, c := range clients {
|
||||
items = append(items, toClientResponse(&c, false))
|
||||
}
|
||||
|
||||
totalPages := int((total + int64(req.PageSize) - 1) / int64(req.PageSize))
|
||||
return &ListClientsResponse{
|
||||
Items: items,
|
||||
Total: total,
|
||||
Page: req.Page,
|
||||
PageSize: req.PageSize,
|
||||
TotalPages: totalPages,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetClientRequest 获取客户端请求
|
||||
type GetClientRequest struct {
|
||||
ID string `json:"id" src:"path@client_id" desc:"客户端ID"`
|
||||
}
|
||||
|
||||
// GetClient 获取客户端详情
|
||||
func GetClient(x *vigo.X, req *GetClientRequest) (*ClientResponse, error) {
|
||||
userID := middleware.CurrentUser(x)
|
||||
if userID == "" {
|
||||
return nil, vigo.ErrNotAuthorized
|
||||
}
|
||||
|
||||
var client model.OAuthClient
|
||||
if err := model.DB.Where("client_id = ?", req.ID).First(&client).Error; err != nil {
|
||||
return nil, vigo.ErrNotFound
|
||||
}
|
||||
|
||||
// 检查权限
|
||||
if client.OwnerID != userID {
|
||||
// 检查是否是组织成员
|
||||
if client.OrgID != "" {
|
||||
var member model.OrgMember
|
||||
if err := model.DB.Where("org_id = ? AND user_id = ? AND status = ?", client.OrgID, userID, model.MemberStatusActive).First(&member).Error; err != nil {
|
||||
return nil, vigo.ErrForbidden
|
||||
}
|
||||
} else {
|
||||
return nil, vigo.ErrForbidden
|
||||
}
|
||||
}
|
||||
|
||||
resp := toClientResponse(&client, false)
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// UpdateClientRequest 更新客户端请求
|
||||
type UpdateClientRequest struct {
|
||||
ID string `json:"id" src:"path@client_id" desc:"客户端ID"`
|
||||
Name *string `json:"name" src:"json" desc:"应用名称"`
|
||||
Description *string `json:"description" src:"json" desc:"应用描述"`
|
||||
RedirectURIs []string `json:"redirect_uris" src:"json" desc:"回调地址列表"`
|
||||
AllowedScopes []string `json:"allowed_scopes" src:"json" desc:"允许的权限范围"`
|
||||
TokenExpiry *int `json:"token_expiry" src:"json" desc:"Token有效期"`
|
||||
RefreshExpiry *int `json:"refresh_expiry" src:"json" desc:"RefreshToken有效期"`
|
||||
Status *int `json:"status" src:"json" desc:"状态"`
|
||||
}
|
||||
|
||||
// UpdateClient 更新OAuth客户端
|
||||
func UpdateClient(x *vigo.X, req *UpdateClientRequest) (*ClientResponse, error) {
|
||||
userID := middleware.CurrentUser(x)
|
||||
if userID == "" {
|
||||
return nil, vigo.ErrNotAuthorized
|
||||
}
|
||||
|
||||
var client model.OAuthClient
|
||||
if err := model.DB.Where("client_id = ?", req.ID).First(&client).Error; err != nil {
|
||||
return nil, vigo.ErrNotFound
|
||||
}
|
||||
|
||||
// 检查权限
|
||||
if client.OwnerID != userID {
|
||||
if client.OrgID != "" {
|
||||
var member model.OrgMember
|
||||
if err := model.DB.Where("org_id = ? AND user_id = ? AND status = ?", client.OrgID, userID, model.MemberStatusActive).First(&member).Error; err != nil {
|
||||
return nil, vigo.ErrForbidden
|
||||
}
|
||||
} else {
|
||||
return nil, vigo.ErrForbidden
|
||||
}
|
||||
}
|
||||
|
||||
updates := make(map[string]interface{})
|
||||
if req.Name != nil {
|
||||
updates["name"] = *req.Name
|
||||
}
|
||||
if req.Description != nil {
|
||||
updates["description"] = *req.Description
|
||||
}
|
||||
if req.RedirectURIs != nil {
|
||||
updates["redirect_uris"] = strings.Join(req.RedirectURIs, ",")
|
||||
}
|
||||
if req.AllowedScopes != nil {
|
||||
updates["allowed_scopes"] = strings.Join(req.AllowedScopes, ",")
|
||||
}
|
||||
if req.TokenExpiry != nil {
|
||||
updates["token_expiry"] = *req.TokenExpiry
|
||||
}
|
||||
if req.RefreshExpiry != nil {
|
||||
updates["refresh_expiry"] = *req.RefreshExpiry
|
||||
}
|
||||
if req.Status != nil {
|
||||
updates["status"] = *req.Status
|
||||
}
|
||||
|
||||
if len(updates) > 0 {
|
||||
if err := model.DB.Model(&client).Updates(updates).Error; err != nil {
|
||||
return nil, vigo.ErrInternalServer.WithError(err)
|
||||
}
|
||||
}
|
||||
|
||||
// 重新获取
|
||||
model.DB.First(&client, "client_id = ?", req.ID)
|
||||
resp := toClientResponse(&client, false)
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// DeleteClientRequest 删除客户端请求
|
||||
type DeleteClientRequest struct {
|
||||
ID string `json:"id" src:"path@client_id" desc:"客户端ID"`
|
||||
}
|
||||
|
||||
// DeleteClient 删除OAuth客户端
|
||||
func DeleteClient(x *vigo.X, req *DeleteClientRequest) error {
|
||||
userID := middleware.CurrentUser(x)
|
||||
if userID == "" {
|
||||
return vigo.ErrNotAuthorized
|
||||
}
|
||||
|
||||
var client model.OAuthClient
|
||||
if err := model.DB.Where("client_id = ?", req.ID).First(&client).Error; err != nil {
|
||||
return vigo.ErrNotFound
|
||||
}
|
||||
|
||||
// 检查权限
|
||||
if client.OwnerID != userID {
|
||||
if client.OrgID != "" {
|
||||
var member model.OrgMember
|
||||
if err := model.DB.Where("org_id = ? AND user_id = ? AND status = ?", client.OrgID, userID, model.MemberStatusActive).First(&member).Error; err != nil {
|
||||
return vigo.ErrForbidden
|
||||
}
|
||||
} else {
|
||||
return vigo.ErrForbidden
|
||||
}
|
||||
}
|
||||
|
||||
// 软删除
|
||||
if err := model.DB.Delete(&client).Error; err != nil {
|
||||
return vigo.ErrInternalServer.WithError(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RegenerateSecretRequest 重新生成密钥请求
|
||||
type RegenerateSecretRequest struct {
|
||||
ID string `json:"id" src:"path@client_id" desc:"客户端ID"`
|
||||
}
|
||||
|
||||
// RegenerateSecret 重新生成客户端密钥
|
||||
func RegenerateSecret(x *vigo.X, req *RegenerateSecretRequest) (*ClientResponse, error) {
|
||||
userID := middleware.CurrentUser(x)
|
||||
if userID == "" {
|
||||
return nil, vigo.ErrNotAuthorized
|
||||
}
|
||||
|
||||
var client model.OAuthClient
|
||||
if err := model.DB.Where("client_id = ?", req.ID).First(&client).Error; err != nil {
|
||||
return nil, vigo.ErrNotFound
|
||||
}
|
||||
|
||||
// 检查权限
|
||||
if client.OwnerID != userID {
|
||||
if client.OrgID != "" {
|
||||
var member model.OrgMember
|
||||
if err := model.DB.Where("org_id = ? AND user_id = ? AND status = ?", client.OrgID, userID, model.MemberStatusActive).First(&member).Error; err != nil {
|
||||
return nil, vigo.ErrForbidden
|
||||
}
|
||||
} else {
|
||||
return nil, vigo.ErrForbidden
|
||||
}
|
||||
}
|
||||
|
||||
// 生成新密钥
|
||||
newSecret := generateClientSecret()
|
||||
if err := model.DB.Model(&client).Update("client_secret", newSecret).Error; err != nil {
|
||||
return nil, vigo.ErrInternalServer.WithError(err)
|
||||
}
|
||||
|
||||
resp := toClientResponse(&client, true)
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// helper functions
|
||||
func toClientResponse(c *model.OAuthClient, includeSecret bool) ClientResponse {
|
||||
resp := ClientResponse{
|
||||
ID: c.ID,
|
||||
Name: c.Name,
|
||||
Description: c.Description,
|
||||
ClientID: c.ClientID,
|
||||
RedirectURIs: strings.Split(c.RedirectURIs, ","),
|
||||
GrantTypes: strings.Split(c.GrantTypes, ","),
|
||||
ResponseTypes: strings.Split(c.ResponseTypes, ","),
|
||||
AllowedScopes: strings.Split(c.AllowedScopes, ","),
|
||||
TokenExpiry: c.TokenExpiry,
|
||||
RefreshExpiry: c.RefreshExpiry,
|
||||
OwnerID: c.OwnerID,
|
||||
OrgID: c.OrgID,
|
||||
Status: c.Status,
|
||||
CreatedAt: c.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||
}
|
||||
if includeSecret {
|
||||
resp.ClientSecret = c.ClientSecret
|
||||
}
|
||||
return resp
|
||||
}
|
||||
@ -0,0 +1,511 @@
|
||||
package org
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/veypi/vbase/internal/api/middleware"
|
||||
"github.com/veypi/vbase/internal/model"
|
||||
"github.com/veypi/vigo"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ListRequest 组织列表请求
|
||||
type ListRequest struct {
|
||||
Page int `json:"page" src:"query" default:"1" desc:"页码"`
|
||||
PageSize int `json:"page_size" src:"query" default:"10" desc:"每页数量"`
|
||||
Keyword string `json:"keyword" src:"query" desc:"搜索关键词"`
|
||||
}
|
||||
|
||||
// ListResponse 组织列表响应
|
||||
type ListResponse struct {
|
||||
Items []OrgInfo `json:"items"`
|
||||
Total int64 `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
}
|
||||
|
||||
// OrgInfo 组织信息
|
||||
type OrgInfo struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Code string `json:"code"`
|
||||
OwnerID string `json:"owner_id"`
|
||||
ParentID string `json:"parent_id,omitempty"`
|
||||
Path string `json:"path"`
|
||||
Level int `json:"level"`
|
||||
LeaderID string `json:"leader_id,omitempty"`
|
||||
Description string `json:"description"`
|
||||
Logo string `json:"logo"`
|
||||
Status int `json:"status"`
|
||||
MaxMembers int `json:"max_members"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
// 当前用户在该组织的信息
|
||||
MyRoles []string `json:"my_roles,omitempty"`
|
||||
MyStatus int `json:"my_status,omitempty"`
|
||||
}
|
||||
|
||||
// List 获取当前用户的组织列表
|
||||
func List(x *vigo.X, req *ListRequest) (*ListResponse, error) {
|
||||
userID := middleware.CurrentUser(x)
|
||||
if userID == "" {
|
||||
return nil, vigo.ErrNotAuthorized
|
||||
}
|
||||
|
||||
if req.Page < 1 {
|
||||
req.Page = 1
|
||||
}
|
||||
if req.PageSize < 1 || req.PageSize > 100 {
|
||||
req.PageSize = 10
|
||||
}
|
||||
|
||||
// 查询用户所属的组织ID
|
||||
var memberOrgs []model.OrgMember
|
||||
if err := model.DB.Where("user_id = ? AND status = ?", userID, model.MemberStatusActive).Find(&memberOrgs).Error; err != nil {
|
||||
return nil, vigo.ErrInternalServer.WithError(err)
|
||||
}
|
||||
|
||||
if len(memberOrgs) == 0 {
|
||||
return &ListResponse{
|
||||
Items: []OrgInfo{},
|
||||
Total: 0,
|
||||
Page: req.Page,
|
||||
PageSize: req.PageSize,
|
||||
TotalPages: 0,
|
||||
}, nil
|
||||
}
|
||||
|
||||
orgIDs := make([]string, 0, len(memberOrgs))
|
||||
memberMap := make(map[string]*model.OrgMember)
|
||||
for _, m := range memberOrgs {
|
||||
orgIDs = append(orgIDs, m.OrgID)
|
||||
memberMap[m.OrgID] = &m
|
||||
}
|
||||
|
||||
// 查询组织详情
|
||||
var total int64
|
||||
query := model.DB.Model(&model.Org{}).Where("id IN ?", orgIDs)
|
||||
if req.Keyword != "" {
|
||||
query = query.Where("name LIKE ? OR code LIKE ?", "%"+req.Keyword+"%", "%"+req.Keyword+"%")
|
||||
}
|
||||
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return nil, vigo.ErrInternalServer.WithError(err)
|
||||
}
|
||||
|
||||
var orgs []model.Org
|
||||
offset := (req.Page - 1) * req.PageSize
|
||||
if err := query.Offset(offset).Limit(req.PageSize).Order("created_at DESC").Find(&orgs).Error; err != nil {
|
||||
return nil, vigo.ErrInternalServer.WithError(err)
|
||||
}
|
||||
|
||||
items := make([]OrgInfo, 0, len(orgs))
|
||||
for _, o := range orgs {
|
||||
info := toOrgInfo(&o)
|
||||
if m, ok := memberMap[o.ID]; ok {
|
||||
info.MyRoles = parseRoles(m.RoleIDs)
|
||||
info.MyStatus = m.Status
|
||||
}
|
||||
items = append(items, info)
|
||||
}
|
||||
|
||||
totalPages := int((total + int64(req.PageSize) - 1) / int64(req.PageSize))
|
||||
|
||||
return &ListResponse{
|
||||
Items: items,
|
||||
Total: total,
|
||||
Page: req.Page,
|
||||
PageSize: req.PageSize,
|
||||
TotalPages: totalPages,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CreateRequest 创建组织请求
|
||||
type CreateRequest struct {
|
||||
Name string `json:"name" src:"json" desc:"组织名称"`
|
||||
Code string `json:"code" src:"json" desc:"组织编码"`
|
||||
ParentID *string `json:"parent_id" src:"json" desc:"父组织ID"`
|
||||
Description *string `json:"description" src:"json" desc:"描述"`
|
||||
Logo *string `json:"logo" src:"json" desc:"Logo"`
|
||||
MaxMembers *int `json:"max_members" src:"json" desc:"最大成员数"`
|
||||
}
|
||||
|
||||
// Create 创建组织
|
||||
func Create(x *vigo.X, req *CreateRequest) (*OrgInfo, error) {
|
||||
userID := middleware.CurrentUser(x)
|
||||
if userID == "" {
|
||||
return nil, vigo.ErrNotAuthorized
|
||||
}
|
||||
|
||||
// 检查编码是否已存在
|
||||
var count int64
|
||||
model.DB.Model(&model.Org{}).Where("code = ?", req.Code).Count(&count)
|
||||
if count > 0 {
|
||||
return nil, vigo.ErrArgInvalid.WithString("organization code already exists")
|
||||
}
|
||||
|
||||
// 构建组织路径
|
||||
path := "/" + req.Code
|
||||
level := 0
|
||||
if req.ParentID != nil && *req.ParentID != "" {
|
||||
var parent model.Org
|
||||
if err := model.DB.First(&parent, "id = ?", *req.ParentID).Error; err != nil {
|
||||
return nil, vigo.ErrArgInvalid.WithString("parent organization not found")
|
||||
}
|
||||
path = parent.Path + "/" + req.Code
|
||||
level = parent.Level + 1
|
||||
}
|
||||
|
||||
org := &model.Org{
|
||||
Name: req.Name,
|
||||
Code: req.Code,
|
||||
OwnerID: userID,
|
||||
Path: path,
|
||||
Level: level,
|
||||
Status: model.OrgStatusActive,
|
||||
MaxMembers: 100,
|
||||
}
|
||||
|
||||
if req.ParentID != nil {
|
||||
org.ParentID = req.ParentID
|
||||
}
|
||||
if req.Description != nil {
|
||||
org.Description = *req.Description
|
||||
}
|
||||
if req.Logo != nil {
|
||||
org.Logo = *req.Logo
|
||||
}
|
||||
if req.MaxMembers != nil {
|
||||
org.MaxMembers = *req.MaxMembers
|
||||
}
|
||||
|
||||
err := model.DB.Transaction(func(tx *gorm.DB) error {
|
||||
// 创建组织
|
||||
if err := tx.Create(org).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 创建成员关系(所有者)
|
||||
member := &model.OrgMember{
|
||||
OrgID: org.ID,
|
||||
UserID: userID,
|
||||
Status: model.MemberStatusActive,
|
||||
JoinedAt: time.Now().Format("2006-01-02 15:04:05"),
|
||||
}
|
||||
return tx.Create(member).Error
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, vigo.ErrInternalServer.WithError(err)
|
||||
}
|
||||
|
||||
info := toOrgInfo(org)
|
||||
return &info, nil
|
||||
}
|
||||
|
||||
// GetRequest 获取组织请求
|
||||
type GetRequest struct {
|
||||
ID string `json:"id" src:"path@org_id" desc:"组织ID"`
|
||||
}
|
||||
|
||||
// Get 获取组织详情
|
||||
func Get(x *vigo.X, req *GetRequest) (*OrgInfo, error) {
|
||||
userID := middleware.CurrentUser(x)
|
||||
if userID == "" {
|
||||
return nil, vigo.ErrNotAuthorized
|
||||
}
|
||||
|
||||
var org model.Org
|
||||
if err := model.DB.First(&org, "id = ?", req.ID).Error; err != nil {
|
||||
return nil, vigo.ErrNotFound
|
||||
}
|
||||
|
||||
// 检查用户是否是组织成员
|
||||
var member model.OrgMember
|
||||
if err := model.DB.Where("org_id = ? AND user_id = ?", req.ID, userID).First(&member).Error; err != nil {
|
||||
return nil, vigo.ErrForbidden.WithString("you are not a member of this organization")
|
||||
}
|
||||
|
||||
info := toOrgInfo(&org)
|
||||
info.MyRoles = parseRoles(member.RoleIDs)
|
||||
info.MyStatus = member.Status
|
||||
return &info, nil
|
||||
}
|
||||
|
||||
// UpdateRequest 更新组织请求
|
||||
type UpdateRequest struct {
|
||||
ID string `json:"id" src:"path@org_id" desc:"组织ID"`
|
||||
Name *string `json:"name" src:"json" desc:"组织名称"`
|
||||
Description *string `json:"description" src:"json" desc:"描述"`
|
||||
Logo *string `json:"logo" src:"json" desc:"Logo"`
|
||||
LeaderID *string `json:"leader_id" src:"json" desc:"负责人ID"`
|
||||
MaxMembers *int `json:"max_members" src:"json" desc:"最大成员数"`
|
||||
Status *int `json:"status" src:"json" desc:"状态"`
|
||||
}
|
||||
|
||||
// Update 更新组织
|
||||
func Update(x *vigo.X, req *UpdateRequest) (*OrgInfo, error) {
|
||||
userID := middleware.CurrentUser(x)
|
||||
if userID == "" {
|
||||
return nil, vigo.ErrNotAuthorized
|
||||
}
|
||||
|
||||
var org model.Org
|
||||
if err := model.DB.First(&org, "id = ?", req.ID).Error; err != nil {
|
||||
return nil, vigo.ErrNotFound
|
||||
}
|
||||
|
||||
// 检查权限(只有所有者可以修改)
|
||||
if org.OwnerID != userID {
|
||||
return nil, vigo.ErrForbidden.WithString("only organization owner can update")
|
||||
}
|
||||
|
||||
updates := make(map[string]interface{})
|
||||
if req.Name != nil {
|
||||
updates["name"] = *req.Name
|
||||
}
|
||||
if req.Description != nil {
|
||||
updates["description"] = *req.Description
|
||||
}
|
||||
if req.Logo != nil {
|
||||
updates["logo"] = *req.Logo
|
||||
}
|
||||
if req.LeaderID != nil {
|
||||
updates["leader_id"] = *req.LeaderID
|
||||
}
|
||||
if req.MaxMembers != nil {
|
||||
updates["max_members"] = *req.MaxMembers
|
||||
}
|
||||
if req.Status != nil {
|
||||
updates["status"] = *req.Status
|
||||
}
|
||||
|
||||
if len(updates) > 0 {
|
||||
if err := model.DB.Model(&org).Updates(updates).Error; err != nil {
|
||||
return nil, vigo.ErrInternalServer.WithError(err)
|
||||
}
|
||||
}
|
||||
|
||||
info := toOrgInfo(&org)
|
||||
return &info, nil
|
||||
}
|
||||
|
||||
// DeleteRequest 删除组织请求
|
||||
type DeleteRequest struct {
|
||||
ID string `json:"id" src:"path@org_id" desc:"组织ID"`
|
||||
}
|
||||
|
||||
// Delete 删除组织
|
||||
func Delete(x *vigo.X, req *DeleteRequest) error {
|
||||
userID := middleware.CurrentUser(x)
|
||||
if userID == "" {
|
||||
return vigo.ErrNotAuthorized
|
||||
}
|
||||
|
||||
var org model.Org
|
||||
if err := model.DB.First(&org, "id = ?", req.ID).Error; err != nil {
|
||||
return vigo.ErrNotFound
|
||||
}
|
||||
|
||||
// 检查权限(只有所有者可以删除)
|
||||
if org.OwnerID != userID {
|
||||
return vigo.ErrForbidden.WithString("only organization owner can delete")
|
||||
}
|
||||
|
||||
// 检查是否有子组织
|
||||
var childCount int64
|
||||
model.DB.Model(&model.Org{}).Where("parent_id = ?", req.ID).Count(&childCount)
|
||||
if childCount > 0 {
|
||||
return vigo.ErrArgInvalid.WithString("cannot delete organization with sub-organizations")
|
||||
}
|
||||
|
||||
// 软删除
|
||||
if err := model.DB.Delete(&org).Error; err != nil {
|
||||
return vigo.ErrInternalServer.WithError(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// TreeResponse 组织树响应
|
||||
type TreeResponse struct {
|
||||
OrgInfo
|
||||
Children []TreeResponse `json:"children,omitempty"`
|
||||
}
|
||||
|
||||
// Tree 获取组织树
|
||||
func Tree(x *vigo.X) ([]TreeResponse, error) {
|
||||
userID := middleware.CurrentUser(x)
|
||||
if userID == "" {
|
||||
return nil, vigo.ErrNotAuthorized
|
||||
}
|
||||
|
||||
// 获取用户所属的所有组织ID
|
||||
var memberOrgs []model.OrgMember
|
||||
if err := model.DB.Where("user_id = ? AND status = ?", userID, model.MemberStatusActive).Find(&memberOrgs).Error; err != nil {
|
||||
return nil, vigo.ErrInternalServer.WithError(err)
|
||||
}
|
||||
|
||||
if len(memberOrgs) == 0 {
|
||||
return []TreeResponse{}, nil
|
||||
}
|
||||
|
||||
orgIDs := make([]string, 0, len(memberOrgs))
|
||||
for _, m := range memberOrgs {
|
||||
orgIDs = append(orgIDs, m.OrgID)
|
||||
}
|
||||
|
||||
// 获取所有组织
|
||||
var orgs []model.Org
|
||||
if err := model.DB.Where("id IN ?", orgIDs).Order("path").Find(&orgs).Error; err != nil {
|
||||
return nil, vigo.ErrInternalServer.WithError(err)
|
||||
}
|
||||
|
||||
// 构建树
|
||||
return buildOrgTree(orgs, ""), nil
|
||||
}
|
||||
|
||||
func buildOrgTree(orgs []model.Org, parentID string) []TreeResponse {
|
||||
var result []TreeResponse
|
||||
for _, o := range orgs {
|
||||
if (parentID == "" && (o.ParentID == nil || *o.ParentID == "")) ||
|
||||
(o.ParentID != nil && *o.ParentID == parentID) {
|
||||
node := TreeResponse{
|
||||
OrgInfo: toOrgInfo(&o),
|
||||
}
|
||||
node.Children = buildOrgTree(orgs, o.ID)
|
||||
result = append(result, node)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// helper functions
|
||||
|
||||
func toOrgInfo(o *model.Org) OrgInfo {
|
||||
info := OrgInfo{
|
||||
ID: o.ID,
|
||||
Name: o.Name,
|
||||
Code: o.Code,
|
||||
OwnerID: o.OwnerID,
|
||||
Path: o.Path,
|
||||
Level: o.Level,
|
||||
Description: o.Description,
|
||||
Logo: o.Logo,
|
||||
Status: o.Status,
|
||||
MaxMembers: o.MaxMembers,
|
||||
CreatedAt: o.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||
UpdatedAt: o.UpdatedAt.Format("2006-01-02 15:04:05"),
|
||||
}
|
||||
if o.ParentID != nil {
|
||||
info.ParentID = *o.ParentID
|
||||
}
|
||||
if o.LeaderID != nil {
|
||||
info.LeaderID = *o.LeaderID
|
||||
}
|
||||
return info
|
||||
}
|
||||
|
||||
func parseRoles(roleIDs string) []string {
|
||||
if roleIDs == "" {
|
||||
return []string{}
|
||||
}
|
||||
return strings.Split(roleIDs, ",")
|
||||
}
|
||||
|
||||
func formatRoleID(roleIDs []string) string {
|
||||
return strings.Join(roleIDs, ",")
|
||||
}
|
||||
|
||||
// ListMembersRequest 成员列表请求
|
||||
type ListMembersRequest struct {
|
||||
OrgID string `json:"org_id" src:"path@org_id" desc:"组织ID"`
|
||||
Page int `json:"page" src:"query" default:"1" desc:"页码"`
|
||||
PageSize int `json:"page_size" src:"query" default:"10" desc:"每页数量"`
|
||||
Status *int `json:"status" src:"query" desc:"状态筛选"`
|
||||
}
|
||||
|
||||
// MemberInfo 成员信息
|
||||
type MemberInfo struct {
|
||||
ID string `json:"id"`
|
||||
OrgID string `json:"org_id"`
|
||||
UserID string `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
Nickname string `json:"nickname"`
|
||||
Avatar string `json:"avatar"`
|
||||
Email string `json:"email"`
|
||||
Roles []string `json:"roles"`
|
||||
Position string `json:"position"`
|
||||
Department string `json:"department"`
|
||||
JoinedAt string `json:"joined_at"`
|
||||
Status int `json:"status"`
|
||||
}
|
||||
|
||||
// ListMembers 获取组织成员列表
|
||||
func ListMembers(x *vigo.X, req *ListMembersRequest) (*ListResponse, error) {
|
||||
userID := middleware.CurrentUser(x)
|
||||
if userID == "" {
|
||||
return nil, vigo.ErrNotAuthorized
|
||||
}
|
||||
|
||||
if req.Page < 1 {
|
||||
req.Page = 1
|
||||
}
|
||||
if req.PageSize < 1 || req.PageSize > 100 {
|
||||
req.PageSize = 10
|
||||
}
|
||||
|
||||
// 检查用户是否是组织成员
|
||||
var currentMember model.OrgMember
|
||||
if err := model.DB.Where("org_id = ? AND user_id = ?", req.OrgID, userID).First(¤tMember).Error; err != nil {
|
||||
return nil, vigo.ErrForbidden.WithString("you are not a member of this organization")
|
||||
}
|
||||
|
||||
var total int64
|
||||
query := model.DB.Model(&model.OrgMember{}).Where("org_id = ?", req.OrgID)
|
||||
if req.Status != nil {
|
||||
query = query.Where("status = ?", *req.Status)
|
||||
}
|
||||
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return nil, vigo.ErrInternalServer.WithError(err)
|
||||
}
|
||||
|
||||
var members []model.OrgMember
|
||||
offset := (req.Page - 1) * req.PageSize
|
||||
if err := query.Offset(offset).Limit(req.PageSize).Find(&members).Error; err != nil {
|
||||
return nil, vigo.ErrInternalServer.WithError(err)
|
||||
}
|
||||
|
||||
items := make([]OrgInfo, 0) // 这里需要修改返回类型
|
||||
_ = items
|
||||
|
||||
// 获取用户信息
|
||||
memberInfos := make([]MemberInfo, 0, len(members))
|
||||
for _, m := range members {
|
||||
var user model.User
|
||||
if err := model.DB.First(&user, "id = ?", m.UserID).Error; err != nil {
|
||||
continue
|
||||
}
|
||||
memberInfos = append(memberInfos, MemberInfo{
|
||||
ID: m.ID,
|
||||
OrgID: m.OrgID,
|
||||
UserID: m.UserID,
|
||||
Username: user.Username,
|
||||
Nickname: user.Nickname,
|
||||
Avatar: user.Avatar,
|
||||
Email: user.Email,
|
||||
Roles: parseRoles(m.RoleIDs),
|
||||
Position: m.Position,
|
||||
Department: m.Department,
|
||||
JoinedAt: m.JoinedAt,
|
||||
Status: m.Status,
|
||||
})
|
||||
}
|
||||
|
||||
_ = memberInfos
|
||||
return nil, fmt.Errorf("not fully implemented")
|
||||
}
|
||||
@ -0,0 +1,83 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"github.com/veypi/vbase/internal/api/auth"
|
||||
"github.com/veypi/vbase/internal/api/middleware"
|
||||
"github.com/veypi/vbase/internal/api/oauth"
|
||||
"github.com/veypi/vbase/internal/api/org"
|
||||
"github.com/veypi/vbase/internal/api/user"
|
||||
"github.com/veypi/vigo"
|
||||
"github.com/veypi/vigo/contrib/common"
|
||||
)
|
||||
|
||||
// NewRouter 创建API路由
|
||||
func NewRouter() vigo.Router {
|
||||
r := vigo.NewRouter()
|
||||
|
||||
// 全局中间件
|
||||
r.Use(middleware.AuthRequired())
|
||||
r.Use(middleware.OrgContext())
|
||||
r.After(common.JsonResponse, common.JsonErrorResponse)
|
||||
|
||||
// === 公开路由 ===
|
||||
authRouter := vigo.NewRouter()
|
||||
authRouter.Use(vigo.SkipBefore)
|
||||
authRouter.Post("/login", "用户登录", auth.Login)
|
||||
authRouter.Post("/register", "用户注册", auth.Register)
|
||||
authRouter.Post("/refresh", "刷新Token", auth.Refresh)
|
||||
authRouter.Post("/logout", "用户登出", auth.Logout)
|
||||
r.Extend("/auth", authRouter)
|
||||
|
||||
// === 当前用户 ===
|
||||
meRouter := vigo.NewRouter()
|
||||
meRouter.Get("/", "获取当前用户信息", auth.Me)
|
||||
meRouter.Patch("/", "更新当前用户信息", auth.UpdateMe)
|
||||
meRouter.Post("/change-password", "修改密码", auth.ChangePassword)
|
||||
r.Extend("/me", meRouter)
|
||||
|
||||
// === 用户管理 ===
|
||||
userRouter := vigo.NewRouter()
|
||||
userRouter.Get("/", "用户列表", user.List)
|
||||
userRouter.Post("/", "创建用户", user.Create)
|
||||
userRouter.Get("/{user_id}", "获取用户详情", user.Get)
|
||||
userRouter.Patch("/{user_id}", "更新用户", user.Update)
|
||||
userRouter.Delete("/{user_id}", "删除用户", user.Delete)
|
||||
userRouter.Patch("/{user_id}/status", "更新用户状态", user.UpdateStatus)
|
||||
r.Extend("/users", userRouter)
|
||||
|
||||
// === 组织管理 ===
|
||||
orgRouter := vigo.NewRouter()
|
||||
orgRouter.Get("/", "组织列表", org.List)
|
||||
orgRouter.Post("/", "创建组织", org.Create)
|
||||
orgRouter.Get("/{org_id}", "获取组织详情", org.Get)
|
||||
orgRouter.Patch("/{org_id}", "更新组织", org.Update)
|
||||
orgRouter.Delete("/{org_id}", "删除组织", org.Delete)
|
||||
orgRouter.Get("/tree", "组织树", org.Tree)
|
||||
orgRouter.Get("/{org_id}/members", "组织成员列表", org.ListMembers)
|
||||
r.Extend("/orgs", orgRouter)
|
||||
|
||||
// === OAuth2.0服务端 ===
|
||||
oauthRouter := vigo.NewRouter()
|
||||
// OAuth公开端点
|
||||
oauthRouter.Use(vigo.SkipBefore)
|
||||
oauthRouter.Get("/authorize", "授权端点", oauth.Authorize)
|
||||
oauthRouter.Post("/token", "令牌端点", oauth.Token)
|
||||
oauthRouter.Post("/revoke", "撤销令牌", oauth.Revoke)
|
||||
oauthRouter.Post("/introspect", "令牌内省", oauth.Introspect)
|
||||
oauthRouter.Get("/userinfo", "用户信息(OIDC)", oauth.UserInfo)
|
||||
oauthRouter.Get("/.well-known/openid-configuration", "OIDC发现文档", oauth.Discovery)
|
||||
oauthRouter.Get("/jwks", "JWKS公钥", oauth.JWKS)
|
||||
r.Extend("/oauth", oauthRouter)
|
||||
|
||||
// OAuth客户端管理
|
||||
oauthClientRouter := vigo.NewRouter()
|
||||
oauthClientRouter.Get("/", "OAuth客户端列表", oauth.ListClients)
|
||||
oauthClientRouter.Post("/", "创建OAuth客户端", oauth.CreateClient)
|
||||
oauthClientRouter.Get("/{client_id}", "获取客户端详情", oauth.GetClient)
|
||||
oauthClientRouter.Patch("/{client_id}", "更新OAuth客户端", oauth.UpdateClient)
|
||||
oauthClientRouter.Delete("/{client_id}", "删除OAuth客户端", oauth.DeleteClient)
|
||||
oauthClientRouter.Post("/{client_id}/regenerate-secret", "重新生成密钥", oauth.RegenerateSecret)
|
||||
r.Extend("/oauth/clients", oauthClientRouter)
|
||||
|
||||
return r
|
||||
}
|
||||
@ -0,0 +1,278 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"github.com/veypi/vbase/internal/model"
|
||||
"github.com/veypi/vbase/internal/pkg/crypto"
|
||||
"github.com/veypi/vigo"
|
||||
)
|
||||
|
||||
// ListRequest 用户列表请求
|
||||
type ListRequest struct {
|
||||
Page int `json:"page" src:"query" default:"1" desc:"页码"`
|
||||
PageSize int `json:"page_size" src:"query" default:"10" desc:"每页数量"`
|
||||
Keyword string `json:"keyword" src:"query" desc:"搜索关键词"`
|
||||
Status *int `json:"status" src:"query" desc:"状态筛选"`
|
||||
}
|
||||
|
||||
// ListResponse 用户列表响应
|
||||
type ListResponse struct {
|
||||
Items []UserInfo `json:"items"`
|
||||
Total int64 `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
}
|
||||
|
||||
// UserInfo 用户信息
|
||||
type UserInfo struct {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Nickname string `json:"nickname"`
|
||||
Avatar string `json:"avatar"`
|
||||
Email string `json:"email"`
|
||||
Phone string `json:"phone"`
|
||||
Status int `json:"status"`
|
||||
EmailVerified bool `json:"email_verified"`
|
||||
PhoneVerified bool `json:"phone_verified"`
|
||||
LastLoginAt string `json:"last_login_at,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// List 用户列表
|
||||
func List(x *vigo.X, req *ListRequest) (*ListResponse, error) {
|
||||
if req.Page < 1 {
|
||||
req.Page = 1
|
||||
}
|
||||
if req.PageSize < 1 || req.PageSize > 100 {
|
||||
req.PageSize = 10
|
||||
}
|
||||
|
||||
var total int64
|
||||
query := model.DB.Model(&model.User{})
|
||||
|
||||
if req.Keyword != "" {
|
||||
query = query.Where("username LIKE ? OR nickname LIKE ? OR email LIKE ?", "%"+req.Keyword+"%", "%"+req.Keyword+"%", "%"+req.Keyword+"%")
|
||||
}
|
||||
if req.Status != nil {
|
||||
query = query.Where("status = ?", *req.Status)
|
||||
}
|
||||
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return nil, vigo.ErrInternalServer.WithError(err)
|
||||
}
|
||||
|
||||
var users []model.User
|
||||
offset := (req.Page - 1) * req.PageSize
|
||||
if err := query.Offset(offset).Limit(req.PageSize).Order("created_at DESC").Find(&users).Error; err != nil {
|
||||
return nil, vigo.ErrInternalServer.WithError(err)
|
||||
}
|
||||
|
||||
items := make([]UserInfo, 0, len(users))
|
||||
for _, u := range users {
|
||||
items = append(items, toUserInfo(&u))
|
||||
}
|
||||
|
||||
totalPages := int((total + int64(req.PageSize) - 1) / int64(req.PageSize))
|
||||
|
||||
return &ListResponse{
|
||||
Items: items,
|
||||
Total: total,
|
||||
Page: req.Page,
|
||||
PageSize: req.PageSize,
|
||||
TotalPages: totalPages,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetRequest 获取用户请求
|
||||
type GetRequest struct {
|
||||
ID string `json:"id" src:"path@user_id" desc:"用户ID"`
|
||||
}
|
||||
|
||||
// Get 获取用户详情
|
||||
func Get(x *vigo.X, req *GetRequest) (*UserInfo, error) {
|
||||
var user model.User
|
||||
if err := model.DB.First(&user, "id = ?", req.ID).Error; err != nil {
|
||||
return nil, vigo.ErrNotFound
|
||||
}
|
||||
|
||||
info := toUserInfo(&user)
|
||||
return &info, nil
|
||||
}
|
||||
|
||||
// CreateRequest 创建用户请求
|
||||
type CreateRequest struct {
|
||||
Username string `json:"username" src:"json" desc:"用户名"`
|
||||
Password string `json:"password" src:"json" desc:"密码"`
|
||||
Nickname *string `json:"nickname" src:"json" desc:"昵称"`
|
||||
Email *string `json:"email" src:"json" desc:"邮箱"`
|
||||
Phone *string `json:"phone" src:"json" desc:"手机号"`
|
||||
Status *int `json:"status" src:"json" desc:"状态"`
|
||||
}
|
||||
|
||||
// Create 创建用户
|
||||
func Create(x *vigo.X, req *CreateRequest) (*UserInfo, error) {
|
||||
// 检查用户名是否已存在
|
||||
var count int64
|
||||
model.DB.Model(&model.User{}).Where("username = ?", req.Username).Count(&count)
|
||||
if count > 0 {
|
||||
return nil, vigo.ErrArgInvalid.WithString("username already exists")
|
||||
}
|
||||
|
||||
// 检查邮箱是否已存在
|
||||
if req.Email != nil && *req.Email != "" {
|
||||
model.DB.Model(&model.User{}).Where("email = ?", *req.Email).Count(&count)
|
||||
if count > 0 {
|
||||
return nil, vigo.ErrArgInvalid.WithString("email already exists")
|
||||
}
|
||||
}
|
||||
|
||||
// 哈希密码
|
||||
hashedPassword, err := crypto.HashPassword(req.Password, 12)
|
||||
if err != nil {
|
||||
return nil, vigo.ErrInternalServer.WithError(err)
|
||||
}
|
||||
|
||||
// 创建用户
|
||||
user := &model.User{
|
||||
Username: req.Username,
|
||||
Password: hashedPassword,
|
||||
Status: model.UserStatusActive,
|
||||
}
|
||||
|
||||
if req.Nickname != nil {
|
||||
user.Nickname = *req.Nickname
|
||||
} else {
|
||||
user.Nickname = req.Username
|
||||
}
|
||||
if req.Email != nil {
|
||||
user.Email = *req.Email
|
||||
}
|
||||
if req.Phone != nil {
|
||||
user.Phone = *req.Phone
|
||||
}
|
||||
if req.Status != nil {
|
||||
user.Status = *req.Status
|
||||
}
|
||||
|
||||
if err := model.DB.Create(user).Error; err != nil {
|
||||
return nil, vigo.ErrInternalServer.WithError(err)
|
||||
}
|
||||
|
||||
info := toUserInfo(user)
|
||||
return &info, nil
|
||||
}
|
||||
|
||||
// UpdateRequest 更新用户请求
|
||||
type UpdateRequest struct {
|
||||
ID string `json:"id" src:"path@user_id" desc:"用户ID"`
|
||||
Nickname *string `json:"nickname" src:"json" desc:"昵称"`
|
||||
Avatar *string `json:"avatar" src:"json" desc:"头像"`
|
||||
Email *string `json:"email" src:"json" desc:"邮箱"`
|
||||
Phone *string `json:"phone" src:"json" desc:"手机号"`
|
||||
Status *int `json:"status" src:"json" desc:"状态"`
|
||||
}
|
||||
|
||||
// Update 更新用户
|
||||
func Update(x *vigo.X, req *UpdateRequest) (*UserInfo, error) {
|
||||
var user model.User
|
||||
if err := model.DB.First(&user, "id = ?", req.ID).Error; err != nil {
|
||||
return nil, vigo.ErrNotFound
|
||||
}
|
||||
|
||||
updates := make(map[string]interface{})
|
||||
if req.Nickname != nil {
|
||||
updates["nickname"] = *req.Nickname
|
||||
}
|
||||
if req.Avatar != nil {
|
||||
updates["avatar"] = *req.Avatar
|
||||
}
|
||||
if req.Email != nil && *req.Email != user.Email {
|
||||
// 检查邮箱是否被其他用户使用
|
||||
var count int64
|
||||
model.DB.Model(&model.User{}).Where("email = ? AND id != ?", *req.Email, req.ID).Count(&count)
|
||||
if count > 0 {
|
||||
return nil, vigo.ErrArgInvalid.WithString("email already exists")
|
||||
}
|
||||
updates["email"] = *req.Email
|
||||
}
|
||||
if req.Phone != nil && *req.Phone != user.Phone {
|
||||
var count int64
|
||||
model.DB.Model(&model.User{}).Where("phone = ? AND id != ?", *req.Phone, req.ID).Count(&count)
|
||||
if count > 0 {
|
||||
return nil, vigo.ErrArgInvalid.WithString("phone already exists")
|
||||
}
|
||||
updates["phone"] = *req.Phone
|
||||
}
|
||||
if req.Status != nil {
|
||||
updates["status"] = *req.Status
|
||||
}
|
||||
|
||||
if len(updates) > 0 {
|
||||
if err := model.DB.Model(&user).Updates(updates).Error; err != nil {
|
||||
return nil, vigo.ErrInternalServer.WithError(err)
|
||||
}
|
||||
}
|
||||
|
||||
info := toUserInfo(&user)
|
||||
return &info, nil
|
||||
}
|
||||
|
||||
// DeleteRequest 删除用户请求
|
||||
type DeleteRequest struct {
|
||||
ID string `json:"id" src:"path@user_id" desc:"用户ID"`
|
||||
}
|
||||
|
||||
// Delete 删除用户(软删除)
|
||||
func Delete(x *vigo.X, req *DeleteRequest) error {
|
||||
var user model.User
|
||||
if err := model.DB.First(&user, "id = ?", req.ID).Error; err != nil {
|
||||
return vigo.ErrNotFound
|
||||
}
|
||||
|
||||
if err := model.DB.Delete(&user).Error; err != nil {
|
||||
return vigo.ErrInternalServer.WithError(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateStatusRequest 更新用户状态请求
|
||||
type UpdateStatusRequest struct {
|
||||
ID string `json:"id" src:"path@user_id" desc:"用户ID"`
|
||||
Status int `json:"status" src:"json" desc:"状态: 0禁用 1正常 2未激活"`
|
||||
}
|
||||
|
||||
// UpdateStatus 更新用户状态
|
||||
func UpdateStatus(x *vigo.X, req *UpdateStatusRequest) (*UserInfo, error) {
|
||||
var user model.User
|
||||
if err := model.DB.First(&user, "id = ?", req.ID).Error; err != nil {
|
||||
return nil, vigo.ErrNotFound
|
||||
}
|
||||
|
||||
if err := model.DB.Model(&user).Update("status", req.Status).Error; err != nil {
|
||||
return nil, vigo.ErrInternalServer.WithError(err)
|
||||
}
|
||||
|
||||
info := toUserInfo(&user)
|
||||
return &info, nil
|
||||
}
|
||||
|
||||
// helper function
|
||||
func toUserInfo(u *model.User) UserInfo {
|
||||
info := UserInfo{
|
||||
ID: u.ID,
|
||||
Username: u.Username,
|
||||
Nickname: u.Nickname,
|
||||
Avatar: u.Avatar,
|
||||
Email: u.Email,
|
||||
Phone: u.Phone,
|
||||
Status: u.Status,
|
||||
EmailVerified: u.EmailVerified,
|
||||
PhoneVerified: u.PhoneVerified,
|
||||
CreatedAt: u.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||
}
|
||||
if u.LastLoginAt != nil {
|
||||
info.LastLoginAt = u.LastLoginAt.Format("2006-01-02 15:04:05")
|
||||
}
|
||||
return info
|
||||
}
|
||||
@ -0,0 +1,167 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Config 全局配置
|
||||
type Config struct {
|
||||
// 服务配置
|
||||
Server ServerConfig
|
||||
|
||||
// 数据库配置
|
||||
Database DatabaseConfig
|
||||
|
||||
// Redis配置
|
||||
Redis RedisConfig
|
||||
|
||||
// JWT配置
|
||||
JWT JWTConfig
|
||||
|
||||
// 安全配置
|
||||
Security SecurityConfig
|
||||
|
||||
// OAuth配置
|
||||
OAuth OAuthConfig
|
||||
|
||||
// 应用信息
|
||||
App AppConfig
|
||||
}
|
||||
|
||||
type ServerConfig struct {
|
||||
Host string
|
||||
Port int
|
||||
Mode string // debug/release
|
||||
}
|
||||
|
||||
type DatabaseConfig struct {
|
||||
Type string // mysql/postgres/sqlite
|
||||
DSN string
|
||||
MaxOpenConns int
|
||||
MaxIdleConns int
|
||||
ConnMaxLifetime time.Duration
|
||||
}
|
||||
|
||||
type RedisConfig struct {
|
||||
Addr string
|
||||
Password string
|
||||
DB int
|
||||
Enabled bool
|
||||
}
|
||||
|
||||
type JWTConfig struct {
|
||||
Secret string
|
||||
AccessExpiry time.Duration
|
||||
RefreshExpiry time.Duration
|
||||
Issuer string
|
||||
}
|
||||
|
||||
type SecurityConfig struct {
|
||||
BcryptCost int
|
||||
MaxLoginAttempts int
|
||||
CaptchaEnabled bool
|
||||
}
|
||||
|
||||
type OAuthConfig struct {
|
||||
AuthorizationExpiry time.Duration
|
||||
AccessExpiry time.Duration
|
||||
RefreshExpiry time.Duration
|
||||
}
|
||||
|
||||
type AppConfig struct {
|
||||
ID string
|
||||
Name string
|
||||
InitAdmin InitAdminConfig
|
||||
}
|
||||
|
||||
type InitAdminConfig struct {
|
||||
Username string
|
||||
Password string
|
||||
Email string
|
||||
}
|
||||
|
||||
var C *Config
|
||||
|
||||
func init() {
|
||||
C = &Config{
|
||||
Server: ServerConfig{
|
||||
Host: getEnv("SERVER_HOST", "0.0.0.0"),
|
||||
Port: getEnvInt("SERVER_PORT", 8080),
|
||||
Mode: getEnv("SERVER_MODE", "debug"),
|
||||
},
|
||||
Database: DatabaseConfig{
|
||||
Type: getEnv("DB_TYPE", "mysql"),
|
||||
DSN: getEnv("DB_DSN", "root:123456@tcp(127.0.0.1:3306)/vbase?charset=utf8mb4&parseTime=True&loc=Local"),
|
||||
MaxOpenConns: getEnvInt("DB_MAX_OPEN", 100),
|
||||
MaxIdleConns: getEnvInt("DB_MAX_IDLE", 10),
|
||||
ConnMaxLifetime: time.Hour,
|
||||
},
|
||||
Redis: RedisConfig{
|
||||
Enabled: getEnvBool("REDIS_ENABLED", true),
|
||||
Addr: getEnv("REDIS_ADDR", "localhost:6379"),
|
||||
Password: getEnv("REDIS_PASSWORD", ""),
|
||||
DB: getEnvInt("REDIS_DB", 0),
|
||||
},
|
||||
JWT: JWTConfig{
|
||||
Secret: getEnv("JWT_SECRET", "your-secret-key-change-in-production-min-32-characters"),
|
||||
AccessExpiry: getEnvDuration("JWT_ACCESS_EXPIRY", time.Hour),
|
||||
RefreshExpiry: getEnvDuration("JWT_REFRESH_EXPIRY", 30*24*time.Hour),
|
||||
Issuer: getEnv("JWT_ISSUER", "vbase"),
|
||||
},
|
||||
Security: SecurityConfig{
|
||||
BcryptCost: getEnvInt("BCRYPT_COST", 12),
|
||||
MaxLoginAttempts: getEnvInt("MAX_LOGIN_ATTEMPTS", 5),
|
||||
CaptchaEnabled: getEnvBool("CAPTCHA_ENABLED", true),
|
||||
},
|
||||
OAuth: OAuthConfig{
|
||||
AuthorizationExpiry: getEnvDuration("OAUTH_AUTH_EXPIRY", 10*time.Minute),
|
||||
AccessExpiry: getEnvDuration("OAUTH_ACCESS_EXPIRY", time.Hour),
|
||||
RefreshExpiry: getEnvDuration("OAUTH_REFRESH_EXPIRY", 30*24*time.Hour),
|
||||
},
|
||||
App: AppConfig{
|
||||
ID: getEnv("APP_ID", "vbase"),
|
||||
Name: getEnv("APP_NAME", "VBase IAM"),
|
||||
InitAdmin: InitAdminConfig{
|
||||
Username: getEnv("INIT_ADMIN_USERNAME", "admin"),
|
||||
Password: getEnv("INIT_ADMIN_PASSWORD", ""), // 为空时随机生成
|
||||
Email: getEnv("INIT_ADMIN_EMAIL", "admin@example.com"),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func getEnv(key, defaultVal string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return defaultVal
|
||||
}
|
||||
|
||||
func getEnvInt(key string, defaultVal int) int {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
if i, err := strconv.Atoi(v); err == nil {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return defaultVal
|
||||
}
|
||||
|
||||
func getEnvBool(key string, defaultVal bool) bool {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
if b, err := strconv.ParseBool(v); err == nil {
|
||||
return b
|
||||
}
|
||||
}
|
||||
return defaultVal
|
||||
}
|
||||
|
||||
func getEnvDuration(key string, defaultVal time.Duration) time.Duration {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
if d, err := time.ParseDuration(v); err == nil {
|
||||
return d
|
||||
}
|
||||
}
|
||||
return defaultVal
|
||||
}
|
||||
@ -0,0 +1,24 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Base 基础模型
|
||||
type Base struct {
|
||||
ID string `json:"id" gorm:"primaryKey;type:varchar(36)"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
}
|
||||
|
||||
// BeforeCreate 自动生成UUID
|
||||
func (b *Base) BeforeCreate(tx *gorm.DB) error {
|
||||
if b.ID == "" {
|
||||
b.ID = uuid.New().String()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@ -0,0 +1,92 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// OAuthClient OAuth客户端
|
||||
type OAuthClient struct {
|
||||
Base
|
||||
Name string `json:"name" gorm:"size:50;not null"`
|
||||
Description string `json:"description" gorm:"size:200"`
|
||||
ClientID string `json:"client_id" gorm:"uniqueIndex;size:32;not null"`
|
||||
ClientSecret string `json:"-" gorm:"size:64;not null"`
|
||||
RedirectURIs string `json:"redirect_uris" gorm:"type:text"` // 逗号分隔
|
||||
GrantTypes string `json:"grant_types" gorm:"size:100"` // authorization_code/refresh_token/client_credentials
|
||||
ResponseTypes string `json:"response_types" gorm:"size:50"` // code/token
|
||||
AllowedScopes string `json:"allowed_scopes" gorm:"size:200"` // openid profile email org roles
|
||||
TokenExpiry int `json:"token_expiry" gorm:"default:3600"` // access_token有效期(秒)
|
||||
RefreshExpiry int `json:"refresh_expiry" gorm:"default:2592000"`
|
||||
OwnerID string `json:"owner_id" gorm:"not null"`
|
||||
OrgID string `json:"org_id" gorm:"index"`
|
||||
Status int `json:"status" gorm:"default:1"`
|
||||
}
|
||||
|
||||
func (OAuthClient) TableName() string {
|
||||
return "oauth_clients"
|
||||
}
|
||||
|
||||
// OAuthAuthorization 授权码
|
||||
type OAuthAuthorization struct {
|
||||
Base
|
||||
UserID string `json:"user_id" gorm:"index;not null"`
|
||||
ClientID string `json:"client_id" gorm:"index;not null"`
|
||||
OrgID string `json:"org_id" gorm:"index"`
|
||||
Code string `json:"code" gorm:"uniqueIndex;size:64"`
|
||||
Scope string `json:"scope" gorm:"size:200"`
|
||||
State string `json:"state" gorm:"size:100"`
|
||||
RedirectURI string `json:"redirect_uri" gorm:"size:500"`
|
||||
CodeChallenge string `json:"-" gorm:"size:128"`
|
||||
CodeChallengeMethod string `json:"-" gorm:"size:10"`
|
||||
Used bool `json:"used" gorm:"default:false"`
|
||||
UsedAt *time.Time `json:"used_at"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
}
|
||||
|
||||
func (OAuthAuthorization) TableName() string {
|
||||
return "oauth_authorizations"
|
||||
}
|
||||
|
||||
// OAuthToken OAuth访问令牌
|
||||
type OAuthToken struct {
|
||||
Base
|
||||
UserID string `json:"user_id" gorm:"index;not null"`
|
||||
ClientID string `json:"client_id" gorm:"index;not null"`
|
||||
OrgID string `json:"org_id" gorm:"index"`
|
||||
AccessToken string `json:"-" gorm:"uniqueIndex;size:64"`
|
||||
RefreshToken string `json:"-" gorm:"uniqueIndex;size:64"`
|
||||
TokenType string `json:"token_type" gorm:"size:10;default:Bearer"`
|
||||
Scope string `json:"scope" gorm:"size:200"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
Revoked bool `json:"revoked" gorm:"default:false"`
|
||||
RevokedAt *time.Time `json:"revoked_at"`
|
||||
}
|
||||
|
||||
func (OAuthToken) TableName() string {
|
||||
return "oauth_tokens"
|
||||
}
|
||||
|
||||
// Grant Types
|
||||
const (
|
||||
GrantTypeAuthorizationCode = "authorization_code"
|
||||
GrantTypeRefreshToken = "refresh_token"
|
||||
GrantTypeClientCredentials = "client_credentials"
|
||||
GrantTypePassword = "password"
|
||||
)
|
||||
|
||||
// Response Types
|
||||
const (
|
||||
ResponseTypeCode = "code"
|
||||
ResponseTypeToken = "token"
|
||||
)
|
||||
|
||||
// Scopes
|
||||
const (
|
||||
ScopeOpenID = "openid"
|
||||
ScopeProfile = "profile"
|
||||
ScopeEmail = "email"
|
||||
ScopePhone = "phone"
|
||||
ScopeOrg = "org"
|
||||
ScopeRoles = "roles"
|
||||
ScopeOffline = "offline_access"
|
||||
)
|
||||
@ -0,0 +1,61 @@
|
||||
package model
|
||||
|
||||
// Org 组织/租户
|
||||
type Org struct {
|
||||
Base
|
||||
Name string `json:"name" gorm:"size:50;not null"`
|
||||
Code string `json:"code" gorm:"uniqueIndex;size:30;not null"`
|
||||
OwnerID string `json:"owner_id" gorm:"not null"`
|
||||
ParentID *string `json:"parent_id" gorm:"index"`
|
||||
Path string `json:"path" gorm:"size:500;index"`
|
||||
Level int `json:"level" gorm:"default:0"`
|
||||
LeaderID *string `json:"leader_id"`
|
||||
Description string `json:"description" gorm:"size:200"`
|
||||
Logo string `json:"logo" gorm:"size:500"`
|
||||
Settings string `json:"-" gorm:"type:text"` // JSON配置
|
||||
Status int `json:"status" gorm:"default:1"`
|
||||
MaxMembers int `json:"max_members" gorm:"default:100"`
|
||||
}
|
||||
|
||||
func (Org) TableName() string {
|
||||
return "orgs"
|
||||
}
|
||||
|
||||
// OrgMember 组织成员关系
|
||||
type OrgMember struct {
|
||||
Base
|
||||
OrgID string `json:"org_id" gorm:"type:varchar(36);uniqueIndex:idx_org_user;not null"`
|
||||
UserID string `json:"user_id" gorm:"type:varchar(36);uniqueIndex:idx_org_user;not null"`
|
||||
RoleIDs string `json:"role_ids" gorm:"size:200"` // 逗号分隔
|
||||
Position string `json:"position" gorm:"size:50"`
|
||||
Department string `json:"department" gorm:"size:50"`
|
||||
JoinedAt string `json:"joined_at"`
|
||||
Status int `json:"status" gorm:"default:1"` // 0:待审核 1:正常 2:禁用
|
||||
}
|
||||
|
||||
func (OrgMember) TableName() string {
|
||||
return "org_members"
|
||||
}
|
||||
|
||||
// OrgStatus 组织状态
|
||||
const (
|
||||
OrgStatusDisabled = 0
|
||||
OrgStatusActive = 1
|
||||
)
|
||||
|
||||
// MemberStatus 成员状态
|
||||
const (
|
||||
MemberStatusPending = 0
|
||||
MemberStatusActive = 1
|
||||
MemberStatusDisabled = 2
|
||||
)
|
||||
|
||||
// IsOwner 检查用户是否是组织所有者
|
||||
func (o *Org) IsOwner(userID string) bool {
|
||||
return o.OwnerID == userID
|
||||
}
|
||||
|
||||
// IsMemberActive 成员是否有效
|
||||
func (m *OrgMember) IsMemberActive() bool {
|
||||
return m.Status == MemberStatusActive
|
||||
}
|
||||
@ -0,0 +1,65 @@
|
||||
package model
|
||||
|
||||
// Policy 策略定义
|
||||
type Policy struct {
|
||||
Base
|
||||
OrgID string `json:"org_id" gorm:"index"` // 空表示全局策略
|
||||
Code string `json:"code" gorm:"uniqueIndex;size:50;not null"`
|
||||
Name string `json:"name" gorm:"size:50;not null"`
|
||||
Description string `json:"description" gorm:"size:200"`
|
||||
Resource string `json:"resource" gorm:"size:50;not null"` // 资源类型
|
||||
Action string `json:"action" gorm:"size:20;not null"` // read/create/update/delete/*
|
||||
Condition string `json:"condition" gorm:"type:text"` // CEL表达式
|
||||
Effect string `json:"effect" gorm:"size:10;default:allow"`
|
||||
Priority int `json:"priority" gorm:"default:0"`
|
||||
IsSystem bool `json:"is_system" gorm:"default:false"`
|
||||
}
|
||||
|
||||
func (Policy) TableName() string {
|
||||
return "policies"
|
||||
}
|
||||
|
||||
// Role 角色
|
||||
type Role struct {
|
||||
Base
|
||||
OrgID string `json:"org_id" gorm:"index;not null"`
|
||||
Code string `json:"code" gorm:"size:50;not null"`
|
||||
Name string `json:"name" gorm:"size:50;not null"`
|
||||
Description string `json:"description" gorm:"size:200"`
|
||||
PolicyIDs string `json:"policy_ids" gorm:"type:text"` // 逗号分隔
|
||||
IsDefault bool `json:"is_default" gorm:"default:false"`
|
||||
IsSystem bool `json:"is_system" gorm:"default:false"`
|
||||
SortOrder int `json:"sort_order" gorm:"default:0"`
|
||||
}
|
||||
|
||||
func (Role) TableName() string {
|
||||
return "roles"
|
||||
}
|
||||
|
||||
// Effect 常量
|
||||
const (
|
||||
EffectAllow = "allow"
|
||||
EffectDeny = "deny"
|
||||
)
|
||||
|
||||
// System Policies 系统内置策略编码
|
||||
const (
|
||||
SysPolicyUserReadOwn = "sys:user:read:own"
|
||||
SysPolicyUserUpdateOwn = "sys:user:update:own"
|
||||
SysPolicyUserDeleteOwn = "sys:user:delete:own"
|
||||
SysPolicyOrgAdmin = "sys:org:admin"
|
||||
SysPolicyOrgRead = "sys:org:read"
|
||||
SysPolicyMemberRead = "sys:member:read"
|
||||
SysPolicyMemberManage = "sys:member:manage"
|
||||
SysPolicyRoleRead = "sys:role:read"
|
||||
SysPolicyRoleManage = "sys:role:manage"
|
||||
SysPolicyPolicyRead = "sys:policy:read"
|
||||
SysPolicyPolicyManage = "sys:policy:manage"
|
||||
)
|
||||
|
||||
// System Roles 系统内置角色编码
|
||||
const (
|
||||
SysRoleOrgOwner = "owner"
|
||||
SysRoleOrgAdmin = "admin"
|
||||
SysRoleOrgMember = "member"
|
||||
)
|
||||
@ -0,0 +1,72 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// User 全局用户表
|
||||
type User struct {
|
||||
Base
|
||||
Username string `json:"username" gorm:"uniqueIndex;size:50;not null"`
|
||||
Password string `json:"-" gorm:"size:255"` // bcrypt hash
|
||||
Nickname string `json:"nickname" gorm:"size:50"`
|
||||
Avatar string `json:"avatar" gorm:"size:500"`
|
||||
Email string `json:"email" gorm:"uniqueIndex;size:100"`
|
||||
Phone string `json:"phone" gorm:"uniqueIndex;size:20"`
|
||||
Status int `json:"status" gorm:"default:1"` // 0:禁用 1:正常 2:未激活
|
||||
EmailVerified bool `json:"email_verified" gorm:"default:false"`
|
||||
PhoneVerified bool `json:"phone_verified" gorm:"default:false"`
|
||||
LastLoginAt *time.Time `json:"last_login_at"`
|
||||
}
|
||||
|
||||
// TableName 表名
|
||||
func (User) TableName() string {
|
||||
return "users"
|
||||
}
|
||||
|
||||
// Identity 第三方身份绑定
|
||||
type Identity struct {
|
||||
Base
|
||||
UserID string `json:"user_id" gorm:"index;not null"`
|
||||
Provider string `json:"provider" gorm:"size:20;not null"` // google/github/wechat/ldap
|
||||
ProviderUID string `json:"provider_uid" gorm:"index;size:100;not null"` // 第三方唯一ID
|
||||
ProviderName string `json:"provider_name" gorm:"size:50"`
|
||||
Avatar string `json:"avatar" gorm:"size:500"`
|
||||
Email string `json:"email" gorm:"size:100"`
|
||||
AccessToken string `json:"-" gorm:"size:500"`
|
||||
RefreshToken string `json:"-" gorm:"size:500"`
|
||||
ExpiresAt *time.Time `json:"-"`
|
||||
}
|
||||
|
||||
func (Identity) TableName() string {
|
||||
return "identities"
|
||||
}
|
||||
|
||||
// Session 登录会话
|
||||
type Session struct {
|
||||
Base
|
||||
UserID string `json:"user_id" gorm:"index;not null"`
|
||||
TokenID string `json:"token_id" gorm:"uniqueIndex;size:36"` // JWT jti
|
||||
Type string `json:"type" gorm:"size:20;not null"` // access/refresh
|
||||
DeviceInfo string `json:"device_info" gorm:"size:200"`
|
||||
IP string `json:"ip" gorm:"size:50"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
Revoked bool `json:"revoked" gorm:"default:false"`
|
||||
RevokedAt *time.Time `json:"revoked_at"`
|
||||
}
|
||||
|
||||
func (Session) TableName() string {
|
||||
return "sessions"
|
||||
}
|
||||
|
||||
// UserStatus 用户状态常量
|
||||
const (
|
||||
UserStatusDisabled = 0
|
||||
UserStatusActive = 1
|
||||
UserStatusInactive = 2
|
||||
)
|
||||
|
||||
// IsActive 用户是否激活
|
||||
func (u *User) IsActive() bool {
|
||||
return u.Status == UserStatusActive
|
||||
}
|
||||
@ -0,0 +1,54 @@
|
||||
package crypto
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// HashPassword 使用bcrypt哈希密码
|
||||
func HashPassword(password string, cost int) (string, error) {
|
||||
bytes, err := bcrypt.GenerateFromPassword([]byte(password), cost)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to hash password: %w", err)
|
||||
}
|
||||
return string(bytes), nil
|
||||
}
|
||||
|
||||
// VerifyPassword 验证密码
|
||||
func VerifyPassword(password, hash string) bool {
|
||||
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// GenerateRandomString 生成随机字符串
|
||||
func GenerateRandomString(length int) (string, error) {
|
||||
bytes := make([]byte, length)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.URLEncoding.EncodeToString(bytes)[:length], nil
|
||||
}
|
||||
|
||||
// GenerateSecret 生成密钥
|
||||
func GenerateSecret(length int) string {
|
||||
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
bytes := make([]byte, length)
|
||||
rand.Read(bytes)
|
||||
for i, b := range bytes {
|
||||
bytes[i] = charset[b%byte(len(charset))]
|
||||
}
|
||||
return string(bytes)
|
||||
}
|
||||
|
||||
// GenerateClientID 生成OAuth客户端ID
|
||||
func GenerateClientID() string {
|
||||
return "vc_" + GenerateSecret(28)
|
||||
}
|
||||
|
||||
// GenerateClientSecret 生成OAuth客户端密钥
|
||||
func GenerateClientSecret() string {
|
||||
return GenerateSecret(64)
|
||||
}
|
||||
@ -0,0 +1,166 @@
|
||||
package jwt
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/google/uuid"
|
||||
"github.com/veypi/vbase/internal/config"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidToken = errors.New("invalid token")
|
||||
ErrExpiredToken = errors.New("token expired")
|
||||
ErrTokenRevoked = errors.New("token revoked")
|
||||
)
|
||||
|
||||
// Claims JWT声明
|
||||
type Claims struct {
|
||||
jwt.RegisteredClaims
|
||||
UserID string `json:"uid"`
|
||||
Username string `json:"username"`
|
||||
Nickname string `json:"nickname"`
|
||||
Avatar string `json:"avatar"`
|
||||
Email string `json:"email"`
|
||||
Orgs []OrgClaim `json:"orgs,omitempty"`
|
||||
Type string `json:"type"` // access/refresh
|
||||
Scope string `json:"scope,omitempty"`
|
||||
}
|
||||
|
||||
// OrgClaim 组织声明
|
||||
type OrgClaim struct {
|
||||
OrgID string `json:"org_id"`
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
Roles []string `json:"roles"`
|
||||
Status int `json:"status"`
|
||||
}
|
||||
|
||||
// TokenPair Token对
|
||||
type TokenPair struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
}
|
||||
|
||||
// GenerateTokenPair 生成token对
|
||||
func GenerateTokenPair(userID, username, nickname, avatar, email string, orgs []OrgClaim) (*TokenPair, error) {
|
||||
accessToken, err := GenerateAccessToken(userID, username, nickname, avatar, email, orgs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
refreshToken, err := GenerateRefreshToken(userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &TokenPair{
|
||||
AccessToken: accessToken,
|
||||
RefreshToken: refreshToken,
|
||||
TokenType: "Bearer",
|
||||
ExpiresIn: int(config.C.JWT.AccessExpiry.Seconds()),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GenerateAccessToken 生成访问令牌
|
||||
func GenerateAccessToken(userID, username, nickname, avatar, email string, orgs []OrgClaim) (string, error) {
|
||||
now := time.Now()
|
||||
claims := Claims{
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ID: uuid.New().String(), // jti
|
||||
Issuer: config.C.JWT.Issuer,
|
||||
Subject: userID,
|
||||
Audience: jwt.ClaimStrings{config.C.App.ID},
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
NotBefore: jwt.NewNumericDate(now),
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(config.C.JWT.AccessExpiry)),
|
||||
},
|
||||
UserID: userID,
|
||||
Username: username,
|
||||
Nickname: nickname,
|
||||
Avatar: avatar,
|
||||
Email: email,
|
||||
Orgs: orgs,
|
||||
Type: "access",
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return token.SignedString([]byte(config.C.JWT.Secret))
|
||||
}
|
||||
|
||||
// GenerateRefreshToken 生成刷新令牌
|
||||
func GenerateRefreshToken(userID string) (string, error) {
|
||||
now := time.Now()
|
||||
claims := Claims{
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ID: uuid.New().String(),
|
||||
Issuer: config.C.JWT.Issuer,
|
||||
Subject: userID,
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(config.C.JWT.RefreshExpiry)),
|
||||
},
|
||||
UserID: userID,
|
||||
Type: "refresh",
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return token.SignedString([]byte(config.C.JWT.Secret))
|
||||
}
|
||||
|
||||
// ParseToken 解析Token
|
||||
func ParseToken(tokenString string) (*Claims, error) {
|
||||
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
|
||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
||||
}
|
||||
return []byte(config.C.JWT.Secret), nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, jwt.ErrTokenExpired) {
|
||||
return nil, ErrExpiredToken
|
||||
}
|
||||
return nil, ErrInvalidToken
|
||||
}
|
||||
|
||||
if claims, ok := token.Claims.(*Claims); ok && token.Valid {
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
return nil, ErrInvalidToken
|
||||
}
|
||||
|
||||
// GetJTI 获取Token ID
|
||||
func GetJTI(tokenString string) (string, error) {
|
||||
claims, err := ParseToken(tokenString)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return claims.ID, nil
|
||||
}
|
||||
|
||||
// GetExpiration 获取过期时间
|
||||
func GetExpiration(tokenString string) (time.Time, error) {
|
||||
claims, err := ParseToken(tokenString)
|
||||
if err != nil {
|
||||
return time.Time{}, err
|
||||
}
|
||||
if claims.ExpiresAt == nil {
|
||||
return time.Time{}, errors.New("no expiration")
|
||||
}
|
||||
return claims.ExpiresAt.Time, nil
|
||||
}
|
||||
|
||||
// IsAccessToken 是否是访问令牌
|
||||
func IsAccessToken(claims *Claims) bool {
|
||||
return claims.Type == "access"
|
||||
}
|
||||
|
||||
// IsRefreshToken 是否是刷新令牌
|
||||
func IsRefreshToken(claims *Claims) bool {
|
||||
return claims.Type == "refresh"
|
||||
}
|
||||
@ -0,0 +1,258 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/veypi/vbase/internal/cache"
|
||||
"github.com/veypi/vbase/internal/model"
|
||||
)
|
||||
|
||||
// PermissionChecker 权限检查器
|
||||
type PermissionChecker struct{}
|
||||
|
||||
// NewPermissionChecker 创建权限检查器
|
||||
func NewPermissionChecker() *PermissionChecker {
|
||||
return &PermissionChecker{}
|
||||
}
|
||||
|
||||
// CheckResult 检查结果
|
||||
type CheckResult struct {
|
||||
Allowed bool `json:"allowed"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
// Check 检查权限
|
||||
func (pc *PermissionChecker) Check(userID, orgID, resource, action string, resourceData map[string]any) (*CheckResult, error) {
|
||||
// 1. 检查缓存
|
||||
if cache.IsEnabled() {
|
||||
allowed, cached, err := cache.GetPermission(userID, orgID, resource, action)
|
||||
if err == nil && cached {
|
||||
return &CheckResult{Allowed: allowed}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 检查是否是组织所有者(拥有所有权限)
|
||||
if orgID != "" {
|
||||
var org model.Org
|
||||
if err := model.DB.First(&org, "id = ?", orgID).Error; err == nil {
|
||||
if org.OwnerID == userID {
|
||||
pc.cacheResult(userID, orgID, resource, action, true)
|
||||
return &CheckResult{Allowed: true, Reason: "organization owner"}, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 获取用户在该组织的角色
|
||||
var member model.OrgMember
|
||||
if err := model.DB.Where("org_id = ? AND user_id = ? AND status = ?", orgID, userID, model.MemberStatusActive).First(&member).Error; err != nil {
|
||||
// 用户不在组织中
|
||||
pc.cacheResult(userID, orgID, resource, action, false)
|
||||
return &CheckResult{Allowed: false, Reason: "not a member of organization"}, nil
|
||||
}
|
||||
|
||||
// 4. 获取角色关联的策略
|
||||
roleIDs := parseRoles(member.RoleIDs)
|
||||
if len(roleIDs) == 0 {
|
||||
pc.cacheResult(userID, orgID, resource, action, false)
|
||||
return &CheckResult{Allowed: false, Reason: "no roles assigned"}, nil
|
||||
}
|
||||
|
||||
var roles []model.Role
|
||||
if err := model.DB.Where("id IN ?", roleIDs).Find(&roles).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 5. 收集所有策略ID
|
||||
policyIDMap := make(map[string]bool)
|
||||
for _, role := range roles {
|
||||
ids := parseRoles(role.PolicyIDs)
|
||||
for _, id := range ids {
|
||||
policyIDMap[id] = true
|
||||
}
|
||||
}
|
||||
|
||||
if len(policyIDMap) == 0 {
|
||||
pc.cacheResult(userID, orgID, resource, action, false)
|
||||
return &CheckResult{Allowed: false, Reason: "no policies assigned"}, nil
|
||||
}
|
||||
|
||||
policyIDs := make([]string, 0, len(policyIDMap))
|
||||
for id := range policyIDMap {
|
||||
policyIDs = append(policyIDs, id)
|
||||
}
|
||||
|
||||
// 6. 获取策略详情
|
||||
var policies []model.Policy
|
||||
if err := model.DB.Where("id IN ?", policyIDs).Find(&policies).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 7. 评估策略
|
||||
allowed := pc.evaluatePolicies(policies, userID, orgID, resource, action, resourceData)
|
||||
pc.cacheResult(userID, orgID, resource, action, allowed)
|
||||
|
||||
if allowed {
|
||||
return &CheckResult{Allowed: true}, nil
|
||||
}
|
||||
return &CheckResult{Allowed: false, Reason: "policy denied"}, nil
|
||||
}
|
||||
|
||||
// evaluatePolicies 评估策略
|
||||
func (pc *PermissionChecker) evaluatePolicies(policies []model.Policy, userID, orgID, resource, action string, resourceData map[string]any) bool {
|
||||
// 先处理deny策略
|
||||
for _, p := range policies {
|
||||
if p.Effect != model.EffectDeny {
|
||||
continue
|
||||
}
|
||||
if pc.matchPolicy(&p, resource, action) {
|
||||
// 检查条件
|
||||
if pc.evaluateCondition(&p, userID, orgID, resourceData) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 再处理allow策略
|
||||
for _, p := range policies {
|
||||
if p.Effect != model.EffectAllow {
|
||||
continue
|
||||
}
|
||||
if pc.matchPolicy(&p, resource, action) {
|
||||
if pc.evaluateCondition(&p, userID, orgID, resourceData) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// matchPolicy 匹配策略资源和方法
|
||||
func (pc *PermissionChecker) matchPolicy(p *model.Policy, resource, action string) bool {
|
||||
// 资源匹配
|
||||
if p.Resource != "*" && p.Resource != resource {
|
||||
return false
|
||||
}
|
||||
// 动作匹配
|
||||
if p.Action != "*" && p.Action != action {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// evaluateCondition 评估条件
|
||||
func (pc *PermissionChecker) evaluateCondition(p *model.Policy, userID, orgID string, resourceData map[string]any) bool {
|
||||
condition := p.Condition
|
||||
if condition == "" || condition == "true" {
|
||||
return true
|
||||
}
|
||||
|
||||
// 简单条件评估
|
||||
switch condition {
|
||||
case "owner":
|
||||
// 检查是否是资源所有者
|
||||
if ownerID, ok := resourceData["owner_id"].(string); ok {
|
||||
return ownerID == userID
|
||||
}
|
||||
if createdBy, ok := resourceData["created_by"].(string); ok {
|
||||
return createdBy == userID
|
||||
}
|
||||
return false
|
||||
case "org_member":
|
||||
// 检查是否是组织成员
|
||||
if orgID == "" {
|
||||
return false
|
||||
}
|
||||
var count int64
|
||||
model.DB.Model(&model.OrgMember{}).Where("org_id = ? AND user_id = ? AND status = ?", orgID, userID, model.MemberStatusActive).Count(&count)
|
||||
return count > 0
|
||||
default:
|
||||
// 其他复杂条件暂时返回true
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// cacheResult 缓存结果
|
||||
func (pc *PermissionChecker) cacheResult(userID, orgID, resource, action string, allowed bool) {
|
||||
if cache.IsEnabled() {
|
||||
cache.SetPermission(userID, orgID, resource, action, allowed, 1*time.Minute)
|
||||
}
|
||||
}
|
||||
|
||||
// GetUserPermissions 获取用户的所有权限
|
||||
func (pc *PermissionChecker) GetUserPermissions(userID, orgID string) ([]string, error) {
|
||||
// 获取用户角色
|
||||
var member model.OrgMember
|
||||
if err := model.DB.Where("org_id = ? AND user_id = ? AND status = ?", orgID, userID, model.MemberStatusActive).First(&member).Error; err != nil {
|
||||
return []string{}, nil
|
||||
}
|
||||
|
||||
roleIDs := parseRoles(member.RoleIDs)
|
||||
if len(roleIDs) == 0 {
|
||||
return []string{}, nil
|
||||
}
|
||||
|
||||
var roles []model.Role
|
||||
if err := model.DB.Where("id IN ?", roleIDs).Find(&roles).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 收集策略
|
||||
policyIDMap := make(map[string]bool)
|
||||
for _, role := range roles {
|
||||
ids := parseRoles(role.PolicyIDs)
|
||||
for _, id := range ids {
|
||||
policyIDMap[id] = true
|
||||
}
|
||||
}
|
||||
|
||||
if len(policyIDMap) == 0 {
|
||||
return []string{}, nil
|
||||
}
|
||||
|
||||
policyIDs := make([]string, 0, len(policyIDMap))
|
||||
for id := range policyIDMap {
|
||||
policyIDs = append(policyIDs, id)
|
||||
}
|
||||
|
||||
var policies []model.Policy
|
||||
if err := model.DB.Where("id IN ?", policyIDs).Find(&policies).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 格式化权限
|
||||
perms := make([]string, 0, len(policies))
|
||||
for _, p := range policies {
|
||||
if p.Effect == model.EffectAllow {
|
||||
perms = append(perms, fmt.Sprintf("%s:%s", p.Resource, p.Action))
|
||||
}
|
||||
}
|
||||
|
||||
return perms, nil
|
||||
}
|
||||
|
||||
// ClearUserPermissionCache 清除用户权限缓存
|
||||
func ClearUserPermissionCache(userID string) {
|
||||
if cache.IsEnabled() {
|
||||
cache.DeleteUserPermissions(userID)
|
||||
}
|
||||
}
|
||||
|
||||
// ClearOrgPermissionCache 清除组织权限缓存
|
||||
func ClearOrgPermissionCache(orgID string) {
|
||||
if cache.IsEnabled() {
|
||||
cache.DeleteOrgPermissions(orgID)
|
||||
}
|
||||
}
|
||||
|
||||
func parseRoles(roleIDs string) []string {
|
||||
if roleIDs == "" {
|
||||
return []string{}
|
||||
}
|
||||
return strings.Split(roleIDs, ",")
|
||||
}
|
||||
|
||||
// InitPermissionChecker 初始化权限检查器
|
||||
var InitPermissionChecker = NewPermissionChecker()
|
||||
Loading…
Reference in New Issue