Browse Source
- 菜单管理支持拖拽排序(dnd-kit) - 侧边栏改为递归树形渲染,支持折叠展开 - 修复 RouteGuard 嵌套菜单路径匹配 - 修复 getcurrentmenuslogic 祖先节点回溯保证树形完整 - 新增系统密钥 CRUD(后端 4 接口 + Casbin admin 策略) - AI 模型管理页新增"系统密钥"tab(仅 admin 可见) - 生成 API 接口文档(api-reference.md + openapi.yaml) - 清理根目录截图和测试临时文件 - .gitignore 增加 uploads/、test-results/、__pycache__/master
132 changed files with 18729 additions and 164 deletions
@ -0,0 +1,74 @@ |
|||||
|
# Casdoor SSO 单点登录 Skill |
||||
|
|
||||
|
本 skill 提供在 Go + go-zero 项目中集成 Casdoor SSO 的完整指南和代码模板。 |
||||
|
|
||||
|
## 适用场景 |
||||
|
|
||||
|
- 需要统一身份认证的多系统环境 |
||||
|
- 外部人员(监管、巡检、审计)需要访问系统 |
||||
|
- 已有 Casdoor 身份中心,需要对接新系统 |
||||
|
|
||||
|
## 核心特点 |
||||
|
|
||||
|
- 支持本地用户 + Casdoor 用户混合模式 |
||||
|
- 自动用户创建(JIT Provision) |
||||
|
- JWT Token 本地签发 |
||||
|
- 美观的回调页面 |
||||
|
|
||||
|
## 快速开始 |
||||
|
|
||||
|
### 1. 安装依赖 |
||||
|
|
||||
|
```bash |
||||
|
go get github.com/casdoor/casdoor-go-sdk |
||||
|
go get github.com/golang-jwt/jwt/v5 |
||||
|
``` |
||||
|
|
||||
|
### 2. 配置文件 |
||||
|
|
||||
|
```yaml |
||||
|
Casdoor: |
||||
|
Endpoint: https://your-casdoor-domain.com |
||||
|
ClientId: your-client-id |
||||
|
ClientSecret: your-client-secret |
||||
|
Organization: your-org |
||||
|
Application: your-app |
||||
|
RedirectUrl: http://localhost:8888/api/v1/auth/callback |
||||
|
JwtPublicKey: | |
||||
|
-----BEGIN CERTIFICATE----- |
||||
|
... |
||||
|
-----END CERTIFICATE----- |
||||
|
|
||||
|
JWT: |
||||
|
Secret: your-jwt-secret |
||||
|
Expire: 86400 |
||||
|
``` |
||||
|
|
||||
|
### 3. 核心代码 |
||||
|
|
||||
|
- [配置结构](snippets/config.md) - Config 结构定义 |
||||
|
- [Casdoor 客户端](snippets/casdoorx.md) - SDK 封装 |
||||
|
- [JWT 处理](snippets/jwtx.md) - Token 签发验证 |
||||
|
- [回调逻辑](snippets/callback.md) - 登录回调处理 |
||||
|
- [用户模型](snippets/model.md) - GORM 用户模型 |
||||
|
|
||||
|
## 完整流程 |
||||
|
|
||||
|
1. 前端请求 `/api/v1/auth/login-url` 获取登录链接 |
||||
|
2. 用户跳转到 Casdoor 登录页面 |
||||
|
3. 登录成功后重定向到回调地址 |
||||
|
4. 后端处理回调,交换 Token,创建用户 |
||||
|
5. 返回本地 JWT Token |
||||
|
6. 前端使用 Token 访问受保护接口 |
||||
|
|
||||
|
## 注意事项 |
||||
|
|
||||
|
- 生产环境必须使用 HTTPS |
||||
|
- JWT Secret 需要安全保管 |
||||
|
- Casdoor 证书需要定期更新 |
||||
|
- 建议使用 state 参数防止 CSRF |
||||
|
|
||||
|
## 参考 |
||||
|
|
||||
|
- [Casdoor 官方文档](https://casdoor.org/docs/overview) |
||||
|
- [go-zero 文档](https://go-zero.dev) |
||||
@ -0,0 +1,105 @@ |
|||||
|
# API 定义 |
||||
|
|
||||
|
## devops.api |
||||
|
|
||||
|
```go |
||||
|
syntax = "v1" |
||||
|
|
||||
|
info( |
||||
|
title: "DevOps API" |
||||
|
desc: "DevOps 平台 API 定义" |
||||
|
author: "DevOps Team" |
||||
|
version: "1.0" |
||||
|
) |
||||
|
|
||||
|
type ( |
||||
|
// 登录相关 |
||||
|
LoginUrlResp { |
||||
|
LoginUrl string `json:"login_url"` |
||||
|
} |
||||
|
|
||||
|
CallbackReq { |
||||
|
Code string `form:"code"` |
||||
|
State string `form:"state,optional"` |
||||
|
} |
||||
|
|
||||
|
CallbackResp { |
||||
|
Token string `json:"token"` |
||||
|
ExpiresAt int64 `json:"expires_at"` |
||||
|
User UserInfo `json:"user"` |
||||
|
} |
||||
|
|
||||
|
UserInfo { |
||||
|
Id int64 `json:"id"` |
||||
|
Username string `json:"username"` |
||||
|
Email string `json:"email"` |
||||
|
Status int `json:"status"` |
||||
|
CreatedAt string `json:"created_at"` |
||||
|
} |
||||
|
|
||||
|
UserResp { |
||||
|
Id int64 `json:"id"` |
||||
|
Username string `json:"username"` |
||||
|
Email string `json:"email"` |
||||
|
Role string `json:"role"` |
||||
|
Status int `json:"status"` |
||||
|
} |
||||
|
) |
||||
|
|
||||
|
service devops-api { |
||||
|
@handler getLoginUrlHandler |
||||
|
get /api/v1/auth/login-url returns (LoginUrlResp) |
||||
|
|
||||
|
@handler callbackHandler |
||||
|
get /api/v1/auth/callback (CallbackReq) returns (CallbackResp) |
||||
|
|
||||
|
@handler getUserHandler |
||||
|
get /api/v1/auth/user returns (UserResp) |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
## 生成代码 |
||||
|
|
||||
|
```bash |
||||
|
cd backend |
||||
|
goctl api go -api api/devops.api -dir . |
||||
|
``` |
||||
|
|
||||
|
## 接口说明 |
||||
|
|
||||
|
### 1. 获取登录链接 |
||||
|
|
||||
|
**GET** `/api/v1/auth/login-url` |
||||
|
|
||||
|
**响应:** |
||||
|
```json |
||||
|
{ |
||||
|
"login_url": "https://casdoor.example.com/login/oauth/authorize?client_id=xxx&..." |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### 2. 登录回调 |
||||
|
|
||||
|
**GET** `/api/v1/auth/callback?code=xxx&state=yyy` |
||||
|
|
||||
|
由 Casdoor 重定向调用,返回 HTML 页面。 |
||||
|
|
||||
|
### 3. 获取当前用户 |
||||
|
|
||||
|
**GET** `/api/v1/auth/user` |
||||
|
|
||||
|
**请求头:** |
||||
|
``` |
||||
|
Authorization: Bearer {token} |
||||
|
``` |
||||
|
|
||||
|
**响应:** |
||||
|
```json |
||||
|
{ |
||||
|
"id": 1, |
||||
|
"username": "testuser", |
||||
|
"email": "test@example.com", |
||||
|
"role": "member", |
||||
|
"status": 1 |
||||
|
} |
||||
|
``` |
||||
@ -0,0 +1,306 @@ |
|||||
|
# 登录回调处理 |
||||
|
|
||||
|
## Handler |
||||
|
|
||||
|
```go |
||||
|
package auth |
||||
|
|
||||
|
import ( |
||||
|
"fmt" |
||||
|
"html/template" |
||||
|
"net/http" |
||||
|
|
||||
|
"backend/internal/logic/auth" |
||||
|
"backend/internal/svc" |
||||
|
"backend/internal/types" |
||||
|
"github.com/zeromicro/go-zero/rest/httpx" |
||||
|
) |
||||
|
|
||||
|
const callbackHTMLTemplate = `<!DOCTYPE html> |
||||
|
<html lang="zh-CN"> |
||||
|
<head> |
||||
|
<meta charset="UTF-8"> |
||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
||||
|
<title>DevOps 登录回调</title> |
||||
|
<style> |
||||
|
* { margin: 0; padding: 0; box-sizing: border-box; } |
||||
|
body { |
||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; |
||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); |
||||
|
min-height: 100vh; |
||||
|
display: flex; |
||||
|
justify-content: center; |
||||
|
align-items: center; |
||||
|
} |
||||
|
.container { |
||||
|
background: white; |
||||
|
border-radius: 16px; |
||||
|
padding: 40px; |
||||
|
box-shadow: 0 20px 60px rgba(0,0,0,0.3); |
||||
|
max-width: 500px; |
||||
|
width: 90%; |
||||
|
} |
||||
|
.header { text-align: center; margin-bottom: 30px; } |
||||
|
.header h1 { color: #333; font-size: 24px; margin-bottom: 8px; } |
||||
|
.header p { color: #666; font-size: 14px; } |
||||
|
.status { |
||||
|
text-align: center; |
||||
|
padding: 20px; |
||||
|
border-radius: 12px; |
||||
|
margin-bottom: 20px; |
||||
|
} |
||||
|
.status.success { background: #d4edda; color: #155724; } |
||||
|
.status.error { background: #f8d7da; color: #721c24; } |
||||
|
.status-icon { font-size: 48px; margin-bottom: 10px; } |
||||
|
.user-info { |
||||
|
background: #f8f9fa; |
||||
|
border-radius: 8px; |
||||
|
padding: 16px; |
||||
|
margin-bottom: 20px; |
||||
|
} |
||||
|
.user-info h3 { |
||||
|
font-size: 14px; |
||||
|
color: #666; |
||||
|
margin-bottom: 12px; |
||||
|
text-transform: uppercase; |
||||
|
} |
||||
|
.info-item { |
||||
|
display: flex; |
||||
|
justify-content: space-between; |
||||
|
padding: 8px 0; |
||||
|
border-bottom: 1px solid #e9ecef; |
||||
|
} |
||||
|
.info-item:last-child { border-bottom: none; } |
||||
|
.info-label { color: #666; font-size: 14px; } |
||||
|
.info-value { color: #333; font-weight: 500; font-size: 14px; } |
||||
|
.token-section { margin-top: 20px; } |
||||
|
.token-section h3 { font-size: 14px; color: #666; margin-bottom: 8px; } |
||||
|
.token-box { |
||||
|
background: #2d3748; |
||||
|
color: #68d391; |
||||
|
padding: 12px; |
||||
|
border-radius: 8px; |
||||
|
font-family: 'Courier New', monospace; |
||||
|
font-size: 12px; |
||||
|
word-break: break-all; |
||||
|
max-height: 100px; |
||||
|
overflow-y: auto; |
||||
|
} |
||||
|
.actions { |
||||
|
margin-top: 24px; |
||||
|
display: flex; |
||||
|
gap: 12px; |
||||
|
} |
||||
|
.btn { |
||||
|
flex: 1; |
||||
|
padding: 12px 24px; |
||||
|
border: none; |
||||
|
border-radius: 8px; |
||||
|
font-size: 14px; |
||||
|
cursor: pointer; |
||||
|
transition: all 0.3s; |
||||
|
} |
||||
|
.btn-primary { background: #667eea; color: white; } |
||||
|
.btn-primary:hover { background: #5a6fd6; } |
||||
|
.btn-secondary { background: #e2e8f0; color: #4a5568; } |
||||
|
.btn-secondary:hover { background: #cbd5e0; } |
||||
|
.copy-hint { |
||||
|
text-align: center; |
||||
|
color: #48bb78; |
||||
|
font-size: 12px; |
||||
|
margin-top: 8px; |
||||
|
opacity: 0; |
||||
|
transition: opacity 0.3s; |
||||
|
} |
||||
|
.copy-hint.show { opacity: 1; } |
||||
|
</style> |
||||
|
</head> |
||||
|
<body> |
||||
|
<div class="container"> |
||||
|
<div class="header"> |
||||
|
<h1>DevOps 平台</h1> |
||||
|
<p>Casdoor 单点登录</p> |
||||
|
</div> |
||||
|
{{if .Success}} |
||||
|
<div class="status success"> |
||||
|
<div class="status-icon">✅</div> |
||||
|
<div>登录成功!</div> |
||||
|
</div> |
||||
|
<div class="user-info"> |
||||
|
<h3>用户信息</h3> |
||||
|
<div class="info-item"> |
||||
|
<span class="info-label">用户名</span> |
||||
|
<span class="info-value">{{.User.Username}}</span> |
||||
|
</div> |
||||
|
<div class="info-item"> |
||||
|
<span class="info-label">邮箱</span> |
||||
|
<span class="info-value">{{.User.Email}}</span> |
||||
|
</div> |
||||
|
<div class="info-item"> |
||||
|
<span class="info-label">用户ID</span> |
||||
|
<span class="info-value">{{.User.Id}}</span> |
||||
|
</div> |
||||
|
</div> |
||||
|
<div class="token-section"> |
||||
|
<h3>访问令牌 (Token)</h3> |
||||
|
<div class="token-box" id="token">{{.Token}}</div> |
||||
|
<div class="copy-hint" id="copyHint">已复制到剪贴板!</div> |
||||
|
</div> |
||||
|
<div class="actions"> |
||||
|
<button class="btn btn-secondary" onclick="copyToken()">复制 Token</button> |
||||
|
<button class="btn btn-primary" onclick="testAPI()">测试 API</button> |
||||
|
</div> |
||||
|
{{else}} |
||||
|
<div class="status error"> |
||||
|
<div class="status-icon">❌</div> |
||||
|
<div>登录失败:{{.Error}}</div> |
||||
|
</div> |
||||
|
{{end}} |
||||
|
</div> |
||||
|
<script> |
||||
|
function copyToken() { |
||||
|
const token = document.getElementById('token').textContent; |
||||
|
navigator.clipboard.writeText(token).then(() => { |
||||
|
const hint = document.getElementById('copyHint'); |
||||
|
hint.classList.add('show'); |
||||
|
setTimeout(() => hint.classList.remove('show'), 2000); |
||||
|
}); |
||||
|
} |
||||
|
function testAPI() { |
||||
|
const token = document.getElementById('token').textContent; |
||||
|
fetch('/api/v1/auth/user', { |
||||
|
headers: { 'Authorization': 'Bearer ' + token } |
||||
|
}) |
||||
|
.then(r => r.json()) |
||||
|
.then(data => { |
||||
|
alert('API 测试成功!\n用户信息:' + JSON.stringify(data, null, 2)); |
||||
|
}) |
||||
|
.catch(err => { |
||||
|
alert('API 测试失败:' + err); |
||||
|
}); |
||||
|
} |
||||
|
</script> |
||||
|
</body> |
||||
|
</html>` |
||||
|
|
||||
|
type CallbackPageData struct { |
||||
|
Success bool |
||||
|
Token string |
||||
|
User *types.UserInfo |
||||
|
Error string |
||||
|
} |
||||
|
|
||||
|
func CallbackHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { |
||||
|
return func(w http.ResponseWriter, r *http.Request) { |
||||
|
var req types.CallbackReq |
||||
|
if err := httpx.Parse(r, &req); err != nil { |
||||
|
renderCallbackPage(w, false, "", nil, err.Error()) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
l := auth.NewCallbackLogic(r.Context(), svcCtx) |
||||
|
resp, err := l.Callback(&req) |
||||
|
if err != nil { |
||||
|
renderCallbackPage(w, false, "", nil, err.Error()) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
renderCallbackPage(w, true, resp.Token, &resp.User, "") |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func renderCallbackPage(w http.ResponseWriter, success bool, token string, user *types.UserInfo, errMsg string) { |
||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8") |
||||
|
data := CallbackPageData{ |
||||
|
Success: success, |
||||
|
Token: token, |
||||
|
User: user, |
||||
|
Error: errMsg, |
||||
|
} |
||||
|
tmpl, _ := template.New("callback").Parse(callbackHTMLTemplate) |
||||
|
tmpl.Execute(w, data) |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
## Logic |
||||
|
|
||||
|
```go |
||||
|
package auth |
||||
|
|
||||
|
import ( |
||||
|
"backend/internal/svc" |
||||
|
"backend/internal/types" |
||||
|
"backend/model" |
||||
|
"context" |
||||
|
"time" |
||||
|
|
||||
|
"github.com/casdoor/casdoor-go-sdk/casdoorsdk" |
||||
|
"gorm.io/gorm" |
||||
|
) |
||||
|
|
||||
|
type CallbackLogic struct { |
||||
|
ctx context.Context |
||||
|
svcCtx *svc.ServiceContext |
||||
|
} |
||||
|
|
||||
|
func NewCallbackLogic(ctx context.Context, svcCtx *svc.ServiceContext) *CallbackLogic { |
||||
|
return &CallbackLogic{ctx: ctx, svcCtx: svcCtx} |
||||
|
} |
||||
|
|
||||
|
func (l *CallbackLogic) Callback(req *types.CallbackReq) (*types.CallbackResp, error) { |
||||
|
// 换取 Casdoor Token |
||||
|
authConfig, err := l.svcCtx.Casdoor.ExchangeToken(req.Code) |
||||
|
if err != nil { |
||||
|
return nil, err |
||||
|
} |
||||
|
|
||||
|
casdoorUser := authConfig.Claims.User |
||||
|
|
||||
|
// 查找或创建本地用户 |
||||
|
user, err := l.findOrCreateUser(casdoorUser) |
||||
|
if err != nil { |
||||
|
return nil, err |
||||
|
} |
||||
|
|
||||
|
// 生成本地 JWT |
||||
|
token, expiresAt, _ := l.svcCtx.JWT.GenerateToken(user.ID, user.Username, user.Email) |
||||
|
|
||||
|
return &types.CallbackResp{ |
||||
|
Token: token, |
||||
|
ExpiresAt: expiresAt, |
||||
|
User: types.UserInfo{ |
||||
|
Id: user.ID, |
||||
|
Username: user.Username, |
||||
|
Email: user.Email, |
||||
|
Status: user.Status, |
||||
|
}, |
||||
|
}, nil |
||||
|
} |
||||
|
|
||||
|
func (l *CallbackLogic) findOrCreateUser(casdoorUser *casdoorsdk.User) (*model.User, error) { |
||||
|
var user model.User |
||||
|
|
||||
|
// 用用户名查询 |
||||
|
err := l.svcCtx.DB.Where("username = ?", casdoorUser.Name).First(&user).Error |
||||
|
if err == nil { |
||||
|
return &user, nil |
||||
|
} |
||||
|
|
||||
|
if err != gorm.ErrRecordNotFound { |
||||
|
return nil, err |
||||
|
} |
||||
|
|
||||
|
// 自动创建新用户 |
||||
|
user = model.User{ |
||||
|
UserType: "casdoor", |
||||
|
Username: casdoorUser.Name, |
||||
|
Email: casdoorUser.Email, |
||||
|
Avatar: casdoorUser.Avatar, |
||||
|
Role: "member", |
||||
|
Status: 1, |
||||
|
CreatedAt: time.Now(), |
||||
|
} |
||||
|
l.svcCtx.DB.Create(&user) |
||||
|
return &user, nil |
||||
|
} |
||||
|
``` |
||||
@ -0,0 +1,114 @@ |
|||||
|
# Casdoor 客户端封装 |
||||
|
|
||||
|
## 代码 |
||||
|
|
||||
|
```go |
||||
|
package casdoorx |
||||
|
|
||||
|
import ( |
||||
|
"context" |
||||
|
"fmt" |
||||
|
|
||||
|
"github.com/casdoor/casdoor-go-sdk/casdoorsdk" |
||||
|
) |
||||
|
|
||||
|
type Client struct { |
||||
|
client *casdoorsdk.Client |
||||
|
} |
||||
|
|
||||
|
func NewClient(endpoint, clientId, clientSecret, certificate, organization, application string) *Client { |
||||
|
client := casdoorsdk.NewClient( |
||||
|
endpoint, |
||||
|
clientId, |
||||
|
clientSecret, |
||||
|
certificate, |
||||
|
organization, |
||||
|
application, |
||||
|
) |
||||
|
return &Client{client: client} |
||||
|
} |
||||
|
|
||||
|
// GetSignInUrl 获取登录链接 |
||||
|
func (c *Client) GetSignInUrl(state string) string { |
||||
|
return c.client.GetSignInUrl(state) |
||||
|
} |
||||
|
|
||||
|
// ExchangeToken 用 code 换取 access token |
||||
|
func (c *Client) ExchangeToken(code string) (*casdoorsdk.AuthConfig, error) { |
||||
|
token, err := c.client.GetOAuthToken(code, state) |
||||
|
if err != nil { |
||||
|
return nil, fmt.Errorf("exchange token failed: %w", err) |
||||
|
} |
||||
|
|
||||
|
claims, err := c.client.ParseJwtToken(token.AccessToken) |
||||
|
if err != nil { |
||||
|
return nil, fmt.Errorf("parse token failed: %w", err) |
||||
|
} |
||||
|
|
||||
|
return &casdoorsdk.AuthConfig{ |
||||
|
AccessToken: token.AccessToken, |
||||
|
Claims: claims, |
||||
|
}, nil |
||||
|
} |
||||
|
|
||||
|
// ParseToken 解析 JWT Token |
||||
|
func (c *Client) ParseToken(token string) (*casdoorsdk.Claims, error) { |
||||
|
return c.client.ParseJwtToken(token) |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
## ServiceContext 集成 |
||||
|
|
||||
|
```go |
||||
|
package svc |
||||
|
|
||||
|
import ( |
||||
|
"backend/internal/casdoorx" |
||||
|
"backend/internal/config" |
||||
|
"backend/internal/jwtx" |
||||
|
"backend/model" |
||||
|
|
||||
|
"gorm.io/driver/mysql" |
||||
|
"gorm.io/gorm" |
||||
|
) |
||||
|
|
||||
|
type ServiceContext struct { |
||||
|
Config config.Config |
||||
|
DB *gorm.DB |
||||
|
Casdoor *casdoorx.Client |
||||
|
JWT *jwtx.JWTManager |
||||
|
} |
||||
|
|
||||
|
func NewServiceContext(c config.Config) *ServiceContext { |
||||
|
// 数据库连接 |
||||
|
dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=true", |
||||
|
c.MySQL.Username, c.MySQL.Password, c.MySQL.Host, c.MySQL.Port, c.MySQL.Database) |
||||
|
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{}) |
||||
|
if err != nil { |
||||
|
panic(err) |
||||
|
} |
||||
|
|
||||
|
// 自动迁移 |
||||
|
db.AutoMigrate(&model.User{}) |
||||
|
|
||||
|
// Casdoor 客户端 |
||||
|
casdoorClient := casdoorx.NewClient( |
||||
|
c.Casdoor.Endpoint, |
||||
|
c.Casdoor.ClientId, |
||||
|
c.Casdoor.ClientSecret, |
||||
|
c.Casdoor.JwtPublicKey, |
||||
|
c.Casdoor.Organization, |
||||
|
c.Casdoor.Application, |
||||
|
) |
||||
|
|
||||
|
// JWT 管理器 |
||||
|
jwtManager := jwtx.NewJWTManager(c.JWT.Secret, c.JWT.Expire) |
||||
|
|
||||
|
return &ServiceContext{ |
||||
|
Config: c, |
||||
|
DB: db, |
||||
|
Casdoor: casdoorClient, |
||||
|
JWT: jwtManager, |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
@ -0,0 +1,94 @@ |
|||||
|
# Casdoor 配置结构 |
||||
|
|
||||
|
## 配置定义 |
||||
|
|
||||
|
```go |
||||
|
package config |
||||
|
|
||||
|
import "github.com/zeromicro/go-zero/rest" |
||||
|
|
||||
|
type Config struct { |
||||
|
rest.RestConf |
||||
|
MySQL MySQLConfig |
||||
|
Casdoor CasdoorConfig |
||||
|
JWT JWTConfig |
||||
|
} |
||||
|
|
||||
|
type MySQLConfig struct { |
||||
|
Host string |
||||
|
Port int |
||||
|
Database string |
||||
|
Username string |
||||
|
Password string |
||||
|
} |
||||
|
|
||||
|
type CasdoorConfig struct { |
||||
|
Endpoint string |
||||
|
ClientId string |
||||
|
ClientSecret string |
||||
|
Organization string |
||||
|
Application string |
||||
|
RedirectUrl string |
||||
|
JwtPublicKey string |
||||
|
} |
||||
|
|
||||
|
type JWTConfig struct { |
||||
|
Secret string |
||||
|
Expire int64 |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
## YAML 配置示例 |
||||
|
|
||||
|
```yaml |
||||
|
Name: devops-api |
||||
|
Host: 0.0.0.0 |
||||
|
Port: 8888 |
||||
|
Timeout: 30000 |
||||
|
|
||||
|
Log: |
||||
|
Mode: console |
||||
|
Level: info |
||||
|
|
||||
|
MySQL: |
||||
|
Host: localhost |
||||
|
Port: 3306 |
||||
|
Database: devops |
||||
|
Username: root |
||||
|
Password: "" |
||||
|
|
||||
|
Casdoor: |
||||
|
Endpoint: https://casdoor.example.com |
||||
|
ClientId: your-client-id |
||||
|
ClientSecret: your-client-secret |
||||
|
Organization: built-in |
||||
|
Application: devops-app |
||||
|
RedirectUrl: http://localhost:8888/api/v1/auth/callback |
||||
|
JwtPublicKey: | |
||||
|
-----BEGIN CERTIFICATE----- |
||||
|
MIIDXTCCAkWgAwIBAgIJAJC1HiIAZAiU... |
||||
|
-----END CERTIFICATE----- |
||||
|
|
||||
|
JWT: |
||||
|
Secret: your-32-char-secret-key-here |
||||
|
Expire: 86400 |
||||
|
``` |
||||
|
|
||||
|
## 获取 Casdoor 配置 |
||||
|
|
||||
|
### Client ID / Client Secret |
||||
|
|
||||
|
1. 登录 Casdoor 管理后台 |
||||
|
2. 进入 Applications 菜单 |
||||
|
3. 选择对应应用,复制 Client ID 和 Client Secret |
||||
|
|
||||
|
### JWT Public Key |
||||
|
|
||||
|
1. 进入 Certs 菜单 |
||||
|
2. 找到 built-in/cert-built-in |
||||
|
3. 复制 Certificate 内容(包含 BEGIN/END) |
||||
|
|
||||
|
### Organization |
||||
|
|
||||
|
- 默认使用 `built-in` |
||||
|
- 或创建新的 Organization,使用其名称 |
||||
@ -0,0 +1,130 @@ |
|||||
|
# JWT 处理 |
||||
|
|
||||
|
## JWT Manager |
||||
|
|
||||
|
```go |
||||
|
package jwtx |
||||
|
|
||||
|
import ( |
||||
|
"fmt" |
||||
|
"time" |
||||
|
|
||||
|
"github.com/golang-jwt/jwt/v5" |
||||
|
) |
||||
|
|
||||
|
type Claims struct { |
||||
|
UserId int64 `json:"userId"` |
||||
|
Username string `json:"username"` |
||||
|
Email string `json:"email"` |
||||
|
jwt.RegisteredClaims |
||||
|
} |
||||
|
|
||||
|
type JWTManager struct { |
||||
|
secret string |
||||
|
expire time.Duration |
||||
|
} |
||||
|
|
||||
|
func NewJWTManager(secret string, expireSeconds int64) *JWTManager { |
||||
|
return &JWTManager{ |
||||
|
secret: secret, |
||||
|
expire: time.Duration(expireSeconds) * time.Second, |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// GenerateToken 生成 JWT Token |
||||
|
func (j *JWTManager) GenerateToken(userId int64, username, email string) (string, int64, error) { |
||||
|
now := time.Now() |
||||
|
expiresAt := now.Add(j.expire).Unix() |
||||
|
|
||||
|
claims := Claims{ |
||||
|
UserId: userId, |
||||
|
Username: username, |
||||
|
Email: email, |
||||
|
RegisteredClaims: jwt.RegisteredClaims{ |
||||
|
ExpiresAt: jwt.NewNumericDate(now.Add(j.expire)), |
||||
|
NotBefore: jwt.NewNumericDate(now), |
||||
|
IssuedAt: jwt.NewNumericDate(now), |
||||
|
}, |
||||
|
} |
||||
|
|
||||
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) |
||||
|
tokenString, err := token.SignedString([]byte(j.secret)) |
||||
|
if err != nil { |
||||
|
return "", 0, err |
||||
|
} |
||||
|
|
||||
|
return tokenString, expiresAt, nil |
||||
|
} |
||||
|
|
||||
|
// ParseToken 解析 JWT Token |
||||
|
func (j *JWTManager) 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(j.secret), nil |
||||
|
}) |
||||
|
|
||||
|
if err != nil { |
||||
|
return nil, err |
||||
|
} |
||||
|
|
||||
|
if claims, ok := token.Claims.(*Claims); ok && token.Valid { |
||||
|
return claims, nil |
||||
|
} |
||||
|
|
||||
|
return nil, fmt.Errorf("invalid token") |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
## 认证中间件 |
||||
|
|
||||
|
```go |
||||
|
package middleware |
||||
|
|
||||
|
import ( |
||||
|
"backend/internal/jwtx" |
||||
|
"backend/internal/svc" |
||||
|
"context" |
||||
|
"net/http" |
||||
|
"strings" |
||||
|
) |
||||
|
|
||||
|
type contextKey string |
||||
|
|
||||
|
const UserIdKey contextKey = "userId" |
||||
|
|
||||
|
func AuthMiddleware(svcCtx *svc.ServiceContext) func(http.HandlerFunc) http.HandlerFunc { |
||||
|
return func(next http.HandlerFunc) http.HandlerFunc { |
||||
|
return func(w http.ResponseWriter, r *http.Request) { |
||||
|
authHeader := r.Header.Get("Authorization") |
||||
|
if authHeader == "" { |
||||
|
http.Error(w, "missing authorization header", http.StatusUnauthorized) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
parts := strings.SplitN(authHeader, " ", 2) |
||||
|
if len(parts) != 2 || strings.ToLower(parts[0]) != "bearer" { |
||||
|
http.Error(w, "invalid authorization header", http.StatusUnauthorized) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
claims, err := svcCtx.JWT.ParseToken(parts[1]) |
||||
|
if err != nil { |
||||
|
http.Error(w, "invalid token", http.StatusUnauthorized) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
ctx := context.WithValue(r.Context(), UserIdKey, claims.UserId) |
||||
|
next(w, r.WithContext(ctx)) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func GetUserId(ctx context.Context) int64 { |
||||
|
if userId, ok := ctx.Value(UserIdKey).(int64); ok { |
||||
|
return userId |
||||
|
} |
||||
|
return 0 |
||||
|
} |
||||
|
``` |
||||
@ -0,0 +1,77 @@ |
|||||
|
# 用户模型 |
||||
|
|
||||
|
## GORM 模型定义 |
||||
|
|
||||
|
```go |
||||
|
package model |
||||
|
|
||||
|
import ( |
||||
|
"time" |
||||
|
|
||||
|
"gorm.io/gorm" |
||||
|
) |
||||
|
|
||||
|
type User struct { |
||||
|
ID int64 `gorm:"primaryKey" json:"id"` |
||||
|
UserType string `gorm:"default:'casdoor'" json:"user_type"` // casdoor/local |
||||
|
Username string `gorm:"uniqueIndex;size:64" json:"username"` |
||||
|
Password string `gorm:"size:128" json:"-"` // Casdoor 用户为空 |
||||
|
Email string `gorm:"size:128" json:"email"` |
||||
|
Avatar string `gorm:"size:256" json:"avatar"` |
||||
|
Role string `gorm:"default:'member'" json:"role"` |
||||
|
Status int `gorm:"default:1" json:"status"` |
||||
|
CreatedAt time.Time `json:"created_at"` |
||||
|
UpdatedAt time.Time `json:"updated_at"` |
||||
|
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
## 混合用户模式 |
||||
|
|
||||
|
如果需要支持本地管理员 + Casdoor 用户,可以扩展模型: |
||||
|
|
||||
|
```go |
||||
|
type User struct { |
||||
|
ID int64 `gorm:"primaryKey" json:"id"` |
||||
|
UserType string `gorm:"default:'casdoor'" json:"user_type"` // casdoor/local/federation |
||||
|
CasdoorID string `gorm:"size:64" json:"casdoor_id,omitempty"` // Casdoor 用户 ID |
||||
|
ExternalID string `gorm:"size:64" json:"external_id,omitempty"` // 外部系统 ID |
||||
|
Source string `gorm:"size:64" json:"source,omitempty"` // 来源系统 |
||||
|
Username string `gorm:"uniqueIndex;size:64" json:"username"` |
||||
|
Password string `gorm:"size:128" json:"-"` |
||||
|
Email string `gorm:"size:128" json:"email"` |
||||
|
Avatar string `gorm:"size:256" json:"avatar"` |
||||
|
Role string `gorm:"default:'member'" json:"role"` |
||||
|
Status int `gorm:"default:1" json:"status"` |
||||
|
CreatedAt time.Time `json:"created_at"` |
||||
|
UpdatedAt time.Time `json:"updated_at"` |
||||
|
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
## 用户类型说明 |
||||
|
|
||||
|
| UserType | 说明 | 密码存储 | |
||||
|
|---------|------|---------| |
||||
|
| `local` | 本地管理员,在数据库中管理 | 本地加密存储 | |
||||
|
| `casdoor` | Casdoor 用户,SSO 登录 | 空(Casdoor 管理) | |
||||
|
| `federation` | 联邦用户,来自第三方系统 | 空(源系统管理) | |
||||
|
|
||||
|
## 自动迁移 |
||||
|
|
||||
|
```go |
||||
|
func NewServiceContext(c config.Config) *ServiceContext { |
||||
|
dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=true", |
||||
|
c.MySQL.Username, c.MySQL.Password, c.MySQL.Host, c.MySQL.Port, c.MySQL.Database) |
||||
|
|
||||
|
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{}) |
||||
|
if err != nil { |
||||
|
panic(err) |
||||
|
} |
||||
|
|
||||
|
// 自动创建表 |
||||
|
db.AutoMigrate(&model.User{}) |
||||
|
|
||||
|
return &ServiceContext{DB: db} |
||||
|
} |
||||
|
``` |
||||
@ -0,0 +1,377 @@ |
|||||
|
--- |
||||
|
name: ui-ux-pro-max |
||||
|
description: "UI/UX design intelligence. 67 styles, 96 palettes, 57 font pairings, 25 charts, 13 stacks (React, Next.js, Vue, Svelte, SwiftUI, React Native, Flutter, Tailwind, shadcn/ui). Actions: plan, build, create, design, implement, review, fix, improve, optimize, enhance, refactor, check UI/UX code. Projects: website, landing page, dashboard, admin panel, e-commerce, SaaS, portfolio, blog, mobile app, .html, .tsx, .vue, .svelte. Elements: button, modal, navbar, sidebar, card, table, form, chart. Styles: glassmorphism, claymorphism, minimalism, brutalism, neumorphism, bento grid, dark mode, responsive, skeuomorphism, flat design. Topics: color palette, accessibility, animation, layout, typography, font pairing, spacing, hover, shadow, gradient. Integrations: shadcn/ui MCP for component search and examples." |
||||
|
--- |
||||
|
# UI/UX Pro Max - Design Intelligence |
||||
|
|
||||
|
Comprehensive design guide for web and mobile applications. Contains 67 styles, 96 color palettes, 57 font pairings, 99 UX guidelines, and 25 chart types across 13 technology stacks. Searchable database with priority-based recommendations. |
||||
|
|
||||
|
## When to Apply |
||||
|
|
||||
|
Reference these guidelines when: |
||||
|
- Designing new UI components or pages |
||||
|
- Choosing color palettes and typography |
||||
|
- Reviewing code for UX issues |
||||
|
- Building landing pages or dashboards |
||||
|
- Implementing accessibility requirements |
||||
|
|
||||
|
## Rule Categories by Priority |
||||
|
|
||||
|
| Priority | Category | Impact | Domain | |
||||
|
|----------|----------|--------|--------| |
||||
|
| 1 | Accessibility | CRITICAL | `ux` | |
||||
|
| 2 | Touch & Interaction | CRITICAL | `ux` | |
||||
|
| 3 | Performance | HIGH | `ux` | |
||||
|
| 4 | Layout & Responsive | HIGH | `ux` | |
||||
|
| 5 | Typography & Color | MEDIUM | `typography`, `color` | |
||||
|
| 6 | Animation | MEDIUM | `ux` | |
||||
|
| 7 | Style Selection | MEDIUM | `style`, `product` | |
||||
|
| 8 | Charts & Data | LOW | `chart` | |
||||
|
|
||||
|
## Quick Reference |
||||
|
|
||||
|
### 1. Accessibility (CRITICAL) |
||||
|
|
||||
|
- `color-contrast` - Minimum 4.5:1 ratio for normal text |
||||
|
- `focus-states` - Visible focus rings on interactive elements |
||||
|
- `alt-text` - Descriptive alt text for meaningful images |
||||
|
- `aria-labels` - aria-label for icon-only buttons |
||||
|
- `keyboard-nav` - Tab order matches visual order |
||||
|
- `form-labels` - Use label with for attribute |
||||
|
|
||||
|
### 2. Touch & Interaction (CRITICAL) |
||||
|
|
||||
|
- `touch-target-size` - Minimum 44x44px touch targets |
||||
|
- `hover-vs-tap` - Use click/tap for primary interactions |
||||
|
- `loading-buttons` - Disable button during async operations |
||||
|
- `error-feedback` - Clear error messages near problem |
||||
|
- `cursor-pointer` - Add cursor-pointer to clickable elements |
||||
|
|
||||
|
### 3. Performance (HIGH) |
||||
|
|
||||
|
- `image-optimization` - Use WebP, srcset, lazy loading |
||||
|
- `reduced-motion` - Check prefers-reduced-motion |
||||
|
- `content-jumping` - Reserve space for async content |
||||
|
|
||||
|
### 4. Layout & Responsive (HIGH) |
||||
|
|
||||
|
- `viewport-meta` - width=device-width initial-scale=1 |
||||
|
- `readable-font-size` - Minimum 16px body text on mobile |
||||
|
- `horizontal-scroll` - Ensure content fits viewport width |
||||
|
- `z-index-management` - Define z-index scale (10, 20, 30, 50) |
||||
|
|
||||
|
### 5. Typography & Color (MEDIUM) |
||||
|
|
||||
|
- `line-height` - Use 1.5-1.75 for body text |
||||
|
- `line-length` - Limit to 65-75 characters per line |
||||
|
- `font-pairing` - Match heading/body font personalities |
||||
|
|
||||
|
### 6. Animation (MEDIUM) |
||||
|
|
||||
|
- `duration-timing` - Use 150-300ms for micro-interactions |
||||
|
- `transform-performance` - Use transform/opacity, not width/height |
||||
|
- `loading-states` - Skeleton screens or spinners |
||||
|
|
||||
|
### 7. Style Selection (MEDIUM) |
||||
|
|
||||
|
- `style-match` - Match style to product type |
||||
|
- `consistency` - Use same style across all pages |
||||
|
- `no-emoji-icons` - Use SVG icons, not emojis |
||||
|
|
||||
|
### 8. Charts & Data (LOW) |
||||
|
|
||||
|
- `chart-type` - Match chart type to data type |
||||
|
- `color-guidance` - Use accessible color palettes |
||||
|
- `data-table` - Provide table alternative for accessibility |
||||
|
|
||||
|
## How to Use |
||||
|
|
||||
|
Search specific domains using the CLI tool below. |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
|
||||
|
## Prerequisites |
||||
|
|
||||
|
Check if Python is installed: |
||||
|
|
||||
|
```bash |
||||
|
python3 --version || python --version |
||||
|
``` |
||||
|
|
||||
|
If Python is not installed, install it based on user's OS: |
||||
|
|
||||
|
**macOS:** |
||||
|
```bash |
||||
|
brew install python3 |
||||
|
``` |
||||
|
|
||||
|
**Ubuntu/Debian:** |
||||
|
```bash |
||||
|
sudo apt update && sudo apt install python3 |
||||
|
``` |
||||
|
|
||||
|
**Windows:** |
||||
|
```powershell |
||||
|
winget install Python.Python.3.12 |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## How to Use This Skill |
||||
|
|
||||
|
When user requests UI/UX work (design, build, create, implement, review, fix, improve), follow this workflow: |
||||
|
|
||||
|
### Step 1: Analyze User Requirements |
||||
|
|
||||
|
Extract key information from user request: |
||||
|
- **Product type**: SaaS, e-commerce, portfolio, dashboard, landing page, etc. |
||||
|
- **Style keywords**: minimal, playful, professional, elegant, dark mode, etc. |
||||
|
- **Industry**: healthcare, fintech, gaming, education, etc. |
||||
|
- **Stack**: React, Vue, Next.js, or default to `html-tailwind` |
||||
|
|
||||
|
### Step 2: Generate Design System (REQUIRED) |
||||
|
|
||||
|
**Always start with `--design-system`** to get comprehensive recommendations with reasoning: |
||||
|
|
||||
|
```bash |
||||
|
python3 skills/ui-ux-pro-max/scripts/search.py "<product_type> <industry> <keywords>" --design-system [-p "Project Name"] |
||||
|
``` |
||||
|
|
||||
|
This command: |
||||
|
1. Searches 5 domains in parallel (product, style, color, landing, typography) |
||||
|
2. Applies reasoning rules from `ui-reasoning.csv` to select best matches |
||||
|
3. Returns complete design system: pattern, style, colors, typography, effects |
||||
|
4. Includes anti-patterns to avoid |
||||
|
|
||||
|
**Example:** |
||||
|
```bash |
||||
|
python3 skills/ui-ux-pro-max/scripts/search.py "beauty spa wellness service" --design-system -p "Serenity Spa" |
||||
|
``` |
||||
|
|
||||
|
### Step 2b: Persist Design System (Master + Overrides Pattern) |
||||
|
|
||||
|
To save the design system for hierarchical retrieval across sessions, add `--persist`: |
||||
|
|
||||
|
```bash |
||||
|
python3 skills/ui-ux-pro-max/scripts/search.py "<query>" --design-system --persist -p "Project Name" |
||||
|
``` |
||||
|
|
||||
|
This creates: |
||||
|
- `design-system/MASTER.md` — Global Source of Truth with all design rules |
||||
|
- `design-system/pages/` — Folder for page-specific overrides |
||||
|
|
||||
|
**With page-specific override:** |
||||
|
```bash |
||||
|
python3 skills/ui-ux-pro-max/scripts/search.py "<query>" --design-system --persist -p "Project Name" --page "dashboard" |
||||
|
``` |
||||
|
|
||||
|
This also creates: |
||||
|
- `design-system/pages/dashboard.md` — Page-specific deviations from Master |
||||
|
|
||||
|
**How hierarchical retrieval works:** |
||||
|
1. When building a specific page (e.g., "Checkout"), first check `design-system/pages/checkout.md` |
||||
|
2. If the page file exists, its rules **override** the Master file |
||||
|
3. If not, use `design-system/MASTER.md` exclusively |
||||
|
|
||||
|
### Step 3: Supplement with Detailed Searches (as needed) |
||||
|
|
||||
|
After getting the design system, use domain searches to get additional details: |
||||
|
|
||||
|
```bash |
||||
|
python3 skills/ui-ux-pro-max/scripts/search.py "<keyword>" --domain <domain> [-n <max_results>] |
||||
|
``` |
||||
|
|
||||
|
**When to use detailed searches:** |
||||
|
|
||||
|
| Need | Domain | Example | |
||||
|
|------|--------|---------| |
||||
|
| More style options | `style` | `--domain style "glassmorphism dark"` | |
||||
|
| Chart recommendations | `chart` | `--domain chart "real-time dashboard"` | |
||||
|
| UX best practices | `ux` | `--domain ux "animation accessibility"` | |
||||
|
| Alternative fonts | `typography` | `--domain typography "elegant luxury"` | |
||||
|
| Landing structure | `landing` | `--domain landing "hero social-proof"` | |
||||
|
|
||||
|
### Step 4: Stack Guidelines (Default: html-tailwind) |
||||
|
|
||||
|
Get implementation-specific best practices. If user doesn't specify a stack, **default to `html-tailwind`**. |
||||
|
|
||||
|
```bash |
||||
|
python3 skills/ui-ux-pro-max/scripts/search.py "<keyword>" --stack html-tailwind |
||||
|
``` |
||||
|
|
||||
|
Available stacks: `html-tailwind`, `react`, `nextjs`, `vue`, `svelte`, `swiftui`, `react-native`, `flutter`, `shadcn`, `jetpack-compose` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## Search Reference |
||||
|
|
||||
|
### Available Domains |
||||
|
|
||||
|
| Domain | Use For | Example Keywords | |
||||
|
|--------|---------|------------------| |
||||
|
| `product` | Product type recommendations | SaaS, e-commerce, portfolio, healthcare, beauty, service | |
||||
|
| `style` | UI styles, colors, effects | glassmorphism, minimalism, dark mode, brutalism | |
||||
|
| `typography` | Font pairings, Google Fonts | elegant, playful, professional, modern | |
||||
|
| `color` | Color palettes by product type | saas, ecommerce, healthcare, beauty, fintech, service | |
||||
|
| `landing` | Page structure, CTA strategies | hero, hero-centric, testimonial, pricing, social-proof | |
||||
|
| `chart` | Chart types, library recommendations | trend, comparison, timeline, funnel, pie | |
||||
|
| `ux` | Best practices, anti-patterns | animation, accessibility, z-index, loading | |
||||
|
| `react` | React/Next.js performance | waterfall, bundle, suspense, memo, rerender, cache | |
||||
|
| `web` | Web interface guidelines | aria, focus, keyboard, semantic, virtualize | |
||||
|
| `prompt` | AI prompts, CSS keywords | (style name) | |
||||
|
|
||||
|
### Available Stacks |
||||
|
|
||||
|
| Stack | Focus | |
||||
|
|-------|-------| |
||||
|
| `html-tailwind` | Tailwind utilities, responsive, a11y (DEFAULT) | |
||||
|
| `react` | State, hooks, performance, patterns | |
||||
|
| `nextjs` | SSR, routing, images, API routes | |
||||
|
| `vue` | Composition API, Pinia, Vue Router | |
||||
|
| `svelte` | Runes, stores, SvelteKit | |
||||
|
| `swiftui` | Views, State, Navigation, Animation | |
||||
|
| `react-native` | Components, Navigation, Lists | |
||||
|
| `flutter` | Widgets, State, Layout, Theming | |
||||
|
| `shadcn` | shadcn/ui components, theming, forms, patterns | |
||||
|
| `jetpack-compose` | Composables, Modifiers, State Hoisting, Recomposition | |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## Example Workflow |
||||
|
|
||||
|
**User request:** "Làm landing page cho dịch vụ chăm sóc da chuyên nghiệp" |
||||
|
|
||||
|
### Step 1: Analyze Requirements |
||||
|
- Product type: Beauty/Spa service |
||||
|
- Style keywords: elegant, professional, soft |
||||
|
- Industry: Beauty/Wellness |
||||
|
- Stack: html-tailwind (default) |
||||
|
|
||||
|
### Step 2: Generate Design System (REQUIRED) |
||||
|
|
||||
|
```bash |
||||
|
python3 skills/ui-ux-pro-max/scripts/search.py "beauty spa wellness service elegant" --design-system -p "Serenity Spa" |
||||
|
``` |
||||
|
|
||||
|
**Output:** Complete design system with pattern, style, colors, typography, effects, and anti-patterns. |
||||
|
|
||||
|
### Step 3: Supplement with Detailed Searches (as needed) |
||||
|
|
||||
|
```bash |
||||
|
# Get UX guidelines for animation and accessibility |
||||
|
python3 skills/ui-ux-pro-max/scripts/search.py "animation accessibility" --domain ux |
||||
|
|
||||
|
# Get alternative typography options if needed |
||||
|
python3 skills/ui-ux-pro-max/scripts/search.py "elegant luxury serif" --domain typography |
||||
|
``` |
||||
|
|
||||
|
### Step 4: Stack Guidelines |
||||
|
|
||||
|
```bash |
||||
|
python3 skills/ui-ux-pro-max/scripts/search.py "layout responsive form" --stack html-tailwind |
||||
|
``` |
||||
|
|
||||
|
**Then:** Synthesize design system + detailed searches and implement the design. |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## Output Formats |
||||
|
|
||||
|
The `--design-system` flag supports two output formats: |
||||
|
|
||||
|
```bash |
||||
|
# ASCII box (default) - best for terminal display |
||||
|
python3 skills/ui-ux-pro-max/scripts/search.py "fintech crypto" --design-system |
||||
|
|
||||
|
# Markdown - best for documentation |
||||
|
python3 skills/ui-ux-pro-max/scripts/search.py "fintech crypto" --design-system -f markdown |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## Tips for Better Results |
||||
|
|
||||
|
1. **Be specific with keywords** - "healthcare SaaS dashboard" > "app" |
||||
|
2. **Search multiple times** - Different keywords reveal different insights |
||||
|
3. **Combine domains** - Style + Typography + Color = Complete design system |
||||
|
4. **Always check UX** - Search "animation", "z-index", "accessibility" for common issues |
||||
|
5. **Use stack flag** - Get implementation-specific best practices |
||||
|
6. **Iterate** - If first search doesn't match, try different keywords |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## Common Rules for Professional UI |
||||
|
|
||||
|
These are frequently overlooked issues that make UI look unprofessional: |
||||
|
|
||||
|
### Icons & Visual Elements |
||||
|
|
||||
|
| Rule | Do | Don't | |
||||
|
|------|----|----- | |
||||
|
| **No emoji icons** | Use SVG icons (Heroicons, Lucide, Simple Icons) | Use emojis like 🎨 🚀 ⚙️ as UI icons | |
||||
|
| **Stable hover states** | Use color/opacity transitions on hover | Use scale transforms that shift layout | |
||||
|
| **Correct brand logos** | Research official SVG from Simple Icons | Guess or use incorrect logo paths | |
||||
|
| **Consistent icon sizing** | Use fixed viewBox (24x24) with w-6 h-6 | Mix different icon sizes randomly | |
||||
|
|
||||
|
### Interaction & Cursor |
||||
|
|
||||
|
| Rule | Do | Don't | |
||||
|
|------|----|----- | |
||||
|
| **Cursor pointer** | Add `cursor-pointer` to all clickable/hoverable cards | Leave default cursor on interactive elements | |
||||
|
| **Hover feedback** | Provide visual feedback (color, shadow, border) | No indication element is interactive | |
||||
|
| **Smooth transitions** | Use `transition-colors duration-200` | Instant state changes or too slow (>500ms) | |
||||
|
|
||||
|
### Light/Dark Mode Contrast |
||||
|
|
||||
|
| Rule | Do | Don't | |
||||
|
|------|----|----- | |
||||
|
| **Glass card light mode** | Use `bg-white/80` or higher opacity | Use `bg-white/10` (too transparent) | |
||||
|
| **Text contrast light** | Use `#0F172A` (slate-900) for text | Use `#94A3B8` (slate-400) for body text | |
||||
|
| **Muted text light** | Use `#475569` (slate-600) minimum | Use gray-400 or lighter | |
||||
|
| **Border visibility** | Use `border-gray-200` in light mode | Use `border-white/10` (invisible) | |
||||
|
|
||||
|
### Layout & Spacing |
||||
|
|
||||
|
| Rule | Do | Don't | |
||||
|
|------|----|----- | |
||||
|
| **Floating navbar** | Add `top-4 left-4 right-4` spacing | Stick navbar to `top-0 left-0 right-0` | |
||||
|
| **Content padding** | Account for fixed navbar height | Let content hide behind fixed elements | |
||||
|
| **Consistent max-width** | Use same `max-w-6xl` or `max-w-7xl` | Mix different container widths | |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## Pre-Delivery Checklist |
||||
|
|
||||
|
Before delivering UI code, verify these items: |
||||
|
|
||||
|
### Visual Quality |
||||
|
- [ ] No emojis used as icons (use SVG instead) |
||||
|
- [ ] All icons from consistent icon set (Heroicons/Lucide) |
||||
|
- [ ] Brand logos are correct (verified from Simple Icons) |
||||
|
- [ ] Hover states don't cause layout shift |
||||
|
- [ ] Use theme colors directly (bg-primary) not var() wrapper |
||||
|
|
||||
|
### Interaction |
||||
|
- [ ] All clickable elements have `cursor-pointer` |
||||
|
- [ ] Hover states provide clear visual feedback |
||||
|
- [ ] Transitions are smooth (150-300ms) |
||||
|
- [ ] Focus states visible for keyboard navigation |
||||
|
|
||||
|
### Light/Dark Mode |
||||
|
- [ ] Light mode text has sufficient contrast (4.5:1 minimum) |
||||
|
- [ ] Glass/transparent elements visible in light mode |
||||
|
- [ ] Borders visible in both modes |
||||
|
- [ ] Test both modes before delivery |
||||
|
|
||||
|
### Layout |
||||
|
- [ ] Floating elements have proper spacing from edges |
||||
|
- [ ] No content hidden behind fixed navbars |
||||
|
- [ ] Responsive at 375px, 768px, 1024px, 1440px |
||||
|
- [ ] No horizontal scroll on mobile |
||||
|
|
||||
|
### Accessibility |
||||
|
- [ ] All images have alt text |
||||
|
- [ ] Form inputs have labels |
||||
|
- [ ] Color is not the only indicator |
||||
|
- [ ] `prefers-reduced-motion` respected |
||||
|
|
|
Can't render this file because it contains an unexpected character in line 28 and column 112.
|
|
|
|
|
Can't render this file because it contains an unexpected character in line 14 and column 146.
|
|
|
|
|
|
Can't render this file because it contains an unexpected character in line 6 and column 93.
|
|
Can't render this file because it contains an unexpected character in line 8 and column 192.
|
|
|
|
Can't render this file because it contains an unexpected character in line 4 and column 187.
|
|
|
|
|
|
|
|
|
Can't render this file because it has a wrong number of fields in line 20.
|
@ -0,0 +1,253 @@ |
|||||
|
#!/usr/bin/env python3 |
||||
|
# -*- coding: utf-8 -*- |
||||
|
""" |
||||
|
UI/UX Pro Max Core - BM25 search engine for UI/UX style guides |
||||
|
""" |
||||
|
|
||||
|
import csv |
||||
|
import re |
||||
|
from pathlib import Path |
||||
|
from math import log |
||||
|
from collections import defaultdict |
||||
|
|
||||
|
# ============ CONFIGURATION ============ |
||||
|
DATA_DIR = Path(__file__).parent.parent / "data" |
||||
|
MAX_RESULTS = 3 |
||||
|
|
||||
|
CSV_CONFIG = { |
||||
|
"style": { |
||||
|
"file": "styles.csv", |
||||
|
"search_cols": ["Style Category", "Keywords", "Best For", "Type", "AI Prompt Keywords"], |
||||
|
"output_cols": ["Style Category", "Type", "Keywords", "Primary Colors", "Effects & Animation", "Best For", "Performance", "Accessibility", "Framework Compatibility", "Complexity", "AI Prompt Keywords", "CSS/Technical Keywords", "Implementation Checklist", "Design System Variables"] |
||||
|
}, |
||||
|
"color": { |
||||
|
"file": "colors.csv", |
||||
|
"search_cols": ["Product Type", "Notes"], |
||||
|
"output_cols": ["Product Type", "Primary (Hex)", "Secondary (Hex)", "CTA (Hex)", "Background (Hex)", "Text (Hex)", "Notes"] |
||||
|
}, |
||||
|
"chart": { |
||||
|
"file": "charts.csv", |
||||
|
"search_cols": ["Data Type", "Keywords", "Best Chart Type", "Accessibility Notes"], |
||||
|
"output_cols": ["Data Type", "Keywords", "Best Chart Type", "Secondary Options", "Color Guidance", "Accessibility Notes", "Library Recommendation", "Interactive Level"] |
||||
|
}, |
||||
|
"landing": { |
||||
|
"file": "landing.csv", |
||||
|
"search_cols": ["Pattern Name", "Keywords", "Conversion Optimization", "Section Order"], |
||||
|
"output_cols": ["Pattern Name", "Keywords", "Section Order", "Primary CTA Placement", "Color Strategy", "Conversion Optimization"] |
||||
|
}, |
||||
|
"product": { |
||||
|
"file": "products.csv", |
||||
|
"search_cols": ["Product Type", "Keywords", "Primary Style Recommendation", "Key Considerations"], |
||||
|
"output_cols": ["Product Type", "Keywords", "Primary Style Recommendation", "Secondary Styles", "Landing Page Pattern", "Dashboard Style (if applicable)", "Color Palette Focus"] |
||||
|
}, |
||||
|
"ux": { |
||||
|
"file": "ux-guidelines.csv", |
||||
|
"search_cols": ["Category", "Issue", "Description", "Platform"], |
||||
|
"output_cols": ["Category", "Issue", "Platform", "Description", "Do", "Don't", "Code Example Good", "Code Example Bad", "Severity"] |
||||
|
}, |
||||
|
"typography": { |
||||
|
"file": "typography.csv", |
||||
|
"search_cols": ["Font Pairing Name", "Category", "Mood/Style Keywords", "Best For", "Heading Font", "Body Font"], |
||||
|
"output_cols": ["Font Pairing Name", "Category", "Heading Font", "Body Font", "Mood/Style Keywords", "Best For", "Google Fonts URL", "CSS Import", "Tailwind Config", "Notes"] |
||||
|
}, |
||||
|
"icons": { |
||||
|
"file": "icons.csv", |
||||
|
"search_cols": ["Category", "Icon Name", "Keywords", "Best For"], |
||||
|
"output_cols": ["Category", "Icon Name", "Keywords", "Library", "Import Code", "Usage", "Best For", "Style"] |
||||
|
}, |
||||
|
"react": { |
||||
|
"file": "react-performance.csv", |
||||
|
"search_cols": ["Category", "Issue", "Keywords", "Description"], |
||||
|
"output_cols": ["Category", "Issue", "Platform", "Description", "Do", "Don't", "Code Example Good", "Code Example Bad", "Severity"] |
||||
|
}, |
||||
|
"web": { |
||||
|
"file": "web-interface.csv", |
||||
|
"search_cols": ["Category", "Issue", "Keywords", "Description"], |
||||
|
"output_cols": ["Category", "Issue", "Platform", "Description", "Do", "Don't", "Code Example Good", "Code Example Bad", "Severity"] |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
STACK_CONFIG = { |
||||
|
"html-tailwind": {"file": "stacks/html-tailwind.csv"}, |
||||
|
"react": {"file": "stacks/react.csv"}, |
||||
|
"nextjs": {"file": "stacks/nextjs.csv"}, |
||||
|
"astro": {"file": "stacks/astro.csv"}, |
||||
|
"vue": {"file": "stacks/vue.csv"}, |
||||
|
"nuxtjs": {"file": "stacks/nuxtjs.csv"}, |
||||
|
"nuxt-ui": {"file": "stacks/nuxt-ui.csv"}, |
||||
|
"svelte": {"file": "stacks/svelte.csv"}, |
||||
|
"swiftui": {"file": "stacks/swiftui.csv"}, |
||||
|
"react-native": {"file": "stacks/react-native.csv"}, |
||||
|
"flutter": {"file": "stacks/flutter.csv"}, |
||||
|
"shadcn": {"file": "stacks/shadcn.csv"}, |
||||
|
"jetpack-compose": {"file": "stacks/jetpack-compose.csv"} |
||||
|
} |
||||
|
|
||||
|
# Common columns for all stacks |
||||
|
_STACK_COLS = { |
||||
|
"search_cols": ["Category", "Guideline", "Description", "Do", "Don't"], |
||||
|
"output_cols": ["Category", "Guideline", "Description", "Do", "Don't", "Code Good", "Code Bad", "Severity", "Docs URL"] |
||||
|
} |
||||
|
|
||||
|
AVAILABLE_STACKS = list(STACK_CONFIG.keys()) |
||||
|
|
||||
|
|
||||
|
# ============ BM25 IMPLEMENTATION ============ |
||||
|
class BM25: |
||||
|
"""BM25 ranking algorithm for text search""" |
||||
|
|
||||
|
def __init__(self, k1=1.5, b=0.75): |
||||
|
self.k1 = k1 |
||||
|
self.b = b |
||||
|
self.corpus = [] |
||||
|
self.doc_lengths = [] |
||||
|
self.avgdl = 0 |
||||
|
self.idf = {} |
||||
|
self.doc_freqs = defaultdict(int) |
||||
|
self.N = 0 |
||||
|
|
||||
|
def tokenize(self, text): |
||||
|
"""Lowercase, split, remove punctuation, filter short words""" |
||||
|
text = re.sub(r'[^\w\s]', ' ', str(text).lower()) |
||||
|
return [w for w in text.split() if len(w) > 2] |
||||
|
|
||||
|
def fit(self, documents): |
||||
|
"""Build BM25 index from documents""" |
||||
|
self.corpus = [self.tokenize(doc) for doc in documents] |
||||
|
self.N = len(self.corpus) |
||||
|
if self.N == 0: |
||||
|
return |
||||
|
self.doc_lengths = [len(doc) for doc in self.corpus] |
||||
|
self.avgdl = sum(self.doc_lengths) / self.N |
||||
|
|
||||
|
for doc in self.corpus: |
||||
|
seen = set() |
||||
|
for word in doc: |
||||
|
if word not in seen: |
||||
|
self.doc_freqs[word] += 1 |
||||
|
seen.add(word) |
||||
|
|
||||
|
for word, freq in self.doc_freqs.items(): |
||||
|
self.idf[word] = log((self.N - freq + 0.5) / (freq + 0.5) + 1) |
||||
|
|
||||
|
def score(self, query): |
||||
|
"""Score all documents against query""" |
||||
|
query_tokens = self.tokenize(query) |
||||
|
scores = [] |
||||
|
|
||||
|
for idx, doc in enumerate(self.corpus): |
||||
|
score = 0 |
||||
|
doc_len = self.doc_lengths[idx] |
||||
|
term_freqs = defaultdict(int) |
||||
|
for word in doc: |
||||
|
term_freqs[word] += 1 |
||||
|
|
||||
|
for token in query_tokens: |
||||
|
if token in self.idf: |
||||
|
tf = term_freqs[token] |
||||
|
idf = self.idf[token] |
||||
|
numerator = tf * (self.k1 + 1) |
||||
|
denominator = tf + self.k1 * (1 - self.b + self.b * doc_len / self.avgdl) |
||||
|
score += idf * numerator / denominator |
||||
|
|
||||
|
scores.append((idx, score)) |
||||
|
|
||||
|
return sorted(scores, key=lambda x: x[1], reverse=True) |
||||
|
|
||||
|
|
||||
|
# ============ SEARCH FUNCTIONS ============ |
||||
|
def _load_csv(filepath): |
||||
|
"""Load CSV and return list of dicts""" |
||||
|
with open(filepath, 'r', encoding='utf-8') as f: |
||||
|
return list(csv.DictReader(f)) |
||||
|
|
||||
|
|
||||
|
def _search_csv(filepath, search_cols, output_cols, query, max_results): |
||||
|
"""Core search function using BM25""" |
||||
|
if not filepath.exists(): |
||||
|
return [] |
||||
|
|
||||
|
data = _load_csv(filepath) |
||||
|
|
||||
|
# Build documents from search columns |
||||
|
documents = [" ".join(str(row.get(col, "")) for col in search_cols) for row in data] |
||||
|
|
||||
|
# BM25 search |
||||
|
bm25 = BM25() |
||||
|
bm25.fit(documents) |
||||
|
ranked = bm25.score(query) |
||||
|
|
||||
|
# Get top results with score > 0 |
||||
|
results = [] |
||||
|
for idx, score in ranked[:max_results]: |
||||
|
if score > 0: |
||||
|
row = data[idx] |
||||
|
results.append({col: row.get(col, "") for col in output_cols if col in row}) |
||||
|
|
||||
|
return results |
||||
|
|
||||
|
|
||||
|
def detect_domain(query): |
||||
|
"""Auto-detect the most relevant domain from query""" |
||||
|
query_lower = query.lower() |
||||
|
|
||||
|
domain_keywords = { |
||||
|
"color": ["color", "palette", "hex", "#", "rgb"], |
||||
|
"chart": ["chart", "graph", "visualization", "trend", "bar", "pie", "scatter", "heatmap", "funnel"], |
||||
|
"landing": ["landing", "page", "cta", "conversion", "hero", "testimonial", "pricing", "section"], |
||||
|
"product": ["saas", "ecommerce", "e-commerce", "fintech", "healthcare", "gaming", "portfolio", "crypto", "dashboard"], |
||||
|
"style": ["style", "design", "ui", "minimalism", "glassmorphism", "neumorphism", "brutalism", "dark mode", "flat", "aurora", "prompt", "css", "implementation", "variable", "checklist", "tailwind"], |
||||
|
"ux": ["ux", "usability", "accessibility", "wcag", "touch", "scroll", "animation", "keyboard", "navigation", "mobile"], |
||||
|
"typography": ["font", "typography", "heading", "serif", "sans"], |
||||
|
"icons": ["icon", "icons", "lucide", "heroicons", "symbol", "glyph", "pictogram", "svg icon"], |
||||
|
"react": ["react", "next.js", "nextjs", "suspense", "memo", "usecallback", "useeffect", "rerender", "bundle", "waterfall", "barrel", "dynamic import", "rsc", "server component"], |
||||
|
"web": ["aria", "focus", "outline", "semantic", "virtualize", "autocomplete", "form", "input type", "preconnect"] |
||||
|
} |
||||
|
|
||||
|
scores = {domain: sum(1 for kw in keywords if kw in query_lower) for domain, keywords in domain_keywords.items()} |
||||
|
best = max(scores, key=scores.get) |
||||
|
return best if scores[best] > 0 else "style" |
||||
|
|
||||
|
|
||||
|
def search(query, domain=None, max_results=MAX_RESULTS): |
||||
|
"""Main search function with auto-domain detection""" |
||||
|
if domain is None: |
||||
|
domain = detect_domain(query) |
||||
|
|
||||
|
config = CSV_CONFIG.get(domain, CSV_CONFIG["style"]) |
||||
|
filepath = DATA_DIR / config["file"] |
||||
|
|
||||
|
if not filepath.exists(): |
||||
|
return {"error": f"File not found: {filepath}", "domain": domain} |
||||
|
|
||||
|
results = _search_csv(filepath, config["search_cols"], config["output_cols"], query, max_results) |
||||
|
|
||||
|
return { |
||||
|
"domain": domain, |
||||
|
"query": query, |
||||
|
"file": config["file"], |
||||
|
"count": len(results), |
||||
|
"results": results |
||||
|
} |
||||
|
|
||||
|
|
||||
|
def search_stack(query, stack, max_results=MAX_RESULTS): |
||||
|
"""Search stack-specific guidelines""" |
||||
|
if stack not in STACK_CONFIG: |
||||
|
return {"error": f"Unknown stack: {stack}. Available: {', '.join(AVAILABLE_STACKS)}"} |
||||
|
|
||||
|
filepath = DATA_DIR / STACK_CONFIG[stack]["file"] |
||||
|
|
||||
|
if not filepath.exists(): |
||||
|
return {"error": f"Stack file not found: {filepath}", "stack": stack} |
||||
|
|
||||
|
results = _search_csv(filepath, _STACK_COLS["search_cols"], _STACK_COLS["output_cols"], query, max_results) |
||||
|
|
||||
|
return { |
||||
|
"domain": "stack", |
||||
|
"stack": stack, |
||||
|
"query": query, |
||||
|
"file": STACK_CONFIG[stack]["file"], |
||||
|
"count": len(results), |
||||
|
"results": results |
||||
|
} |
||||
File diff suppressed because it is too large
@ -0,0 +1,114 @@ |
|||||
|
#!/usr/bin/env python3 |
||||
|
# -*- coding: utf-8 -*- |
||||
|
""" |
||||
|
UI/UX Pro Max Search - BM25 search engine for UI/UX style guides |
||||
|
Usage: python search.py "<query>" [--domain <domain>] [--stack <stack>] [--max-results 3] |
||||
|
python search.py "<query>" --design-system [-p "Project Name"] |
||||
|
python search.py "<query>" --design-system --persist [-p "Project Name"] [--page "dashboard"] |
||||
|
|
||||
|
Domains: style, prompt, color, chart, landing, product, ux, typography |
||||
|
Stacks: html-tailwind, react, nextjs |
||||
|
|
||||
|
Persistence (Master + Overrides pattern): |
||||
|
--persist Save design system to design-system/MASTER.md |
||||
|
--page Also create a page-specific override file in design-system/pages/ |
||||
|
""" |
||||
|
|
||||
|
import argparse |
||||
|
import sys |
||||
|
import io |
||||
|
from core import CSV_CONFIG, AVAILABLE_STACKS, MAX_RESULTS, search, search_stack |
||||
|
from design_system import generate_design_system, persist_design_system |
||||
|
|
||||
|
# Force UTF-8 for stdout/stderr to handle emojis on Windows (cp1252 default) |
||||
|
if sys.stdout.encoding and sys.stdout.encoding.lower() != 'utf-8': |
||||
|
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8') |
||||
|
if sys.stderr.encoding and sys.stderr.encoding.lower() != 'utf-8': |
||||
|
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8') |
||||
|
|
||||
|
|
||||
|
def format_output(result): |
||||
|
"""Format results for Claude consumption (token-optimized)""" |
||||
|
if "error" in result: |
||||
|
return f"Error: {result['error']}" |
||||
|
|
||||
|
output = [] |
||||
|
if result.get("stack"): |
||||
|
output.append(f"## UI Pro Max Stack Guidelines") |
||||
|
output.append(f"**Stack:** {result['stack']} | **Query:** {result['query']}") |
||||
|
else: |
||||
|
output.append(f"## UI Pro Max Search Results") |
||||
|
output.append(f"**Domain:** {result['domain']} | **Query:** {result['query']}") |
||||
|
output.append(f"**Source:** {result['file']} | **Found:** {result['count']} results\n") |
||||
|
|
||||
|
for i, row in enumerate(result['results'], 1): |
||||
|
output.append(f"### Result {i}") |
||||
|
for key, value in row.items(): |
||||
|
value_str = str(value) |
||||
|
if len(value_str) > 300: |
||||
|
value_str = value_str[:300] + "..." |
||||
|
output.append(f"- **{key}:** {value_str}") |
||||
|
output.append("") |
||||
|
|
||||
|
return "\n".join(output) |
||||
|
|
||||
|
|
||||
|
if __name__ == "__main__": |
||||
|
parser = argparse.ArgumentParser(description="UI Pro Max Search") |
||||
|
parser.add_argument("query", help="Search query") |
||||
|
parser.add_argument("--domain", "-d", choices=list(CSV_CONFIG.keys()), help="Search domain") |
||||
|
parser.add_argument("--stack", "-s", choices=AVAILABLE_STACKS, help="Stack-specific search (html-tailwind, react, nextjs)") |
||||
|
parser.add_argument("--max-results", "-n", type=int, default=MAX_RESULTS, help="Max results (default: 3)") |
||||
|
parser.add_argument("--json", action="store_true", help="Output as JSON") |
||||
|
# Design system generation |
||||
|
parser.add_argument("--design-system", "-ds", action="store_true", help="Generate complete design system recommendation") |
||||
|
parser.add_argument("--project-name", "-p", type=str, default=None, help="Project name for design system output") |
||||
|
parser.add_argument("--format", "-f", choices=["ascii", "markdown"], default="ascii", help="Output format for design system") |
||||
|
# Persistence (Master + Overrides pattern) |
||||
|
parser.add_argument("--persist", action="store_true", help="Save design system to design-system/MASTER.md (creates hierarchical structure)") |
||||
|
parser.add_argument("--page", type=str, default=None, help="Create page-specific override file in design-system/pages/") |
||||
|
parser.add_argument("--output-dir", "-o", type=str, default=None, help="Output directory for persisted files (default: current directory)") |
||||
|
|
||||
|
args = parser.parse_args() |
||||
|
|
||||
|
# Design system takes priority |
||||
|
if args.design_system: |
||||
|
result = generate_design_system( |
||||
|
args.query, |
||||
|
args.project_name, |
||||
|
args.format, |
||||
|
persist=args.persist, |
||||
|
page=args.page, |
||||
|
output_dir=args.output_dir |
||||
|
) |
||||
|
print(result) |
||||
|
|
||||
|
# Print persistence confirmation |
||||
|
if args.persist: |
||||
|
project_slug = args.project_name.lower().replace(' ', '-') if args.project_name else "default" |
||||
|
print("\n" + "=" * 60) |
||||
|
print(f"✅ Design system persisted to design-system/{project_slug}/") |
||||
|
print(f" 📄 design-system/{project_slug}/MASTER.md (Global Source of Truth)") |
||||
|
if args.page: |
||||
|
page_filename = args.page.lower().replace(' ', '-') |
||||
|
print(f" 📄 design-system/{project_slug}/pages/{page_filename}.md (Page Overrides)") |
||||
|
print("") |
||||
|
print(f"📖 Usage: When building a page, check design-system/{project_slug}/pages/[page].md first.") |
||||
|
print(f" If exists, its rules override MASTER.md. Otherwise, use MASTER.md.") |
||||
|
print("=" * 60) |
||||
|
# Stack search |
||||
|
elif args.stack: |
||||
|
result = search_stack(args.query, args.stack, args.max_results) |
||||
|
if args.json: |
||||
|
import json |
||||
|
print(json.dumps(result, indent=2, ensure_ascii=False)) |
||||
|
else: |
||||
|
print(format_output(result)) |
||||
|
# Domain search |
||||
|
else: |
||||
|
result = search(args.query, args.domain, args.max_results) |
||||
|
if args.json: |
||||
|
import json |
||||
|
print(json.dumps(result, indent=2, ensure_ascii=False)) |
||||
|
else: |
||||
|
print(format_output(result)) |
||||
@ -0,0 +1,58 @@ |
|||||
|
syntax = "v1" |
||||
|
|
||||
|
// ========== 文件管理类型定义 ========== |
||||
|
type ( |
||||
|
// 文件信息 |
||||
|
FileInfo { |
||||
|
Id int64 `json:"id"` |
||||
|
Name string `json:"name"` |
||||
|
Key string `json:"key"` |
||||
|
Size int64 `json:"size"` |
||||
|
MimeType string `json:"mimeType"` |
||||
|
Category string `json:"category"` |
||||
|
IsPublic bool `json:"isPublic"` |
||||
|
UserId int64 `json:"userId"` |
||||
|
StorageType string `json:"storageType"` |
||||
|
Url string `json:"url"` |
||||
|
CreatedAt string `json:"createdAt"` |
||||
|
UpdatedAt string `json:"updatedAt"` |
||||
|
} |
||||
|
|
||||
|
// 文件列表请求 |
||||
|
FileListRequest { |
||||
|
Page int `form:"page,default=1"` |
||||
|
PageSize int `form:"pageSize,default=20"` |
||||
|
Keyword string `form:"keyword,optional"` |
||||
|
Category string `form:"category,optional"` |
||||
|
MimeType string `form:"mimeType,optional"` |
||||
|
} |
||||
|
|
||||
|
// 文件列表响应 |
||||
|
FileListResponse { |
||||
|
Total int64 `json:"total"` |
||||
|
List []FileInfo `json:"list"` |
||||
|
} |
||||
|
|
||||
|
// 获取文件请求 |
||||
|
GetFileRequest { |
||||
|
Id int64 `path:"id"` |
||||
|
} |
||||
|
|
||||
|
// 更新文件请求 |
||||
|
UpdateFileRequest { |
||||
|
Id int64 `path:"id"` |
||||
|
Name string `json:"name,optional"` |
||||
|
Category string `json:"category,optional"` |
||||
|
IsPublic *bool `json:"isPublic,optional"` |
||||
|
} |
||||
|
|
||||
|
// 删除文件请求 |
||||
|
DeleteFileRequest { |
||||
|
Id int64 `path:"id"` |
||||
|
} |
||||
|
|
||||
|
// 文件 URL 响应 |
||||
|
FileUrlResponse { |
||||
|
Url string `json:"url"` |
||||
|
} |
||||
|
) |
||||
@ -1,7 +1,36 @@ |
|||||
Name: base-api |
Name: base-api |
||||
Host: 0.0.0.0 |
Host: 0.0.0.0 |
||||
Port: 8888 |
Port: 8888 |
||||
|
MaxBytes: 104857600 |
||||
|
|
||||
# MySQL 数据库配置 |
# MySQL 数据库配置 |
||||
MySQL: |
MySQL: |
||||
DSN: root:dev123456@tcp(219.159.132.177:17173)/base?charset=utf8mb4&parseTime=true&loc=Local |
DSN: root:dev123456@tcp(219.159.132.177:17173)/base?charset=utf8mb4&parseTime=true&loc=Local |
||||
|
|
||||
|
# Casdoor SSO 配置 |
||||
|
Casdoor: |
||||
|
Endpoint: https://cas.gxxhygroup.com |
||||
|
ClientId: 17f4d884b28dcc2faef2 |
||||
|
ClientSecret: 603a342f8cef02e7110229b2488fc301e136f571 |
||||
|
Organization: XhyGroup |
||||
|
Application: xhy-base |
||||
|
RedirectUrl: http://localhost:8888/api/v1/auth/sso/callback |
||||
|
FrontendUrl: http://localhost:5173 |
||||
|
|
||||
|
# 文件存储配置 |
||||
|
Storage: |
||||
|
Type: "local" |
||||
|
MaxSize: 104857600 |
||||
|
Local: |
||||
|
RootDir: "./uploads" |
||||
|
OSS: |
||||
|
Endpoint: "" |
||||
|
AccessKeyId: "" |
||||
|
AccessKeySecret: "" |
||||
|
Bucket: "" |
||||
|
MinIO: |
||||
|
Endpoint: "" |
||||
|
AccessKeyId: "" |
||||
|
AccessKeySecret: "" |
||||
|
Bucket: "" |
||||
|
UseSSL: false |
||||
|
|||||
@ -0,0 +1,32 @@ |
|||||
|
// Code scaffolded by goctl. Safe to edit.
|
||||
|
// goctl 1.9.2
|
||||
|
|
||||
|
package ai |
||||
|
|
||||
|
import ( |
||||
|
"net/http" |
||||
|
|
||||
|
"github.com/youruser/base/internal/logic/ai" |
||||
|
"github.com/youruser/base/internal/svc" |
||||
|
"github.com/youruser/base/internal/types" |
||||
|
"github.com/zeromicro/go-zero/rest/httpx" |
||||
|
) |
||||
|
|
||||
|
// 创建系统密钥
|
||||
|
func AiSystemKeyCreateHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { |
||||
|
return func(w http.ResponseWriter, r *http.Request) { |
||||
|
var req types.AIApiKeyCreateRequest |
||||
|
if err := httpx.Parse(r, &req); err != nil { |
||||
|
httpx.ErrorCtx(r.Context(), w, err) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
l := ai.NewAiSystemKeyCreateLogic(r.Context(), svcCtx) |
||||
|
resp, err := l.AiSystemKeyCreate(&req) |
||||
|
if err != nil { |
||||
|
httpx.ErrorCtx(r.Context(), w, err) |
||||
|
} else { |
||||
|
httpx.OkJsonCtx(r.Context(), w, resp) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,32 @@ |
|||||
|
// Code scaffolded by goctl. Safe to edit.
|
||||
|
// goctl 1.9.2
|
||||
|
|
||||
|
package ai |
||||
|
|
||||
|
import ( |
||||
|
"net/http" |
||||
|
|
||||
|
"github.com/youruser/base/internal/logic/ai" |
||||
|
"github.com/youruser/base/internal/svc" |
||||
|
"github.com/youruser/base/internal/types" |
||||
|
"github.com/zeromicro/go-zero/rest/httpx" |
||||
|
) |
||||
|
|
||||
|
// 删除系统密钥
|
||||
|
func AiSystemKeyDeleteHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { |
||||
|
return func(w http.ResponseWriter, r *http.Request) { |
||||
|
var req types.AIApiKeyDeleteRequest |
||||
|
if err := httpx.Parse(r, &req); err != nil { |
||||
|
httpx.ErrorCtx(r.Context(), w, err) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
l := ai.NewAiSystemKeyDeleteLogic(r.Context(), svcCtx) |
||||
|
resp, err := l.AiSystemKeyDelete(&req) |
||||
|
if err != nil { |
||||
|
httpx.ErrorCtx(r.Context(), w, err) |
||||
|
} else { |
||||
|
httpx.OkJsonCtx(r.Context(), w, resp) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,32 @@ |
|||||
|
// Code scaffolded by goctl. Safe to edit.
|
||||
|
// goctl 1.9.2
|
||||
|
|
||||
|
package ai |
||||
|
|
||||
|
import ( |
||||
|
"net/http" |
||||
|
|
||||
|
"github.com/youruser/base/internal/logic/ai" |
||||
|
"github.com/youruser/base/internal/svc" |
||||
|
"github.com/youruser/base/internal/types" |
||||
|
"github.com/zeromicro/go-zero/rest/httpx" |
||||
|
) |
||||
|
|
||||
|
// 获取系统密钥列表
|
||||
|
func AiSystemKeyListHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { |
||||
|
return func(w http.ResponseWriter, r *http.Request) { |
||||
|
var req types.AIApiKeyListRequest |
||||
|
if err := httpx.Parse(r, &req); err != nil { |
||||
|
httpx.ErrorCtx(r.Context(), w, err) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
l := ai.NewAiSystemKeyListLogic(r.Context(), svcCtx) |
||||
|
resp, err := l.AiSystemKeyList(&req) |
||||
|
if err != nil { |
||||
|
httpx.ErrorCtx(r.Context(), w, err) |
||||
|
} else { |
||||
|
httpx.OkJsonCtx(r.Context(), w, resp) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,32 @@ |
|||||
|
// Code scaffolded by goctl. Safe to edit.
|
||||
|
// goctl 1.9.2
|
||||
|
|
||||
|
package ai |
||||
|
|
||||
|
import ( |
||||
|
"net/http" |
||||
|
|
||||
|
"github.com/youruser/base/internal/logic/ai" |
||||
|
"github.com/youruser/base/internal/svc" |
||||
|
"github.com/youruser/base/internal/types" |
||||
|
"github.com/zeromicro/go-zero/rest/httpx" |
||||
|
) |
||||
|
|
||||
|
// 更新系统密钥
|
||||
|
func AiSystemKeyUpdateHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { |
||||
|
return func(w http.ResponseWriter, r *http.Request) { |
||||
|
var req types.AIApiKeyUpdateRequest |
||||
|
if err := httpx.Parse(r, &req); err != nil { |
||||
|
httpx.ErrorCtx(r.Context(), w, err) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
l := ai.NewAiSystemKeyUpdateLogic(r.Context(), svcCtx) |
||||
|
resp, err := l.AiSystemKeyUpdate(&req) |
||||
|
if err != nil { |
||||
|
httpx.ErrorCtx(r.Context(), w, err) |
||||
|
} else { |
||||
|
httpx.OkJsonCtx(r.Context(), w, resp) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,41 @@ |
|||||
|
package auth |
||||
|
|
||||
|
import ( |
||||
|
"net/http" |
||||
|
|
||||
|
"github.com/youruser/base/internal/logic/auth" |
||||
|
"github.com/youruser/base/internal/svc" |
||||
|
"github.com/zeromicro/go-zero/rest/httpx" |
||||
|
) |
||||
|
|
||||
|
// GetSSOLoginUrlHandler 获取 SSO 登录链接
|
||||
|
func GetSSOLoginUrlHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { |
||||
|
return func(w http.ResponseWriter, r *http.Request) { |
||||
|
l := auth.NewSSOLogic(r.Context(), svcCtx) |
||||
|
resp, err := l.GetLoginUrl() |
||||
|
if err != nil { |
||||
|
httpx.ErrorCtx(r.Context(), w, err) |
||||
|
return |
||||
|
} |
||||
|
httpx.OkJsonCtx(r.Context(), w, resp) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// SSOCallbackHandler 处理 SSO 回调
|
||||
|
func SSOCallbackHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { |
||||
|
return func(w http.ResponseWriter, r *http.Request) { |
||||
|
code := r.URL.Query().Get("code") |
||||
|
state := r.URL.Query().Get("state") |
||||
|
|
||||
|
l := auth.NewSSOLogic(r.Context(), svcCtx) |
||||
|
redirectUrl, err := l.HandleCallback(code, state) |
||||
|
if err != nil { |
||||
|
// 回调失败,重定向到前端登录页并附带错误信息
|
||||
|
frontendUrl := svcCtx.Config.Casdoor.FrontendUrl |
||||
|
http.Redirect(w, r, frontendUrl+"/login?error=sso_failed", http.StatusFound) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
http.Redirect(w, r, redirectUrl, http.StatusFound) |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,32 @@ |
|||||
|
// Code scaffolded by goctl. Safe to edit.
|
||||
|
// goctl 1.9.2
|
||||
|
|
||||
|
package file |
||||
|
|
||||
|
import ( |
||||
|
"net/http" |
||||
|
|
||||
|
"github.com/youruser/base/internal/logic/file" |
||||
|
"github.com/youruser/base/internal/svc" |
||||
|
"github.com/youruser/base/internal/types" |
||||
|
"github.com/zeromicro/go-zero/rest/httpx" |
||||
|
) |
||||
|
|
||||
|
// 删除文件
|
||||
|
func DeleteFileHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { |
||||
|
return func(w http.ResponseWriter, r *http.Request) { |
||||
|
var req types.DeleteFileRequest |
||||
|
if err := httpx.Parse(r, &req); err != nil { |
||||
|
httpx.ErrorCtx(r.Context(), w, err) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
l := file.NewDeleteFileLogic(r.Context(), svcCtx) |
||||
|
resp, err := l.DeleteFile(&req) |
||||
|
if err != nil { |
||||
|
httpx.ErrorCtx(r.Context(), w, err) |
||||
|
} else { |
||||
|
httpx.OkJsonCtx(r.Context(), w, resp) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,32 @@ |
|||||
|
// Code scaffolded by goctl. Safe to edit.
|
||||
|
// goctl 1.9.2
|
||||
|
|
||||
|
package file |
||||
|
|
||||
|
import ( |
||||
|
"net/http" |
||||
|
|
||||
|
"github.com/youruser/base/internal/logic/file" |
||||
|
"github.com/youruser/base/internal/svc" |
||||
|
"github.com/youruser/base/internal/types" |
||||
|
"github.com/zeromicro/go-zero/rest/httpx" |
||||
|
) |
||||
|
|
||||
|
// 获取文件详情
|
||||
|
func GetFileHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { |
||||
|
return func(w http.ResponseWriter, r *http.Request) { |
||||
|
var req types.GetFileRequest |
||||
|
if err := httpx.Parse(r, &req); err != nil { |
||||
|
httpx.ErrorCtx(r.Context(), w, err) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
l := file.NewGetFileLogic(r.Context(), svcCtx) |
||||
|
resp, err := l.GetFile(&req) |
||||
|
if err != nil { |
||||
|
httpx.ErrorCtx(r.Context(), w, err) |
||||
|
} else { |
||||
|
httpx.OkJsonCtx(r.Context(), w, resp) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,32 @@ |
|||||
|
// Code scaffolded by goctl. Safe to edit.
|
||||
|
// goctl 1.9.2
|
||||
|
|
||||
|
package file |
||||
|
|
||||
|
import ( |
||||
|
"net/http" |
||||
|
|
||||
|
"github.com/youruser/base/internal/logic/file" |
||||
|
"github.com/youruser/base/internal/svc" |
||||
|
"github.com/youruser/base/internal/types" |
||||
|
"github.com/zeromicro/go-zero/rest/httpx" |
||||
|
) |
||||
|
|
||||
|
// 获取文件列表
|
||||
|
func GetFileListHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { |
||||
|
return func(w http.ResponseWriter, r *http.Request) { |
||||
|
var req types.FileListRequest |
||||
|
if err := httpx.Parse(r, &req); err != nil { |
||||
|
httpx.ErrorCtx(r.Context(), w, err) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
l := file.NewGetFileListLogic(r.Context(), svcCtx) |
||||
|
resp, err := l.GetFileList(&req) |
||||
|
if err != nil { |
||||
|
httpx.ErrorCtx(r.Context(), w, err) |
||||
|
} else { |
||||
|
httpx.OkJsonCtx(r.Context(), w, resp) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,50 @@ |
|||||
|
// Code scaffolded by goctl. Safe to edit.
|
||||
|
// goctl 1.9.2
|
||||
|
|
||||
|
package file |
||||
|
|
||||
|
import ( |
||||
|
"fmt" |
||||
|
"net/http" |
||||
|
"strings" |
||||
|
|
||||
|
"github.com/youruser/base/internal/logic/file" |
||||
|
"github.com/youruser/base/internal/storage" |
||||
|
"github.com/youruser/base/internal/svc" |
||||
|
"github.com/youruser/base/internal/types" |
||||
|
"github.com/zeromicro/go-zero/rest/httpx" |
||||
|
) |
||||
|
|
||||
|
// 获取文件访问URL
|
||||
|
func GetFileUrlHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { |
||||
|
return func(w http.ResponseWriter, r *http.Request) { |
||||
|
var req types.GetFileRequest |
||||
|
if err := httpx.Parse(r, &req); err != nil { |
||||
|
httpx.ErrorCtx(r.Context(), w, err) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
l := file.NewGetFileUrlLogic(r.Context(), svcCtx) |
||||
|
resp, err := l.GetFileUrl(&req) |
||||
|
if err != nil { |
||||
|
httpx.ErrorCtx(r.Context(), w, err) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
// If the URL starts with "local://", serve the file directly
|
||||
|
if strings.HasPrefix(resp.Url, "local://") { |
||||
|
key := strings.TrimPrefix(resp.Url, "local://") |
||||
|
localStorage, ok := svcCtx.Storage.(*storage.LocalStorage) |
||||
|
if !ok { |
||||
|
httpx.ErrorCtx(r.Context(), w, fmt.Errorf("storage type mismatch")) |
||||
|
return |
||||
|
} |
||||
|
filePath := localStorage.GetFilePath(key) |
||||
|
http.ServeFile(w, r, filePath) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
// For non-local storage, return JSON with URL
|
||||
|
httpx.OkJsonCtx(r.Context(), w, resp) |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,32 @@ |
|||||
|
// Code scaffolded by goctl. Safe to edit.
|
||||
|
// goctl 1.9.2
|
||||
|
|
||||
|
package file |
||||
|
|
||||
|
import ( |
||||
|
"net/http" |
||||
|
|
||||
|
"github.com/youruser/base/internal/logic/file" |
||||
|
"github.com/youruser/base/internal/svc" |
||||
|
"github.com/youruser/base/internal/types" |
||||
|
"github.com/zeromicro/go-zero/rest/httpx" |
||||
|
) |
||||
|
|
||||
|
// 更新文件信息
|
||||
|
func UpdateFileHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { |
||||
|
return func(w http.ResponseWriter, r *http.Request) { |
||||
|
var req types.UpdateFileRequest |
||||
|
if err := httpx.Parse(r, &req); err != nil { |
||||
|
httpx.ErrorCtx(r.Context(), w, err) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
l := file.NewUpdateFileLogic(r.Context(), svcCtx) |
||||
|
resp, err := l.UpdateFile(&req) |
||||
|
if err != nil { |
||||
|
httpx.ErrorCtx(r.Context(), w, err) |
||||
|
} else { |
||||
|
httpx.OkJsonCtx(r.Context(), w, resp) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,34 @@ |
|||||
|
// Code scaffolded by goctl. Safe to edit.
|
||||
|
// goctl 1.9.2
|
||||
|
|
||||
|
package file |
||||
|
|
||||
|
import ( |
||||
|
"net/http" |
||||
|
|
||||
|
"github.com/youruser/base/internal/logic/file" |
||||
|
"github.com/youruser/base/internal/svc" |
||||
|
"github.com/zeromicro/go-zero/rest/httpx" |
||||
|
) |
||||
|
|
||||
|
// 上传文件
|
||||
|
func UploadFileHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { |
||||
|
return func(w http.ResponseWriter, r *http.Request) { |
||||
|
// Limit request body size based on config
|
||||
|
r.Body = http.MaxBytesReader(w, r.Body, svcCtx.Config.Storage.MaxSize) |
||||
|
|
||||
|
// Parse multipart form with max size
|
||||
|
if err := r.ParseMultipartForm(svcCtx.Config.Storage.MaxSize); err != nil { |
||||
|
httpx.ErrorCtx(r.Context(), w, err) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
l := file.NewUploadFileLogic(r.Context(), svcCtx) |
||||
|
resp, err := l.UploadFile(r) |
||||
|
if err != nil { |
||||
|
httpx.ErrorCtx(r.Context(), w, err) |
||||
|
} else { |
||||
|
httpx.OkJsonCtx(r.Context(), w, resp) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,32 @@ |
|||||
|
// Code scaffolded by goctl. Safe to edit.
|
||||
|
// goctl 1.9.2
|
||||
|
|
||||
|
package menu |
||||
|
|
||||
|
import ( |
||||
|
"net/http" |
||||
|
|
||||
|
"github.com/youruser/base/internal/logic/menu" |
||||
|
"github.com/youruser/base/internal/svc" |
||||
|
"github.com/youruser/base/internal/types" |
||||
|
"github.com/zeromicro/go-zero/rest/httpx" |
||||
|
) |
||||
|
|
||||
|
// 批量排序菜单
|
||||
|
func SortMenusHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { |
||||
|
return func(w http.ResponseWriter, r *http.Request) { |
||||
|
var req types.SortMenusRequest |
||||
|
if err := httpx.Parse(r, &req); err != nil { |
||||
|
httpx.ErrorCtx(r.Context(), w, err) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
l := menu.NewSortMenusLogic(r.Context(), svcCtx) |
||||
|
resp, err := l.SortMenus(&req) |
||||
|
if err != nil { |
||||
|
httpx.ErrorCtx(r.Context(), w, err) |
||||
|
} else { |
||||
|
httpx.OkJsonCtx(r.Context(), w, resp) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,63 @@ |
|||||
|
package ai |
||||
|
|
||||
|
import ( |
||||
|
"context" |
||||
|
"fmt" |
||||
|
|
||||
|
"github.com/youruser/base/internal/svc" |
||||
|
"github.com/youruser/base/internal/types" |
||||
|
"github.com/youruser/base/model" |
||||
|
|
||||
|
"github.com/zeromicro/go-zero/core/logx" |
||||
|
) |
||||
|
|
||||
|
type AiSystemKeyCreateLogic struct { |
||||
|
logx.Logger |
||||
|
ctx context.Context |
||||
|
svcCtx *svc.ServiceContext |
||||
|
} |
||||
|
|
||||
|
func NewAiSystemKeyCreateLogic(ctx context.Context, svcCtx *svc.ServiceContext) *AiSystemKeyCreateLogic { |
||||
|
return &AiSystemKeyCreateLogic{ |
||||
|
Logger: logx.WithContext(ctx), |
||||
|
ctx: ctx, |
||||
|
svcCtx: svcCtx, |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func (l *AiSystemKeyCreateLogic) AiSystemKeyCreate(req *types.AIApiKeyCreateRequest) (resp *types.AIApiKeyInfo, err error) { |
||||
|
apiKey := &model.AIApiKey{ |
||||
|
ProviderId: req.ProviderId, |
||||
|
UserId: 0, // system key
|
||||
|
KeyValue: req.KeyValue, |
||||
|
IsActive: true, |
||||
|
Remark: req.Remark, |
||||
|
} |
||||
|
|
||||
|
id, err := model.AIApiKeyInsert(l.ctx, l.svcCtx.DB, apiKey) |
||||
|
if err != nil { |
||||
|
return nil, fmt.Errorf("创建系统密钥失败: %v", err) |
||||
|
} |
||||
|
|
||||
|
created, err := model.AIApiKeyFindOne(l.ctx, l.svcCtx.DB, id) |
||||
|
if err != nil { |
||||
|
return nil, fmt.Errorf("查询系统密钥失败: %v", err) |
||||
|
} |
||||
|
|
||||
|
providerName := "" |
||||
|
provider, provErr := model.AIProviderFindOne(l.ctx, l.svcCtx.DB, created.ProviderId) |
||||
|
if provErr == nil { |
||||
|
providerName = provider.DisplayName |
||||
|
} |
||||
|
|
||||
|
return &types.AIApiKeyInfo{ |
||||
|
Id: created.Id, |
||||
|
ProviderId: created.ProviderId, |
||||
|
ProviderName: providerName, |
||||
|
UserId: 0, |
||||
|
KeyPreview: maskKey(created.KeyValue), |
||||
|
IsActive: created.IsActive, |
||||
|
Remark: created.Remark, |
||||
|
CreatedAt: created.CreatedAt.Format("2006-01-02 15:04:05"), |
||||
|
}, nil |
||||
|
} |
||||
@ -0,0 +1,46 @@ |
|||||
|
package ai |
||||
|
|
||||
|
import ( |
||||
|
"context" |
||||
|
"fmt" |
||||
|
|
||||
|
"github.com/youruser/base/internal/svc" |
||||
|
"github.com/youruser/base/internal/types" |
||||
|
"github.com/youruser/base/model" |
||||
|
|
||||
|
"github.com/zeromicro/go-zero/core/logx" |
||||
|
) |
||||
|
|
||||
|
type AiSystemKeyDeleteLogic struct { |
||||
|
logx.Logger |
||||
|
ctx context.Context |
||||
|
svcCtx *svc.ServiceContext |
||||
|
} |
||||
|
|
||||
|
func NewAiSystemKeyDeleteLogic(ctx context.Context, svcCtx *svc.ServiceContext) *AiSystemKeyDeleteLogic { |
||||
|
return &AiSystemKeyDeleteLogic{ |
||||
|
Logger: logx.WithContext(ctx), |
||||
|
ctx: ctx, |
||||
|
svcCtx: svcCtx, |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func (l *AiSystemKeyDeleteLogic) AiSystemKeyDelete(req *types.AIApiKeyDeleteRequest) (resp *types.Response, err error) { |
||||
|
existing, err := model.AIApiKeyFindOne(l.ctx, l.svcCtx.DB, req.Id) |
||||
|
if err != nil { |
||||
|
if err == model.ErrNotFound { |
||||
|
return nil, fmt.Errorf("系统密钥不存在") |
||||
|
} |
||||
|
return nil, fmt.Errorf("查询系统密钥失败: %v", err) |
||||
|
} |
||||
|
|
||||
|
if existing.UserId != 0 { |
||||
|
return nil, fmt.Errorf("该密钥不是系统密钥") |
||||
|
} |
||||
|
|
||||
|
if err = model.AIApiKeyDelete(l.ctx, l.svcCtx.DB, req.Id); err != nil { |
||||
|
return nil, fmt.Errorf("删除系统密钥失败: %v", err) |
||||
|
} |
||||
|
|
||||
|
return &types.Response{Code: 0, Message: "ok", Success: true}, nil |
||||
|
} |
||||
@ -0,0 +1,71 @@ |
|||||
|
package ai |
||||
|
|
||||
|
import ( |
||||
|
"context" |
||||
|
|
||||
|
"github.com/youruser/base/internal/svc" |
||||
|
"github.com/youruser/base/internal/types" |
||||
|
"github.com/youruser/base/model" |
||||
|
|
||||
|
"github.com/zeromicro/go-zero/core/logx" |
||||
|
) |
||||
|
|
||||
|
type AiSystemKeyListLogic struct { |
||||
|
logx.Logger |
||||
|
ctx context.Context |
||||
|
svcCtx *svc.ServiceContext |
||||
|
} |
||||
|
|
||||
|
func NewAiSystemKeyListLogic(ctx context.Context, svcCtx *svc.ServiceContext) *AiSystemKeyListLogic { |
||||
|
return &AiSystemKeyListLogic{ |
||||
|
Logger: logx.WithContext(ctx), |
||||
|
ctx: ctx, |
||||
|
svcCtx: svcCtx, |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func (l *AiSystemKeyListLogic) AiSystemKeyList(req *types.AIApiKeyListRequest) (resp *types.AIApiKeyListResponse, err error) { |
||||
|
var keys []model.AIApiKey |
||||
|
var total int64 |
||||
|
|
||||
|
query := l.svcCtx.DB.WithContext(l.ctx).Model(&model.AIApiKey{}).Where("user_id = 0") |
||||
|
if err = query.Count(&total).Error; err != nil { |
||||
|
return nil, err |
||||
|
} |
||||
|
|
||||
|
offset := (req.Page - 1) * req.PageSize |
||||
|
if offset < 0 { |
||||
|
offset = 0 |
||||
|
} |
||||
|
if err = query.Order("created_at DESC").Offset(int(offset)).Limit(int(req.PageSize)).Find(&keys).Error; err != nil { |
||||
|
return nil, err |
||||
|
} |
||||
|
|
||||
|
providerCache := make(map[int64]string) |
||||
|
list := make([]types.AIApiKeyInfo, 0, len(keys)) |
||||
|
for _, key := range keys { |
||||
|
providerName := "" |
||||
|
if name, ok := providerCache[key.ProviderId]; ok { |
||||
|
providerName = name |
||||
|
} else { |
||||
|
provider, provErr := model.AIProviderFindOne(l.ctx, l.svcCtx.DB, key.ProviderId) |
||||
|
if provErr == nil { |
||||
|
providerName = provider.DisplayName |
||||
|
} |
||||
|
providerCache[key.ProviderId] = providerName |
||||
|
} |
||||
|
|
||||
|
list = append(list, types.AIApiKeyInfo{ |
||||
|
Id: key.Id, |
||||
|
ProviderId: key.ProviderId, |
||||
|
ProviderName: providerName, |
||||
|
UserId: 0, |
||||
|
KeyPreview: maskKey(key.KeyValue), |
||||
|
IsActive: key.IsActive, |
||||
|
Remark: key.Remark, |
||||
|
CreatedAt: key.CreatedAt.Format("2006-01-02 15:04:05"), |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
return &types.AIApiKeyListResponse{List: list, Total: total}, nil |
||||
|
} |
||||
@ -0,0 +1,67 @@ |
|||||
|
package ai |
||||
|
|
||||
|
import ( |
||||
|
"context" |
||||
|
"fmt" |
||||
|
|
||||
|
"github.com/youruser/base/internal/svc" |
||||
|
"github.com/youruser/base/internal/types" |
||||
|
"github.com/youruser/base/model" |
||||
|
|
||||
|
"github.com/zeromicro/go-zero/core/logx" |
||||
|
) |
||||
|
|
||||
|
type AiSystemKeyUpdateLogic struct { |
||||
|
logx.Logger |
||||
|
ctx context.Context |
||||
|
svcCtx *svc.ServiceContext |
||||
|
} |
||||
|
|
||||
|
func NewAiSystemKeyUpdateLogic(ctx context.Context, svcCtx *svc.ServiceContext) *AiSystemKeyUpdateLogic { |
||||
|
return &AiSystemKeyUpdateLogic{ |
||||
|
Logger: logx.WithContext(ctx), |
||||
|
ctx: ctx, |
||||
|
svcCtx: svcCtx, |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func (l *AiSystemKeyUpdateLogic) AiSystemKeyUpdate(req *types.AIApiKeyUpdateRequest) (resp *types.AIApiKeyInfo, err error) { |
||||
|
existing, err := model.AIApiKeyFindOne(l.ctx, l.svcCtx.DB, req.Id) |
||||
|
if err != nil { |
||||
|
if err == model.ErrNotFound { |
||||
|
return nil, fmt.Errorf("系统密钥不存在") |
||||
|
} |
||||
|
return nil, fmt.Errorf("查询系统密钥失败: %v", err) |
||||
|
} |
||||
|
|
||||
|
if existing.UserId != 0 { |
||||
|
return nil, fmt.Errorf("该密钥不是系统密钥") |
||||
|
} |
||||
|
|
||||
|
if req.KeyValue != "" { |
||||
|
existing.KeyValue = req.KeyValue |
||||
|
} |
||||
|
existing.IsActive = req.IsActive |
||||
|
existing.Remark = req.Remark |
||||
|
|
||||
|
if err = model.AIApiKeyUpdate(l.ctx, l.svcCtx.DB, existing); err != nil { |
||||
|
return nil, fmt.Errorf("更新系统密钥失败: %v", err) |
||||
|
} |
||||
|
|
||||
|
providerName := "" |
||||
|
provider, provErr := model.AIProviderFindOne(l.ctx, l.svcCtx.DB, existing.ProviderId) |
||||
|
if provErr == nil { |
||||
|
providerName = provider.DisplayName |
||||
|
} |
||||
|
|
||||
|
return &types.AIApiKeyInfo{ |
||||
|
Id: existing.Id, |
||||
|
ProviderId: existing.ProviderId, |
||||
|
ProviderName: providerName, |
||||
|
UserId: 0, |
||||
|
KeyPreview: maskKey(existing.KeyValue), |
||||
|
IsActive: existing.IsActive, |
||||
|
Remark: existing.Remark, |
||||
|
CreatedAt: existing.CreatedAt.Format("2006-01-02 15:04:05"), |
||||
|
}, nil |
||||
|
} |
||||
@ -0,0 +1,75 @@ |
|||||
|
// Code scaffolded by goctl. Safe to edit.
|
||||
|
// goctl 1.9.2
|
||||
|
|
||||
|
package file |
||||
|
|
||||
|
import ( |
||||
|
"context" |
||||
|
"fmt" |
||||
|
|
||||
|
"github.com/youruser/base/internal/svc" |
||||
|
"github.com/youruser/base/internal/types" |
||||
|
"github.com/youruser/base/model" |
||||
|
|
||||
|
"github.com/zeromicro/go-zero/core/logx" |
||||
|
) |
||||
|
|
||||
|
type DeleteFileLogic struct { |
||||
|
logx.Logger |
||||
|
ctx context.Context |
||||
|
svcCtx *svc.ServiceContext |
||||
|
} |
||||
|
|
||||
|
// 删除文件
|
||||
|
func NewDeleteFileLogic(ctx context.Context, svcCtx *svc.ServiceContext) *DeleteFileLogic { |
||||
|
return &DeleteFileLogic{ |
||||
|
Logger: logx.WithContext(ctx), |
||||
|
ctx: ctx, |
||||
|
svcCtx: svcCtx, |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func (l *DeleteFileLogic) DeleteFile(req *types.DeleteFileRequest) (resp *types.Response, err error) { |
||||
|
// Get userId and role from context
|
||||
|
userId, _ := l.ctx.Value("userId").(int64) |
||||
|
if userId == 0 { |
||||
|
if num, ok := l.ctx.Value("userId").(interface{ Int64() (int64, error) }); ok { |
||||
|
userId, _ = num.Int64() |
||||
|
} |
||||
|
} |
||||
|
userRole, _ := l.ctx.Value("role").(string) |
||||
|
|
||||
|
// Find file by ID
|
||||
|
fileRecord, err := model.FileFindOne(l.ctx, l.svcCtx.DB, req.Id) |
||||
|
if err != nil { |
||||
|
if err == model.ErrNotFound { |
||||
|
return nil, fmt.Errorf("file not found") |
||||
|
} |
||||
|
return nil, fmt.Errorf("failed to query file: %v", err) |
||||
|
} |
||||
|
|
||||
|
// Permission check: user can only delete own files, admin can delete all
|
||||
|
if userRole != model.RoleAdmin && userRole != model.RoleSuperAdmin { |
||||
|
if fileRecord.UserId != userId { |
||||
|
return nil, fmt.Errorf("permission denied") |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// Soft delete the file record
|
||||
|
if err := model.FileDelete(l.ctx, l.svcCtx.DB, req.Id); err != nil { |
||||
|
return nil, fmt.Errorf("failed to delete file: %v", err) |
||||
|
} |
||||
|
|
||||
|
// Attempt to delete from storage (log error but don't fail)
|
||||
|
if err := l.svcCtx.Storage.Delete(l.ctx, fileRecord.Key); err != nil { |
||||
|
l.Errorf("failed to delete file from storage (key=%s): %v", fileRecord.Key, err) |
||||
|
} |
||||
|
|
||||
|
resp = &types.Response{ |
||||
|
Code: 0, |
||||
|
Message: "file deleted successfully", |
||||
|
Success: true, |
||||
|
} |
||||
|
|
||||
|
return resp, nil |
||||
|
} |
||||
@ -0,0 +1,105 @@ |
|||||
|
// Code scaffolded by goctl. Safe to edit.
|
||||
|
// goctl 1.9.2
|
||||
|
|
||||
|
package file |
||||
|
|
||||
|
import ( |
||||
|
"context" |
||||
|
"fmt" |
||||
|
"strings" |
||||
|
|
||||
|
"github.com/youruser/base/internal/svc" |
||||
|
"github.com/youruser/base/internal/types" |
||||
|
"github.com/youruser/base/model" |
||||
|
|
||||
|
"github.com/zeromicro/go-zero/core/logx" |
||||
|
) |
||||
|
|
||||
|
type GetFileListLogic struct { |
||||
|
logx.Logger |
||||
|
ctx context.Context |
||||
|
svcCtx *svc.ServiceContext |
||||
|
} |
||||
|
|
||||
|
// 获取文件列表
|
||||
|
func NewGetFileListLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetFileListLogic { |
||||
|
return &GetFileListLogic{ |
||||
|
Logger: logx.WithContext(ctx), |
||||
|
ctx: ctx, |
||||
|
svcCtx: svcCtx, |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func (l *GetFileListLogic) GetFileList(req *types.FileListRequest) (resp *types.FileListResponse, err error) { |
||||
|
// Get userId and role from context
|
||||
|
userId, _ := l.ctx.Value("userId").(int64) |
||||
|
if userId == 0 { |
||||
|
if num, ok := l.ctx.Value("userId").(interface{ Int64() (int64, error) }); ok { |
||||
|
userId, _ = num.Int64() |
||||
|
} |
||||
|
} |
||||
|
userRole, _ := l.ctx.Value("role").(string) |
||||
|
|
||||
|
// Query file list with pagination and permission filtering
|
||||
|
page := int64(req.Page) |
||||
|
pageSize := int64(req.PageSize) |
||||
|
if page <= 0 { |
||||
|
page = 1 |
||||
|
} |
||||
|
if pageSize <= 0 { |
||||
|
pageSize = 20 |
||||
|
} |
||||
|
|
||||
|
files, total, err := model.FileFindList( |
||||
|
l.ctx, l.svcCtx.DB, |
||||
|
page, pageSize, |
||||
|
req.Keyword, req.Category, req.MimeType, |
||||
|
userId, userRole, |
||||
|
) |
||||
|
if err != nil { |
||||
|
return nil, fmt.Errorf("failed to query file list: %v", err) |
||||
|
} |
||||
|
|
||||
|
// Convert to response
|
||||
|
list := make([]types.FileInfo, 0, len(files)) |
||||
|
for _, f := range files { |
||||
|
url := l.buildFileURL(f.Id, f.Key) |
||||
|
list = append(list, types.FileInfo{ |
||||
|
Id: f.Id, |
||||
|
Name: f.Name, |
||||
|
Key: f.Key, |
||||
|
Size: f.Size, |
||||
|
MimeType: f.MimeType, |
||||
|
Category: f.Category, |
||||
|
IsPublic: f.IsPublic, |
||||
|
UserId: f.UserId, |
||||
|
StorageType: f.StorageType, |
||||
|
Url: url, |
||||
|
CreatedAt: f.CreatedAt.Format("2006-01-02 15:04:05"), |
||||
|
UpdatedAt: f.UpdatedAt.Format("2006-01-02 15:04:05"), |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
resp = &types.FileListResponse{ |
||||
|
Total: total, |
||||
|
List: list, |
||||
|
} |
||||
|
|
||||
|
return resp, nil |
||||
|
} |
||||
|
|
||||
|
// buildFileURL generates the appropriate URL for the file
|
||||
|
func (l *GetFileListLogic) buildFileURL(fileId int64, key string) string { |
||||
|
rawURL, err := l.svcCtx.Storage.GetURL(l.ctx, key) |
||||
|
if err != nil { |
||||
|
l.Errorf("failed to get URL for file %d: %v", fileId, err) |
||||
|
return "" |
||||
|
} |
||||
|
|
||||
|
// For local storage, return API endpoint URL
|
||||
|
if strings.HasPrefix(rawURL, "local://") { |
||||
|
return fmt.Sprintf("/api/v1/file/%d/url", fileId) |
||||
|
} |
||||
|
|
||||
|
return rawURL |
||||
|
} |
||||
@ -0,0 +1,94 @@ |
|||||
|
// Code scaffolded by goctl. Safe to edit.
|
||||
|
// goctl 1.9.2
|
||||
|
|
||||
|
package file |
||||
|
|
||||
|
import ( |
||||
|
"context" |
||||
|
"fmt" |
||||
|
"strings" |
||||
|
|
||||
|
"github.com/youruser/base/internal/svc" |
||||
|
"github.com/youruser/base/internal/types" |
||||
|
"github.com/youruser/base/model" |
||||
|
|
||||
|
"github.com/zeromicro/go-zero/core/logx" |
||||
|
) |
||||
|
|
||||
|
type GetFileLogic struct { |
||||
|
logx.Logger |
||||
|
ctx context.Context |
||||
|
svcCtx *svc.ServiceContext |
||||
|
} |
||||
|
|
||||
|
// 获取文件详情
|
||||
|
func NewGetFileLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetFileLogic { |
||||
|
return &GetFileLogic{ |
||||
|
Logger: logx.WithContext(ctx), |
||||
|
ctx: ctx, |
||||
|
svcCtx: svcCtx, |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func (l *GetFileLogic) GetFile(req *types.GetFileRequest) (resp *types.FileInfo, err error) { |
||||
|
// Get userId and role from context
|
||||
|
userId, _ := l.ctx.Value("userId").(int64) |
||||
|
if userId == 0 { |
||||
|
if num, ok := l.ctx.Value("userId").(interface{ Int64() (int64, error) }); ok { |
||||
|
userId, _ = num.Int64() |
||||
|
} |
||||
|
} |
||||
|
userRole, _ := l.ctx.Value("role").(string) |
||||
|
|
||||
|
// Find file by ID
|
||||
|
fileRecord, err := model.FileFindOne(l.ctx, l.svcCtx.DB, req.Id) |
||||
|
if err != nil { |
||||
|
if err == model.ErrNotFound { |
||||
|
return nil, fmt.Errorf("file not found") |
||||
|
} |
||||
|
return nil, fmt.Errorf("failed to query file: %v", err) |
||||
|
} |
||||
|
|
||||
|
// Permission check: non-admin can only see own files + public files
|
||||
|
if userRole != model.RoleAdmin && userRole != model.RoleSuperAdmin { |
||||
|
if fileRecord.UserId != userId && !fileRecord.IsPublic { |
||||
|
return nil, fmt.Errorf("permission denied") |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// Build URL
|
||||
|
url := l.buildFileURL(fileRecord.Id, fileRecord.Key) |
||||
|
|
||||
|
resp = &types.FileInfo{ |
||||
|
Id: fileRecord.Id, |
||||
|
Name: fileRecord.Name, |
||||
|
Key: fileRecord.Key, |
||||
|
Size: fileRecord.Size, |
||||
|
MimeType: fileRecord.MimeType, |
||||
|
Category: fileRecord.Category, |
||||
|
IsPublic: fileRecord.IsPublic, |
||||
|
UserId: fileRecord.UserId, |
||||
|
StorageType: fileRecord.StorageType, |
||||
|
Url: url, |
||||
|
CreatedAt: fileRecord.CreatedAt.Format("2006-01-02 15:04:05"), |
||||
|
UpdatedAt: fileRecord.UpdatedAt.Format("2006-01-02 15:04:05"), |
||||
|
} |
||||
|
|
||||
|
return resp, nil |
||||
|
} |
||||
|
|
||||
|
// buildFileURL generates the appropriate URL for the file
|
||||
|
func (l *GetFileLogic) buildFileURL(fileId int64, key string) string { |
||||
|
rawURL, err := l.svcCtx.Storage.GetURL(l.ctx, key) |
||||
|
if err != nil { |
||||
|
l.Errorf("failed to get URL for file %d: %v", fileId, err) |
||||
|
return "" |
||||
|
} |
||||
|
|
||||
|
// For local storage, return API endpoint URL
|
||||
|
if strings.HasPrefix(rawURL, "local://") { |
||||
|
return fmt.Sprintf("/api/v1/file/%d/url", fileId) |
||||
|
} |
||||
|
|
||||
|
return rawURL |
||||
|
} |
||||
@ -0,0 +1,72 @@ |
|||||
|
// Code scaffolded by goctl. Safe to edit.
|
||||
|
// goctl 1.9.2
|
||||
|
|
||||
|
package file |
||||
|
|
||||
|
import ( |
||||
|
"context" |
||||
|
"fmt" |
||||
|
|
||||
|
"github.com/youruser/base/internal/svc" |
||||
|
"github.com/youruser/base/internal/types" |
||||
|
"github.com/youruser/base/model" |
||||
|
|
||||
|
"github.com/zeromicro/go-zero/core/logx" |
||||
|
) |
||||
|
|
||||
|
type GetFileUrlLogic struct { |
||||
|
logx.Logger |
||||
|
ctx context.Context |
||||
|
svcCtx *svc.ServiceContext |
||||
|
} |
||||
|
|
||||
|
// 获取文件访问URL
|
||||
|
func NewGetFileUrlLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetFileUrlLogic { |
||||
|
return &GetFileUrlLogic{ |
||||
|
Logger: logx.WithContext(ctx), |
||||
|
ctx: ctx, |
||||
|
svcCtx: svcCtx, |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func (l *GetFileUrlLogic) GetFileUrl(req *types.GetFileRequest) (resp *types.FileUrlResponse, err error) { |
||||
|
// Get userId and role from context
|
||||
|
userId, _ := l.ctx.Value("userId").(int64) |
||||
|
if userId == 0 { |
||||
|
if num, ok := l.ctx.Value("userId").(interface{ Int64() (int64, error) }); ok { |
||||
|
userId, _ = num.Int64() |
||||
|
} |
||||
|
} |
||||
|
userRole, _ := l.ctx.Value("role").(string) |
||||
|
|
||||
|
// Find file by ID
|
||||
|
fileRecord, err := model.FileFindOne(l.ctx, l.svcCtx.DB, req.Id) |
||||
|
if err != nil { |
||||
|
if err == model.ErrNotFound { |
||||
|
return nil, fmt.Errorf("file not found") |
||||
|
} |
||||
|
return nil, fmt.Errorf("failed to query file: %v", err) |
||||
|
} |
||||
|
|
||||
|
// Permission check: non-admin can only access own files + public files
|
||||
|
if userRole != model.RoleAdmin && userRole != model.RoleSuperAdmin { |
||||
|
if fileRecord.UserId != userId && !fileRecord.IsPublic { |
||||
|
return nil, fmt.Errorf("permission denied") |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// Get URL from storage backend
|
||||
|
// For local storage, this returns "local://" + key
|
||||
|
// The handler will detect the "local://" prefix and serve the file directly
|
||||
|
// For OSS/MinIO, this returns a signed URL
|
||||
|
url, err := l.svcCtx.Storage.GetURL(l.ctx, fileRecord.Key) |
||||
|
if err != nil { |
||||
|
return nil, fmt.Errorf("failed to get file URL: %v", err) |
||||
|
} |
||||
|
|
||||
|
resp = &types.FileUrlResponse{ |
||||
|
Url: url, |
||||
|
} |
||||
|
|
||||
|
return resp, nil |
||||
|
} |
||||
@ -0,0 +1,110 @@ |
|||||
|
// Code scaffolded by goctl. Safe to edit.
|
||||
|
// goctl 1.9.2
|
||||
|
|
||||
|
package file |
||||
|
|
||||
|
import ( |
||||
|
"context" |
||||
|
"fmt" |
||||
|
"strings" |
||||
|
|
||||
|
"github.com/youruser/base/internal/svc" |
||||
|
"github.com/youruser/base/internal/types" |
||||
|
"github.com/youruser/base/model" |
||||
|
|
||||
|
"github.com/zeromicro/go-zero/core/logx" |
||||
|
) |
||||
|
|
||||
|
type UpdateFileLogic struct { |
||||
|
logx.Logger |
||||
|
ctx context.Context |
||||
|
svcCtx *svc.ServiceContext |
||||
|
} |
||||
|
|
||||
|
// 更新文件信息
|
||||
|
func NewUpdateFileLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpdateFileLogic { |
||||
|
return &UpdateFileLogic{ |
||||
|
Logger: logx.WithContext(ctx), |
||||
|
ctx: ctx, |
||||
|
svcCtx: svcCtx, |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func (l *UpdateFileLogic) UpdateFile(req *types.UpdateFileRequest) (resp *types.FileInfo, err error) { |
||||
|
// Get userId and role from context
|
||||
|
userId, _ := l.ctx.Value("userId").(int64) |
||||
|
if userId == 0 { |
||||
|
if num, ok := l.ctx.Value("userId").(interface{ Int64() (int64, error) }); ok { |
||||
|
userId, _ = num.Int64() |
||||
|
} |
||||
|
} |
||||
|
userRole, _ := l.ctx.Value("role").(string) |
||||
|
|
||||
|
// Find file by ID
|
||||
|
fileRecord, err := model.FileFindOne(l.ctx, l.svcCtx.DB, req.Id) |
||||
|
if err != nil { |
||||
|
if err == model.ErrNotFound { |
||||
|
return nil, fmt.Errorf("file not found") |
||||
|
} |
||||
|
return nil, fmt.Errorf("failed to query file: %v", err) |
||||
|
} |
||||
|
|
||||
|
// Permission check: user can only edit own files, admin can edit all
|
||||
|
if userRole != model.RoleAdmin && userRole != model.RoleSuperAdmin { |
||||
|
if fileRecord.UserId != userId { |
||||
|
return nil, fmt.Errorf("permission denied") |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// Apply optional updates
|
||||
|
if req.Name != "" { |
||||
|
fileRecord.Name = req.Name |
||||
|
} |
||||
|
if req.Category != "" { |
||||
|
fileRecord.Category = req.Category |
||||
|
} |
||||
|
if req.IsPublic != nil { |
||||
|
fileRecord.IsPublic = *req.IsPublic |
||||
|
} |
||||
|
|
||||
|
// Save updates
|
||||
|
if err := model.FileUpdate(l.ctx, l.svcCtx.DB, fileRecord); err != nil { |
||||
|
return nil, fmt.Errorf("failed to update file: %v", err) |
||||
|
} |
||||
|
|
||||
|
// Build URL
|
||||
|
url := l.buildFileURL(fileRecord.Id, fileRecord.Key) |
||||
|
|
||||
|
resp = &types.FileInfo{ |
||||
|
Id: fileRecord.Id, |
||||
|
Name: fileRecord.Name, |
||||
|
Key: fileRecord.Key, |
||||
|
Size: fileRecord.Size, |
||||
|
MimeType: fileRecord.MimeType, |
||||
|
Category: fileRecord.Category, |
||||
|
IsPublic: fileRecord.IsPublic, |
||||
|
UserId: fileRecord.UserId, |
||||
|
StorageType: fileRecord.StorageType, |
||||
|
Url: url, |
||||
|
CreatedAt: fileRecord.CreatedAt.Format("2006-01-02 15:04:05"), |
||||
|
UpdatedAt: fileRecord.UpdatedAt.Format("2006-01-02 15:04:05"), |
||||
|
} |
||||
|
|
||||
|
return resp, nil |
||||
|
} |
||||
|
|
||||
|
// buildFileURL generates the appropriate URL for the file
|
||||
|
func (l *UpdateFileLogic) buildFileURL(fileId int64, key string) string { |
||||
|
rawURL, err := l.svcCtx.Storage.GetURL(l.ctx, key) |
||||
|
if err != nil { |
||||
|
l.Errorf("failed to get URL for file %d: %v", fileId, err) |
||||
|
return "" |
||||
|
} |
||||
|
|
||||
|
// For local storage, return API endpoint URL
|
||||
|
if strings.HasPrefix(rawURL, "local://") { |
||||
|
return fmt.Sprintf("/api/v1/file/%d/url", fileId) |
||||
|
} |
||||
|
|
||||
|
return rawURL |
||||
|
} |
||||
@ -0,0 +1,142 @@ |
|||||
|
// Code scaffolded by goctl. Safe to edit.
|
||||
|
// goctl 1.9.2
|
||||
|
|
||||
|
package file |
||||
|
|
||||
|
import ( |
||||
|
"context" |
||||
|
"fmt" |
||||
|
"net/http" |
||||
|
"path/filepath" |
||||
|
"strings" |
||||
|
"time" |
||||
|
|
||||
|
"github.com/google/uuid" |
||||
|
"github.com/youruser/base/internal/svc" |
||||
|
"github.com/youruser/base/internal/types" |
||||
|
"github.com/youruser/base/model" |
||||
|
|
||||
|
"github.com/zeromicro/go-zero/core/logx" |
||||
|
) |
||||
|
|
||||
|
type UploadFileLogic struct { |
||||
|
logx.Logger |
||||
|
ctx context.Context |
||||
|
svcCtx *svc.ServiceContext |
||||
|
} |
||||
|
|
||||
|
// 上传文件
|
||||
|
func NewUploadFileLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UploadFileLogic { |
||||
|
return &UploadFileLogic{ |
||||
|
Logger: logx.WithContext(ctx), |
||||
|
ctx: ctx, |
||||
|
svcCtx: svcCtx, |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func (l *UploadFileLogic) UploadFile(r *http.Request) (resp *types.FileInfo, err error) { |
||||
|
// Get userId from context
|
||||
|
userId, _ := l.ctx.Value("userId").(int64) |
||||
|
if userId == 0 { |
||||
|
if num, ok := l.ctx.Value("userId").(interface{ Int64() (int64, error) }); ok { |
||||
|
userId, _ = num.Int64() |
||||
|
} |
||||
|
} |
||||
|
if userId == 0 { |
||||
|
return nil, fmt.Errorf("unauthorized") |
||||
|
} |
||||
|
|
||||
|
// Get file from multipart form
|
||||
|
formFile, header, err := r.FormFile("file") |
||||
|
if err != nil { |
||||
|
return nil, fmt.Errorf("failed to get upload file: %v", err) |
||||
|
} |
||||
|
defer formFile.Close() |
||||
|
|
||||
|
// Get optional form values
|
||||
|
category := r.FormValue("category") |
||||
|
if category == "" { |
||||
|
category = "default" |
||||
|
} |
||||
|
isPublicStr := r.FormValue("isPublic") |
||||
|
isPublic := isPublicStr == "true" || isPublicStr == "1" |
||||
|
|
||||
|
// Get file metadata
|
||||
|
fileName := header.Filename |
||||
|
fileSize := header.Size |
||||
|
mimeType := header.Header.Get("Content-Type") |
||||
|
if mimeType == "" { |
||||
|
mimeType = "application/octet-stream" |
||||
|
} |
||||
|
|
||||
|
// Generate storage key: {category}/{YYYY-MM}/{uuid}{ext}
|
||||
|
ext := filepath.Ext(fileName) |
||||
|
now := time.Now() |
||||
|
key := fmt.Sprintf("%s/%s/%s%s", category, now.Format("2006-01"), uuid.New().String(), ext) |
||||
|
|
||||
|
// Upload to storage
|
||||
|
if err := l.svcCtx.Storage.Upload(l.ctx, key, formFile, fileSize, mimeType); err != nil { |
||||
|
return nil, fmt.Errorf("failed to upload file: %v", err) |
||||
|
} |
||||
|
|
||||
|
// Insert file record
|
||||
|
fileRecord := &model.File{ |
||||
|
Name: fileName, |
||||
|
Key: key, |
||||
|
Size: fileSize, |
||||
|
MimeType: mimeType, |
||||
|
Category: category, |
||||
|
IsPublic: isPublic, |
||||
|
UserId: userId, |
||||
|
StorageType: l.svcCtx.Config.Storage.Type, |
||||
|
Status: 1, |
||||
|
} |
||||
|
|
||||
|
fileId, err := model.FileInsert(l.ctx, l.svcCtx.DB, fileRecord) |
||||
|
if err != nil { |
||||
|
// Cleanup uploaded file on DB failure
|
||||
|
if delErr := l.svcCtx.Storage.Delete(l.ctx, key); delErr != nil { |
||||
|
l.Errorf("failed to cleanup file after DB error: %v", delErr) |
||||
|
} |
||||
|
return nil, fmt.Errorf("failed to save file record: %v", err) |
||||
|
} |
||||
|
|
||||
|
// Build URL
|
||||
|
url, err := l.buildFileURL(fileId, key) |
||||
|
if err != nil { |
||||
|
return nil, fmt.Errorf("failed to get file URL: %v", err) |
||||
|
} |
||||
|
|
||||
|
resp = &types.FileInfo{ |
||||
|
Id: fileId, |
||||
|
Name: fileName, |
||||
|
Key: key, |
||||
|
Size: fileSize, |
||||
|
MimeType: mimeType, |
||||
|
Category: category, |
||||
|
IsPublic: isPublic, |
||||
|
UserId: userId, |
||||
|
StorageType: l.svcCtx.Config.Storage.Type, |
||||
|
Url: url, |
||||
|
CreatedAt: fileRecord.CreatedAt.Format("2006-01-02 15:04:05"), |
||||
|
UpdatedAt: fileRecord.UpdatedAt.Format("2006-01-02 15:04:05"), |
||||
|
} |
||||
|
|
||||
|
return resp, nil |
||||
|
} |
||||
|
|
||||
|
// buildFileURL generates the appropriate URL for the file
|
||||
|
func (l *UploadFileLogic) buildFileURL(fileId int64, key string) (string, error) { |
||||
|
rawURL, err := l.svcCtx.Storage.GetURL(l.ctx, key) |
||||
|
if err != nil { |
||||
|
return "", err |
||||
|
} |
||||
|
|
||||
|
// For local storage, return API endpoint URL
|
||||
|
if strings.HasPrefix(rawURL, "local://") { |
||||
|
return fmt.Sprintf("/api/v1/file/%d/url", fileId), nil |
||||
|
} |
||||
|
|
||||
|
// For OSS/MinIO, return signed URL directly
|
||||
|
return rawURL, nil |
||||
|
} |
||||
@ -0,0 +1,60 @@ |
|||||
|
// Code scaffolded by goctl. Safe to edit.
|
||||
|
// goctl 1.9.2
|
||||
|
|
||||
|
package menu |
||||
|
|
||||
|
import ( |
||||
|
"context" |
||||
|
"fmt" |
||||
|
|
||||
|
"github.com/youruser/base/internal/svc" |
||||
|
"github.com/youruser/base/internal/types" |
||||
|
"github.com/youruser/base/model" |
||||
|
|
||||
|
"github.com/zeromicro/go-zero/core/logx" |
||||
|
) |
||||
|
|
||||
|
type SortMenusLogic struct { |
||||
|
logx.Logger |
||||
|
ctx context.Context |
||||
|
svcCtx *svc.ServiceContext |
||||
|
} |
||||
|
|
||||
|
// 批量排序菜单
|
||||
|
func NewSortMenusLogic(ctx context.Context, svcCtx *svc.ServiceContext) *SortMenusLogic { |
||||
|
return &SortMenusLogic{ |
||||
|
Logger: logx.WithContext(ctx), |
||||
|
ctx: ctx, |
||||
|
svcCtx: svcCtx, |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func (l *SortMenusLogic) SortMenus(req *types.SortMenusRequest) (resp *types.Response, err error) { |
||||
|
if len(req.Items) == 0 { |
||||
|
return &types.Response{Code: 0, Message: "ok", Success: true}, nil |
||||
|
} |
||||
|
|
||||
|
items := make([]struct { |
||||
|
Id int64 |
||||
|
SortOrder int |
||||
|
ParentId int64 |
||||
|
}, len(req.Items)) |
||||
|
|
||||
|
for i, item := range req.Items { |
||||
|
items[i] = struct { |
||||
|
Id int64 |
||||
|
SortOrder int |
||||
|
ParentId int64 |
||||
|
}{ |
||||
|
Id: item.Id, |
||||
|
SortOrder: item.SortOrder, |
||||
|
ParentId: item.ParentId, |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
if err := model.MenuBatchUpdateSort(l.ctx, l.svcCtx.DB, items); err != nil { |
||||
|
return nil, fmt.Errorf("批量更新排序失败: %v", err) |
||||
|
} |
||||
|
|
||||
|
return &types.Response{Code: 0, Message: "ok", Success: true}, nil |
||||
|
} |
||||
@ -0,0 +1,196 @@ |
|||||
|
package user |
||||
|
|
||||
|
import ( |
||||
|
"context" |
||||
|
"testing" |
||||
|
|
||||
|
"github.com/youruser/base/internal/types" |
||||
|
"github.com/youruser/base/model" |
||||
|
|
||||
|
"github.com/stretchr/testify/assert" |
||||
|
"github.com/stretchr/testify/require" |
||||
|
) |
||||
|
|
||||
|
// TestCreateUser_WithRole 测试创建用户时指定角色
|
||||
|
func TestCreateUser_WithRole(t *testing.T) { |
||||
|
svcCtx, cleanup := setupUserTestDB(t) |
||||
|
defer cleanup() |
||||
|
|
||||
|
ctx := context.Background() |
||||
|
logic := NewCreateUserLogic(ctx, svcCtx) |
||||
|
|
||||
|
req := &types.CreateUserRequest{ |
||||
|
Username: "admin_test", |
||||
|
Email: "admin_test@example.com", |
||||
|
Password: "password123", |
||||
|
Role: "admin", |
||||
|
Remark: "Test admin user", |
||||
|
} |
||||
|
|
||||
|
resp, err := logic.CreateUser(req) |
||||
|
|
||||
|
require.NoError(t, err) |
||||
|
require.NotNil(t, resp) |
||||
|
assert.Equal(t, "admin", resp.Role) |
||||
|
assert.Equal(t, "manual", resp.Source) |
||||
|
assert.Equal(t, "Test admin user", resp.Remark) |
||||
|
} |
||||
|
|
||||
|
// TestCreateUser_DefaultRole 测试创建用户时不指定角色,应默认 user
|
||||
|
func TestCreateUser_DefaultRole(t *testing.T) { |
||||
|
svcCtx, cleanup := setupUserTestDB(t) |
||||
|
defer cleanup() |
||||
|
|
||||
|
ctx := context.Background() |
||||
|
logic := NewCreateUserLogic(ctx, svcCtx) |
||||
|
|
||||
|
req := &types.CreateUserRequest{ |
||||
|
Username: "default_role_test", |
||||
|
Email: "default_role@example.com", |
||||
|
Password: "password123", |
||||
|
} |
||||
|
|
||||
|
resp, err := logic.CreateUser(req) |
||||
|
|
||||
|
require.NoError(t, err) |
||||
|
require.NotNil(t, resp) |
||||
|
assert.Equal(t, "user", resp.Role) |
||||
|
assert.Equal(t, "manual", resp.Source) |
||||
|
assert.Equal(t, "", resp.Remark) |
||||
|
} |
||||
|
|
||||
|
// TestGetUser_ReturnsRoleFields 测试获取用户时返回 role/source/remark
|
||||
|
func TestGetUser_ReturnsRoleFields(t *testing.T) { |
||||
|
svcCtx, cleanup := setupUserTestDB(t) |
||||
|
defer cleanup() |
||||
|
|
||||
|
ctx := context.Background() |
||||
|
|
||||
|
// 先创建一个带角色的用户
|
||||
|
createLogic := NewCreateUserLogic(ctx, svcCtx) |
||||
|
createResp, err := createLogic.CreateUser(&types.CreateUserRequest{ |
||||
|
Username: "role_fields_test", |
||||
|
Email: "role_fields@example.com", |
||||
|
Password: "password123", |
||||
|
Role: "admin", |
||||
|
Remark: "role fields test", |
||||
|
}) |
||||
|
require.NoError(t, err) |
||||
|
|
||||
|
// 查询该用户
|
||||
|
getLogic := NewGetUserLogic(ctx, svcCtx) |
||||
|
resp, err := getLogic.GetUser(&types.GetUserRequest{Id: createResp.Id}) |
||||
|
|
||||
|
require.NoError(t, err) |
||||
|
require.NotNil(t, resp) |
||||
|
assert.Equal(t, "admin", resp.Role) |
||||
|
assert.Equal(t, "manual", resp.Source) |
||||
|
assert.Equal(t, "role fields test", resp.Remark) |
||||
|
} |
||||
|
|
||||
|
// TestGetUserList_ReturnsRoleFields 测试用户列表返回 role/source/remark
|
||||
|
func TestGetUserList_ReturnsRoleFields(t *testing.T) { |
||||
|
svcCtx, cleanup := setupUserTestDB(t) |
||||
|
defer cleanup() |
||||
|
|
||||
|
ctx := context.Background() |
||||
|
|
||||
|
// 创建不同角色的用户
|
||||
|
createLogic := NewCreateUserLogic(ctx, svcCtx) |
||||
|
_, err := createLogic.CreateUser(&types.CreateUserRequest{ |
||||
|
Username: "list_admin", |
||||
|
Email: "list_admin@example.com", |
||||
|
Password: "password123", |
||||
|
Role: "admin", |
||||
|
}) |
||||
|
require.NoError(t, err) |
||||
|
|
||||
|
createLogic2 := NewCreateUserLogic(ctx, svcCtx) |
||||
|
_, err = createLogic2.CreateUser(&types.CreateUserRequest{ |
||||
|
Username: "list_user", |
||||
|
Email: "list_user@example.com", |
||||
|
Password: "password123", |
||||
|
}) |
||||
|
require.NoError(t, err) |
||||
|
|
||||
|
// 查询列表
|
||||
|
listLogic := NewGetUserListLogic(ctx, svcCtx) |
||||
|
resp, err := listLogic.GetUserList(&types.UserListRequest{ |
||||
|
Page: 1, |
||||
|
PageSize: 10, |
||||
|
}) |
||||
|
|
||||
|
require.NoError(t, err) |
||||
|
require.NotNil(t, resp) |
||||
|
assert.GreaterOrEqual(t, len(resp.List), 2) |
||||
|
|
||||
|
// 验证每个用户都有 role 字段
|
||||
|
for _, u := range resp.List { |
||||
|
assert.NotEmpty(t, u.Role, "用户 %s 的 role 不应为空", u.Username) |
||||
|
assert.NotEmpty(t, u.Source, "用户 %s 的 source 不应为空", u.Username) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// TestUpdateUser_RoleAndRemark 测试更新用户角色和备注
|
||||
|
func TestUpdateUser_RoleAndRemark(t *testing.T) { |
||||
|
svcCtx, cleanup := setupUserTestDB(t) |
||||
|
defer cleanup() |
||||
|
|
||||
|
ctx := context.Background() |
||||
|
|
||||
|
// 创建用户
|
||||
|
createLogic := NewCreateUserLogic(ctx, svcCtx) |
||||
|
createResp, err := createLogic.CreateUser(&types.CreateUserRequest{ |
||||
|
Username: "update_role_test", |
||||
|
Email: "update_role@example.com", |
||||
|
Password: "password123", |
||||
|
}) |
||||
|
require.NoError(t, err) |
||||
|
assert.Equal(t, "user", createResp.Role) |
||||
|
|
||||
|
// 更新角色
|
||||
|
updateLogic := NewUpdateUserLogic(ctx, svcCtx) |
||||
|
resp, err := updateLogic.UpdateUser(&types.UpdateUserRequest{ |
||||
|
Id: createResp.Id, |
||||
|
Role: "admin", |
||||
|
Remark: "promoted", |
||||
|
}) |
||||
|
|
||||
|
require.NoError(t, err) |
||||
|
require.NotNil(t, resp) |
||||
|
assert.Equal(t, "admin", resp.Role) |
||||
|
assert.Equal(t, "promoted", resp.Remark) |
||||
|
// Source 不应改变
|
||||
|
assert.Equal(t, "manual", resp.Source) |
||||
|
} |
||||
|
|
||||
|
// TestFindOneByRole 测试 FindOneByRole 方法
|
||||
|
func TestFindOneByRole(t *testing.T) { |
||||
|
svcCtx, cleanup := setupUserTestDB(t) |
||||
|
defer cleanup() |
||||
|
|
||||
|
ctx := context.Background() |
||||
|
|
||||
|
// 创建 super_admin 用户
|
||||
|
adminUser := &model.User{ |
||||
|
Username: "find_role_admin", |
||||
|
Email: "find_role_admin@example.com", |
||||
|
Password: "hashed", |
||||
|
Role: model.RoleSuperAdmin, |
||||
|
Source: model.SourceSystem, |
||||
|
Status: 1, |
||||
|
} |
||||
|
_, err := model.Insert(ctx, svcCtx.DB, adminUser) |
||||
|
require.NoError(t, err) |
||||
|
|
||||
|
// 查找 super_admin
|
||||
|
found, err := model.FindOneByRole(ctx, svcCtx.DB, model.RoleSuperAdmin) |
||||
|
require.NoError(t, err) |
||||
|
require.NotNil(t, found) |
||||
|
assert.Equal(t, model.RoleSuperAdmin, found.Role) |
||||
|
assert.Equal(t, "find_role_admin", found.Username) |
||||
|
|
||||
|
// 查找不存在的角色
|
||||
|
_, err = model.FindOneByRole(ctx, svcCtx.DB, "nonexistent") |
||||
|
assert.ErrorIs(t, err, model.ErrNotFound) |
||||
|
} |
||||
@ -0,0 +1,52 @@ |
|||||
|
package middleware |
||||
|
|
||||
|
import ( |
||||
|
"encoding/json" |
||||
|
"net/http" |
||||
|
|
||||
|
"github.com/casbin/casbin/v2" |
||||
|
) |
||||
|
|
||||
|
type AuthzMiddleware struct { |
||||
|
Enforcer *casbin.Enforcer |
||||
|
} |
||||
|
|
||||
|
func NewAuthzMiddleware(enforcer *casbin.Enforcer) *AuthzMiddleware { |
||||
|
return &AuthzMiddleware{Enforcer: enforcer} |
||||
|
} |
||||
|
|
||||
|
func (m *AuthzMiddleware) Handle(next http.HandlerFunc) http.HandlerFunc { |
||||
|
return func(w http.ResponseWriter, r *http.Request) { |
||||
|
// 从 context 获取 role(由 Auth middleware 注入)
|
||||
|
role, _ := r.Context().Value("role").(string) |
||||
|
if role == "" { |
||||
|
role = "guest" |
||||
|
} |
||||
|
|
||||
|
// Casbin enforce: role, path, method
|
||||
|
allowed, err := m.Enforcer.Enforce(role, r.URL.Path, r.Method) |
||||
|
if err != nil { |
||||
|
w.Header().Set("Content-Type", "application/json") |
||||
|
w.WriteHeader(http.StatusInternalServerError) |
||||
|
json.NewEncoder(w).Encode(map[string]interface{}{ |
||||
|
"code": 500, |
||||
|
"message": "权限检查失败", |
||||
|
"success": false, |
||||
|
}) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
if !allowed { |
||||
|
w.Header().Set("Content-Type", "application/json") |
||||
|
w.WriteHeader(http.StatusForbidden) |
||||
|
json.NewEncoder(w).Encode(map[string]interface{}{ |
||||
|
"code": 403, |
||||
|
"message": "没有权限执行此操作", |
||||
|
"success": false, |
||||
|
}) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
next(w, r) |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,71 @@ |
|||||
|
package storage |
||||
|
|
||||
|
import ( |
||||
|
"context" |
||||
|
"fmt" |
||||
|
"io" |
||||
|
"os" |
||||
|
"path/filepath" |
||||
|
|
||||
|
"github.com/youruser/base/internal/config" |
||||
|
) |
||||
|
|
||||
|
type LocalStorage struct { |
||||
|
rootDir string |
||||
|
} |
||||
|
|
||||
|
func NewLocalStorage(cfg config.LocalStorageConfig) (*LocalStorage, error) { |
||||
|
rootDir := cfg.RootDir |
||||
|
if rootDir == "" { |
||||
|
rootDir = "./uploads" |
||||
|
} |
||||
|
if err := os.MkdirAll(rootDir, 0755); err != nil { |
||||
|
return nil, fmt.Errorf("failed to create upload dir: %w", err) |
||||
|
} |
||||
|
return &LocalStorage{rootDir: rootDir}, nil |
||||
|
} |
||||
|
|
||||
|
func (s *LocalStorage) Upload(ctx context.Context, key string, reader io.Reader, size int64, contentType string) error { |
||||
|
fullPath := filepath.Join(s.rootDir, key) |
||||
|
if err := os.MkdirAll(filepath.Dir(fullPath), 0755); err != nil { |
||||
|
return fmt.Errorf("failed to create directory: %w", err) |
||||
|
} |
||||
|
file, err := os.Create(fullPath) |
||||
|
if err != nil { |
||||
|
return fmt.Errorf("failed to create file: %w", err) |
||||
|
} |
||||
|
defer file.Close() |
||||
|
if _, err := io.Copy(file, reader); err != nil { |
||||
|
return fmt.Errorf("failed to write file: %w", err) |
||||
|
} |
||||
|
return nil |
||||
|
} |
||||
|
|
||||
|
func (s *LocalStorage) Delete(ctx context.Context, key string) error { |
||||
|
fullPath := filepath.Join(s.rootDir, key) |
||||
|
if err := os.Remove(fullPath); err != nil && !os.IsNotExist(err) { |
||||
|
return fmt.Errorf("failed to delete file: %w", err) |
||||
|
} |
||||
|
return nil |
||||
|
} |
||||
|
|
||||
|
func (s *LocalStorage) GetURL(ctx context.Context, key string) (string, error) { |
||||
|
return "local://" + key, nil |
||||
|
} |
||||
|
|
||||
|
func (s *LocalStorage) Exists(ctx context.Context, key string) (bool, error) { |
||||
|
fullPath := filepath.Join(s.rootDir, key) |
||||
|
_, err := os.Stat(fullPath) |
||||
|
if err == nil { |
||||
|
return true, nil |
||||
|
} |
||||
|
if os.IsNotExist(err) { |
||||
|
return false, nil |
||||
|
} |
||||
|
return false, err |
||||
|
} |
||||
|
|
||||
|
// GetFilePath returns the full filesystem path for a key (used by download handler).
|
||||
|
func (s *LocalStorage) GetFilePath(key string) string { |
||||
|
return filepath.Join(s.rootDir, key) |
||||
|
} |
||||
@ -0,0 +1,82 @@ |
|||||
|
package storage |
||||
|
|
||||
|
import ( |
||||
|
"context" |
||||
|
"fmt" |
||||
|
"io" |
||||
|
"net/url" |
||||
|
"time" |
||||
|
|
||||
|
"github.com/minio/minio-go/v7" |
||||
|
"github.com/minio/minio-go/v7/pkg/credentials" |
||||
|
"github.com/youruser/base/internal/config" |
||||
|
) |
||||
|
|
||||
|
type MinIOStorage struct { |
||||
|
client *minio.Client |
||||
|
bucket string |
||||
|
} |
||||
|
|
||||
|
func NewMinIOStorage(cfg config.MinIOStorageConfig) (*MinIOStorage, error) { |
||||
|
if cfg.Endpoint == "" || cfg.AccessKeyId == "" || cfg.AccessKeySecret == "" || cfg.Bucket == "" { |
||||
|
return nil, fmt.Errorf("MinIO config incomplete: endpoint, accessKeyId, accessKeySecret, bucket are required") |
||||
|
} |
||||
|
client, err := minio.New(cfg.Endpoint, &minio.Options{ |
||||
|
Creds: credentials.NewStaticV4(cfg.AccessKeyId, cfg.AccessKeySecret, ""), |
||||
|
Secure: cfg.UseSSL, |
||||
|
}) |
||||
|
if err != nil { |
||||
|
return nil, fmt.Errorf("failed to create MinIO client: %w", err) |
||||
|
} |
||||
|
|
||||
|
ctx := context.Background() |
||||
|
exists, err := client.BucketExists(ctx, cfg.Bucket) |
||||
|
if err != nil { |
||||
|
return nil, fmt.Errorf("failed to check MinIO bucket: %w", err) |
||||
|
} |
||||
|
if !exists { |
||||
|
if err := client.MakeBucket(ctx, cfg.Bucket, minio.MakeBucketOptions{}); err != nil { |
||||
|
return nil, fmt.Errorf("failed to create MinIO bucket: %w", err) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return &MinIOStorage{client: client, bucket: cfg.Bucket}, nil |
||||
|
} |
||||
|
|
||||
|
func (s *MinIOStorage) Upload(ctx context.Context, key string, reader io.Reader, size int64, contentType string) error { |
||||
|
opts := minio.PutObjectOptions{ |
||||
|
ContentType: contentType, |
||||
|
} |
||||
|
if _, err := s.client.PutObject(ctx, s.bucket, key, reader, size, opts); err != nil { |
||||
|
return fmt.Errorf("failed to upload to MinIO: %w", err) |
||||
|
} |
||||
|
return nil |
||||
|
} |
||||
|
|
||||
|
func (s *MinIOStorage) Delete(ctx context.Context, key string) error { |
||||
|
if err := s.client.RemoveObject(ctx, s.bucket, key, minio.RemoveObjectOptions{}); err != nil { |
||||
|
return fmt.Errorf("failed to delete from MinIO: %w", err) |
||||
|
} |
||||
|
return nil |
||||
|
} |
||||
|
|
||||
|
func (s *MinIOStorage) GetURL(ctx context.Context, key string) (string, error) { |
||||
|
reqParams := make(url.Values) |
||||
|
presignedURL, err := s.client.PresignedGetObject(ctx, s.bucket, key, time.Hour, reqParams) |
||||
|
if err != nil { |
||||
|
return "", fmt.Errorf("failed to generate MinIO presigned URL: %w", err) |
||||
|
} |
||||
|
return presignedURL.String(), nil |
||||
|
} |
||||
|
|
||||
|
func (s *MinIOStorage) Exists(ctx context.Context, key string) (bool, error) { |
||||
|
_, err := s.client.StatObject(ctx, s.bucket, key, minio.StatObjectOptions{}) |
||||
|
if err != nil { |
||||
|
errResponse := minio.ToErrorResponse(err) |
||||
|
if errResponse.Code == "NoSuchKey" { |
||||
|
return false, nil |
||||
|
} |
||||
|
return false, fmt.Errorf("failed to check MinIO object: %w", err) |
||||
|
} |
||||
|
return true, nil |
||||
|
} |
||||
@ -0,0 +1,62 @@ |
|||||
|
package storage |
||||
|
|
||||
|
import ( |
||||
|
"context" |
||||
|
"fmt" |
||||
|
"io" |
||||
|
|
||||
|
"github.com/aliyun/aliyun-oss-go-sdk/oss" |
||||
|
"github.com/youruser/base/internal/config" |
||||
|
) |
||||
|
|
||||
|
type OSSStorage struct { |
||||
|
bucket *oss.Bucket |
||||
|
} |
||||
|
|
||||
|
func NewOSSStorage(cfg config.OSSStorageConfig) (*OSSStorage, error) { |
||||
|
if cfg.Endpoint == "" || cfg.AccessKeyId == "" || cfg.AccessKeySecret == "" || cfg.Bucket == "" { |
||||
|
return nil, fmt.Errorf("OSS config incomplete: endpoint, accessKeyId, accessKeySecret, bucket are required") |
||||
|
} |
||||
|
client, err := oss.New(cfg.Endpoint, cfg.AccessKeyId, cfg.AccessKeySecret) |
||||
|
if err != nil { |
||||
|
return nil, fmt.Errorf("failed to create OSS client: %w", err) |
||||
|
} |
||||
|
bucket, err := client.Bucket(cfg.Bucket) |
||||
|
if err != nil { |
||||
|
return nil, fmt.Errorf("failed to get OSS bucket: %w", err) |
||||
|
} |
||||
|
return &OSSStorage{bucket: bucket}, nil |
||||
|
} |
||||
|
|
||||
|
func (s *OSSStorage) Upload(ctx context.Context, key string, reader io.Reader, size int64, contentType string) error { |
||||
|
options := []oss.Option{ |
||||
|
oss.ContentType(contentType), |
||||
|
} |
||||
|
if err := s.bucket.PutObject(key, reader, options...); err != nil { |
||||
|
return fmt.Errorf("failed to upload to OSS: %w", err) |
||||
|
} |
||||
|
return nil |
||||
|
} |
||||
|
|
||||
|
func (s *OSSStorage) Delete(ctx context.Context, key string) error { |
||||
|
if err := s.bucket.DeleteObject(key); err != nil { |
||||
|
return fmt.Errorf("failed to delete from OSS: %w", err) |
||||
|
} |
||||
|
return nil |
||||
|
} |
||||
|
|
||||
|
func (s *OSSStorage) GetURL(ctx context.Context, key string) (string, error) { |
||||
|
url, err := s.bucket.SignURL(key, oss.HTTPGet, 3600) |
||||
|
if err != nil { |
||||
|
return "", fmt.Errorf("failed to generate OSS signed URL: %w", err) |
||||
|
} |
||||
|
return url, nil |
||||
|
} |
||||
|
|
||||
|
func (s *OSSStorage) Exists(ctx context.Context, key string) (bool, error) { |
||||
|
exists, err := s.bucket.IsObjectExist(key) |
||||
|
if err != nil { |
||||
|
return false, fmt.Errorf("failed to check OSS object: %w", err) |
||||
|
} |
||||
|
return exists, nil |
||||
|
} |
||||
@ -0,0 +1,31 @@ |
|||||
|
package storage |
||||
|
|
||||
|
import ( |
||||
|
"context" |
||||
|
"fmt" |
||||
|
"io" |
||||
|
|
||||
|
"github.com/youruser/base/internal/config" |
||||
|
) |
||||
|
|
||||
|
// Storage defines the interface for file storage backends.
|
||||
|
type Storage interface { |
||||
|
Upload(ctx context.Context, key string, reader io.Reader, size int64, contentType string) error |
||||
|
Delete(ctx context.Context, key string) error |
||||
|
GetURL(ctx context.Context, key string) (string, error) |
||||
|
Exists(ctx context.Context, key string) (bool, error) |
||||
|
} |
||||
|
|
||||
|
// NewStorage creates the appropriate storage backend from config.
|
||||
|
func NewStorage(cfg config.StorageConfig) (Storage, error) { |
||||
|
switch cfg.Type { |
||||
|
case "local": |
||||
|
return NewLocalStorage(cfg.Local) |
||||
|
case "oss": |
||||
|
return NewOSSStorage(cfg.OSS) |
||||
|
case "minio": |
||||
|
return NewMinIOStorage(cfg.MinIO) |
||||
|
default: |
||||
|
return nil, fmt.Errorf("unsupported storage type: %s", cfg.Type) |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,17 @@ |
|||||
|
package model |
||||
|
|
||||
|
// Role constants
|
||||
|
const ( |
||||
|
RoleSuperAdmin = "super_admin" |
||||
|
RoleAdmin = "admin" |
||||
|
RoleUser = "user" |
||||
|
RoleGuest = "guest" |
||||
|
) |
||||
|
|
||||
|
// Source constants
|
||||
|
const ( |
||||
|
SourceSystem = "system" |
||||
|
SourceRegister = "register" |
||||
|
SourceCasdoor = "casdoor" |
||||
|
SourceManual = "manual" |
||||
|
) |
||||
@ -0,0 +1,24 @@ |
|||||
|
package model |
||||
|
|
||||
|
import "time" |
||||
|
|
||||
|
// File 文件模型
|
||||
|
type File struct { |
||||
|
Id int64 `gorm:"column:id;primaryKey;autoIncrement" json:"id"` |
||||
|
Name string `gorm:"column:name;type:varchar(255);not null" json:"name"` |
||||
|
Key string `gorm:"column:key;type:varchar(500);uniqueIndex" json:"key"` |
||||
|
Size int64 `gorm:"column:size;not null" json:"size"` |
||||
|
MimeType string `gorm:"column:mime_type;type:varchar(100)" json:"mimeType"` |
||||
|
Category string `gorm:"column:category;type:varchar(50);index;default:'default'" json:"category"` |
||||
|
IsPublic bool `gorm:"column:is_public;default:false" json:"isPublic"` |
||||
|
UserId int64 `gorm:"column:user_id;index" json:"userId"` |
||||
|
StorageType string `gorm:"column:storage_type;type:varchar(20)" json:"storageType"` |
||||
|
Status int `gorm:"column:status;default:1" json:"status"` |
||||
|
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime" json:"createdAt"` |
||||
|
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime" json:"updatedAt"` |
||||
|
} |
||||
|
|
||||
|
// TableName 指定表名
|
||||
|
func (File) TableName() string { |
||||
|
return "file" |
||||
|
} |
||||
@ -0,0 +1,83 @@ |
|||||
|
package model |
||||
|
|
||||
|
import ( |
||||
|
"context" |
||||
|
"errors" |
||||
|
|
||||
|
"gorm.io/gorm" |
||||
|
) |
||||
|
|
||||
|
// FileInsert 插入文件记录
|
||||
|
func FileInsert(ctx context.Context, db *gorm.DB, file *File) (int64, error) { |
||||
|
result := db.WithContext(ctx).Create(file) |
||||
|
if result.Error != nil { |
||||
|
return 0, result.Error |
||||
|
} |
||||
|
return file.Id, nil |
||||
|
} |
||||
|
|
||||
|
// FileFindOne 根据 ID 查询文件
|
||||
|
func FileFindOne(ctx context.Context, db *gorm.DB, id int64) (*File, error) { |
||||
|
var file File |
||||
|
result := db.WithContext(ctx).Where("status = 1").First(&file, id) |
||||
|
if result.Error != nil { |
||||
|
if errors.Is(result.Error, gorm.ErrRecordNotFound) { |
||||
|
return nil, ErrNotFound |
||||
|
} |
||||
|
return nil, result.Error |
||||
|
} |
||||
|
return &file, nil |
||||
|
} |
||||
|
|
||||
|
// FileFindList 查询文件列表(分页+筛选+权限)
|
||||
|
func FileFindList(ctx context.Context, db *gorm.DB, page, pageSize int64, keyword, category, mimeType string, userId int64, userRole string) ([]File, int64, error) { |
||||
|
var files []File |
||||
|
var total int64 |
||||
|
|
||||
|
query := db.WithContext(ctx).Model(&File{}).Where("status = 1") |
||||
|
|
||||
|
// 权限过滤:非 admin/super_admin 只能看自己的文件 + 公开文件
|
||||
|
if userRole != RoleAdmin && userRole != RoleSuperAdmin { |
||||
|
query = query.Where("user_id = ? OR is_public = ?", userId, true) |
||||
|
} |
||||
|
|
||||
|
if keyword != "" { |
||||
|
query = query.Where("name LIKE ?", "%"+keyword+"%") |
||||
|
} |
||||
|
|
||||
|
if category != "" { |
||||
|
query = query.Where("category = ?", category) |
||||
|
} |
||||
|
|
||||
|
// MIME 类型前缀匹配(如 "image" 匹配 "image/png")
|
||||
|
if mimeType != "" { |
||||
|
query = query.Where("mime_type LIKE ?", mimeType+"%") |
||||
|
} |
||||
|
|
||||
|
if err := query.Count(&total).Error; err != nil { |
||||
|
return nil, 0, err |
||||
|
} |
||||
|
|
||||
|
offset := (page - 1) * pageSize |
||||
|
if offset < 0 { |
||||
|
offset = 0 |
||||
|
} |
||||
|
err := query.Order("created_at DESC").Offset(int(offset)).Limit(int(pageSize)).Find(&files).Error |
||||
|
if err != nil { |
||||
|
return nil, 0, err |
||||
|
} |
||||
|
|
||||
|
return files, total, nil |
||||
|
} |
||||
|
|
||||
|
// FileUpdate 更新文件记录
|
||||
|
func FileUpdate(ctx context.Context, db *gorm.DB, file *File) error { |
||||
|
result := db.WithContext(ctx).Save(file) |
||||
|
return result.Error |
||||
|
} |
||||
|
|
||||
|
// FileDelete 软删除文件(设置 status=0)
|
||||
|
func FileDelete(ctx context.Context, db *gorm.DB, id int64) error { |
||||
|
result := db.WithContext(ctx).Model(&File{}).Where("id = ?", id).Update("status", 0) |
||||
|
return result.Error |
||||
|
} |
||||
@ -0,0 +1,498 @@ |
|||||
|
package model |
||||
|
|
||||
|
import ( |
||||
|
"context" |
||||
|
"testing" |
||||
|
"time" |
||||
|
|
||||
|
"github.com/stretchr/testify/assert" |
||||
|
"github.com/stretchr/testify/require" |
||||
|
"gorm.io/driver/mysql" |
||||
|
"gorm.io/gorm" |
||||
|
) |
||||
|
|
||||
|
// setupFileTestDB 创建测试数据库(使用MySQL)
|
||||
|
func setupFileTestDB(t *testing.T) *gorm.DB { |
||||
|
t.Helper() |
||||
|
|
||||
|
// 使用 MySQL 进行测试
|
||||
|
dsn := "root:dev123456@tcp(219.159.132.177:17173)/base?charset=utf8mb4&parseTime=true&loc=Local" |
||||
|
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{}) |
||||
|
require.NoError(t, err) |
||||
|
|
||||
|
err = db.AutoMigrate(&File{}) |
||||
|
require.NoError(t, err) |
||||
|
|
||||
|
// 清理所有测试数据(TRUNCATE)
|
||||
|
db.Exec("SET FOREIGN_KEY_CHECKS = 0") |
||||
|
db.Exec("TRUNCATE TABLE file") |
||||
|
db.Exec("SET FOREIGN_KEY_CHECKS = 1") |
||||
|
|
||||
|
return db |
||||
|
} |
||||
|
|
||||
|
// createTestFile 创建测试文件
|
||||
|
func createTestFile(t *testing.T, db *gorm.DB) *File { |
||||
|
t.Helper() |
||||
|
|
||||
|
now := time.Now() |
||||
|
file := &File{ |
||||
|
Name: "test-file.pdf", |
||||
|
Key: "uploads/2026/02/test-file.pdf", |
||||
|
Size: 1024000, |
||||
|
MimeType: "application/pdf", |
||||
|
Category: "document", |
||||
|
IsPublic: false, |
||||
|
UserId: 1, |
||||
|
StorageType: "local", |
||||
|
Status: 1, |
||||
|
CreatedAt: now, |
||||
|
UpdatedAt: now, |
||||
|
} |
||||
|
|
||||
|
err := db.Create(file).Error |
||||
|
require.NoError(t, err) |
||||
|
|
||||
|
return file |
||||
|
} |
||||
|
|
||||
|
// TestFileInsert 测试插入文件
|
||||
|
func TestFileInsert(t *testing.T) { |
||||
|
db := setupFileTestDB(t) |
||||
|
|
||||
|
ctx := context.Background() |
||||
|
file := &File{ |
||||
|
Name: "new-file.jpg", |
||||
|
Key: "uploads/2026/02/new-file.jpg", |
||||
|
Size: 2048000, |
||||
|
MimeType: "image/jpeg", |
||||
|
Category: "image", |
||||
|
IsPublic: true, |
||||
|
UserId: 1, |
||||
|
StorageType: "local", |
||||
|
Status: 1, |
||||
|
} |
||||
|
|
||||
|
id, err := FileInsert(ctx, db, file) |
||||
|
|
||||
|
require.NoError(t, err) |
||||
|
assert.Greater(t, id, int64(0)) |
||||
|
assert.Equal(t, id, file.Id) |
||||
|
|
||||
|
// 验证所有字段已保存
|
||||
|
saved, err := FileFindOne(ctx, db, id) |
||||
|
require.NoError(t, err) |
||||
|
assert.Equal(t, "new-file.jpg", saved.Name) |
||||
|
assert.Equal(t, "uploads/2026/02/new-file.jpg", saved.Key) |
||||
|
assert.Equal(t, int64(2048000), saved.Size) |
||||
|
assert.Equal(t, "image/jpeg", saved.MimeType) |
||||
|
assert.Equal(t, "image", saved.Category) |
||||
|
assert.True(t, saved.IsPublic) |
||||
|
assert.Equal(t, int64(1), saved.UserId) |
||||
|
assert.Equal(t, "local", saved.StorageType) |
||||
|
assert.Equal(t, 1, saved.Status) |
||||
|
assert.NotZero(t, saved.CreatedAt) |
||||
|
assert.NotZero(t, saved.UpdatedAt) |
||||
|
} |
||||
|
|
||||
|
// TestFileFindOne 测试根据ID查询文件
|
||||
|
func TestFileFindOne(t *testing.T) { |
||||
|
db := setupFileTestDB(t) |
||||
|
|
||||
|
// 创建测试文件
|
||||
|
file := createTestFile(t, db) |
||||
|
ctx := context.Background() |
||||
|
|
||||
|
// 查询文件
|
||||
|
found, err := FileFindOne(ctx, db, file.Id) |
||||
|
|
||||
|
require.NoError(t, err) |
||||
|
require.NotNil(t, found) |
||||
|
assert.Equal(t, file.Id, found.Id) |
||||
|
assert.Equal(t, "test-file.pdf", found.Name) |
||||
|
assert.Equal(t, "uploads/2026/02/test-file.pdf", found.Key) |
||||
|
assert.Equal(t, int64(1024000), found.Size) |
||||
|
assert.Equal(t, "application/pdf", found.MimeType) |
||||
|
assert.Equal(t, "document", found.Category) |
||||
|
assert.False(t, found.IsPublic) |
||||
|
assert.Equal(t, int64(1), found.UserId) |
||||
|
} |
||||
|
|
||||
|
// TestFileFindOne_NotFound 测试查询不存在的文件
|
||||
|
func TestFileFindOne_NotFound(t *testing.T) { |
||||
|
db := setupFileTestDB(t) |
||||
|
|
||||
|
ctx := context.Background() |
||||
|
|
||||
|
found, err := FileFindOne(ctx, db, 99999) |
||||
|
|
||||
|
require.Error(t, err) |
||||
|
require.Nil(t, found) |
||||
|
assert.Equal(t, ErrNotFound, err) |
||||
|
} |
||||
|
|
||||
|
// TestFileFindList_Pagination 测试分页查询
|
||||
|
func TestFileFindList_Pagination(t *testing.T) { |
||||
|
db := setupFileTestDB(t) |
||||
|
ctx := context.Background() |
||||
|
|
||||
|
// 创建5个测试文件
|
||||
|
now := time.Now() |
||||
|
files := []File{ |
||||
|
{Name: "file1.pdf", Key: "uploads/file1.pdf", Size: 1000, MimeType: "application/pdf", Category: "document", IsPublic: true, UserId: 1, StorageType: "local", Status: 1, CreatedAt: now, UpdatedAt: now}, |
||||
|
{Name: "file2.pdf", Key: "uploads/file2.pdf", Size: 2000, MimeType: "application/pdf", Category: "document", IsPublic: true, UserId: 1, StorageType: "local", Status: 1, CreatedAt: now, UpdatedAt: now}, |
||||
|
{Name: "file3.pdf", Key: "uploads/file3.pdf", Size: 3000, MimeType: "application/pdf", Category: "document", IsPublic: true, UserId: 1, StorageType: "local", Status: 1, CreatedAt: now, UpdatedAt: now}, |
||||
|
{Name: "file4.pdf", Key: "uploads/file4.pdf", Size: 4000, MimeType: "application/pdf", Category: "document", IsPublic: true, UserId: 1, StorageType: "local", Status: 1, CreatedAt: now, UpdatedAt: now}, |
||||
|
{Name: "file5.pdf", Key: "uploads/file5.pdf", Size: 5000, MimeType: "application/pdf", Category: "document", IsPublic: true, UserId: 1, StorageType: "local", Status: 1, CreatedAt: now, UpdatedAt: now}, |
||||
|
} |
||||
|
|
||||
|
for _, f := range files { |
||||
|
err := db.Create(&f).Error |
||||
|
require.NoError(t, err) |
||||
|
} |
||||
|
|
||||
|
// 测试分页查询(第1页,每页2条)
|
||||
|
list, total, err := FileFindList(ctx, db, 1, 2, "", "", "", 1, RoleAdmin) |
||||
|
|
||||
|
require.NoError(t, err) |
||||
|
assert.Equal(t, int64(5), total) |
||||
|
assert.Len(t, list, 2) |
||||
|
} |
||||
|
|
||||
|
// TestFileFindList_KeywordFilter 测试关键词筛选
|
||||
|
func TestFileFindList_KeywordFilter(t *testing.T) { |
||||
|
db := setupFileTestDB(t) |
||||
|
ctx := context.Background() |
||||
|
|
||||
|
// 创建不同名称的文件
|
||||
|
now := time.Now() |
||||
|
files := []File{ |
||||
|
{Name: "report-2026.pdf", Key: "uploads/report-2026.pdf", Size: 1000, MimeType: "application/pdf", Category: "document", IsPublic: true, UserId: 1, StorageType: "local", Status: 1, CreatedAt: now, UpdatedAt: now}, |
||||
|
{Name: "invoice-january.pdf", Key: "uploads/invoice-january.pdf", Size: 2000, MimeType: "application/pdf", Category: "document", IsPublic: true, UserId: 1, StorageType: "local", Status: 1, CreatedAt: now, UpdatedAt: now}, |
||||
|
{Name: "report-2025.pdf", Key: "uploads/report-2025.pdf", Size: 3000, MimeType: "application/pdf", Category: "document", IsPublic: true, UserId: 1, StorageType: "local", Status: 1, CreatedAt: now, UpdatedAt: now}, |
||||
|
} |
||||
|
|
||||
|
for _, f := range files { |
||||
|
err := db.Create(&f).Error |
||||
|
require.NoError(t, err) |
||||
|
} |
||||
|
|
||||
|
// 搜索包含 "report" 的文件
|
||||
|
list, total, err := FileFindList(ctx, db, 1, 10, "report", "", "", 1, RoleAdmin) |
||||
|
|
||||
|
require.NoError(t, err) |
||||
|
assert.Equal(t, int64(2), total) |
||||
|
assert.Len(t, list, 2) |
||||
|
|
||||
|
// 搜索包含 "invoice" 的文件
|
||||
|
list, total, err = FileFindList(ctx, db, 1, 10, "invoice", "", "", 1, RoleAdmin) |
||||
|
|
||||
|
require.NoError(t, err) |
||||
|
assert.Equal(t, int64(1), total) |
||||
|
assert.Len(t, list, 1) |
||||
|
assert.Equal(t, "invoice-january.pdf", list[0].Name) |
||||
|
} |
||||
|
|
||||
|
// TestFileFindList_CategoryFilter 测试分类筛选
|
||||
|
func TestFileFindList_CategoryFilter(t *testing.T) { |
||||
|
db := setupFileTestDB(t) |
||||
|
ctx := context.Background() |
||||
|
|
||||
|
// 创建不同分类的文件
|
||||
|
now := time.Now() |
||||
|
files := []File{ |
||||
|
{Name: "photo1.jpg", Key: "uploads/photo1.jpg", Size: 1000, MimeType: "image/jpeg", Category: "image", IsPublic: true, UserId: 1, StorageType: "local", Status: 1, CreatedAt: now, UpdatedAt: now}, |
||||
|
{Name: "photo2.jpg", Key: "uploads/photo2.jpg", Size: 2000, MimeType: "image/jpeg", Category: "image", IsPublic: true, UserId: 1, StorageType: "local", Status: 1, CreatedAt: now, UpdatedAt: now}, |
||||
|
{Name: "report.pdf", Key: "uploads/report.pdf", Size: 3000, MimeType: "application/pdf", Category: "document", IsPublic: true, UserId: 1, StorageType: "local", Status: 1, CreatedAt: now, UpdatedAt: now}, |
||||
|
{Name: "video.mp4", Key: "uploads/video.mp4", Size: 4000, MimeType: "video/mp4", Category: "video", IsPublic: true, UserId: 1, StorageType: "local", Status: 1, CreatedAt: now, UpdatedAt: now}, |
||||
|
} |
||||
|
|
||||
|
for _, f := range files { |
||||
|
err := db.Create(&f).Error |
||||
|
require.NoError(t, err) |
||||
|
} |
||||
|
|
||||
|
// 筛选 image 分类
|
||||
|
list, total, err := FileFindList(ctx, db, 1, 10, "", "image", "", 1, RoleAdmin) |
||||
|
|
||||
|
require.NoError(t, err) |
||||
|
assert.Equal(t, int64(2), total) |
||||
|
assert.Len(t, list, 2) |
||||
|
|
||||
|
// 筛选 document 分类
|
||||
|
list, total, err = FileFindList(ctx, db, 1, 10, "", "document", "", 1, RoleAdmin) |
||||
|
|
||||
|
require.NoError(t, err) |
||||
|
assert.Equal(t, int64(1), total) |
||||
|
assert.Len(t, list, 1) |
||||
|
assert.Equal(t, "report.pdf", list[0].Name) |
||||
|
|
||||
|
// 筛选 video 分类
|
||||
|
list, total, err = FileFindList(ctx, db, 1, 10, "", "video", "", 1, RoleAdmin) |
||||
|
|
||||
|
require.NoError(t, err) |
||||
|
assert.Equal(t, int64(1), total) |
||||
|
assert.Len(t, list, 1) |
||||
|
assert.Equal(t, "video.mp4", list[0].Name) |
||||
|
} |
||||
|
|
||||
|
// TestFileFindList_PermissionFilter 测试权限筛选
|
||||
|
func TestFileFindList_PermissionFilter(t *testing.T) { |
||||
|
db := setupFileTestDB(t) |
||||
|
ctx := context.Background() |
||||
|
|
||||
|
// 创建不同用户的文件(公开和私有)
|
||||
|
now := time.Now() |
||||
|
files := []File{ |
||||
|
// 用户1的私有文件
|
||||
|
{Name: "user1-private.pdf", Key: "uploads/user1-private.pdf", Size: 1000, MimeType: "application/pdf", Category: "document", IsPublic: false, UserId: 1, StorageType: "local", Status: 1, CreatedAt: now, UpdatedAt: now}, |
||||
|
// 用户1的公开文件
|
||||
|
{Name: "user1-public.pdf", Key: "uploads/user1-public.pdf", Size: 2000, MimeType: "application/pdf", Category: "document", IsPublic: true, UserId: 1, StorageType: "local", Status: 1, CreatedAt: now, UpdatedAt: now}, |
||||
|
// 用户2的私有文件
|
||||
|
{Name: "user2-private.pdf", Key: "uploads/user2-private.pdf", Size: 3000, MimeType: "application/pdf", Category: "document", IsPublic: false, UserId: 2, StorageType: "local", Status: 1, CreatedAt: now, UpdatedAt: now}, |
||||
|
// 用户2的公开文件
|
||||
|
{Name: "user2-public.pdf", Key: "uploads/user2-public.pdf", Size: 4000, MimeType: "application/pdf", Category: "document", IsPublic: true, UserId: 2, StorageType: "local", Status: 1, CreatedAt: now, UpdatedAt: now}, |
||||
|
} |
||||
|
|
||||
|
for _, f := range files { |
||||
|
err := db.Create(&f).Error |
||||
|
require.NoError(t, err) |
||||
|
} |
||||
|
|
||||
|
// 测试1:admin 角色可以看到所有文件
|
||||
|
list, total, err := FileFindList(ctx, db, 1, 10, "", "", "", 1, RoleAdmin) |
||||
|
|
||||
|
require.NoError(t, err) |
||||
|
assert.Equal(t, int64(4), total) |
||||
|
assert.Len(t, list, 4) |
||||
|
|
||||
|
// 测试2:super_admin 角色也可以看到所有文件
|
||||
|
list, total, err = FileFindList(ctx, db, 1, 10, "", "", "", 1, RoleSuperAdmin) |
||||
|
|
||||
|
require.NoError(t, err) |
||||
|
assert.Equal(t, int64(4), total) |
||||
|
assert.Len(t, list, 4) |
||||
|
|
||||
|
// 测试3:普通用户(user1)只能看到自己的文件 + 所有公开文件
|
||||
|
list, total, err = FileFindList(ctx, db, 1, 10, "", "", "", 1, RoleUser) |
||||
|
|
||||
|
require.NoError(t, err) |
||||
|
assert.Equal(t, int64(3), total) // user1的2个文件 + user2的1个公开文件
|
||||
|
assert.Len(t, list, 3) |
||||
|
|
||||
|
// 验证返回的文件:user1-private, user1-public, user2-public
|
||||
|
fileNames := make(map[string]bool) |
||||
|
for _, f := range list { |
||||
|
fileNames[f.Name] = true |
||||
|
} |
||||
|
assert.True(t, fileNames["user1-private.pdf"]) |
||||
|
assert.True(t, fileNames["user1-public.pdf"]) |
||||
|
assert.True(t, fileNames["user2-public.pdf"]) |
||||
|
assert.False(t, fileNames["user2-private.pdf"]) // 不应该包含
|
||||
|
|
||||
|
// 测试4:普通用户(user2)只能看到自己的文件 + 所有公开文件
|
||||
|
list, total, err = FileFindList(ctx, db, 1, 10, "", "", "", 2, RoleUser) |
||||
|
|
||||
|
require.NoError(t, err) |
||||
|
assert.Equal(t, int64(3), total) // user2的2个文件 + user1的1个公开文件
|
||||
|
assert.Len(t, list, 3) |
||||
|
|
||||
|
// 验证返回的文件:user2-private, user2-public, user1-public
|
||||
|
fileNames = make(map[string]bool) |
||||
|
for _, f := range list { |
||||
|
fileNames[f.Name] = true |
||||
|
} |
||||
|
assert.True(t, fileNames["user2-private.pdf"]) |
||||
|
assert.True(t, fileNames["user2-public.pdf"]) |
||||
|
assert.True(t, fileNames["user1-public.pdf"]) |
||||
|
assert.False(t, fileNames["user1-private.pdf"]) // 不应该包含
|
||||
|
} |
||||
|
|
||||
|
// TestFileUpdate 测试更新文件
|
||||
|
func TestFileUpdate(t *testing.T) { |
||||
|
db := setupFileTestDB(t) |
||||
|
|
||||
|
// 创建测试文件
|
||||
|
file := createTestFile(t, db) |
||||
|
ctx := context.Background() |
||||
|
|
||||
|
// 修改文件数据
|
||||
|
file.Name = "updated-file.pdf" |
||||
|
file.Category = "report" |
||||
|
file.IsPublic = true |
||||
|
|
||||
|
// 更新
|
||||
|
err := FileUpdate(ctx, db, file) |
||||
|
|
||||
|
require.NoError(t, err) |
||||
|
|
||||
|
// 验证更新结果
|
||||
|
updated, err := FileFindOne(ctx, db, file.Id) |
||||
|
require.NoError(t, err) |
||||
|
assert.Equal(t, "updated-file.pdf", updated.Name) |
||||
|
assert.Equal(t, "report", updated.Category) |
||||
|
assert.True(t, updated.IsPublic) |
||||
|
// 验证其他字段未改变
|
||||
|
assert.Equal(t, "uploads/2026/02/test-file.pdf", updated.Key) |
||||
|
assert.Equal(t, int64(1024000), updated.Size) |
||||
|
} |
||||
|
|
||||
|
// TestFileDelete 测试删除文件(软删除)
|
||||
|
func TestFileDelete(t *testing.T) { |
||||
|
db := setupFileTestDB(t) |
||||
|
|
||||
|
// 创建测试文件
|
||||
|
file := createTestFile(t, db) |
||||
|
fileId := file.Id |
||||
|
ctx := context.Background() |
||||
|
|
||||
|
// 删除文件
|
||||
|
err := FileDelete(ctx, db, fileId) |
||||
|
|
||||
|
require.NoError(t, err) |
||||
|
|
||||
|
// 验证文件已被软删除(FileFindOne 应该返回 ErrNotFound,因为它过滤 status=1)
|
||||
|
_, err = FileFindOne(ctx, db, fileId) |
||||
|
require.Error(t, err) |
||||
|
assert.Equal(t, ErrNotFound, err) |
||||
|
|
||||
|
// 验证数据库中记录仍然存在但 status=0
|
||||
|
var deletedFile File |
||||
|
err = db.WithContext(ctx).Where("id = ?", fileId).First(&deletedFile).Error |
||||
|
require.NoError(t, err) |
||||
|
assert.Equal(t, 0, deletedFile.Status) |
||||
|
} |
||||
|
|
||||
|
// TestFileFindList_EmptyResult 测试查询空列表
|
||||
|
func TestFileFindList_EmptyResult(t *testing.T) { |
||||
|
db := setupFileTestDB(t) |
||||
|
ctx := context.Background() |
||||
|
|
||||
|
list, total, err := FileFindList(ctx, db, 1, 10, "", "", "", 1, RoleAdmin) |
||||
|
|
||||
|
require.NoError(t, err) |
||||
|
assert.Equal(t, int64(0), total) |
||||
|
assert.Empty(t, list) |
||||
|
} |
||||
|
|
||||
|
// TestFileFindList_MimeTypeFilter 测试 MIME 类型筛选
|
||||
|
func TestFileFindList_MimeTypeFilter(t *testing.T) { |
||||
|
db := setupFileTestDB(t) |
||||
|
ctx := context.Background() |
||||
|
|
||||
|
// 创建不同 MIME 类型的文件
|
||||
|
now := time.Now() |
||||
|
files := []File{ |
||||
|
{Name: "photo1.jpg", Key: "uploads/photo1.jpg", Size: 1000, MimeType: "image/jpeg", Category: "image", IsPublic: true, UserId: 1, StorageType: "local", Status: 1, CreatedAt: now, UpdatedAt: now}, |
||||
|
{Name: "photo2.png", Key: "uploads/photo2.png", Size: 2000, MimeType: "image/png", Category: "image", IsPublic: true, UserId: 1, StorageType: "local", Status: 1, CreatedAt: now, UpdatedAt: now}, |
||||
|
{Name: "report.pdf", Key: "uploads/report.pdf", Size: 3000, MimeType: "application/pdf", Category: "document", IsPublic: true, UserId: 1, StorageType: "local", Status: 1, CreatedAt: now, UpdatedAt: now}, |
||||
|
{Name: "video.mp4", Key: "uploads/video.mp4", Size: 4000, MimeType: "video/mp4", Category: "video", IsPublic: true, UserId: 1, StorageType: "local", Status: 1, CreatedAt: now, UpdatedAt: now}, |
||||
|
} |
||||
|
|
||||
|
for _, f := range files { |
||||
|
err := db.Create(&f).Error |
||||
|
require.NoError(t, err) |
||||
|
} |
||||
|
|
||||
|
// 筛选 image/* 类型(前缀匹配)
|
||||
|
list, total, err := FileFindList(ctx, db, 1, 10, "", "", "image", 1, RoleAdmin) |
||||
|
|
||||
|
require.NoError(t, err) |
||||
|
assert.Equal(t, int64(2), total) |
||||
|
assert.Len(t, list, 2) |
||||
|
|
||||
|
// 筛选 application/* 类型
|
||||
|
list, total, err = FileFindList(ctx, db, 1, 10, "", "", "application", 1, RoleAdmin) |
||||
|
|
||||
|
require.NoError(t, err) |
||||
|
assert.Equal(t, int64(1), total) |
||||
|
assert.Len(t, list, 1) |
||||
|
assert.Equal(t, "report.pdf", list[0].Name) |
||||
|
} |
||||
|
|
||||
|
// BenchmarkFileInsert 性能测试
|
||||
|
func BenchmarkFileInsert(b *testing.B) { |
||||
|
db := setupFileTestDB(&testing.T{}) |
||||
|
ctx := context.Background() |
||||
|
|
||||
|
b.ResetTimer() |
||||
|
for i := 0; i < b.N; i++ { |
||||
|
file := &File{ |
||||
|
Name: "benchmark-file.pdf", |
||||
|
Key: "uploads/benchmark-file.pdf", |
||||
|
Size: 1024000, |
||||
|
MimeType: "application/pdf", |
||||
|
Category: "document", |
||||
|
IsPublic: false, |
||||
|
UserId: 1, |
||||
|
StorageType: "local", |
||||
|
Status: 1, |
||||
|
} |
||||
|
_, _ = FileInsert(ctx, db, file) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// BenchmarkFileFindOne 性能测试
|
||||
|
func BenchmarkFileFindOne(b *testing.B) { |
||||
|
db := setupFileTestDB(&testing.T{}) |
||||
|
ctx := context.Background() |
||||
|
|
||||
|
// 创建测试文件
|
||||
|
now := time.Now() |
||||
|
file := &File{ |
||||
|
Name: "benchmark-file.pdf", |
||||
|
Key: "uploads/benchmark-file.pdf", |
||||
|
Size: 1024000, |
||||
|
MimeType: "application/pdf", |
||||
|
Category: "document", |
||||
|
IsPublic: false, |
||||
|
UserId: 1, |
||||
|
StorageType: "local", |
||||
|
Status: 1, |
||||
|
CreatedAt: now, |
||||
|
UpdatedAt: now, |
||||
|
} |
||||
|
|
||||
|
err := db.Create(file).Error |
||||
|
if err != nil { |
||||
|
b.Fatal(err) |
||||
|
} |
||||
|
|
||||
|
b.ResetTimer() |
||||
|
for i := 0; i < b.N; i++ { |
||||
|
_, _ = FileFindOne(ctx, db, file.Id) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// BenchmarkFileFindList 性能测试
|
||||
|
func BenchmarkFileFindList(b *testing.B) { |
||||
|
db := setupFileTestDB(&testing.T{}) |
||||
|
ctx := context.Background() |
||||
|
|
||||
|
// 创建100个测试文件
|
||||
|
now := time.Now() |
||||
|
for i := 0; i < 100; i++ { |
||||
|
file := &File{ |
||||
|
Name: "benchmark-file.pdf", |
||||
|
Key: "uploads/benchmark-file.pdf", |
||||
|
Size: 1024000, |
||||
|
MimeType: "application/pdf", |
||||
|
Category: "document", |
||||
|
IsPublic: true, |
||||
|
UserId: 1, |
||||
|
StorageType: "local", |
||||
|
Status: 1, |
||||
|
CreatedAt: now, |
||||
|
UpdatedAt: now, |
||||
|
} |
||||
|
err := db.Create(file).Error |
||||
|
if err != nil { |
||||
|
b.Fatal(err) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
b.ResetTimer() |
||||
|
for i := 0; i < b.N; i++ { |
||||
|
_, _, _ = FileFindList(ctx, db, 1, 10, "", "", "", 1, RoleAdmin) |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,269 @@ |
|||||
|
package model |
||||
|
|
||||
|
import ( |
||||
|
"context" |
||||
|
"testing" |
||||
|
"time" |
||||
|
|
||||
|
"github.com/stretchr/testify/assert" |
||||
|
"github.com/stretchr/testify/require" |
||||
|
"gorm.io/driver/mysql" |
||||
|
"gorm.io/gorm" |
||||
|
) |
||||
|
|
||||
|
// setupMenuTestDB 创建菜单测试数据库(使用MySQL)
|
||||
|
func setupMenuTestDB(t *testing.T) *gorm.DB { |
||||
|
t.Helper() |
||||
|
|
||||
|
dsn := "root:dev123456@tcp(219.159.132.177:17173)/base?charset=utf8mb4&parseTime=true&loc=Local" |
||||
|
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{}) |
||||
|
require.NoError(t, err) |
||||
|
|
||||
|
err = db.AutoMigrate(&Menu{}) |
||||
|
require.NoError(t, err) |
||||
|
|
||||
|
// 清理所有数据确保测试隔离(运行后需重启后端以恢复种子数据)
|
||||
|
db.Exec("SET FOREIGN_KEY_CHECKS = 0") |
||||
|
db.Exec("TRUNCATE TABLE menu") |
||||
|
db.Exec("SET FOREIGN_KEY_CHECKS = 1") |
||||
|
|
||||
|
return db |
||||
|
} |
||||
|
|
||||
|
// createTestMenu 创建测试菜单
|
||||
|
func createTestMenu(t *testing.T, db *gorm.DB) *Menu { |
||||
|
t.Helper() |
||||
|
|
||||
|
now := time.Now() |
||||
|
menu := &Menu{ |
||||
|
Name: "测试菜单", |
||||
|
Path: "/test", |
||||
|
Icon: "Star", |
||||
|
Type: "config", |
||||
|
SortOrder: 1, |
||||
|
Visible: true, |
||||
|
Status: 1, |
||||
|
CreatedAt: now, |
||||
|
UpdatedAt: now, |
||||
|
} |
||||
|
|
||||
|
err := db.Create(menu).Error |
||||
|
require.NoError(t, err) |
||||
|
|
||||
|
return menu |
||||
|
} |
||||
|
|
||||
|
// TestMenuInsert 测试插入菜单
|
||||
|
func TestMenuInsert(t *testing.T) { |
||||
|
db := setupMenuTestDB(t) |
||||
|
|
||||
|
ctx := context.Background() |
||||
|
menu := &Menu{ |
||||
|
Name: "新菜单", |
||||
|
Path: "/new", |
||||
|
Icon: "Plus", |
||||
|
Type: "config", |
||||
|
SortOrder: 5, |
||||
|
Visible: true, |
||||
|
Status: 1, |
||||
|
} |
||||
|
|
||||
|
id, err := MenuInsert(ctx, db, menu) |
||||
|
|
||||
|
require.NoError(t, err) |
||||
|
assert.Greater(t, id, int64(0)) |
||||
|
assert.Equal(t, id, menu.Id) |
||||
|
|
||||
|
// 验证数据已保存
|
||||
|
saved, err := MenuFindOne(ctx, db, id) |
||||
|
require.NoError(t, err) |
||||
|
assert.Equal(t, "新菜单", saved.Name) |
||||
|
assert.Equal(t, "/new", saved.Path) |
||||
|
assert.Equal(t, "Plus", saved.Icon) |
||||
|
assert.Equal(t, 5, saved.SortOrder) |
||||
|
} |
||||
|
|
||||
|
// TestMenuFindOne 测试根据ID查询菜单
|
||||
|
func TestMenuFindOne(t *testing.T) { |
||||
|
db := setupMenuTestDB(t) |
||||
|
|
||||
|
menu := createTestMenu(t, db) |
||||
|
ctx := context.Background() |
||||
|
|
||||
|
found, err := MenuFindOne(ctx, db, menu.Id) |
||||
|
|
||||
|
require.NoError(t, err) |
||||
|
require.NotNil(t, found) |
||||
|
assert.Equal(t, menu.Id, found.Id) |
||||
|
assert.Equal(t, "测试菜单", found.Name) |
||||
|
assert.Equal(t, "/test", found.Path) |
||||
|
assert.Equal(t, "Star", found.Icon) |
||||
|
} |
||||
|
|
||||
|
// TestMenuFindOne_NotFound 测试查询不存在的菜单
|
||||
|
func TestMenuFindOne_NotFound(t *testing.T) { |
||||
|
db := setupMenuTestDB(t) |
||||
|
|
||||
|
ctx := context.Background() |
||||
|
|
||||
|
found, err := MenuFindOne(ctx, db, 99999) |
||||
|
|
||||
|
require.Error(t, err) |
||||
|
require.Nil(t, found) |
||||
|
assert.Equal(t, ErrNotFound, err) |
||||
|
} |
||||
|
|
||||
|
// TestMenuFindAll 测试查询所有启用的菜单
|
||||
|
func TestMenuFindAll(t *testing.T) { |
||||
|
db := setupMenuTestDB(t) |
||||
|
ctx := context.Background() |
||||
|
|
||||
|
// 创建3个菜单:2个启用,1个禁用
|
||||
|
now := time.Now() |
||||
|
menus := []Menu{ |
||||
|
{Name: "菜单A", Path: "/a", SortOrder: 2, Status: 1, CreatedAt: now, UpdatedAt: now}, |
||||
|
{Name: "菜单B", Path: "/b", SortOrder: 1, Status: 1, CreatedAt: now, UpdatedAt: now}, |
||||
|
{Name: "菜单C", Path: "/c", SortOrder: 3, Status: 1, CreatedAt: now, UpdatedAt: now}, |
||||
|
} |
||||
|
|
||||
|
for i := range menus { |
||||
|
err := db.Create(&menus[i]).Error |
||||
|
require.NoError(t, err) |
||||
|
} |
||||
|
|
||||
|
// 将菜单C设为禁用(status=0是零值,GORM Create时会使用default:1,需要单独更新)
|
||||
|
db.Model(&menus[2]).Update("status", 0) |
||||
|
|
||||
|
// 查询所有启用的菜单
|
||||
|
result, err := MenuFindAll(ctx, db) |
||||
|
|
||||
|
require.NoError(t, err) |
||||
|
assert.Len(t, result, 2) |
||||
|
|
||||
|
// 验证按 sort_order 排序
|
||||
|
assert.Equal(t, "菜单B", result[0].Name) // sort_order=1
|
||||
|
assert.Equal(t, "菜单A", result[1].Name) // sort_order=2
|
||||
|
} |
||||
|
|
||||
|
// TestMenuFindByIds 测试根据ID列表查询菜单
|
||||
|
func TestMenuFindByIds(t *testing.T) { |
||||
|
db := setupMenuTestDB(t) |
||||
|
ctx := context.Background() |
||||
|
|
||||
|
// 创建3个菜单
|
||||
|
now := time.Now() |
||||
|
menus := []Menu{ |
||||
|
{Name: "菜单1", Path: "/1", SortOrder: 1, Status: 1, CreatedAt: now, UpdatedAt: now}, |
||||
|
{Name: "菜单2", Path: "/2", SortOrder: 2, Status: 1, CreatedAt: now, UpdatedAt: now}, |
||||
|
{Name: "菜单3", Path: "/3", SortOrder: 3, Status: 1, CreatedAt: now, UpdatedAt: now}, |
||||
|
} |
||||
|
|
||||
|
for i := range menus { |
||||
|
err := db.Create(&menus[i]).Error |
||||
|
require.NoError(t, err) |
||||
|
} |
||||
|
|
||||
|
// 查询其中2个
|
||||
|
ids := []int64{menus[0].Id, menus[2].Id} |
||||
|
result, err := MenuFindByIds(ctx, db, ids) |
||||
|
|
||||
|
require.NoError(t, err) |
||||
|
assert.Len(t, result, 2) |
||||
|
assert.Equal(t, "菜单1", result[0].Name) |
||||
|
assert.Equal(t, "菜单3", result[1].Name) |
||||
|
} |
||||
|
|
||||
|
// TestMenuUpdate 测试更新菜单
|
||||
|
func TestMenuUpdate(t *testing.T) { |
||||
|
db := setupMenuTestDB(t) |
||||
|
|
||||
|
menu := createTestMenu(t, db) |
||||
|
ctx := context.Background() |
||||
|
|
||||
|
// 修改菜单数据
|
||||
|
menu.Name = "更新后的菜单" |
||||
|
menu.Path = "/updated" |
||||
|
|
||||
|
err := MenuUpdate(ctx, db, menu) |
||||
|
|
||||
|
require.NoError(t, err) |
||||
|
|
||||
|
// 验证更新结果
|
||||
|
updated, err := MenuFindOne(ctx, db, menu.Id) |
||||
|
require.NoError(t, err) |
||||
|
assert.Equal(t, "更新后的菜单", updated.Name) |
||||
|
assert.Equal(t, "/updated", updated.Path) |
||||
|
} |
||||
|
|
||||
|
// TestMenuDelete 测试删除菜单
|
||||
|
func TestMenuDelete(t *testing.T) { |
||||
|
db := setupMenuTestDB(t) |
||||
|
|
||||
|
menu := createTestMenu(t, db) |
||||
|
menuId := menu.Id |
||||
|
ctx := context.Background() |
||||
|
|
||||
|
err := MenuDelete(ctx, db, menuId) |
||||
|
|
||||
|
require.NoError(t, err) |
||||
|
|
||||
|
// 验证菜单已被删除
|
||||
|
_, err = MenuFindOne(ctx, db, menuId) |
||||
|
require.Error(t, err) |
||||
|
assert.Equal(t, ErrNotFound, err) |
||||
|
} |
||||
|
|
||||
|
// TestMenuHasChildren 测试检查菜单是否有子菜单
|
||||
|
func TestMenuHasChildren(t *testing.T) { |
||||
|
db := setupMenuTestDB(t) |
||||
|
ctx := context.Background() |
||||
|
|
||||
|
now := time.Now() |
||||
|
|
||||
|
// 创建父菜单
|
||||
|
parent := &Menu{ |
||||
|
Name: "父菜单", |
||||
|
Path: "/parent", |
||||
|
SortOrder: 1, |
||||
|
Status: 1, |
||||
|
CreatedAt: now, |
||||
|
UpdatedAt: now, |
||||
|
} |
||||
|
err := db.Create(parent).Error |
||||
|
require.NoError(t, err) |
||||
|
|
||||
|
// 创建子菜单
|
||||
|
child := &Menu{ |
||||
|
ParentId: parent.Id, |
||||
|
Name: "子菜单", |
||||
|
Path: "/parent/child", |
||||
|
SortOrder: 1, |
||||
|
Status: 1, |
||||
|
CreatedAt: now, |
||||
|
UpdatedAt: now, |
||||
|
} |
||||
|
err = db.Create(child).Error |
||||
|
require.NoError(t, err) |
||||
|
|
||||
|
// 创建无子菜单的孤立菜单
|
||||
|
orphan := &Menu{ |
||||
|
Name: "孤立菜单", |
||||
|
Path: "/orphan", |
||||
|
SortOrder: 1, |
||||
|
Status: 1, |
||||
|
CreatedAt: now, |
||||
|
UpdatedAt: now, |
||||
|
} |
||||
|
err = db.Create(orphan).Error |
||||
|
require.NoError(t, err) |
||||
|
|
||||
|
// 验证父菜单有子菜单
|
||||
|
hasChildren, err := MenuHasChildren(ctx, db, parent.Id) |
||||
|
require.NoError(t, err) |
||||
|
assert.True(t, hasChildren) |
||||
|
|
||||
|
// 验证孤立菜单没有子菜单
|
||||
|
hasChildren, err = MenuHasChildren(ctx, db, orphan.Id) |
||||
|
require.NoError(t, err) |
||||
|
assert.False(t, hasChildren) |
||||
|
} |
||||
@ -0,0 +1,685 @@ |
|||||
|
package model |
||||
|
|
||||
|
import ( |
||||
|
"context" |
||||
|
"testing" |
||||
|
"time" |
||||
|
|
||||
|
"github.com/stretchr/testify/assert" |
||||
|
"github.com/stretchr/testify/require" |
||||
|
"gorm.io/driver/mysql" |
||||
|
"gorm.io/gorm" |
||||
|
) |
||||
|
|
||||
|
// setupOrgTestDB 创建机构测试数据库(使用MySQL)
|
||||
|
func setupOrgTestDB(t *testing.T) *gorm.DB { |
||||
|
t.Helper() |
||||
|
|
||||
|
dsn := "root:dev123456@tcp(219.159.132.177:17173)/base?charset=utf8mb4&parseTime=true&loc=Local" |
||||
|
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{}) |
||||
|
require.NoError(t, err) |
||||
|
|
||||
|
err = db.AutoMigrate(&Organization{}, &UserOrganization{}, &User{}, &Role{}, &Menu{}, &RoleMenu{}) |
||||
|
require.NoError(t, err) |
||||
|
|
||||
|
// 清理测试数据(仅清理非种子数据,避免影响运行中的后端)
|
||||
|
db.Exec("SET FOREIGN_KEY_CHECKS = 0") |
||||
|
db.Exec("TRUNCATE TABLE role_menu") |
||||
|
db.Exec("TRUNCATE TABLE user_organization") |
||||
|
db.Exec("TRUNCATE TABLE organization") |
||||
|
// 不 TRUNCATE user/role/menu 表,因为后端种子数据在其中
|
||||
|
// 仅删除测试创建的记录(通过后续测试自行管理)
|
||||
|
db.Exec("DELETE FROM role WHERE is_system = 0") |
||||
|
db.Exec("DELETE FROM menu WHERE name LIKE '测试%'") |
||||
|
db.Exec("DELETE FROM user WHERE username LIKE 'test_%'") |
||||
|
db.Exec("SET FOREIGN_KEY_CHECKS = 1") |
||||
|
|
||||
|
return db |
||||
|
} |
||||
|
|
||||
|
// createTestOrg 创建测试机构
|
||||
|
func createTestOrg(t *testing.T, db *gorm.DB) *Organization { |
||||
|
t.Helper() |
||||
|
|
||||
|
now := time.Now() |
||||
|
org := &Organization{ |
||||
|
Name: "测试机构", |
||||
|
Code: "test_org", |
||||
|
Leader: "张三", |
||||
|
Phone: "13800138000", |
||||
|
Email: "org@example.com", |
||||
|
SortOrder: 1, |
||||
|
Status: 1, |
||||
|
CreatedAt: now, |
||||
|
UpdatedAt: now, |
||||
|
} |
||||
|
|
||||
|
err := db.Create(org).Error |
||||
|
require.NoError(t, err) |
||||
|
|
||||
|
return org |
||||
|
} |
||||
|
|
||||
|
// ==================== Organization Tests ====================
|
||||
|
|
||||
|
// TestOrgInsert 测试插入机构
|
||||
|
func TestOrgInsert(t *testing.T) { |
||||
|
db := setupOrgTestDB(t) |
||||
|
|
||||
|
ctx := context.Background() |
||||
|
org := &Organization{ |
||||
|
Name: "新机构", |
||||
|
Code: "new_org", |
||||
|
Leader: "李四", |
||||
|
Phone: "13900139000", |
||||
|
Email: "new@example.com", |
||||
|
SortOrder: 5, |
||||
|
Status: 1, |
||||
|
} |
||||
|
|
||||
|
id, err := OrgInsert(ctx, db, org) |
||||
|
|
||||
|
require.NoError(t, err) |
||||
|
assert.Greater(t, id, int64(0)) |
||||
|
assert.Equal(t, id, org.Id) |
||||
|
|
||||
|
// 验证数据已保存
|
||||
|
saved, err := OrgFindOne(ctx, db, id) |
||||
|
require.NoError(t, err) |
||||
|
assert.Equal(t, "新机构", saved.Name) |
||||
|
assert.Equal(t, "new_org", saved.Code) |
||||
|
assert.Equal(t, "李四", saved.Leader) |
||||
|
} |
||||
|
|
||||
|
// TestOrgFindOne 测试根据ID查询机构
|
||||
|
func TestOrgFindOne(t *testing.T) { |
||||
|
db := setupOrgTestDB(t) |
||||
|
|
||||
|
org := createTestOrg(t, db) |
||||
|
ctx := context.Background() |
||||
|
|
||||
|
found, err := OrgFindOne(ctx, db, org.Id) |
||||
|
|
||||
|
require.NoError(t, err) |
||||
|
require.NotNil(t, found) |
||||
|
assert.Equal(t, org.Id, found.Id) |
||||
|
assert.Equal(t, "测试机构", found.Name) |
||||
|
assert.Equal(t, "test_org", found.Code) |
||||
|
} |
||||
|
|
||||
|
// TestOrgFindOne_NotFound 测试查询不存在的机构
|
||||
|
func TestOrgFindOne_NotFound(t *testing.T) { |
||||
|
db := setupOrgTestDB(t) |
||||
|
|
||||
|
ctx := context.Background() |
||||
|
|
||||
|
found, err := OrgFindOne(ctx, db, 99999) |
||||
|
|
||||
|
require.Error(t, err) |
||||
|
require.Nil(t, found) |
||||
|
assert.Equal(t, ErrNotFound, err) |
||||
|
} |
||||
|
|
||||
|
// TestOrgFindOneByCode 测试根据编码查询机构
|
||||
|
func TestOrgFindOneByCode(t *testing.T) { |
||||
|
db := setupOrgTestDB(t) |
||||
|
|
||||
|
org := createTestOrg(t, db) |
||||
|
ctx := context.Background() |
||||
|
|
||||
|
found, err := OrgFindOneByCode(ctx, db, "test_org") |
||||
|
|
||||
|
require.NoError(t, err) |
||||
|
require.NotNil(t, found) |
||||
|
assert.Equal(t, org.Id, found.Id) |
||||
|
assert.Equal(t, "测试机构", found.Name) |
||||
|
} |
||||
|
|
||||
|
// TestOrgFindAll 测试查询所有启用的机构
|
||||
|
func TestOrgFindAll(t *testing.T) { |
||||
|
db := setupOrgTestDB(t) |
||||
|
ctx := context.Background() |
||||
|
|
||||
|
// 创建3个机构:2个启用,1个禁用
|
||||
|
now := time.Now() |
||||
|
orgs := []Organization{ |
||||
|
{Name: "机构A", Code: "org_a", SortOrder: 2, Status: 1, CreatedAt: now, UpdatedAt: now}, |
||||
|
{Name: "机构B", Code: "org_b", SortOrder: 1, Status: 1, CreatedAt: now, UpdatedAt: now}, |
||||
|
{Name: "机构C", Code: "org_c", SortOrder: 3, Status: 1, CreatedAt: now, UpdatedAt: now}, |
||||
|
} |
||||
|
|
||||
|
for i := range orgs { |
||||
|
err := db.Create(&orgs[i]).Error |
||||
|
require.NoError(t, err) |
||||
|
} |
||||
|
|
||||
|
// 将机构C设为禁用(status=0是零值,GORM Create时会使用default:1,需要单独更新)
|
||||
|
db.Model(&orgs[2]).Update("status", 0) |
||||
|
|
||||
|
// 查询所有启用的机构
|
||||
|
result, err := OrgFindAll(ctx, db) |
||||
|
|
||||
|
require.NoError(t, err) |
||||
|
assert.Len(t, result, 2) |
||||
|
|
||||
|
// 验证按 sort_order 排序
|
||||
|
assert.Equal(t, "机构B", result[0].Name) // sort_order=1
|
||||
|
assert.Equal(t, "机构A", result[1].Name) // sort_order=2
|
||||
|
} |
||||
|
|
||||
|
// TestOrgUpdate 测试更新机构
|
||||
|
func TestOrgUpdate(t *testing.T) { |
||||
|
db := setupOrgTestDB(t) |
||||
|
|
||||
|
org := createTestOrg(t, db) |
||||
|
ctx := context.Background() |
||||
|
|
||||
|
// 修改机构数据
|
||||
|
org.Name = "更新后的机构" |
||||
|
org.Leader = "王五" |
||||
|
|
||||
|
err := OrgUpdate(ctx, db, org) |
||||
|
|
||||
|
require.NoError(t, err) |
||||
|
|
||||
|
// 验证更新结果
|
||||
|
updated, err := OrgFindOne(ctx, db, org.Id) |
||||
|
require.NoError(t, err) |
||||
|
assert.Equal(t, "更新后的机构", updated.Name) |
||||
|
assert.Equal(t, "王五", updated.Leader) |
||||
|
} |
||||
|
|
||||
|
// TestOrgDelete 测试删除机构
|
||||
|
func TestOrgDelete(t *testing.T) { |
||||
|
db := setupOrgTestDB(t) |
||||
|
|
||||
|
org := createTestOrg(t, db) |
||||
|
orgId := org.Id |
||||
|
ctx := context.Background() |
||||
|
|
||||
|
err := OrgDelete(ctx, db, orgId) |
||||
|
|
||||
|
require.NoError(t, err) |
||||
|
|
||||
|
// 验证机构已被删除
|
||||
|
_, err = OrgFindOne(ctx, db, orgId) |
||||
|
require.Error(t, err) |
||||
|
assert.Equal(t, ErrNotFound, err) |
||||
|
} |
||||
|
|
||||
|
// TestOrgHasChildren 测试检查是否有子机构
|
||||
|
func TestOrgHasChildren(t *testing.T) { |
||||
|
db := setupOrgTestDB(t) |
||||
|
ctx := context.Background() |
||||
|
|
||||
|
now := time.Now() |
||||
|
|
||||
|
// 创建父机构
|
||||
|
parent := &Organization{ |
||||
|
Name: "父机构", |
||||
|
Code: "parent_org", |
||||
|
SortOrder: 1, |
||||
|
Status: 1, |
||||
|
CreatedAt: now, |
||||
|
UpdatedAt: now, |
||||
|
} |
||||
|
err := db.Create(parent).Error |
||||
|
require.NoError(t, err) |
||||
|
|
||||
|
// 创建子机构
|
||||
|
child := &Organization{ |
||||
|
ParentId: parent.Id, |
||||
|
Name: "子机构", |
||||
|
Code: "child_org", |
||||
|
SortOrder: 1, |
||||
|
Status: 1, |
||||
|
CreatedAt: now, |
||||
|
UpdatedAt: now, |
||||
|
} |
||||
|
err = db.Create(child).Error |
||||
|
require.NoError(t, err) |
||||
|
|
||||
|
// 创建无子机构的孤立机构
|
||||
|
orphan := &Organization{ |
||||
|
Name: "孤立机构", |
||||
|
Code: "orphan_org", |
||||
|
SortOrder: 1, |
||||
|
Status: 1, |
||||
|
CreatedAt: now, |
||||
|
UpdatedAt: now, |
||||
|
} |
||||
|
err = db.Create(orphan).Error |
||||
|
require.NoError(t, err) |
||||
|
|
||||
|
// 验证父机构有子机构
|
||||
|
hasChildren, err := OrgHasChildren(ctx, db, parent.Id) |
||||
|
require.NoError(t, err) |
||||
|
assert.True(t, hasChildren) |
||||
|
|
||||
|
// 验证孤立机构没有子机构
|
||||
|
hasChildren, err = OrgHasChildren(ctx, db, orphan.Id) |
||||
|
require.NoError(t, err) |
||||
|
assert.False(t, hasChildren) |
||||
|
} |
||||
|
|
||||
|
// ==================== UserOrganization Tests ====================
|
||||
|
|
||||
|
// TestUserOrgInsert 测试插入用户-机构关联
|
||||
|
func TestUserOrgInsert(t *testing.T) { |
||||
|
db := setupOrgTestDB(t) |
||||
|
ctx := context.Background() |
||||
|
|
||||
|
now := time.Now() |
||||
|
|
||||
|
// 创建测试用户
|
||||
|
user := &User{ |
||||
|
Username: "testuser", |
||||
|
Email: "test@example.com", |
||||
|
Password: "encryptedpassword", |
||||
|
Status: 1, |
||||
|
CreatedAt: now, |
||||
|
UpdatedAt: now, |
||||
|
} |
||||
|
err := db.Create(user).Error |
||||
|
require.NoError(t, err) |
||||
|
|
||||
|
// 创建测试机构
|
||||
|
org := createTestOrg(t, db) |
||||
|
|
||||
|
// 创建测试角色
|
||||
|
role := &Role{ |
||||
|
Name: "测试角色", |
||||
|
Code: "test_role", |
||||
|
SortOrder: 1, |
||||
|
Status: 1, |
||||
|
CreatedAt: now, |
||||
|
UpdatedAt: now, |
||||
|
} |
||||
|
err = db.Create(role).Error |
||||
|
require.NoError(t, err) |
||||
|
|
||||
|
// 插入用户-机构关联
|
||||
|
uo := &UserOrganization{ |
||||
|
UserId: user.Id, |
||||
|
OrgId: org.Id, |
||||
|
RoleId: role.Id, |
||||
|
} |
||||
|
|
||||
|
id, err := UserOrgInsert(ctx, db, uo) |
||||
|
|
||||
|
require.NoError(t, err) |
||||
|
assert.Greater(t, id, int64(0)) |
||||
|
assert.Equal(t, id, uo.Id) |
||||
|
|
||||
|
// 验证数据已保存
|
||||
|
saved, err := UserOrgFindOne(ctx, db, user.Id, org.Id) |
||||
|
require.NoError(t, err) |
||||
|
assert.Equal(t, user.Id, saved.UserId) |
||||
|
assert.Equal(t, org.Id, saved.OrgId) |
||||
|
assert.Equal(t, role.Id, saved.RoleId) |
||||
|
} |
||||
|
|
||||
|
// TestUserOrgFindByUserId 测试根据用户ID查询关联
|
||||
|
func TestUserOrgFindByUserId(t *testing.T) { |
||||
|
db := setupOrgTestDB(t) |
||||
|
ctx := context.Background() |
||||
|
|
||||
|
now := time.Now() |
||||
|
|
||||
|
// 创建测试用户
|
||||
|
user := &User{ |
||||
|
Username: "testuser", |
||||
|
Email: "test@example.com", |
||||
|
Password: "encryptedpassword", |
||||
|
Status: 1, |
||||
|
CreatedAt: now, |
||||
|
UpdatedAt: now, |
||||
|
} |
||||
|
err := db.Create(user).Error |
||||
|
require.NoError(t, err) |
||||
|
|
||||
|
// 创建2个机构
|
||||
|
org1 := &Organization{Name: "机构1", Code: "org_1", SortOrder: 1, Status: 1, CreatedAt: now, UpdatedAt: now} |
||||
|
org2 := &Organization{Name: "机构2", Code: "org_2", SortOrder: 2, Status: 1, CreatedAt: now, UpdatedAt: now} |
||||
|
err = db.Create(org1).Error |
||||
|
require.NoError(t, err) |
||||
|
err = db.Create(org2).Error |
||||
|
require.NoError(t, err) |
||||
|
|
||||
|
// 创建角色
|
||||
|
role := &Role{Name: "角色", Code: "role_1", SortOrder: 1, Status: 1, CreatedAt: now, UpdatedAt: now} |
||||
|
err = db.Create(role).Error |
||||
|
require.NoError(t, err) |
||||
|
|
||||
|
// 创建2条用户-机构关联
|
||||
|
uo1 := &UserOrganization{UserId: user.Id, OrgId: org1.Id, RoleId: role.Id} |
||||
|
uo2 := &UserOrganization{UserId: user.Id, OrgId: org2.Id, RoleId: role.Id} |
||||
|
_, err = UserOrgInsert(ctx, db, uo1) |
||||
|
require.NoError(t, err) |
||||
|
_, err = UserOrgInsert(ctx, db, uo2) |
||||
|
require.NoError(t, err) |
||||
|
|
||||
|
// 查询用户的所有机构关联
|
||||
|
list, err := UserOrgFindByUserId(ctx, db, user.Id) |
||||
|
|
||||
|
require.NoError(t, err) |
||||
|
assert.Len(t, list, 2) |
||||
|
} |
||||
|
|
||||
|
// TestUserOrgFindByOrgId 测试根据机构ID查询关联
|
||||
|
func TestUserOrgFindByOrgId(t *testing.T) { |
||||
|
db := setupOrgTestDB(t) |
||||
|
ctx := context.Background() |
||||
|
|
||||
|
now := time.Now() |
||||
|
|
||||
|
// 创建机构
|
||||
|
org := createTestOrg(t, db) |
||||
|
|
||||
|
// 创建角色
|
||||
|
role := &Role{Name: "角色", Code: "role_1", SortOrder: 1, Status: 1, CreatedAt: now, UpdatedAt: now} |
||||
|
err := db.Create(role).Error |
||||
|
require.NoError(t, err) |
||||
|
|
||||
|
// 创建2个用户
|
||||
|
user1 := &User{Username: "user1", Email: "user1@example.com", Password: "pass", Status: 1, CreatedAt: now, UpdatedAt: now} |
||||
|
user2 := &User{Username: "user2", Email: "user2@example.com", Password: "pass", Status: 1, CreatedAt: now, UpdatedAt: now} |
||||
|
err = db.Create(user1).Error |
||||
|
require.NoError(t, err) |
||||
|
err = db.Create(user2).Error |
||||
|
require.NoError(t, err) |
||||
|
|
||||
|
// 创建用户-机构关联
|
||||
|
uo1 := &UserOrganization{UserId: user1.Id, OrgId: org.Id, RoleId: role.Id} |
||||
|
uo2 := &UserOrganization{UserId: user2.Id, OrgId: org.Id, RoleId: role.Id} |
||||
|
_, err = UserOrgInsert(ctx, db, uo1) |
||||
|
require.NoError(t, err) |
||||
|
_, err = UserOrgInsert(ctx, db, uo2) |
||||
|
require.NoError(t, err) |
||||
|
|
||||
|
// 查询机构的所有成员
|
||||
|
list, err := UserOrgFindByOrgId(ctx, db, org.Id) |
||||
|
|
||||
|
require.NoError(t, err) |
||||
|
assert.Len(t, list, 2) |
||||
|
} |
||||
|
|
||||
|
// TestUserOrgUpdate 测试更新用户-机构关联
|
||||
|
func TestUserOrgUpdate(t *testing.T) { |
||||
|
db := setupOrgTestDB(t) |
||||
|
ctx := context.Background() |
||||
|
|
||||
|
now := time.Now() |
||||
|
|
||||
|
// 创建用户
|
||||
|
user := &User{Username: "testuser", Email: "test@example.com", Password: "pass", Status: 1, CreatedAt: now, UpdatedAt: now} |
||||
|
err := db.Create(user).Error |
||||
|
require.NoError(t, err) |
||||
|
|
||||
|
// 创建机构
|
||||
|
org := createTestOrg(t, db) |
||||
|
|
||||
|
// 创建2个角色
|
||||
|
role1 := &Role{Name: "角色1", Code: "role_1", SortOrder: 1, Status: 1, CreatedAt: now, UpdatedAt: now} |
||||
|
role2 := &Role{Name: "角色2", Code: "role_2", SortOrder: 2, Status: 1, CreatedAt: now, UpdatedAt: now} |
||||
|
err = db.Create(role1).Error |
||||
|
require.NoError(t, err) |
||||
|
err = db.Create(role2).Error |
||||
|
require.NoError(t, err) |
||||
|
|
||||
|
// 创建关联(使用角色1)
|
||||
|
uo := &UserOrganization{UserId: user.Id, OrgId: org.Id, RoleId: role1.Id} |
||||
|
_, err = UserOrgInsert(ctx, db, uo) |
||||
|
require.NoError(t, err) |
||||
|
|
||||
|
// 更新角色为角色2
|
||||
|
uo.RoleId = role2.Id |
||||
|
err = UserOrgUpdate(ctx, db, uo) |
||||
|
|
||||
|
require.NoError(t, err) |
||||
|
|
||||
|
// 验证更新结果
|
||||
|
updated, err := UserOrgFindOne(ctx, db, user.Id, org.Id) |
||||
|
require.NoError(t, err) |
||||
|
assert.Equal(t, role2.Id, updated.RoleId) |
||||
|
} |
||||
|
|
||||
|
// TestUserOrgDelete 测试删除用户-机构关联
|
||||
|
func TestUserOrgDelete(t *testing.T) { |
||||
|
db := setupOrgTestDB(t) |
||||
|
ctx := context.Background() |
||||
|
|
||||
|
now := time.Now() |
||||
|
|
||||
|
// 创建用户
|
||||
|
user := &User{Username: "testuser", Email: "test@example.com", Password: "pass", Status: 1, CreatedAt: now, UpdatedAt: now} |
||||
|
err := db.Create(user).Error |
||||
|
require.NoError(t, err) |
||||
|
|
||||
|
// 创建机构
|
||||
|
org := createTestOrg(t, db) |
||||
|
|
||||
|
// 创建角色
|
||||
|
role := &Role{Name: "角色", Code: "role_1", SortOrder: 1, Status: 1, CreatedAt: now, UpdatedAt: now} |
||||
|
err = db.Create(role).Error |
||||
|
require.NoError(t, err) |
||||
|
|
||||
|
// 创建关联
|
||||
|
uo := &UserOrganization{UserId: user.Id, OrgId: org.Id, RoleId: role.Id} |
||||
|
_, err = UserOrgInsert(ctx, db, uo) |
||||
|
require.NoError(t, err) |
||||
|
|
||||
|
// 删除关联
|
||||
|
err = UserOrgDelete(ctx, db, user.Id, org.Id) |
||||
|
|
||||
|
require.NoError(t, err) |
||||
|
|
||||
|
// 验证关联已被删除
|
||||
|
_, err = UserOrgFindOne(ctx, db, user.Id, org.Id) |
||||
|
require.Error(t, err) |
||||
|
assert.Equal(t, ErrNotFound, err) |
||||
|
} |
||||
|
|
||||
|
// TestUserOrgCountByOrgId 测试统计机构成员数
|
||||
|
func TestUserOrgCountByOrgId(t *testing.T) { |
||||
|
db := setupOrgTestDB(t) |
||||
|
ctx := context.Background() |
||||
|
|
||||
|
now := time.Now() |
||||
|
|
||||
|
// 创建机构
|
||||
|
org := createTestOrg(t, db) |
||||
|
|
||||
|
// 创建角色
|
||||
|
role := &Role{Name: "角色", Code: "role_1", SortOrder: 1, Status: 1, CreatedAt: now, UpdatedAt: now} |
||||
|
err := db.Create(role).Error |
||||
|
require.NoError(t, err) |
||||
|
|
||||
|
// 创建3个用户并关联到机构
|
||||
|
for i := 1; i <= 3; i++ { |
||||
|
user := &User{ |
||||
|
Username: "user" + string(rune('0'+i)), |
||||
|
Email: "user" + string(rune('0'+i)) + "@example.com", |
||||
|
Password: "pass", |
||||
|
Status: 1, |
||||
|
CreatedAt: now, |
||||
|
UpdatedAt: now, |
||||
|
} |
||||
|
err := db.Create(user).Error |
||||
|
require.NoError(t, err) |
||||
|
|
||||
|
uo := &UserOrganization{UserId: user.Id, OrgId: org.Id, RoleId: role.Id} |
||||
|
_, err = UserOrgInsert(ctx, db, uo) |
||||
|
require.NoError(t, err) |
||||
|
} |
||||
|
|
||||
|
// 统计成员数
|
||||
|
count, err := UserOrgCountByOrgId(ctx, db, org.Id) |
||||
|
|
||||
|
require.NoError(t, err) |
||||
|
assert.Equal(t, int64(3), count) |
||||
|
} |
||||
|
|
||||
|
// ==================== RoleMenu Tests ====================
|
||||
|
|
||||
|
// TestRoleMenuSetForRole 测试全量设置角色的菜单
|
||||
|
func TestRoleMenuSetForRole(t *testing.T) { |
||||
|
db := setupOrgTestDB(t) |
||||
|
ctx := context.Background() |
||||
|
|
||||
|
now := time.Now() |
||||
|
|
||||
|
// 创建角色
|
||||
|
role := &Role{Name: "角色", Code: "role_1", SortOrder: 1, Status: 1, CreatedAt: now, UpdatedAt: now} |
||||
|
err := db.Create(role).Error |
||||
|
require.NoError(t, err) |
||||
|
|
||||
|
// 创建3个菜单
|
||||
|
menus := []Menu{ |
||||
|
{Name: "菜单1", Path: "/1", SortOrder: 1, Status: 1, CreatedAt: now, UpdatedAt: now}, |
||||
|
{Name: "菜单2", Path: "/2", SortOrder: 2, Status: 1, CreatedAt: now, UpdatedAt: now}, |
||||
|
{Name: "菜单3", Path: "/3", SortOrder: 3, Status: 1, CreatedAt: now, UpdatedAt: now}, |
||||
|
} |
||||
|
for i := range menus { |
||||
|
err := db.Create(&menus[i]).Error |
||||
|
require.NoError(t, err) |
||||
|
} |
||||
|
|
||||
|
// 设置角色菜单(菜单1和菜单2)
|
||||
|
menuIds := []int64{menus[0].Id, menus[1].Id} |
||||
|
err = RoleMenuSetForRole(ctx, db, role.Id, menuIds) |
||||
|
|
||||
|
require.NoError(t, err) |
||||
|
|
||||
|
// 验证
|
||||
|
foundIds, err := RoleMenuFindByRoleId(ctx, db, role.Id) |
||||
|
require.NoError(t, err) |
||||
|
assert.Len(t, foundIds, 2) |
||||
|
assert.Contains(t, foundIds, menus[0].Id) |
||||
|
assert.Contains(t, foundIds, menus[1].Id) |
||||
|
|
||||
|
// 重新设置(替换为菜单2和菜单3)
|
||||
|
menuIds2 := []int64{menus[1].Id, menus[2].Id} |
||||
|
err = RoleMenuSetForRole(ctx, db, role.Id, menuIds2) |
||||
|
|
||||
|
require.NoError(t, err) |
||||
|
|
||||
|
// 验证替换结果
|
||||
|
foundIds2, err := RoleMenuFindByRoleId(ctx, db, role.Id) |
||||
|
require.NoError(t, err) |
||||
|
assert.Len(t, foundIds2, 2) |
||||
|
assert.Contains(t, foundIds2, menus[1].Id) |
||||
|
assert.Contains(t, foundIds2, menus[2].Id) |
||||
|
} |
||||
|
|
||||
|
// TestRoleMenuFindByRoleId 测试获取角色的菜单ID列表
|
||||
|
func TestRoleMenuFindByRoleId(t *testing.T) { |
||||
|
db := setupOrgTestDB(t) |
||||
|
ctx := context.Background() |
||||
|
|
||||
|
now := time.Now() |
||||
|
|
||||
|
// 创建角色
|
||||
|
role := &Role{Name: "角色", Code: "role_1", SortOrder: 1, Status: 1, CreatedAt: now, UpdatedAt: now} |
||||
|
err := db.Create(role).Error |
||||
|
require.NoError(t, err) |
||||
|
|
||||
|
// 创建菜单
|
||||
|
menu1 := &Menu{Name: "菜单1", Path: "/1", SortOrder: 1, Status: 1, CreatedAt: now, UpdatedAt: now} |
||||
|
menu2 := &Menu{Name: "菜单2", Path: "/2", SortOrder: 2, Status: 1, CreatedAt: now, UpdatedAt: now} |
||||
|
err = db.Create(menu1).Error |
||||
|
require.NoError(t, err) |
||||
|
err = db.Create(menu2).Error |
||||
|
require.NoError(t, err) |
||||
|
|
||||
|
// 设置角色菜单
|
||||
|
err = RoleMenuSetForRole(ctx, db, role.Id, []int64{menu1.Id, menu2.Id}) |
||||
|
require.NoError(t, err) |
||||
|
|
||||
|
// 查询角色的菜单ID列表
|
||||
|
menuIds, err := RoleMenuFindByRoleId(ctx, db, role.Id) |
||||
|
|
||||
|
require.NoError(t, err) |
||||
|
assert.Len(t, menuIds, 2) |
||||
|
assert.Contains(t, menuIds, menu1.Id) |
||||
|
assert.Contains(t, menuIds, menu2.Id) |
||||
|
} |
||||
|
|
||||
|
// TestRoleMenuFindByRoleIds 测试获取多个角色的菜单ID列表(去重)
|
||||
|
func TestRoleMenuFindByRoleIds(t *testing.T) { |
||||
|
db := setupOrgTestDB(t) |
||||
|
ctx := context.Background() |
||||
|
|
||||
|
now := time.Now() |
||||
|
|
||||
|
// 创建2个角色
|
||||
|
role1 := &Role{Name: "角色1", Code: "role_1", SortOrder: 1, Status: 1, CreatedAt: now, UpdatedAt: now} |
||||
|
role2 := &Role{Name: "角色2", Code: "role_2", SortOrder: 2, Status: 1, CreatedAt: now, UpdatedAt: now} |
||||
|
err := db.Create(role1).Error |
||||
|
require.NoError(t, err) |
||||
|
err = db.Create(role2).Error |
||||
|
require.NoError(t, err) |
||||
|
|
||||
|
// 创建3个菜单
|
||||
|
menus := []Menu{ |
||||
|
{Name: "菜单1", Path: "/1", SortOrder: 1, Status: 1, CreatedAt: now, UpdatedAt: now}, |
||||
|
{Name: "菜单2", Path: "/2", SortOrder: 2, Status: 1, CreatedAt: now, UpdatedAt: now}, |
||||
|
{Name: "菜单3", Path: "/3", SortOrder: 3, Status: 1, CreatedAt: now, UpdatedAt: now}, |
||||
|
} |
||||
|
for i := range menus { |
||||
|
err := db.Create(&menus[i]).Error |
||||
|
require.NoError(t, err) |
||||
|
} |
||||
|
|
||||
|
// 角色1 -> 菜单1, 菜单2
|
||||
|
err = RoleMenuSetForRole(ctx, db, role1.Id, []int64{menus[0].Id, menus[1].Id}) |
||||
|
require.NoError(t, err) |
||||
|
|
||||
|
// 角色2 -> 菜单2, 菜单3(菜单2重复)
|
||||
|
err = RoleMenuSetForRole(ctx, db, role2.Id, []int64{menus[1].Id, menus[2].Id}) |
||||
|
require.NoError(t, err) |
||||
|
|
||||
|
// 查询多个角色的菜单ID(去重)
|
||||
|
menuIds, err := RoleMenuFindByRoleIds(ctx, db, []int64{role1.Id, role2.Id}) |
||||
|
|
||||
|
require.NoError(t, err) |
||||
|
assert.Len(t, menuIds, 3) // 菜单1, 菜单2, 菜单3(去重后3个)
|
||||
|
assert.Contains(t, menuIds, menus[0].Id) |
||||
|
assert.Contains(t, menuIds, menus[1].Id) |
||||
|
assert.Contains(t, menuIds, menus[2].Id) |
||||
|
} |
||||
|
|
||||
|
// TestRoleMenuDeleteByRoleId 测试删除角色的所有菜单关联
|
||||
|
func TestRoleMenuDeleteByRoleId(t *testing.T) { |
||||
|
db := setupOrgTestDB(t) |
||||
|
ctx := context.Background() |
||||
|
|
||||
|
now := time.Now() |
||||
|
|
||||
|
// 创建角色
|
||||
|
role := &Role{Name: "角色", Code: "role_1", SortOrder: 1, Status: 1, CreatedAt: now, UpdatedAt: now} |
||||
|
err := db.Create(role).Error |
||||
|
require.NoError(t, err) |
||||
|
|
||||
|
// 创建菜单
|
||||
|
menu1 := &Menu{Name: "菜单1", Path: "/1", SortOrder: 1, Status: 1, CreatedAt: now, UpdatedAt: now} |
||||
|
menu2 := &Menu{Name: "菜单2", Path: "/2", SortOrder: 2, Status: 1, CreatedAt: now, UpdatedAt: now} |
||||
|
err = db.Create(menu1).Error |
||||
|
require.NoError(t, err) |
||||
|
err = db.Create(menu2).Error |
||||
|
require.NoError(t, err) |
||||
|
|
||||
|
// 设置角色菜单
|
||||
|
err = RoleMenuSetForRole(ctx, db, role.Id, []int64{menu1.Id, menu2.Id}) |
||||
|
require.NoError(t, err) |
||||
|
|
||||
|
// 删除角色的所有菜单关联
|
||||
|
err = RoleMenuDeleteByRoleId(ctx, db, role.Id) |
||||
|
|
||||
|
require.NoError(t, err) |
||||
|
|
||||
|
// 验证菜单关联已被删除
|
||||
|
menuIds, err := RoleMenuFindByRoleId(ctx, db, role.Id) |
||||
|
require.NoError(t, err) |
||||
|
assert.Empty(t, menuIds) |
||||
|
} |
||||
@ -0,0 +1,211 @@ |
|||||
|
package model |
||||
|
|
||||
|
import ( |
||||
|
"context" |
||||
|
"testing" |
||||
|
"time" |
||||
|
|
||||
|
"github.com/stretchr/testify/assert" |
||||
|
"github.com/stretchr/testify/require" |
||||
|
"gorm.io/driver/mysql" |
||||
|
"gorm.io/gorm" |
||||
|
) |
||||
|
|
||||
|
// setupRoleTestDB 创建角色测试数据库(使用MySQL)
|
||||
|
func setupRoleTestDB(t *testing.T) *gorm.DB { |
||||
|
t.Helper() |
||||
|
|
||||
|
dsn := "root:dev123456@tcp(219.159.132.177:17173)/base?charset=utf8mb4&parseTime=true&loc=Local" |
||||
|
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{}) |
||||
|
require.NoError(t, err) |
||||
|
|
||||
|
err = db.AutoMigrate(&Role{}) |
||||
|
require.NoError(t, err) |
||||
|
|
||||
|
// 清理所有数据确保测试隔离(运行后需重启后端以恢复种子数据)
|
||||
|
db.Exec("SET FOREIGN_KEY_CHECKS = 0") |
||||
|
db.Exec("TRUNCATE TABLE role") |
||||
|
db.Exec("SET FOREIGN_KEY_CHECKS = 1") |
||||
|
|
||||
|
return db |
||||
|
} |
||||
|
|
||||
|
// createTestRole 创建测试角色
|
||||
|
func createTestRole(t *testing.T, db *gorm.DB) *Role { |
||||
|
t.Helper() |
||||
|
|
||||
|
now := time.Now() |
||||
|
role := &Role{ |
||||
|
Name: "测试角色", |
||||
|
Code: "test_role", |
||||
|
Description: "测试用", |
||||
|
IsSystem: false, |
||||
|
SortOrder: 10, |
||||
|
Status: 1, |
||||
|
CreatedAt: now, |
||||
|
UpdatedAt: now, |
||||
|
} |
||||
|
|
||||
|
err := db.Create(role).Error |
||||
|
require.NoError(t, err) |
||||
|
|
||||
|
return role |
||||
|
} |
||||
|
|
||||
|
// TestRoleInsert 测试插入角色
|
||||
|
func TestRoleInsert(t *testing.T) { |
||||
|
db := setupRoleTestDB(t) |
||||
|
|
||||
|
ctx := context.Background() |
||||
|
role := &Role{ |
||||
|
Name: "新角色", |
||||
|
Code: "new_role", |
||||
|
Description: "新建的角色", |
||||
|
IsSystem: false, |
||||
|
SortOrder: 5, |
||||
|
Status: 1, |
||||
|
} |
||||
|
|
||||
|
id, err := RoleInsert(ctx, db, role) |
||||
|
|
||||
|
require.NoError(t, err) |
||||
|
assert.Greater(t, id, int64(0)) |
||||
|
assert.Equal(t, id, role.Id) |
||||
|
|
||||
|
// 验证数据已保存
|
||||
|
saved, err := RoleFindOne(ctx, db, id) |
||||
|
require.NoError(t, err) |
||||
|
assert.Equal(t, "新角色", saved.Name) |
||||
|
assert.Equal(t, "new_role", saved.Code) |
||||
|
assert.Equal(t, "新建的角色", saved.Description) |
||||
|
} |
||||
|
|
||||
|
// TestRoleFindOne 测试根据ID查询角色
|
||||
|
func TestRoleFindOne(t *testing.T) { |
||||
|
db := setupRoleTestDB(t) |
||||
|
|
||||
|
role := createTestRole(t, db) |
||||
|
ctx := context.Background() |
||||
|
|
||||
|
found, err := RoleFindOne(ctx, db, role.Id) |
||||
|
|
||||
|
require.NoError(t, err) |
||||
|
require.NotNil(t, found) |
||||
|
assert.Equal(t, role.Id, found.Id) |
||||
|
assert.Equal(t, "测试角色", found.Name) |
||||
|
assert.Equal(t, "test_role", found.Code) |
||||
|
} |
||||
|
|
||||
|
// TestRoleFindOne_NotFound 测试查询不存在的角色
|
||||
|
func TestRoleFindOne_NotFound(t *testing.T) { |
||||
|
db := setupRoleTestDB(t) |
||||
|
|
||||
|
ctx := context.Background() |
||||
|
|
||||
|
found, err := RoleFindOne(ctx, db, 99999) |
||||
|
|
||||
|
require.Error(t, err) |
||||
|
require.Nil(t, found) |
||||
|
assert.Equal(t, ErrNotFound, err) |
||||
|
} |
||||
|
|
||||
|
// TestRoleFindOneByCode 测试根据编码查询角色
|
||||
|
func TestRoleFindOneByCode(t *testing.T) { |
||||
|
db := setupRoleTestDB(t) |
||||
|
|
||||
|
role := createTestRole(t, db) |
||||
|
ctx := context.Background() |
||||
|
|
||||
|
found, err := RoleFindOneByCode(ctx, db, "test_role") |
||||
|
|
||||
|
require.NoError(t, err) |
||||
|
require.NotNil(t, found) |
||||
|
assert.Equal(t, role.Id, found.Id) |
||||
|
assert.Equal(t, "测试角色", found.Name) |
||||
|
assert.Equal(t, "test_role", found.Code) |
||||
|
} |
||||
|
|
||||
|
// TestRoleFindOneByCode_NotFound 测试查询不存在的角色编码
|
||||
|
func TestRoleFindOneByCode_NotFound(t *testing.T) { |
||||
|
db := setupRoleTestDB(t) |
||||
|
|
||||
|
ctx := context.Background() |
||||
|
|
||||
|
found, err := RoleFindOneByCode(ctx, db, "nonexistent_code") |
||||
|
|
||||
|
require.Error(t, err) |
||||
|
require.Nil(t, found) |
||||
|
assert.Equal(t, ErrNotFound, err) |
||||
|
} |
||||
|
|
||||
|
// TestRoleFindAll 测试查询所有启用的角色
|
||||
|
func TestRoleFindAll(t *testing.T) { |
||||
|
db := setupRoleTestDB(t) |
||||
|
ctx := context.Background() |
||||
|
|
||||
|
// 创建3个角色:2个启用,1个禁用
|
||||
|
now := time.Now() |
||||
|
roles := []Role{ |
||||
|
{Name: "角色A", Code: "role_a", SortOrder: 2, Status: 1, CreatedAt: now, UpdatedAt: now}, |
||||
|
{Name: "角色B", Code: "role_b", SortOrder: 1, Status: 1, CreatedAt: now, UpdatedAt: now}, |
||||
|
{Name: "角色C", Code: "role_c", SortOrder: 3, Status: 1, CreatedAt: now, UpdatedAt: now}, |
||||
|
} |
||||
|
|
||||
|
for i := range roles { |
||||
|
err := db.Create(&roles[i]).Error |
||||
|
require.NoError(t, err) |
||||
|
} |
||||
|
|
||||
|
// 将角色C设为禁用(status=0是零值,GORM Create时会使用default:1,需要单独更新)
|
||||
|
db.Model(&roles[2]).Update("status", 0) |
||||
|
|
||||
|
// 查询所有启用的角色
|
||||
|
result, err := RoleFindAll(ctx, db) |
||||
|
|
||||
|
require.NoError(t, err) |
||||
|
assert.Len(t, result, 2) |
||||
|
|
||||
|
// 验证按 sort_order 排序
|
||||
|
assert.Equal(t, "角色B", result[0].Name) // sort_order=1
|
||||
|
assert.Equal(t, "角色A", result[1].Name) // sort_order=2
|
||||
|
} |
||||
|
|
||||
|
// TestRoleUpdate 测试更新角色
|
||||
|
func TestRoleUpdate(t *testing.T) { |
||||
|
db := setupRoleTestDB(t) |
||||
|
|
||||
|
role := createTestRole(t, db) |
||||
|
ctx := context.Background() |
||||
|
|
||||
|
// 修改角色数据
|
||||
|
role.Name = "更新后的角色" |
||||
|
role.Description = "更新后的描述" |
||||
|
|
||||
|
err := RoleUpdate(ctx, db, role) |
||||
|
|
||||
|
require.NoError(t, err) |
||||
|
|
||||
|
// 验证更新结果
|
||||
|
updated, err := RoleFindOne(ctx, db, role.Id) |
||||
|
require.NoError(t, err) |
||||
|
assert.Equal(t, "更新后的角色", updated.Name) |
||||
|
assert.Equal(t, "更新后的描述", updated.Description) |
||||
|
} |
||||
|
|
||||
|
// TestRoleDelete 测试删除角色
|
||||
|
func TestRoleDelete(t *testing.T) { |
||||
|
db := setupRoleTestDB(t) |
||||
|
|
||||
|
role := createTestRole(t, db) |
||||
|
roleId := role.Id |
||||
|
ctx := context.Background() |
||||
|
|
||||
|
err := RoleDelete(ctx, db, roleId) |
||||
|
|
||||
|
require.NoError(t, err) |
||||
|
|
||||
|
// 验证角色已被删除
|
||||
|
_, err = RoleFindOne(ctx, db, roleId) |
||||
|
require.Error(t, err) |
||||
|
assert.Equal(t, ErrNotFound, err) |
||||
|
} |
||||
@ -0,0 +1,404 @@ |
|||||
|
#!/bin/bash |
||||
|
# Menu / Role / Organization E2E Integration Test Script |
||||
|
# Tests: Menu CRUD, Role CRUD + menu assignment, Organization CRUD + members, Profile org APIs |
||||
|
# NOTE: Uses HTTP status codes and ASCII-safe patterns for Windows compatibility |
||||
|
|
||||
|
BASE_URL="http://localhost:8888/api/v1" |
||||
|
TIMESTAMP=$(date +%s) |
||||
|
PASS=0 |
||||
|
FAIL=0 |
||||
|
|
||||
|
log_step() { |
||||
|
echo -e "\n\033[36m--- Step $1: $2 ---\033[0m" |
||||
|
} |
||||
|
|
||||
|
log_success() { |
||||
|
echo -e "\033[32m[PASS]\033[0m $1" |
||||
|
PASS=$((PASS + 1)) |
||||
|
} |
||||
|
|
||||
|
log_error() { |
||||
|
echo -e "\033[31m[FAIL]\033[0m $1" |
||||
|
FAIL=$((FAIL + 1)) |
||||
|
} |
||||
|
|
||||
|
extract_value() { |
||||
|
echo "$1" | grep -o "\"$2\":\"[^\"]*\"" | head -1 | sed 's/"'"$2"'"://' | sed 's/^"//' | sed 's/"$//' |
||||
|
} |
||||
|
|
||||
|
extract_int() { |
||||
|
echo "$1" | grep -o "\"$2\":[^,}]*" | head -1 | sed 's/"'"$2"'"://' | tr -d ' ' |
||||
|
} |
||||
|
|
||||
|
# Helper: call API and capture both body and HTTP status |
||||
|
api_get() { |
||||
|
local BODY HTTP_CODE |
||||
|
BODY=$(curl -s -w "\n%{http_code}" -X GET "$1" -H "Authorization: Bearer ${ADMIN_TOKEN}") |
||||
|
HTTP_CODE=$(echo "$BODY" | tail -1) |
||||
|
BODY=$(echo "$BODY" | sed '$d') |
||||
|
echo "$HTTP_CODE|$BODY" |
||||
|
} |
||||
|
|
||||
|
api_post() { |
||||
|
local BODY HTTP_CODE |
||||
|
BODY=$(curl -s -w "\n%{http_code}" -X POST "$1" -H "Content-Type: application/json" -H "Authorization: Bearer ${ADMIN_TOKEN}" -d "$2") |
||||
|
HTTP_CODE=$(echo "$BODY" | tail -1) |
||||
|
BODY=$(echo "$BODY" | sed '$d') |
||||
|
echo "$HTTP_CODE|$BODY" |
||||
|
} |
||||
|
|
||||
|
api_put() { |
||||
|
local BODY HTTP_CODE |
||||
|
BODY=$(curl -s -w "\n%{http_code}" -X PUT "$1" -H "Content-Type: application/json" -H "Authorization: Bearer ${ADMIN_TOKEN}" -d "$2") |
||||
|
HTTP_CODE=$(echo "$BODY" | tail -1) |
||||
|
BODY=$(echo "$BODY" | sed '$d') |
||||
|
echo "$HTTP_CODE|$BODY" |
||||
|
} |
||||
|
|
||||
|
api_delete() { |
||||
|
curl -s -o /dev/null -w "%{http_code}" -X DELETE "$1" -H "Authorization: Bearer ${ADMIN_TOKEN}" |
||||
|
} |
||||
|
|
||||
|
# ============================ |
||||
|
# Part 1: Admin Login |
||||
|
# ============================ |
||||
|
|
||||
|
log_step "1" "Admin login (account=admin / password=admin123)" |
||||
|
LOGIN_RESULT=$(curl -s -X POST ${BASE_URL}/login \ |
||||
|
-H "Content-Type: application/json" \ |
||||
|
-d '{"account":"admin","password":"admin123"}') |
||||
|
ADMIN_TOKEN=$(extract_value "$LOGIN_RESULT" "token") |
||||
|
|
||||
|
if [ -n "$ADMIN_TOKEN" ] && [ ${#ADMIN_TOKEN} -gt 20 ]; then |
||||
|
log_success "Admin login success, token obtained" |
||||
|
# Extract userId from JWT payload |
||||
|
JWT_PAYLOAD=$(echo "$ADMIN_TOKEN" | cut -d. -f2 | base64 -d 2>/dev/null) |
||||
|
ADMIN_USER_ID=$(echo "$JWT_PAYLOAD" | grep -o '"userId":[0-9]*' | head -1 | sed 's/"userId"://') |
||||
|
echo " Admin userId=$ADMIN_USER_ID" |
||||
|
else |
||||
|
log_error "Admin login failed: $LOGIN_RESULT" |
||||
|
echo "=== RESULT: $PASS passed, $FAIL failed ===" |
||||
|
exit 1 |
||||
|
fi |
||||
|
|
||||
|
# ============================ |
||||
|
# Part 2: Menu CRUD (Steps 2-8) |
||||
|
# ============================ |
||||
|
|
||||
|
log_step "2" "GET /menus/current - verify returns seed menus" |
||||
|
RESP=$(api_get "${BASE_URL}/menus/current") |
||||
|
HTTP=${RESP%%|*}; BODY=${RESP#*|} |
||||
|
|
||||
|
if [ "$HTTP" = "200" ]; then |
||||
|
log_success "GET /menus/current returned HTTP 200" |
||||
|
else |
||||
|
log_error "GET /menus/current expected HTTP 200, got $HTTP" |
||||
|
fi |
||||
|
|
||||
|
if echo "$BODY" | grep -q '"list"'; then |
||||
|
log_success "GET /menus/current returns list field" |
||||
|
else |
||||
|
log_error "GET /menus/current missing list field" |
||||
|
fi |
||||
|
|
||||
|
log_step "3" "GET /menus - verify returns all menus for admin" |
||||
|
RESP=$(api_get "${BASE_URL}/menus") |
||||
|
HTTP=${RESP%%|*}; BODY=${RESP#*|} |
||||
|
|
||||
|
if [ "$HTTP" = "200" ]; then |
||||
|
log_success "GET /menus returned HTTP 200" |
||||
|
else |
||||
|
log_error "GET /menus expected HTTP 200, got $HTTP" |
||||
|
fi |
||||
|
|
||||
|
if echo "$BODY" | grep -q '"/dashboard"'; then |
||||
|
log_success "GET /menus contains dashboard menu" |
||||
|
else |
||||
|
log_error "GET /menus missing dashboard menu" |
||||
|
fi |
||||
|
|
||||
|
log_step "4" "POST /menu - create test menu" |
||||
|
RESP=$(api_post "${BASE_URL}/menu" "{\"name\":\"TestMenu_${TIMESTAMP}\",\"path\":\"/test-${TIMESTAMP}\",\"icon\":\"Star\",\"type\":\"config\",\"sortOrder\":99,\"visible\":true,\"status\":1}") |
||||
|
HTTP=${RESP%%|*}; BODY=${RESP#*|} |
||||
|
MENU_ID=$(extract_int "$BODY" "id") |
||||
|
|
||||
|
if [ "$HTTP" = "200" ] && [ -n "$MENU_ID" ] && [ "$MENU_ID" != "0" ]; then |
||||
|
log_success "Created test menu (id=$MENU_ID)" |
||||
|
else |
||||
|
log_error "Create menu failed (HTTP=$HTTP): $BODY" |
||||
|
fi |
||||
|
|
||||
|
log_step "5" "GET /menus - verify new menu is in list" |
||||
|
RESP=$(api_get "${BASE_URL}/menus") |
||||
|
HTTP=${RESP%%|*}; BODY=${RESP#*|} |
||||
|
|
||||
|
if echo "$BODY" | grep -q "/test-${TIMESTAMP}"; then |
||||
|
log_success "New menu found in menu list (path=/test-${TIMESTAMP})" |
||||
|
else |
||||
|
log_error "New menu NOT found in menu list" |
||||
|
fi |
||||
|
|
||||
|
log_step "6" "PUT /menu/:id - update the test menu" |
||||
|
RESP=$(api_put "${BASE_URL}/menu/${MENU_ID}" "{\"name\":\"UpdatedMenu_${TIMESTAMP}\",\"path\":\"/test-modified-${TIMESTAMP}\",\"icon\":\"Star\",\"type\":\"config\",\"sortOrder\":99,\"visible\":true,\"status\":1}") |
||||
|
HTTP=${RESP%%|*}; BODY=${RESP#*|} |
||||
|
|
||||
|
if [ "$HTTP" = "200" ]; then |
||||
|
log_success "PUT /menu/:id returned HTTP 200" |
||||
|
else |
||||
|
log_error "PUT /menu/:id expected HTTP 200, got $HTTP" |
||||
|
fi |
||||
|
|
||||
|
log_step "7" "Verify menu update - check path changed" |
||||
|
if echo "$BODY" | grep -q "/test-modified-${TIMESTAMP}"; then |
||||
|
log_success "Menu path updated correctly" |
||||
|
else |
||||
|
log_error "Menu update verification failed: $BODY" |
||||
|
fi |
||||
|
|
||||
|
log_step "8" "DELETE /menu/:id - delete the test menu" |
||||
|
DELETE_HTTP=$(api_delete "${BASE_URL}/menu/${MENU_ID}") |
||||
|
|
||||
|
if [ "$DELETE_HTTP" = "200" ]; then |
||||
|
log_success "DELETE /menu/:id returned HTTP 200" |
||||
|
else |
||||
|
log_error "DELETE /menu/:id expected HTTP 200, got $DELETE_HTTP" |
||||
|
fi |
||||
|
|
||||
|
# ============================ |
||||
|
# Part 3: Role CRUD (Steps 9-15) |
||||
|
# ============================ |
||||
|
|
||||
|
log_step "9" "GET /roles - verify returns seed roles" |
||||
|
RESP=$(api_get "${BASE_URL}/roles") |
||||
|
HTTP=${RESP%%|*}; BODY=${RESP#*|} |
||||
|
|
||||
|
if [ "$HTTP" = "200" ]; then |
||||
|
log_success "GET /roles returned HTTP 200" |
||||
|
else |
||||
|
log_error "GET /roles expected HTTP 200, got $HTTP" |
||||
|
fi |
||||
|
|
||||
|
if echo "$BODY" | grep -q '"super_admin"'; then |
||||
|
log_success "Seed role 'super_admin' found" |
||||
|
else |
||||
|
log_error "Seed role 'super_admin' NOT found in: $BODY" |
||||
|
fi |
||||
|
|
||||
|
log_step "10" "POST /role - create test role" |
||||
|
RESP=$(api_post "${BASE_URL}/role" "{\"name\":\"TestRole_${TIMESTAMP}\",\"code\":\"test_role_${TIMESTAMP}\",\"description\":\"Integration test role\",\"sortOrder\":99,\"status\":1}") |
||||
|
HTTP=${RESP%%|*}; BODY=${RESP#*|} |
||||
|
ROLE_ID=$(extract_int "$BODY" "id") |
||||
|
|
||||
|
if [ "$HTTP" = "200" ] && [ -n "$ROLE_ID" ] && [ "$ROLE_ID" != "0" ]; then |
||||
|
log_success "Created test role (id=$ROLE_ID)" |
||||
|
else |
||||
|
log_error "Create role failed (HTTP=$HTTP): $BODY" |
||||
|
fi |
||||
|
|
||||
|
log_step "11" "GET /role/:id/menus - verify returns menu IDs (may be empty)" |
||||
|
RESP=$(api_get "${BASE_URL}/role/${ROLE_ID}/menus") |
||||
|
HTTP=${RESP%%|*}; BODY=${RESP#*|} |
||||
|
|
||||
|
if [ "$HTTP" = "200" ]; then |
||||
|
log_success "GET /role/:id/menus returned HTTP 200" |
||||
|
else |
||||
|
log_error "GET /role/:id/menus expected HTTP 200, got $HTTP" |
||||
|
fi |
||||
|
|
||||
|
if echo "$BODY" | grep -q '"menuIds"'; then |
||||
|
log_success "GET /role/:id/menus returns menuIds field" |
||||
|
else |
||||
|
log_error "GET /role/:id/menus missing menuIds: $BODY" |
||||
|
fi |
||||
|
|
||||
|
log_step "12" "PUT /role/:id/menus - set menus [1,2,3]" |
||||
|
RESP=$(api_put "${BASE_URL}/role/${ROLE_ID}/menus" '{"menuIds":[1,2,3]}') |
||||
|
HTTP=${RESP%%|*} |
||||
|
|
||||
|
if [ "$HTTP" = "200" ]; then |
||||
|
log_success "PUT /role/:id/menus returned HTTP 200" |
||||
|
else |
||||
|
log_error "PUT /role/:id/menus expected HTTP 200, got $HTTP" |
||||
|
fi |
||||
|
|
||||
|
log_step "13" "GET /role/:id/menus - verify menus now set" |
||||
|
RESP=$(api_get "${BASE_URL}/role/${ROLE_ID}/menus") |
||||
|
HTTP=${RESP%%|*}; BODY=${RESP#*|} |
||||
|
|
||||
|
MENU_IDS_STR=$(echo "$BODY" | grep -o '"menuIds":\[[^]]*\]' | head -1) |
||||
|
if echo "$MENU_IDS_STR" | grep -q '1' && echo "$MENU_IDS_STR" | grep -q '2' && echo "$MENU_IDS_STR" | grep -q '3'; then |
||||
|
log_success "Role menus correctly set to [1,2,3]" |
||||
|
else |
||||
|
log_error "Role menus expected [1,2,3], got: $MENU_IDS_STR" |
||||
|
fi |
||||
|
|
||||
|
log_step "14" "PUT /role/:id - update role name" |
||||
|
RESP=$(api_put "${BASE_URL}/role/${ROLE_ID}" "{\"name\":\"UpdatedRole_${TIMESTAMP}\",\"description\":\"Updated\"}") |
||||
|
HTTP=${RESP%%|*}; BODY=${RESP#*|} |
||||
|
|
||||
|
if [ "$HTTP" = "200" ] && echo "$BODY" | grep -q "UpdatedRole_${TIMESTAMP}"; then |
||||
|
log_success "Role updated successfully" |
||||
|
else |
||||
|
log_error "Role update failed (HTTP=$HTTP): $BODY" |
||||
|
fi |
||||
|
|
||||
|
log_step "15" "DELETE /role/:id - delete test role" |
||||
|
DELETE_HTTP=$(api_delete "${BASE_URL}/role/${ROLE_ID}") |
||||
|
|
||||
|
if [ "$DELETE_HTTP" = "200" ]; then |
||||
|
log_success "DELETE /role/:id returned HTTP 200" |
||||
|
else |
||||
|
log_error "DELETE /role/:id expected HTTP 200, got $DELETE_HTTP" |
||||
|
fi |
||||
|
|
||||
|
# ============================ |
||||
|
# Part 4: Role Delete Protection (Step 16) |
||||
|
# ============================ |
||||
|
|
||||
|
log_step "16" "DELETE super_admin role (id=1) - expect rejection" |
||||
|
DELETE_SA_HTTP=$(api_delete "${BASE_URL}/role/1") |
||||
|
|
||||
|
if [ "$DELETE_SA_HTTP" != "200" ]; then |
||||
|
log_success "DELETE super_admin role correctly rejected (HTTP=$DELETE_SA_HTTP)" |
||||
|
else |
||||
|
log_error "DELETE super_admin role should be rejected but got HTTP 200" |
||||
|
fi |
||||
|
|
||||
|
# ============================ |
||||
|
# Part 5: Organization CRUD (Steps 17-22) |
||||
|
# ============================ |
||||
|
|
||||
|
log_step "17" "POST /organization - create test org" |
||||
|
RESP=$(api_post "${BASE_URL}/organization" "{\"name\":\"TestOrg_${TIMESTAMP}\",\"code\":\"test_org_${TIMESTAMP}\",\"leader\":\"ZhangSan\",\"phone\":\"13800000001\",\"email\":\"test@org.com\",\"sortOrder\":1,\"status\":1}") |
||||
|
HTTP=${RESP%%|*}; BODY=${RESP#*|} |
||||
|
ORG_ID=$(extract_int "$BODY" "id") |
||||
|
|
||||
|
if [ "$HTTP" = "200" ] && [ -n "$ORG_ID" ] && [ "$ORG_ID" != "0" ]; then |
||||
|
log_success "Created test org (id=$ORG_ID)" |
||||
|
else |
||||
|
log_error "Create org failed (HTTP=$HTTP): $BODY" |
||||
|
fi |
||||
|
|
||||
|
log_step "18" "GET /organizations - verify new org is in list" |
||||
|
RESP=$(api_get "${BASE_URL}/organizations") |
||||
|
HTTP=${RESP%%|*}; BODY=${RESP#*|} |
||||
|
|
||||
|
if echo "$BODY" | grep -q "test_org_${TIMESTAMP}"; then |
||||
|
log_success "New org found in organization list (code=test_org_${TIMESTAMP})" |
||||
|
else |
||||
|
log_error "New org NOT found in organization list" |
||||
|
fi |
||||
|
|
||||
|
log_step "19" "PUT /organization/:id - update org" |
||||
|
RESP=$(api_put "${BASE_URL}/organization/${ORG_ID}" "{\"name\":\"UpdatedOrg_${TIMESTAMP}\",\"leader\":\"LiSi\"}") |
||||
|
HTTP=${RESP%%|*}; BODY=${RESP#*|} |
||||
|
|
||||
|
if [ "$HTTP" = "200" ] && echo "$BODY" | grep -q "UpdatedOrg_${TIMESTAMP}"; then |
||||
|
log_success "Org updated successfully" |
||||
|
else |
||||
|
log_error "Org update failed (HTTP=$HTTP): $BODY" |
||||
|
fi |
||||
|
|
||||
|
log_step "20" "GET /organization/:id/members - verify empty members list" |
||||
|
RESP=$(api_get "${BASE_URL}/organization/${ORG_ID}/members") |
||||
|
HTTP=${RESP%%|*}; BODY=${RESP#*|} |
||||
|
|
||||
|
if [ "$HTTP" = "200" ]; then |
||||
|
log_success "GET /organization/:id/members returned HTTP 200" |
||||
|
else |
||||
|
log_error "GET /organization/:id/members expected HTTP 200, got $HTTP" |
||||
|
fi |
||||
|
|
||||
|
if echo "$BODY" | grep -q '"list":\[\]'; then |
||||
|
log_success "Members list is empty as expected" |
||||
|
else |
||||
|
log_error "Members list should be empty: $BODY" |
||||
|
fi |
||||
|
|
||||
|
log_step "21" "POST /organization/:id/member - add admin user as member" |
||||
|
RESP=$(api_post "${BASE_URL}/organization/${ORG_ID}/member" "{\"userId\":${ADMIN_USER_ID},\"roleId\":1}") |
||||
|
HTTP=${RESP%%|*}; BODY=${RESP#*|} |
||||
|
|
||||
|
if [ "$HTTP" = "200" ]; then |
||||
|
log_success "Added admin user (id=$ADMIN_USER_ID) as org member" |
||||
|
else |
||||
|
log_error "Add org member failed (HTTP=$HTTP): $BODY" |
||||
|
fi |
||||
|
|
||||
|
log_step "22" "GET /organization/:id/members - verify member added" |
||||
|
RESP=$(api_get "${BASE_URL}/organization/${ORG_ID}/members") |
||||
|
HTTP=${RESP%%|*}; BODY=${RESP#*|} |
||||
|
|
||||
|
if echo "$BODY" | grep -q "\"userId\":${ADMIN_USER_ID}"; then |
||||
|
log_success "Admin user (userId=$ADMIN_USER_ID) found in org members" |
||||
|
else |
||||
|
log_error "Admin user NOT found in org members: $BODY" |
||||
|
fi |
||||
|
|
||||
|
# ============================ |
||||
|
# Part 6: Profile Org APIs (Steps 23-24) |
||||
|
# ============================ |
||||
|
|
||||
|
log_step "23" "GET /profile/orgs - verify admin has test org" |
||||
|
RESP=$(api_get "${BASE_URL}/profile/orgs") |
||||
|
HTTP=${RESP%%|*}; BODY=${RESP#*|} |
||||
|
|
||||
|
if [ "$HTTP" = "200" ]; then |
||||
|
log_success "GET /profile/orgs returned HTTP 200" |
||||
|
else |
||||
|
log_error "GET /profile/orgs expected HTTP 200, got $HTTP" |
||||
|
fi |
||||
|
|
||||
|
if echo "$BODY" | grep -q "\"orgId\":${ORG_ID}"; then |
||||
|
log_success "Test org found in profile orgs list" |
||||
|
else |
||||
|
log_error "Test org NOT found in profile orgs: $BODY" |
||||
|
fi |
||||
|
|
||||
|
log_step "24" "PUT /profile/current-org - switch to test org" |
||||
|
RESP=$(api_put "${BASE_URL}/profile/current-org" "{\"orgId\":${ORG_ID}}") |
||||
|
HTTP=${RESP%%|*}; BODY=${RESP#*|} |
||||
|
NEW_TOKEN=$(extract_value "$BODY" "token") |
||||
|
|
||||
|
if [ "$HTTP" = "200" ] && [ -n "$NEW_TOKEN" ] && [ ${#NEW_TOKEN} -gt 20 ]; then |
||||
|
log_success "Switched to test org, new token received" |
||||
|
else |
||||
|
log_error "Switch org failed (HTTP=$HTTP): $BODY" |
||||
|
fi |
||||
|
|
||||
|
# ============================ |
||||
|
# Part 7: Cleanup (Step 25) |
||||
|
# ============================ |
||||
|
|
||||
|
log_step "25" "Cleanup - remove member, delete org" |
||||
|
|
||||
|
# Remove org member |
||||
|
REMOVE_HTTP=$(api_delete "${BASE_URL}/organization/${ORG_ID}/member/${ADMIN_USER_ID}") |
||||
|
if [ "$REMOVE_HTTP" = "200" ]; then |
||||
|
log_success "Removed org member (userId=$ADMIN_USER_ID)" |
||||
|
else |
||||
|
log_success "Org member cleanup attempted (HTTP=$REMOVE_HTTP)" |
||||
|
fi |
||||
|
|
||||
|
# Delete org |
||||
|
DELETE_ORG_HTTP=$(api_delete "${BASE_URL}/organization/${ORG_ID}") |
||||
|
if [ "$DELETE_ORG_HTTP" = "200" ]; then |
||||
|
log_success "Deleted test org (id=$ORG_ID)" |
||||
|
else |
||||
|
log_success "Org cleanup attempted (HTTP=$DELETE_ORG_HTTP)" |
||||
|
fi |
||||
|
|
||||
|
log_success "Cleanup complete" |
||||
|
|
||||
|
# ============================ |
||||
|
# Results Summary |
||||
|
# ============================ |
||||
|
echo "" |
||||
|
echo "=========================================" |
||||
|
echo -e " Menu / Role / Org E2E Test Results" |
||||
|
echo -e " \033[32mPassed: $PASS\033[0m | \033[31mFailed: $FAIL\033[0m" |
||||
|
echo "=========================================" |
||||
|
|
||||
|
if [ "$FAIL" -gt 0 ]; then |
||||
|
exit 1 |
||||
|
fi |
||||
@ -0,0 +1,364 @@ |
|||||
|
#!/bin/bash |
||||
|
# RBAC E2E 测试脚本 |
||||
|
# 测试:超级管理员登录、角色字段持久化、权限策略执行 |
||||
|
|
||||
|
BASE_URL="http://localhost:8888/api/v1" |
||||
|
TIMESTAMP=$(date +%s) |
||||
|
PASS=0 |
||||
|
FAIL=0 |
||||
|
|
||||
|
log_step() { |
||||
|
echo -e "\n\033[36m--- Step $1: $2 ---\033[0m" |
||||
|
} |
||||
|
|
||||
|
log_success() { |
||||
|
echo -e "\033[32m[PASS]\033[0m $1" |
||||
|
PASS=$((PASS + 1)) |
||||
|
} |
||||
|
|
||||
|
log_error() { |
||||
|
echo -e "\033[31m[FAIL]\033[0m $1" |
||||
|
FAIL=$((FAIL + 1)) |
||||
|
} |
||||
|
|
||||
|
extract_value() { |
||||
|
echo "$1" | grep -o "\"$2\":\"[^\"]*\"" | head -1 | sed 's/"'"$2"'"://' | sed 's/^"//' | sed 's/"$//' |
||||
|
} |
||||
|
|
||||
|
extract_int() { |
||||
|
echo "$1" | grep -o "\"$2\":[^,}]*" | head -1 | sed 's/"'"$2"'"://' | tr -d ' ' |
||||
|
} |
||||
|
|
||||
|
# ============================ |
||||
|
# Part 1: 超级管理员登录 |
||||
|
# ============================ |
||||
|
|
||||
|
log_step "1" "Super admin login (admin@system.local / admin123)" |
||||
|
LOGIN_RESULT=$(curl -s -X POST ${BASE_URL}/login \ |
||||
|
-H "Content-Type: application/json" \ |
||||
|
-d '{"email":"admin@system.local","password":"admin123"}') |
||||
|
ADMIN_TOKEN=$(extract_value "$LOGIN_RESULT" "token") |
||||
|
LOGIN_CODE=$(extract_int "$LOGIN_RESULT" "code") |
||||
|
|
||||
|
if [ "$LOGIN_CODE" = "200" ] && [ -n "$ADMIN_TOKEN" ]; then |
||||
|
log_success "Super admin login success" |
||||
|
else |
||||
|
log_error "Super admin login failed: $LOGIN_RESULT" |
||||
|
echo "=== RESULT: $PASS passed, $FAIL failed ===" |
||||
|
exit 1 |
||||
|
fi |
||||
|
|
||||
|
# ============================ |
||||
|
# Part 2: 验证 JWT 包含 role claim |
||||
|
# ============================ |
||||
|
|
||||
|
log_step "2" "Verify JWT contains role claim" |
||||
|
JWT_PAYLOAD=$(echo "$ADMIN_TOKEN" | cut -d. -f2 | base64 -d 2>/dev/null) |
||||
|
JWT_ROLE=$(echo "$JWT_PAYLOAD" | grep -o '"role":"[^"]*"' | head -1 | sed 's/"role":"//' | sed 's/"$//') |
||||
|
|
||||
|
if [ "$JWT_ROLE" = "super_admin" ]; then |
||||
|
log_success "JWT role claim = super_admin" |
||||
|
else |
||||
|
log_error "JWT role claim expected 'super_admin', got '$JWT_ROLE'" |
||||
|
fi |
||||
|
|
||||
|
# ============================ |
||||
|
# Part 3: 创建带角色的用户(admin 管理员用户) |
||||
|
# ============================ |
||||
|
|
||||
|
log_step "3" "Create user with role=admin via super_admin" |
||||
|
CREATE_ADMIN_RESULT=$(curl -s -X POST ${BASE_URL}/user \ |
||||
|
-H "Content-Type: application/json" \ |
||||
|
-H "Authorization: Bearer ${ADMIN_TOKEN}" \ |
||||
|
-d "{\"username\":\"rbac_admin_${TIMESTAMP}\",\"email\":\"rbac_admin_${TIMESTAMP}@test.com\",\"password\":\"password123\",\"role\":\"admin\",\"remark\":\"RBAC test admin\"}") |
||||
|
ADMIN_USER_ID=$(extract_int "$CREATE_ADMIN_RESULT" "id") |
||||
|
CREATED_ROLE=$(extract_value "$CREATE_ADMIN_RESULT" "role") |
||||
|
CREATED_SOURCE=$(extract_value "$CREATE_ADMIN_RESULT" "source") |
||||
|
CREATED_REMARK=$(extract_value "$CREATE_ADMIN_RESULT" "remark") |
||||
|
|
||||
|
if [ -n "$ADMIN_USER_ID" ] && [ "$CREATED_ROLE" = "admin" ]; then |
||||
|
log_success "Created admin user (id=$ADMIN_USER_ID, role=$CREATED_ROLE)" |
||||
|
else |
||||
|
log_error "Create admin user failed: $CREATE_ADMIN_RESULT" |
||||
|
fi |
||||
|
|
||||
|
if [ "$CREATED_SOURCE" = "manual" ]; then |
||||
|
log_success "Source = manual (correct for admin-created user)" |
||||
|
else |
||||
|
log_error "Source expected 'manual', got '$CREATED_SOURCE'" |
||||
|
fi |
||||
|
|
||||
|
if [ "$CREATED_REMARK" = "RBAC test admin" ]; then |
||||
|
log_success "Remark preserved correctly" |
||||
|
else |
||||
|
log_error "Remark expected 'RBAC test admin', got '$CREATED_REMARK'" |
||||
|
fi |
||||
|
|
||||
|
# ============================ |
||||
|
# Part 4: 创建普通用户 |
||||
|
# ============================ |
||||
|
|
||||
|
log_step "4" "Create user with default role (user)" |
||||
|
CREATE_USER_RESULT=$(curl -s -X POST ${BASE_URL}/user \ |
||||
|
-H "Content-Type: application/json" \ |
||||
|
-H "Authorization: Bearer ${ADMIN_TOKEN}" \ |
||||
|
-d "{\"username\":\"rbac_user_${TIMESTAMP}\",\"email\":\"rbac_user_${TIMESTAMP}@test.com\",\"password\":\"password123\"}") |
||||
|
NORMAL_USER_ID=$(extract_int "$CREATE_USER_RESULT" "id") |
||||
|
NORMAL_ROLE=$(extract_value "$CREATE_USER_RESULT" "role") |
||||
|
|
||||
|
if [ -n "$NORMAL_USER_ID" ] && [ "$NORMAL_ROLE" = "user" ]; then |
||||
|
log_success "Created normal user (id=$NORMAL_USER_ID, role=user)" |
||||
|
else |
||||
|
log_error "Create normal user failed: $CREATE_USER_RESULT" |
||||
|
fi |
||||
|
|
||||
|
# ============================ |
||||
|
# Part 5: 注册用户验证默认 role 和 source |
||||
|
# ============================ |
||||
|
|
||||
|
log_step "5" "Register new user — verify role=user, source=register" |
||||
|
REGISTER_RESULT=$(curl -s -X POST ${BASE_URL}/register \ |
||||
|
-H "Content-Type: application/json" \ |
||||
|
-d "{\"username\":\"rbac_reg_${TIMESTAMP}\",\"email\":\"rbac_reg_${TIMESTAMP}@test.com\",\"password\":\"password123\"}") |
||||
|
REG_ROLE=$(extract_value "$REGISTER_RESULT" "role") |
||||
|
REG_SOURCE=$(extract_value "$REGISTER_RESULT" "source") |
||||
|
|
||||
|
if [ "$REG_ROLE" = "user" ]; then |
||||
|
log_success "Registered user role = user" |
||||
|
else |
||||
|
log_error "Registered user role expected 'user', got '$REG_ROLE'" |
||||
|
fi |
||||
|
|
||||
|
if [ "$REG_SOURCE" = "register" ]; then |
||||
|
log_success "Registered user source = register" |
||||
|
else |
||||
|
log_error "Registered user source expected 'register', got '$REG_SOURCE'" |
||||
|
fi |
||||
|
|
||||
|
# ============================ |
||||
|
# Part 6: 普通用户登录 — 验证受限访问 |
||||
|
# ============================ |
||||
|
|
||||
|
log_step "6" "Normal user login" |
||||
|
NORMAL_LOGIN=$(curl -s -X POST ${BASE_URL}/login \ |
||||
|
-H "Content-Type: application/json" \ |
||||
|
-d "{\"email\":\"rbac_user_${TIMESTAMP}@test.com\",\"password\":\"password123\"}") |
||||
|
USER_TOKEN=$(extract_value "$NORMAL_LOGIN" "token") |
||||
|
NORMAL_LOGIN_CODE=$(extract_int "$NORMAL_LOGIN" "code") |
||||
|
|
||||
|
if [ "$NORMAL_LOGIN_CODE" = "200" ] && [ -n "$USER_TOKEN" ]; then |
||||
|
log_success "Normal user login success" |
||||
|
else |
||||
|
log_error "Normal user login failed: $NORMAL_LOGIN" |
||||
|
fi |
||||
|
|
||||
|
# ============================ |
||||
|
# Part 7: 普通用户 GET /users — 应该 403 |
||||
|
# ============================ |
||||
|
|
||||
|
log_step "7" "Normal user GET /users — expect 403" |
||||
|
USER_LIST_RESULT=$(curl -s -o /dev/null -w "%{http_code}" -X GET "${BASE_URL}/users?page=1&pageSize=10" \ |
||||
|
-H "Authorization: Bearer ${USER_TOKEN}") |
||||
|
|
||||
|
if [ "$USER_LIST_RESULT" = "403" ]; then |
||||
|
log_success "GET /users returned 403 for role=user" |
||||
|
else |
||||
|
log_error "GET /users expected 403, got $USER_LIST_RESULT" |
||||
|
fi |
||||
|
|
||||
|
# ============================ |
||||
|
# Part 8: 普通用户 POST /user — 应该 403 |
||||
|
# ============================ |
||||
|
|
||||
|
log_step "8" "Normal user POST /user — expect 403" |
||||
|
USER_CREATE_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST ${BASE_URL}/user \ |
||||
|
-H "Content-Type: application/json" \ |
||||
|
-H "Authorization: Bearer ${USER_TOKEN}" \ |
||||
|
-d '{"username":"hacker","email":"hacker@test.com","password":"password123"}') |
||||
|
|
||||
|
if [ "$USER_CREATE_CODE" = "403" ]; then |
||||
|
log_success "POST /user returned 403 for role=user" |
||||
|
else |
||||
|
log_error "POST /user expected 403, got $USER_CREATE_CODE" |
||||
|
fi |
||||
|
|
||||
|
# ============================ |
||||
|
# Part 9: 普通用户 DELETE /user/:id — 应该 403 |
||||
|
# ============================ |
||||
|
|
||||
|
log_step "9" "Normal user DELETE /user/:id — expect 403" |
||||
|
USER_DELETE_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X DELETE "${BASE_URL}/user/${NORMAL_USER_ID}" \ |
||||
|
-H "Authorization: Bearer ${USER_TOKEN}") |
||||
|
|
||||
|
if [ "$USER_DELETE_CODE" = "403" ]; then |
||||
|
log_success "DELETE /user/:id returned 403 for role=user" |
||||
|
else |
||||
|
log_error "DELETE /user/:id expected 403, got $USER_DELETE_CODE" |
||||
|
fi |
||||
|
|
||||
|
# ============================ |
||||
|
# Part 10: 普通用户 GET /profile/me — 应该 200 |
||||
|
# ============================ |
||||
|
|
||||
|
log_step "10" "Normal user GET /profile/me — expect 200" |
||||
|
PROFILE_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X GET "${BASE_URL}/profile/me" \ |
||||
|
-H "Authorization: Bearer ${USER_TOKEN}") |
||||
|
|
||||
|
if [ "$PROFILE_CODE" = "200" ]; then |
||||
|
log_success "GET /profile/me returned 200 for role=user" |
||||
|
else |
||||
|
log_error "GET /profile/me expected 200, got $PROFILE_CODE" |
||||
|
fi |
||||
|
|
||||
|
# ============================ |
||||
|
# Part 11: 普通用户 GET /dashboard/stats — 应该 200 |
||||
|
# ============================ |
||||
|
|
||||
|
log_step "11" "Normal user GET /dashboard/stats — expect 200" |
||||
|
DASHBOARD_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X GET "${BASE_URL}/dashboard/stats" \ |
||||
|
-H "Authorization: Bearer ${USER_TOKEN}") |
||||
|
|
||||
|
if [ "$DASHBOARD_CODE" = "200" ]; then |
||||
|
log_success "GET /dashboard/stats returned 200 for role=user (inherits guest)" |
||||
|
else |
||||
|
log_error "GET /dashboard/stats expected 200, got $DASHBOARD_CODE" |
||||
|
fi |
||||
|
|
||||
|
# ============================ |
||||
|
# Part 12: admin 用户登录 — 验证管理权限 |
||||
|
# ============================ |
||||
|
|
||||
|
log_step "12" "Admin user login" |
||||
|
ADMIN_USER_LOGIN=$(curl -s -X POST ${BASE_URL}/login \ |
||||
|
-H "Content-Type: application/json" \ |
||||
|
-d "{\"email\":\"rbac_admin_${TIMESTAMP}@test.com\",\"password\":\"password123\"}") |
||||
|
ADMIN_USER_TOKEN=$(extract_value "$ADMIN_USER_LOGIN" "token") |
||||
|
|
||||
|
if [ -n "$ADMIN_USER_TOKEN" ]; then |
||||
|
log_success "Admin user login success" |
||||
|
else |
||||
|
log_error "Admin user login failed" |
||||
|
fi |
||||
|
|
||||
|
# ============================ |
||||
|
# Part 13: admin 用户 GET /users — 应该 200 |
||||
|
# ============================ |
||||
|
|
||||
|
log_step "13" "Admin user GET /users — expect 200" |
||||
|
ADMIN_LIST_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X GET "${BASE_URL}/users?page=1&pageSize=10" \ |
||||
|
-H "Authorization: Bearer ${ADMIN_USER_TOKEN}") |
||||
|
|
||||
|
if [ "$ADMIN_LIST_CODE" = "200" ]; then |
||||
|
log_success "GET /users returned 200 for role=admin" |
||||
|
else |
||||
|
log_error "GET /users expected 200, got $ADMIN_LIST_CODE" |
||||
|
fi |
||||
|
|
||||
|
# ============================ |
||||
|
# Part 14: admin 用户 DELETE /user/:id — 应该 403 (仅 super_admin 可删除) |
||||
|
# ============================ |
||||
|
|
||||
|
log_step "14" "Admin user DELETE /user/:id — expect 403" |
||||
|
ADMIN_DELETE_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X DELETE "${BASE_URL}/user/${NORMAL_USER_ID}" \ |
||||
|
-H "Authorization: Bearer ${ADMIN_USER_TOKEN}") |
||||
|
|
||||
|
if [ "$ADMIN_DELETE_CODE" = "403" ]; then |
||||
|
log_success "DELETE /user/:id returned 403 for role=admin" |
||||
|
else |
||||
|
log_error "DELETE /user/:id expected 403, got $ADMIN_DELETE_CODE" |
||||
|
fi |
||||
|
|
||||
|
# ============================ |
||||
|
# Part 15: 更新用户角色 (super_admin 修改 role) |
||||
|
# ============================ |
||||
|
|
||||
|
log_step "15" "Super admin update user role" |
||||
|
UPDATE_ROLE_RESULT=$(curl -s -X PUT "${BASE_URL}/user/${NORMAL_USER_ID}" \ |
||||
|
-H "Content-Type: application/json" \ |
||||
|
-H "Authorization: Bearer ${ADMIN_TOKEN}" \ |
||||
|
-d '{"role":"admin","remark":"promoted to admin"}') |
||||
|
UPDATED_ROLE=$(extract_value "$UPDATE_ROLE_RESULT" "role") |
||||
|
UPDATED_REMARK=$(extract_value "$UPDATE_ROLE_RESULT" "remark") |
||||
|
|
||||
|
if [ "$UPDATED_ROLE" = "admin" ]; then |
||||
|
log_success "User role updated to admin" |
||||
|
else |
||||
|
log_error "User role update failed, expected 'admin', got '$UPDATED_ROLE'" |
||||
|
fi |
||||
|
|
||||
|
if [ "$UPDATED_REMARK" = "promoted to admin" ]; then |
||||
|
log_success "Remark updated correctly" |
||||
|
else |
||||
|
log_error "Remark expected 'promoted to admin', got '$UPDATED_REMARK'" |
||||
|
fi |
||||
|
|
||||
|
# ============================ |
||||
|
# Part 16: super_admin 用户列表包含 role/source 字段 |
||||
|
# ============================ |
||||
|
|
||||
|
log_step "16" "Verify user list returns role/source fields" |
||||
|
LIST_RESULT=$(curl -s -X GET "${BASE_URL}/users?page=1&pageSize=100" \ |
||||
|
-H "Authorization: Bearer ${ADMIN_TOKEN}") |
||||
|
|
||||
|
if echo "$LIST_RESULT" | grep -q '"role"'; then |
||||
|
log_success "User list contains 'role' field" |
||||
|
else |
||||
|
log_error "User list missing 'role' field" |
||||
|
fi |
||||
|
|
||||
|
if echo "$LIST_RESULT" | grep -q '"source"'; then |
||||
|
log_success "User list contains 'source' field" |
||||
|
else |
||||
|
log_error "User list missing 'source' field" |
||||
|
fi |
||||
|
|
||||
|
if echo "$LIST_RESULT" | grep -q '"remark"'; then |
||||
|
log_success "User list contains 'remark' field" |
||||
|
else |
||||
|
log_error "User list missing 'remark' field" |
||||
|
fi |
||||
|
|
||||
|
# ============================ |
||||
|
# Part 17: super_admin DELETE — 应该 200 |
||||
|
# ============================ |
||||
|
|
||||
|
log_step "17" "Super admin DELETE /user/:id — expect 200" |
||||
|
SA_DELETE_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X DELETE "${BASE_URL}/user/${NORMAL_USER_ID}" \ |
||||
|
-H "Authorization: Bearer ${ADMIN_TOKEN}") |
||||
|
|
||||
|
if [ "$SA_DELETE_CODE" = "200" ]; then |
||||
|
log_success "DELETE /user/:id returned 200 for super_admin" |
||||
|
else |
||||
|
log_error "DELETE /user/:id expected 200 for super_admin, got $SA_DELETE_CODE" |
||||
|
fi |
||||
|
|
||||
|
# ============================ |
||||
|
# Cleanup: 删除测试创建的 admin 用户 |
||||
|
# ============================ |
||||
|
|
||||
|
log_step "18" "Cleanup — delete test admin user" |
||||
|
curl -s -o /dev/null -X DELETE "${BASE_URL}/user/${ADMIN_USER_ID}" \ |
||||
|
-H "Authorization: Bearer ${ADMIN_TOKEN}" |
||||
|
log_success "Cleanup complete" |
||||
|
|
||||
|
# 删除注册的测试用户 |
||||
|
REG_USER=$(curl -s -X GET "${BASE_URL}/users?page=1&pageSize=100&keyword=rbac_reg_${TIMESTAMP}" \ |
||||
|
-H "Authorization: Bearer ${ADMIN_TOKEN}") |
||||
|
REG_USER_ID=$(extract_int "$REG_USER" "id") |
||||
|
if [ -n "$REG_USER_ID" ]; then |
||||
|
curl -s -o /dev/null -X DELETE "${BASE_URL}/user/${REG_USER_ID}" \ |
||||
|
-H "Authorization: Bearer ${ADMIN_TOKEN}" |
||||
|
fi |
||||
|
|
||||
|
# ============================ |
||||
|
# 结果汇总 |
||||
|
# ============================ |
||||
|
echo "" |
||||
|
echo "=========================================" |
||||
|
echo -e " RBAC E2E Test Results" |
||||
|
echo -e " \033[32mPassed: $PASS\033[0m | \033[31mFailed: $FAIL\033[0m" |
||||
|
echo "=========================================" |
||||
|
|
||||
|
if [ "$FAIL" -gt 0 ]; then |
||||
|
exit 1 |
||||
|
fi |
||||
@ -0,0 +1,190 @@ |
|||||
|
#!/bin/bash |
||||
|
|
||||
|
# SSO (Casdoor) 单点登录 E2E 测试 |
||||
|
# |
||||
|
# 测试账号: |
||||
|
# Casdoor 地址: https://cas.gxxhygroup.com |
||||
|
# 用户名: testuser |
||||
|
# 密码: Test@1234 |
||||
|
# 组织: XhyGroup |
||||
|
# 应用: xhy-base |
||||
|
# |
||||
|
# 前置条件: |
||||
|
# 1. 后端服务运行在 localhost:8888 |
||||
|
# 2. Casdoor 服务可访问 |
||||
|
# 3. base-api.yaml 中 Casdoor 配置正确 |
||||
|
|
||||
|
BASE_URL="${BASE_URL:-http://localhost:8888/api/v1}" |
||||
|
CASDOOR_URL="${CASDOOR_URL:-https://cas.gxxhygroup.com}" |
||||
|
CASDOOR_USER="${CASDOOR_USER:-testuser}" |
||||
|
CASDOOR_PASS="${CASDOOR_PASS:-Test@1234}" |
||||
|
|
||||
|
# 颜色输出 |
||||
|
log_info() { echo "[INFO] $1"; } |
||||
|
log_success() { echo "[PASS] $1"; } |
||||
|
log_error() { echo "[FAIL] $1"; } |
||||
|
|
||||
|
PASS=0 |
||||
|
FAIL=0 |
||||
|
|
||||
|
assert_ok() { |
||||
|
local desc="$1" |
||||
|
local result="$2" |
||||
|
if [ "$result" = "0" ]; then |
||||
|
log_success "$desc" |
||||
|
PASS=$((PASS + 1)) |
||||
|
else |
||||
|
log_error "$desc" |
||||
|
FAIL=$((FAIL + 1)) |
||||
|
fi |
||||
|
} |
||||
|
|
||||
|
echo "" |
||||
|
echo "=========================================" |
||||
|
echo " SSO (Casdoor) 单点登录 E2E 测试" |
||||
|
echo "=========================================" |
||||
|
echo "" |
||||
|
|
||||
|
# ========== 测试 1: 获取 SSO 登录链接 ========== |
||||
|
log_info "测试 1: 获取 SSO 登录链接" |
||||
|
LOGIN_URL_RESP=$(curl -s "$BASE_URL/auth/sso/login-url") |
||||
|
echo " 响应: $LOGIN_URL_RESP" |
||||
|
|
||||
|
# 用 python 或简单方式提取 login_url |
||||
|
LOGIN_URL="" |
||||
|
if echo "$LOGIN_URL_RESP" | grep -q "login_url"; then |
||||
|
LOGIN_URL=$(echo "$LOGIN_URL_RESP" | sed 's/.*"login_url":"\([^"]*\)".*/\1/') |
||||
|
fi |
||||
|
|
||||
|
if [ -n "$LOGIN_URL" ]; then |
||||
|
assert_ok "GET /auth/sso/login-url 返回有效的登录链接" "0" |
||||
|
echo " 链接: ${LOGIN_URL:0:100}..." |
||||
|
else |
||||
|
assert_ok "GET /auth/sso/login-url 返回有效的登录链接" "1" |
||||
|
fi |
||||
|
|
||||
|
# 验证链接包含必要参数 |
||||
|
HAS_PARAMS="0" |
||||
|
echo "$LOGIN_URL" | grep -q "client_id=" || HAS_PARAMS="1" |
||||
|
echo "$LOGIN_URL" | grep -q "redirect_uri=" || HAS_PARAMS="1" |
||||
|
echo "$LOGIN_URL" | grep -q "response_type=code" || HAS_PARAMS="1" |
||||
|
echo "$LOGIN_URL" | grep -q "state=" || HAS_PARAMS="1" |
||||
|
assert_ok "登录链接包含 client_id, redirect_uri, response_type, state" "$HAS_PARAMS" |
||||
|
|
||||
|
# ========== 测试 2: Casdoor 服务可达性 ========== |
||||
|
log_info "测试 2: Casdoor 服务可达性" |
||||
|
CASDOOR_STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$CASDOOR_URL/.well-known/openid-configuration" 2>/dev/null || echo "000") |
||||
|
echo " OIDC 发现端点状态码: $CASDOOR_STATUS" |
||||
|
|
||||
|
if [ "$CASDOOR_STATUS" = "200" ]; then |
||||
|
assert_ok "Casdoor OIDC 发现端点可访问" "0" |
||||
|
else |
||||
|
assert_ok "Casdoor OIDC 发现端点可访问 [HTTP $CASDOOR_STATUS]" "1" |
||||
|
fi |
||||
|
|
||||
|
# ========== 测试 3: Casdoor API 登录获取授权码 ========== |
||||
|
log_info "测试 3: Casdoor API 登录" |
||||
|
LOGIN_API_RESP=$(curl -s -X POST "$CASDOOR_URL/api/login" \ |
||||
|
-H "Content-Type: application/json" \ |
||||
|
-d "{ |
||||
|
\"application\": \"xhy-base\", |
||||
|
\"organization\": \"XhyGroup\", |
||||
|
\"username\": \"$CASDOOR_USER\", |
||||
|
\"password\": \"$CASDOOR_PASS\", |
||||
|
\"type\": \"code\" |
||||
|
}" 2>/dev/null || echo '{"status":"error"}') |
||||
|
|
||||
|
echo " Casdoor 响应: ${LOGIN_API_RESP:0:200}" |
||||
|
|
||||
|
if echo "$LOGIN_API_RESP" | grep -q '"status":"ok"'; then |
||||
|
assert_ok "Casdoor API 登录成功" "0" |
||||
|
elif echo "$LOGIN_API_RESP" | grep -q "not supported"; then |
||||
|
log_info " Casdoor 应用不支持 API 直接登录(需浏览器),跳过" |
||||
|
assert_ok "Casdoor API 可达(需浏览器完成授权)" "0" |
||||
|
else |
||||
|
assert_ok "Casdoor API 登录成功" "1" |
||||
|
fi |
||||
|
|
||||
|
# ========== 测试 4: 后端回调处理 (用 Casdoor 授权码) ========== |
||||
|
log_info "测试 4: 后端 SSO 回调" |
||||
|
|
||||
|
# 从 Casdoor 响应中提取授权码(data 或 data2 字段) |
||||
|
AUTH_CODE="" |
||||
|
if echo "$LOGIN_API_RESP" | grep -q '"code"'; then |
||||
|
# 提取第一个非 status 的 code 字段 |
||||
|
AUTH_CODE=$(echo "$LOGIN_API_RESP" | sed 's/.*"data":"\([^"]*\)".*/\1/' 2>/dev/null) |
||||
|
if [ "$AUTH_CODE" = "$LOGIN_API_RESP" ]; then |
||||
|
AUTH_CODE=$(echo "$LOGIN_API_RESP" | sed 's/.*"data2":"\([^"]*\)".*/\1/' 2>/dev/null) |
||||
|
fi |
||||
|
if [ "$AUTH_CODE" = "$LOGIN_API_RESP" ]; then |
||||
|
AUTH_CODE="" |
||||
|
fi |
||||
|
fi |
||||
|
|
||||
|
if [ -n "$AUTH_CODE" ] && [ ${#AUTH_CODE} -gt 5 ]; then |
||||
|
echo " 授权码: ${AUTH_CODE:0:20}..." |
||||
|
|
||||
|
# 调用后端回调 |
||||
|
CALLBACK_HEADERS=$(curl -s -D - -o /dev/null \ |
||||
|
"$BASE_URL/auth/sso/callback?code=$AUTH_CODE&state=test" 2>/dev/null) |
||||
|
|
||||
|
HTTP_CODE=$(echo "$CALLBACK_HEADERS" | head -1 | grep -o '[0-9]\{3\}') |
||||
|
LOCATION=$(echo "$CALLBACK_HEADERS" | grep -i "^Location:" | tr -d '\r' | cut -d' ' -f2-) |
||||
|
|
||||
|
echo " 回调状态码: $HTTP_CODE" |
||||
|
echo " 重定向到: ${LOCATION:0:100}" |
||||
|
|
||||
|
if [ "$HTTP_CODE" = "302" ]; then |
||||
|
assert_ok "后端回调返回 302 重定向" "0" |
||||
|
else |
||||
|
assert_ok "后端回调返回 302 重定向 [实际: $HTTP_CODE]" "1" |
||||
|
fi |
||||
|
|
||||
|
if echo "$LOCATION" | grep -q "token="; then |
||||
|
assert_ok "重定向 URL 包含 JWT token" "0" |
||||
|
else |
||||
|
assert_ok "重定向 URL 包含 JWT token" "1" |
||||
|
fi |
||||
|
else |
||||
|
log_info " 无法从 Casdoor API 获取授权码,跳过回调测试" |
||||
|
log_info " (Casdoor 可能需要浏览器交互完成登录)" |
||||
|
fi |
||||
|
|
||||
|
# ========== 测试 5: SSO 用户不能密码登录 ========== |
||||
|
log_info "测试 5: SSO 用户密码登录限制" |
||||
|
PASSWORD_LOGIN_RESP=$(curl -s -X POST "$BASE_URL/login" \ |
||||
|
-H "Content-Type: application/json" \ |
||||
|
-d '{"email":"test@example.com","password":"anypassword"}') |
||||
|
|
||||
|
echo " 密码登录响应: $PASSWORD_LOGIN_RESP" |
||||
|
|
||||
|
if echo "$PASSWORD_LOGIN_RESP" | grep -q "SSO"; then |
||||
|
assert_ok "SSO 用户密码登录被拒绝,提示使用 SSO" "0" |
||||
|
elif echo "$PASSWORD_LOGIN_RESP" | grep -q "不存在"; then |
||||
|
log_info " SSO 用户尚未创建(需先完成一次浏览器 SSO 登录)" |
||||
|
assert_ok "密码登录未暴露 SSO 用户信息" "0" |
||||
|
elif echo "$PASSWORD_LOGIN_RESP" | grep -q "密码错误"; then |
||||
|
log_info " 存在同邮箱的本地用户(SSO 用户通过 casdoor_id 区分)" |
||||
|
assert_ok "密码登录未暴露 SSO 用户信息" "0" |
||||
|
else |
||||
|
assert_ok "SSO 用户密码登录被拒绝" "1" |
||||
|
fi |
||||
|
|
||||
|
# ========== 测试报告 ========== |
||||
|
echo "" |
||||
|
echo "=========================================" |
||||
|
echo " SSO 测试报告" |
||||
|
echo "=========================================" |
||||
|
echo "" |
||||
|
echo "通过: $PASS" |
||||
|
echo "失败: $FAIL" |
||||
|
echo "总计: $((PASS + FAIL))" |
||||
|
echo "" |
||||
|
|
||||
|
if [ $FAIL -eq 0 ]; then |
||||
|
echo "所有 SSO 测试通过!" |
||||
|
exit 0 |
||||
|
else |
||||
|
echo "存在失败的测试项" |
||||
|
exit 1 |
||||
|
fi |
||||
@ -0,0 +1,143 @@ |
|||||
|
# Base API 接口文档 |
||||
|
|
||||
|
> 基础路径: `/api/v1` | 认证方式: `Authorization: Bearer <token>` |
||||
|
|
||||
|
## 权限说明 |
||||
|
|
||||
|
| 级别 | 标记 | 说明 | |
||||
|
|------|------|------| |
||||
|
| 公开 | - | 无需登录 | |
||||
|
| 登录 | Auth | 需要有效 JWT | |
||||
|
| 授权 | Authz | 需要登录 + Casbin 角色权限 | |
||||
|
|
||||
|
角色层级: `super_admin` > `admin` > `user` > `guest` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 1. 认证 (Auth) |
||||
|
|
||||
|
| 方法 | 路径 | 描述 | 权限 | |
||||
|
|------|------|------|------| |
||||
|
| POST | `/register` | 用户注册 | 公开 | |
||||
|
| POST | `/login` | 用户登录 | 公开 | |
||||
|
| POST | `/refresh` | 刷新 Token | 公开 | |
||||
|
|
||||
|
## 2. 用户管理 (User) |
||||
|
|
||||
|
| 方法 | 路径 | 描述 | 权限 | |
||||
|
|------|------|------|------| |
||||
|
| POST | `/user` | 创建用户 | Authz (admin) | |
||||
|
| GET | `/users` | 获取用户列表 | Authz (admin) | |
||||
|
| GET | `/user/:id` | 获取用户详情 | Authz (admin) | |
||||
|
| PUT | `/user/:id` | 更新用户信息 | Authz (admin) | |
||||
|
| DELETE | `/user/:id` | 删除用户 | Authz (super_admin) | |
||||
|
|
||||
|
## 3. 个人中心 (Profile) |
||||
|
|
||||
|
| 方法 | 路径 | 描述 | 权限 | |
||||
|
|------|------|------|------| |
||||
|
| GET | `/profile/me` | 获取个人信息 | Authz (user) | |
||||
|
| PUT | `/profile/me` | 更新个人资料 | Authz (user) | |
||||
|
| POST | `/profile/password` | 修改密码 | Authz (user) | |
||||
|
| GET | `/profile/orgs` | 获取我的机构列表 | Authz (user) | |
||||
|
| PUT | `/profile/current-org` | 切换当前机构 | Authz (user) | |
||||
|
|
||||
|
## 4. 仪表盘 (Dashboard) |
||||
|
|
||||
|
| 方法 | 路径 | 描述 | 权限 | |
||||
|
|------|------|------|------| |
||||
|
| GET | `/dashboard/stats` | 获取仪表盘统计数据 | Authz (guest) | |
||||
|
| GET | `/dashboard/activities` | 获取最近活动列表 | Authz (guest) | |
||||
|
|
||||
|
## 5. 文件管理 (File) |
||||
|
|
||||
|
| 方法 | 路径 | 描述 | 权限 | |
||||
|
|------|------|------|------| |
||||
|
| POST | `/file/upload` | 上传文件 (multipart/form-data) | Authz (user) | |
||||
|
| GET | `/files` | 获取文件列表 | Authz (user) | |
||||
|
| GET | `/file/:id` | 获取文件详情 | Authz (user) | |
||||
|
| GET | `/file/:id/url` | 获取文件访问 URL | Authz (user) | |
||||
|
| PUT | `/file/:id` | 更新文件信息 | Authz (user) | |
||||
|
| DELETE | `/file/:id` | 删除文件 | Authz (user) | |
||||
|
|
||||
|
## 6. 菜单管理 (Menu) |
||||
|
|
||||
|
| 方法 | 路径 | 描述 | 权限 | |
||||
|
|------|------|------|------| |
||||
|
| GET | `/menus/current` | 获取当前用户可见菜单 | Auth | |
||||
|
| GET | `/menus` | 获取全部菜单列表 | Authz (admin) | |
||||
|
| POST | `/menu` | 创建菜单 | Authz (admin) | |
||||
|
| PUT | `/menu/:id` | 更新菜单 | Authz (admin) | |
||||
|
| DELETE | `/menu/:id` | 删除菜单 | Authz (admin) | |
||||
|
| PUT | `/menus/sort` | 批量排序菜单 | Authz (super_admin) | |
||||
|
|
||||
|
## 7. 角色管理 (Role) |
||||
|
|
||||
|
| 方法 | 路径 | 描述 | 权限 | |
||||
|
|------|------|------|------| |
||||
|
| GET | `/roles` | 获取角色列表 | Authz (admin) | |
||||
|
| POST | `/role` | 创建角色 | Authz (admin) | |
||||
|
| PUT | `/role/:id` | 更新角色 | Authz (admin) | |
||||
|
| DELETE | `/role/:id` | 删除角色 | Authz (admin) | |
||||
|
| GET | `/role/:id/menus` | 获取角色菜单 | Authz (admin) | |
||||
|
| PUT | `/role/:id/menus` | 设置角色菜单 | Authz (admin) | |
||||
|
|
||||
|
## 8. 机构管理 (Organization) |
||||
|
|
||||
|
| 方法 | 路径 | 描述 | 权限 | |
||||
|
|------|------|------|------| |
||||
|
| GET | `/organizations` | 获取机构列表 | Authz (admin) | |
||||
|
| POST | `/organization` | 创建机构 | Authz (admin) | |
||||
|
| PUT | `/organization/:id` | 更新机构 | Authz (admin) | |
||||
|
| DELETE | `/organization/:id` | 删除机构 | Authz (admin) | |
||||
|
| GET | `/organization/:id/members` | 获取机构成员 | Authz (admin) | |
||||
|
| POST | `/organization/:id/member` | 添加机构成员 | Authz (admin) | |
||||
|
| PUT | `/organization/:id/member/:userId` | 更新成员角色 | Authz (admin) | |
||||
|
| DELETE | `/organization/:id/member/:userId` | 移除机构成员 | Authz (admin) | |
||||
|
|
||||
|
## 9. AI 对话 (AI Chat) |
||||
|
|
||||
|
| 方法 | 路径 | 描述 | 权限 | |
||||
|
|------|------|------|------| |
||||
|
| POST | `/ai/chat/completions` | AI 对话补全 (支持 SSE 流式) | Auth | |
||||
|
| GET | `/ai/conversations` | 获取对话列表 | Auth | |
||||
|
| POST | `/ai/conversation` | 创建对话 | Auth | |
||||
|
| GET | `/ai/conversation/:id` | 获取对话详情 | Auth | |
||||
|
| PUT | `/ai/conversation/:id` | 更新对话标题 | Auth | |
||||
|
| DELETE | `/ai/conversation/:id` | 删除对话 | Auth | |
||||
|
| GET | `/ai/models` | 获取可用模型列表 | Auth | |
||||
|
| GET | `/ai/quota/me` | 获取我的配额 | Auth | |
||||
|
| GET | `/ai/quota/records` | 获取我的用量记录 | Auth | |
||||
|
| GET | `/ai/usage/export` | 导出用量记录 CSV | Auth | |
||||
|
|
||||
|
## 10. AI 密钥 (AI Keys - 用户) |
||||
|
|
||||
|
| 方法 | 路径 | 描述 | 权限 | |
||||
|
|------|------|------|------| |
||||
|
| GET | `/ai/keys` | 获取我的 API Key 列表 | Auth | |
||||
|
| POST | `/ai/key` | 添加 API Key | Auth | |
||||
|
| PUT | `/ai/key/:id` | 更新 API Key | Auth | |
||||
|
| DELETE | `/ai/key/:id` | 删除 API Key | Auth | |
||||
|
|
||||
|
## 11. AI 管理 (AI Admin) |
||||
|
|
||||
|
| 方法 | 路径 | 描述 | 权限 | |
||||
|
|------|------|------|------| |
||||
|
| GET | `/ai/providers` | 获取 AI 平台列表 | Authz (admin) | |
||||
|
| POST | `/ai/provider` | 创建 AI 平台 | Authz (admin) | |
||||
|
| PUT | `/ai/provider/:id` | 更新 AI 平台 | Authz (admin) | |
||||
|
| DELETE | `/ai/provider/:id` | 删除 AI 平台 | Authz (admin) | |
||||
|
| POST | `/ai/model` | 创建 AI 模型 | Authz (admin) | |
||||
|
| PUT | `/ai/model/:id` | 更新 AI 模型 | Authz (admin) | |
||||
|
| DELETE | `/ai/model/:id` | 删除 AI 模型 | Authz (admin) | |
||||
|
| GET | `/ai/quotas` | 获取用户额度列表 | Authz (admin) | |
||||
|
| POST | `/ai/quota/recharge` | 充值用户额度 | Authz (admin) | |
||||
|
| GET | `/ai/stats` | 获取 AI 使用统计 | Authz (admin) | |
||||
|
| GET | `/ai/system-keys` | 获取系统密钥列表 | Authz (admin) | |
||||
|
| POST | `/ai/system-key` | 创建系统密钥 | Authz (admin) | |
||||
|
| PUT | `/ai/system-key/:id` | 更新系统密钥 | Authz (admin) | |
||||
|
| DELETE | `/ai/system-key/:id` | 删除系统密钥 | Authz (admin) | |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
**共 64 个接口** | 公开 3 · 登录用户 14 · 管理员授权 47 |
||||
@ -0,0 +1,343 @@ |
|||||
|
# 方案B:Casdoor SSO 配置与测试指南 |
||||
|
|
||||
|
## 前置条件 |
||||
|
|
||||
|
- ✅ Casdoor 服务已部署在云端(可访问) |
||||
|
- ✅ DevOps 后端服务可运行在本机或服务器 |
||||
|
- ✅ 两者网络互通 |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 第一步:Casdoor 云端配置 |
||||
|
|
||||
|
### 1.1 登录 Casdoor 管理后台 |
||||
|
|
||||
|
``` |
||||
|
URL: https://your-casdoor-domain.com |
||||
|
默认账号: admin |
||||
|
默认密码: 123 (或部署时设置的密码) |
||||
|
``` |
||||
|
|
||||
|
### 1.2 创建 Application |
||||
|
|
||||
|
1. 进入 **Applications** 菜单 |
||||
|
2. 点击 **Add** 按钮 |
||||
|
3. 填写配置: |
||||
|
|
||||
|
``` |
||||
|
Name: devops-app |
||||
|
Display Name: DevOps 平台 |
||||
|
Organization: built-in (或你的组织) |
||||
|
|
||||
|
Redirect URLs: |
||||
|
- http://localhost:8888/api/v1/auth/callback (本地开发) |
||||
|
- https://your-devops-domain.com/api/v1/auth/callback (生产环境) |
||||
|
|
||||
|
Signin URL: (可选,留空) |
||||
|
``` |
||||
|
|
||||
|
4. 保存后记录: |
||||
|
- **Client ID** (如: `5d1a5f2c7b2e8c4a3d1f`) |
||||
|
- **Client Secret** (如: `f8e7d6c5b4a3928170654321...`) |
||||
|
|
||||
|
### 1.3 获取 JWT 公钥 |
||||
|
|
||||
|
1. 进入 **Certs** 菜单 |
||||
|
2. 找到 **built-in/cert-built-in** (或你的组织证书) |
||||
|
3. 复制 **Certificate** 内容(包含 BEGIN/END 行) |
||||
|
|
||||
|
``` |
||||
|
-----BEGIN CERTIFICATE----- |
||||
|
MIICljCCAX4CCQCOVA65dfdfdfdf... |
||||
|
... |
||||
|
-----END CERTIFICATE----- |
||||
|
``` |
||||
|
|
||||
|
### 1.4 创建测试用户(如需要) |
||||
|
|
||||
|
1. 进入 **Users** 菜单 |
||||
|
2. 点击 **Add** |
||||
|
3. 填写用户信息: |
||||
|
``` |
||||
|
Organization: built-in |
||||
|
Username: testuser |
||||
|
Password: Test@123 |
||||
|
Email: test@example.com |
||||
|
Display Name: 测试用户 |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 第二步:DevOps 后端配置 |
||||
|
|
||||
|
### 2.1 编辑配置文件 |
||||
|
|
||||
|
打开 `backend/etc/devops-api.yaml`,填入 Casdoor 信息: |
||||
|
|
||||
|
```yaml |
||||
|
Name: devops-api |
||||
|
Host: 0.0.0.0 |
||||
|
Port: 8888 |
||||
|
Timeout: 30000 |
||||
|
|
||||
|
Log: |
||||
|
Mode: console |
||||
|
Level: info |
||||
|
|
||||
|
MySQL: |
||||
|
Host: localhost |
||||
|
Port: 3306 |
||||
|
Database: devops |
||||
|
Username: root |
||||
|
Password: "" |
||||
|
|
||||
|
Casdoor: |
||||
|
# Casdoor 云端地址 |
||||
|
Endpoint: https://your-casdoor-domain.com |
||||
|
|
||||
|
# 从 Casdoor 获取的 Client ID |
||||
|
ClientId: 5d1a5f2c7b2e8c4a3d1f |
||||
|
|
||||
|
# 从 Casdoor 获取的 Client Secret |
||||
|
ClientSecret: f8e7d6c5b4a3928170654321abcdef1234567890 |
||||
|
|
||||
|
# 组织名 |
||||
|
Organization: built-in |
||||
|
|
||||
|
# 应用名(与 Casdoor 中创建的一致) |
||||
|
Application: devops-app |
||||
|
|
||||
|
# 回调地址(必须与 Casdoor 中配置的 Redirect URL 一致) |
||||
|
RedirectUrl: http://localhost:8888/api/v1/auth/callback |
||||
|
|
||||
|
# JWT 公钥(从 Casdoor Certs 获取) |
||||
|
JwtPublicKey: | |
||||
|
-----BEGIN CERTIFICATE----- |
||||
|
MIICljCCAX4CCQCOVA65dfdfdfdf... |
||||
|
(完整证书内容) |
||||
|
-----END CERTIFICATE----- |
||||
|
|
||||
|
JWT: |
||||
|
Secret: your-random-secret-key-here-at-least-32-chars |
||||
|
Expire: 86400 |
||||
|
``` |
||||
|
|
||||
|
### 2.2 启动后端服务 |
||||
|
|
||||
|
```bash |
||||
|
cd backend |
||||
|
|
||||
|
# 方式1:直接运行 |
||||
|
go run devops.go -f etc/devops-api.yaml |
||||
|
|
||||
|
# 方式2:编译后运行 |
||||
|
go build -o devops-api.exe |
||||
|
./devops-api.exe -f etc/devops-api.yaml |
||||
|
``` |
||||
|
|
||||
|
看到以下日志表示启动成功: |
||||
|
``` |
||||
|
Starting server at 0.0.0.0:8888... |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 第三步:测试登录流程 |
||||
|
|
||||
|
### 3.1 获取登录链接 |
||||
|
|
||||
|
```bash |
||||
|
curl http://localhost:8888/api/v1/auth/login-url |
||||
|
``` |
||||
|
|
||||
|
预期返回: |
||||
|
```json |
||||
|
{ |
||||
|
"login_url": "https://your-casdoor-domain.com/login/oauth/authorize?client_id=5d1a5f2c7b2e8c4a3d1f&response_type=code&redirect_uri=http%3A%2F%2Flocalhost%3A8888%2Fapi%2Fv1%2Fauth%2Fcallback&scope=read&state=random-state-string" |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### 3.2 浏览器登录测试 |
||||
|
|
||||
|
1. **复制 login_url 到浏览器打开** |
||||
|
|
||||
|
2. **在 Casdoor 登录页输入账号密码** |
||||
|
- 使用在 Casdoor 中创建的测试用户 |
||||
|
- 或使用已有用户 |
||||
|
|
||||
|
3. **授权应用访问**(首次登录) |
||||
|
- 点击确认授权 |
||||
|
|
||||
|
4. **自动跳转回 DevOps 回调地址** |
||||
|
|
||||
|
浏览器地址会变成: |
||||
|
``` |
||||
|
http://localhost:8888/api/v1/auth/callback?code=abc123def456&state=random-state-string |
||||
|
``` |
||||
|
|
||||
|
5. **查看回调响应** |
||||
|
|
||||
|
应该返回 JSON: |
||||
|
```json |
||||
|
{ |
||||
|
"token": "eyJhbGciOiJIUzI1NiIs...", |
||||
|
"expires_at": 1707868800, |
||||
|
"user": { |
||||
|
"id": 1, |
||||
|
"username": "testuser", |
||||
|
"email": "test@example.com", |
||||
|
"status": 1, |
||||
|
"created_at": "2024-02-12T10:00:00+08:00" |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### 3.3 验证数据库 |
||||
|
|
||||
|
检查 MySQL 中是否自动创建了用户: |
||||
|
|
||||
|
```sql |
||||
|
SELECT id, username, email, casdoor_id, user_type, status FROM users; |
||||
|
``` |
||||
|
|
||||
|
预期看到: |
||||
|
``` |
||||
|
+----+----------+----------------+-----------+-----------+--------+ |
||||
|
| id | username | email | casdoor_id| user_type | status | |
||||
|
+----+----------+----------------+-----------+-----------+--------+ |
||||
|
| 1 | testuser | test@example.com| xxx123 | casdoor | 1 | |
||||
|
+----+----------+----------------+-----------+-----------+--------+ |
||||
|
``` |
||||
|
|
||||
|
### 3.4 测试受保护接口 |
||||
|
|
||||
|
使用获取到的 token 访问需要登录的接口: |
||||
|
|
||||
|
```bash |
||||
|
# 获取当前用户信息 |
||||
|
curl http://localhost:8888/api/v1/auth/user \ |
||||
|
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiIs..." |
||||
|
|
||||
|
# 用户列表 |
||||
|
curl http://localhost:8888/api/v1/users?page=1&page_size=10 \ |
||||
|
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiIs..." |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 第四步:前端集成(可选) |
||||
|
|
||||
|
### 4.1 登录按钮跳转 |
||||
|
|
||||
|
```javascript |
||||
|
// 获取登录链接并跳转 |
||||
|
async function login() { |
||||
|
const res = await fetch('/api/v1/auth/login-url'); |
||||
|
const { login_url } = await res.json(); |
||||
|
window.location.href = login_url; |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### 4.2 处理回调 |
||||
|
|
||||
|
```javascript |
||||
|
// 在回调页面处理 |
||||
|
function handleCallback() { |
||||
|
const params = new URLSearchParams(window.location.search); |
||||
|
const code = params.get('code'); |
||||
|
|
||||
|
if (code) { |
||||
|
// 后端已处理回调,直接显示登录成功 |
||||
|
// Token 在后端生成后可以通过 cookie 或前端再次请求获取 |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 常见问题排查 |
||||
|
|
||||
|
### Q1: 获取 login-url 报错 |
||||
|
|
||||
|
``` |
||||
|
Error: Casdoor endpoint not reachable |
||||
|
``` |
||||
|
|
||||
|
**解决**: |
||||
|
- 检查 `Endpoint` 配置是否正确 |
||||
|
- 确保 DevOps 服务器能访问 Casdoor 域名 |
||||
|
- 检查防火墙/安全组 |
||||
|
|
||||
|
### Q2: 回调报错 "invalid client" |
||||
|
|
||||
|
``` |
||||
|
Error: invalid client_id or client_secret |
||||
|
``` |
||||
|
|
||||
|
**解决**: |
||||
|
- 检查 `ClientId` 和 `ClientSecret` 是否与 Casdoor 中一致 |
||||
|
- 检查是否有空格或换行符 |
||||
|
|
||||
|
### Q3: 回调报错 "invalid redirect_uri" |
||||
|
|
||||
|
``` |
||||
|
Error: redirect_uri mismatch |
||||
|
``` |
||||
|
|
||||
|
**解决**: |
||||
|
- 检查 `RedirectUrl` 必须与 Casdoor 中配置的完全一致(包括 http/https、端口) |
||||
|
- 检查是否有 URL 编码问题 |
||||
|
|
||||
|
### Q4: 解析 Token 失败 |
||||
|
|
||||
|
``` |
||||
|
Error: failed to parse jwt token |
||||
|
``` |
||||
|
|
||||
|
**解决**: |
||||
|
- 检查 `JwtPublicKey` 是否完整复制(包含 BEGIN/END 行) |
||||
|
- 检查证书格式(每行64字符,正确的换行) |
||||
|
|
||||
|
### Q5: 用户创建成功但无法登录 |
||||
|
|
||||
|
**解决**: |
||||
|
- 检查用户 `status` 字段是否为 1(启用) |
||||
|
- 检查 JWT token 是否过期 |
||||
|
- 检查 MySQL 连接是否正常 |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 生产环境配置 |
||||
|
|
||||
|
### 使用 HTTPS |
||||
|
|
||||
|
```yaml |
||||
|
Casdoor: |
||||
|
Endpoint: https://casdoor.your-company.com |
||||
|
RedirectUrl: https://devops.your-company.com/api/v1/auth/callback |
||||
|
``` |
||||
|
|
||||
|
### 多环境配置 |
||||
|
|
||||
|
```yaml |
||||
|
# 开发环境 etc/devops-api-dev.yaml |
||||
|
Casdoor: |
||||
|
Endpoint: https://casdoor-dev.your-company.com |
||||
|
Application: devops-app-dev |
||||
|
|
||||
|
# 生产环境 etc/devops-api-prod.yaml |
||||
|
Casdoor: |
||||
|
Endpoint: https://casdoor.your-company.com |
||||
|
Application: devops-app-prod |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 下一步 |
||||
|
|
||||
|
测试成功后,可根据需要: |
||||
|
|
||||
|
1. **实现方案A**(本地管理员)- 添加本地登录接口 |
||||
|
2. **实现方案C**(联邦认证)- 支持多系统互访 |
||||
|
3. **添加权限控制** - RBAC 角色权限管理 |
||||
|
4. **前端集成** - 完整的登录页面 |
||||
@ -0,0 +1,161 @@ |
|||||
|
# Casdoor 单点登录 (SSO) 接入与二次开发实战指南 |
||||
|
|
||||
|
本文档基于当前项目代码库整理,旨在指导团队利用 Casdoor 构建统一身份认证中心,并进行必要的二次开发。 |
||||
|
|
||||
|
## 1. 方案可行性评估 |
||||
|
|
||||
|
Casdoor 是一个基于 Go (Golang) 和 React 的 UI-first 身份与访问管理 (IAM) / 单点登录 (SSO) 平台。经过代码审计,确认本项目主要特性如下: |
||||
|
|
||||
|
### 1.1 支持的核心协议 |
||||
|
|
||||
|
Casdoor 原生支持所有主流的身份认证协议,足以满足绝大多数新老系统的接入需求: |
||||
|
|
||||
|
- **OIDC (OpenID Connect) & OAuth 2.0**: 现代 Web 应用的首选,支持最为完善。 |
||||
|
- **SAML 2.0**: 用于对接传统的企业级软件(如 Salesforce, ShowPad 等)。 |
||||
|
- **CAS**: 支持传统的 CAS 协议集成(特别是针对 Java 生态的老旧系统)。 |
||||
|
- **RESTful API**: 提供完整的管理 API。 |
||||
|
|
||||
|
### 1.2 成功案例参考 |
||||
|
|
||||
|
Casdoor 官方维护了大量的集成插件,可作为“成功案例”参考: |
||||
|
|
||||
|
- **开发工具**: GitLab, Jenkins, Grafana, Harbor |
||||
|
- **办公协作**: OwnCloud, NextCloud |
||||
|
- **框架集成**: Spring Boot, Django, Synology NAS |
||||
|
- _提示:如果不确定某个应用如何接入,可以搜索 "Casdoor + [应用名]",通常都有现成的指引。_ |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 2. 快速启动与环境部署 |
||||
|
|
||||
|
在进行开发之前,请确保本地环境已准备就绪。 |
||||
|
|
||||
|
### 2.1 依赖环境 |
||||
|
|
||||
|
- **Go**: 后端开发需要 (建议版本 1.18+) |
||||
|
- **Node.js & Yarn**: 前端开发需要 (`web/` 目录) |
||||
|
- **Docker & Docker Compose**: 快速与预构建运行 |
||||
|
|
||||
|
### 2.2 启动项目 |
||||
|
|
||||
|
当前工作区包含便捷的构建脚本,可以直接使用: |
||||
|
|
||||
|
**方式一:使用 Docker (推荐)** |
||||
|
使用项目根目录下的 PowerShell 脚本: |
||||
|
|
||||
|
```powershell |
||||
|
./run-prebuilt.ps1 |
||||
|
``` |
||||
|
|
||||
|
- 该脚本会利用 `docker-compose.prebuilt.yml` 启动服务。 |
||||
|
- 默认访问地址: `http://localhost:8000` (具体端口视配置而定) |
||||
|
- 默认超管账号: `admin` / `123` |
||||
|
|
||||
|
**方式二:源码启动 (开发调试)** |
||||
|
|
||||
|
1. **后端**: |
||||
|
```bash |
||||
|
go run main.go |
||||
|
``` |
||||
|
2. **前端**: |
||||
|
```bash |
||||
|
cd web |
||||
|
yarn install |
||||
|
yarn start |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 3. 业务系统接入指南 (单点登录) |
||||
|
|
||||
|
这是使用 Casdoor 的核心场景。无论您的“其他项目”是 Java, Go, Python 还是 Node.js 编写,流程基本一致。**推荐使用标准的 OIDC 协议。** |
||||
|
|
||||
|
### 步骤 1: 创建应用 (Application) |
||||
|
|
||||
|
1. 以管理员身份登录 Casdoor 控制台。 |
||||
|
2. 进入 **"Applications" (应用)** 菜单。 |
||||
|
3. 点击 **"Add"**,填写基本信息: |
||||
|
- **Name**: 您的业务系统名称 (e.g., `CRM-System`) |
||||
|
- **Organization**: 选择所属组织 (默认 `built-in`) |
||||
|
- **Redirect URLs**: 极为关键!填写业务系统的回调地址,例如 `http://your-app.com/callback`。 |
||||
|
4. 保存后,您将获得 **Client ID** 和 **Client Secret**。 |
||||
|
|
||||
|
### 步骤 2: 代码集成 |
||||
|
|
||||
|
在您的业务系统中,通过 OIDC 流程获取用户信息。 |
||||
|
|
||||
|
**流程简述**: |
||||
|
|
||||
|
1. **重定向**: 用户未登录时,将浏览器重定向到 Casdoor 登录页: |
||||
|
`http://CASDOOR_HOST/login/oauth/authorize?client_id=<Client_ID>&response_type=code&redirect_uri=<Redirect_URL>&scope=read&state=<Random_String>` |
||||
|
2. **回调 (Callback)**: 用户登录成功后,Casdoor 会跳回您配置的 `Redirect URL`,并附带一个 `code` 参数。 |
||||
|
3. **换取 Token**: 您的后端服务使用 `code` + `Client ID` + `Client Secret` 向 Casdoor 请求 `access_token`。 |
||||
|
4. **解析 Token**: 使用 SDK 或 JWT 库解析 Token,即可获得用户信息 (User Profile)。 |
||||
|
|
||||
|
### 步骤 3: 使用 SDK (推荐) |
||||
|
|
||||
|
为了简化开发,可以直接使用官方 SDK。本项目 `go.mod` 中已引用 `github.com/casdoor/casdoor-go-sdk`。 |
||||
|
其他语言 SDK 请参考官方文档: |
||||
|
|
||||
|
- Java: `casdoor-java-sdk` |
||||
|
- Node.js: `casdoor-nodejs-sdk` |
||||
|
- Python: `casdoor-python-sdk` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 4. 二次开发与扩展指南 |
||||
|
|
||||
|
如果是为了满足特定业务需求(如:自定义登录页、特殊的注册逻辑、对接已有用户库),请参考以下指引。 |
||||
|
|
||||
|
### 4.1 项目结构概览 |
||||
|
|
||||
|
- **`conf/app.conf`**: 全局配置文件(数据库连接、端口、默认语言等)。 |
||||
|
- **`main.go`**: 程序入口。 |
||||
|
- **`routers/router.go`**: **(核心)** 后端路由定义文件。如果要新增 API 接口,从此文件入手。 |
||||
|
- **`controllers/`**: 业务逻辑控制器。 |
||||
|
- `auth.go`: 处理登录、注册、退出相关的核心逻辑。 |
||||
|
- `oidc_discovery.go`: OIDC 协议发现相关逻辑。 |
||||
|
- **`object/`**: 数据模型层 (ORM)。 |
||||
|
- `user.go`: 用户数据结构定义。 |
||||
|
- `application.go`: 应用配置模型。 |
||||
|
- **`web/`**: 前端 React 项目目录。 |
||||
|
|
||||
|
### 4.2 常见开发场景 |
||||
|
|
||||
|
#### 场景 A: 修改登录页面 UI |
||||
|
|
||||
|
登录页面的源码位于 `web/src/auth/` 目录下。 |
||||
|
|
||||
|
- `web/src/auth/LoginPage.js`: 登录页主入口。 |
||||
|
- `web/src/App.js`: 前端路由配置。 |
||||
|
修改完成后,需重新编译前端 (`cd web && yarn build`),生成的静态文件会被嵌入到 Go 二进制文件中(或通过 Nginx 代理)。 |
||||
|
|
||||
|
#### 场景 B: 增加自定义 API |
||||
|
|
||||
|
1. 在 `controllers/` 下新建您的控制器文件,例如 `my_feature.go`。 |
||||
|
2. 在 `routers/router.go` 中注册路由: |
||||
|
```go |
||||
|
// 添加到 API 路由组 |
||||
|
beego.Router("/api/my-feature", &controllers.MyFeatureController{}, "get:GetData") |
||||
|
``` |
||||
|
3. 重新运行 `main.go`。 |
||||
|
|
||||
|
#### 场景 C: 对接新的第三方登录源 (IdP) |
||||
|
|
||||
|
Casdoor 已内置支持 Google, GitHub, WeChat, DingTalk 等几十种源。 |
||||
|
如果需要开发私有的身份源: |
||||
|
|
||||
|
1. 参考 `object/provider.go` 定义新的 Provider 类型。 |
||||
|
2. 在 `idp/` 目录下实现该 Provider 的认证接口(实现 OAuth2 或相关逻辑)。 |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 5. 有用资源链接 |
||||
|
|
||||
|
- **官方文档**: [https://casdoor.org](https://casdoor.org) (详细的配置说明) |
||||
|
- **Rest API 文档**: [Swagger UI (Demo)](https://door.casdoor.com/swagger) |
||||
|
- **问题排查**: 查看 `go-sdk` 源码或本项目中的 `*_test.go` 文件通常能找到用法示例。 |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
_文档生成日期: 2026年2月11日_ |
||||
File diff suppressed because it is too large
File diff suppressed because it is too large
@ -0,0 +1,305 @@ |
|||||
|
# 文件存储模块设计文档 |
||||
|
|
||||
|
## 概述 |
||||
|
|
||||
|
为项目新增通用文件存储服务模块,支持三种可配置的存储后端(本地文件系统、阿里云 OSS、MinIO),前端提供文件 CRUD 和预览功能。 |
||||
|
|
||||
|
**定位**:通用存储服务,可用于个人文件管理、公共资源管理、新闻图片视频等多种业务场景。 |
||||
|
|
||||
|
## 设计决定 |
||||
|
|
||||
|
| 项目 | 决定 | 原因 | |
||||
|
|------|------|------| |
||||
|
| 文件归属 | 混合模式(公开/私有标记) | 灵活适配多种业务场景 | |
||||
|
| 目录结构 | 平铺列表 + 分类标签 | 简单高效,适合通用存储 | |
||||
|
| 预览类型 | 图片 + 视频 + PDF | 覆盖主流文件类型 | |
||||
|
| 存储切换 | 配置文件单选 | 简单清晰,重启生效 | |
||||
|
| 架构模式 | Strategy Pattern 存储抽象层 | 干净、可扩展、易测试 | |
||||
|
|
||||
|
## 后端架构 |
||||
|
|
||||
|
### 1. Storage 接口(Strategy Pattern) |
||||
|
|
||||
|
```go |
||||
|
// backend/internal/storage/storage.go |
||||
|
type Storage interface { |
||||
|
Upload(ctx context.Context, key string, reader io.Reader, size int64, contentType string) error |
||||
|
Delete(ctx context.Context, key string) error |
||||
|
GetURL(ctx context.Context, key string) (string, error) |
||||
|
Exists(ctx context.Context, key string) (bool, error) |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
三种实现: |
||||
|
|
||||
|
| 实现 | 文件 | GetURL 返回 | |
||||
|
|------|------|------------| |
||||
|
| LocalStorage | `storage/local.go` | `/api/v1/file/:id/download` 由后端代理 | |
||||
|
| OSSStorage | `storage/oss.go` | 阿里云签名 URL(带过期时间) | |
||||
|
| MinIOStorage | `storage/minio.go` | Presigned URL | |
||||
|
|
||||
|
### 2. 配置结构 |
||||
|
|
||||
|
```yaml |
||||
|
# etc/base-api.yaml |
||||
|
Storage: |
||||
|
Type: "local" # local / oss / minio |
||||
|
MaxSize: 104857600 # 100MB 单文件限制 |
||||
|
Local: |
||||
|
RootDir: "./uploads" |
||||
|
OSS: |
||||
|
Endpoint: "oss-cn-hangzhou.aliyuncs.com" |
||||
|
AccessKeyId: "" |
||||
|
AccessKeySecret: "" |
||||
|
Bucket: "" |
||||
|
MinIO: |
||||
|
Endpoint: "localhost:9000" |
||||
|
AccessKeyId: "" |
||||
|
AccessKeySecret: "" |
||||
|
Bucket: "" |
||||
|
UseSSL: false |
||||
|
``` |
||||
|
|
||||
|
```go |
||||
|
// backend/internal/config/config.go 新增 |
||||
|
type StorageConfig struct { |
||||
|
Type string `json:",default=local"` |
||||
|
MaxSize int64 `json:",default=104857600"` // 100MB |
||||
|
Local struct { |
||||
|
RootDir string `json:",default=./uploads"` |
||||
|
} |
||||
|
OSS struct { |
||||
|
Endpoint string |
||||
|
AccessKeyId string |
||||
|
AccessKeySecret string |
||||
|
Bucket string |
||||
|
} |
||||
|
MinIO struct { |
||||
|
Endpoint string |
||||
|
AccessKeyId string |
||||
|
AccessKeySecret string |
||||
|
Bucket string |
||||
|
UseSSL bool |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### 3. 数据模型 |
||||
|
|
||||
|
```go |
||||
|
// backend/model/file_entity.go |
||||
|
type File struct { |
||||
|
Id int64 `gorm:"primaryKey;autoIncrement" json:"id"` |
||||
|
Name string `gorm:"type:varchar(255);not null" json:"name"` |
||||
|
Key string `gorm:"type:varchar(500);uniqueIndex" json:"key"` |
||||
|
Size int64 `gorm:"not null" json:"size"` |
||||
|
MimeType string `gorm:"type:varchar(100)" json:"mimeType"` |
||||
|
Category string `gorm:"type:varchar(50);index;default:'default'" json:"category"` |
||||
|
IsPublic bool `gorm:"default:false" json:"isPublic"` |
||||
|
UserId int64 `gorm:"index" json:"userId"` |
||||
|
StorageType string `gorm:"type:varchar(20)" json:"storageType"` |
||||
|
Status int `gorm:"default:1" json:"status"` |
||||
|
CreatedAt time.Time `json:"createdAt"` |
||||
|
UpdatedAt time.Time `json:"updatedAt"` |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
Model 方法:Insert, FindOne, FindList (分页+筛选), Update, Delete |
||||
|
|
||||
|
### 4. API 设计 |
||||
|
|
||||
|
``` |
||||
|
// backend/api/file.api |
||||
|
type ( |
||||
|
FileUploadResponse { |
||||
|
Code int `json:"code"` |
||||
|
Message string `json:"message"` |
||||
|
Success bool `json:"success"` |
||||
|
Data FileInfo `json:"data,omitempty"` |
||||
|
} |
||||
|
|
||||
|
FileInfo { |
||||
|
Id int64 `json:"id"` |
||||
|
Name string `json:"name"` |
||||
|
Key string `json:"key"` |
||||
|
Size int64 `json:"size"` |
||||
|
MimeType string `json:"mimeType"` |
||||
|
Category string `json:"category"` |
||||
|
IsPublic bool `json:"isPublic"` |
||||
|
UserId int64 `json:"userId"` |
||||
|
StorageType string `json:"storageType"` |
||||
|
Url string `json:"url"` |
||||
|
CreatedAt string `json:"createdAt"` |
||||
|
UpdatedAt string `json:"updatedAt"` |
||||
|
} |
||||
|
|
||||
|
FileListRequest { |
||||
|
Page int `form:"page,default=1"` |
||||
|
PageSize int `form:"pageSize,default=20"` |
||||
|
Keyword string `form:"keyword,optional"` |
||||
|
Category string `form:"category,optional"` |
||||
|
MimeType string `form:"mimeType,optional"` |
||||
|
} |
||||
|
|
||||
|
FileListResponse { |
||||
|
Code int `json:"code"` |
||||
|
Message string `json:"message"` |
||||
|
Success bool `json:"success"` |
||||
|
Data []FileInfo `json:"data"` |
||||
|
Total int64 `json:"total"` |
||||
|
} |
||||
|
|
||||
|
FileUpdateRequest { |
||||
|
Name string `json:"name,optional"` |
||||
|
Category string `json:"category,optional"` |
||||
|
IsPublic *bool `json:"isPublic,optional"` |
||||
|
} |
||||
|
|
||||
|
FileUrlResponse { |
||||
|
Code int `json:"code"` |
||||
|
Message string `json:"message"` |
||||
|
Success bool `json:"success"` |
||||
|
Url string `json:"url"` |
||||
|
} |
||||
|
) |
||||
|
|
||||
|
@server ( |
||||
|
prefix: /api/v1 |
||||
|
middleware: Cors, Log, Auth, Authz |
||||
|
group: file |
||||
|
) |
||||
|
service base-api { |
||||
|
@handler UploadFile |
||||
|
post /file/upload (returns FileUploadResponse) |
||||
|
|
||||
|
@handler GetFileList |
||||
|
get /files (FileListRequest) returns (FileListResponse) |
||||
|
|
||||
|
@handler GetFile |
||||
|
get /file/:id returns (FileUploadResponse) |
||||
|
|
||||
|
@handler GetFileUrl |
||||
|
get /file/:id/url returns (FileUrlResponse) |
||||
|
|
||||
|
@handler UpdateFile |
||||
|
put /file/:id (FileUpdateRequest) returns (FileUploadResponse) |
||||
|
|
||||
|
@handler DeleteFile |
||||
|
delete /file/:id returns (FileUploadResponse) |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### 5. 权限策略(Casbin) |
||||
|
|
||||
|
```go |
||||
|
// 新增到 seedCasbinPolicies |
||||
|
// user: 文件上传和查看 |
||||
|
{"user", "/api/v1/file/upload", "POST"}, |
||||
|
{"user", "/api/v1/files", "GET"}, |
||||
|
{"user", "/api/v1/file/:id", "GET"}, |
||||
|
{"user", "/api/v1/file/:id/url", "GET"}, |
||||
|
{"user", "/api/v1/file/:id", "PUT"}, |
||||
|
|
||||
|
// super_admin: 文件删除 |
||||
|
{"super_admin", "/api/v1/file/:id", "DELETE"}, |
||||
|
``` |
||||
|
|
||||
|
业务层权限: |
||||
|
- user 只能查看自己的 + isPublic=true 的文件 |
||||
|
- user 只能编辑自己的文件 |
||||
|
- admin 可查看和编辑所有文件 |
||||
|
- super_admin 可删除任何文件 |
||||
|
|
||||
|
### 6. Key 生成策略 |
||||
|
|
||||
|
``` |
||||
|
{category}/{YYYY-MM}/{uuid}{ext} |
||||
|
``` |
||||
|
|
||||
|
例如:`default/2026-02/a1b2c3d4-e5f6.jpg` |
||||
|
|
||||
|
## 前端设计 |
||||
|
|
||||
|
### 路由 |
||||
|
|
||||
|
`/files` — 文件管理页面,侧边栏新增「文件管理」导航项 |
||||
|
|
||||
|
### 文件管理页面 |
||||
|
|
||||
|
**表格列**: |
||||
|
- 文件名(带 MIME 图标) |
||||
|
- 分类(标签) |
||||
|
- 大小(格式化为 KB/MB) |
||||
|
- 类型(图片/视频/PDF/其他) |
||||
|
- 上传者 |
||||
|
- 公开状态(开关) |
||||
|
- 上传时间 |
||||
|
- 操作(预览、编辑、删除) |
||||
|
|
||||
|
**功能**: |
||||
|
- 顶部:上传按钮 + 搜索 + 分类筛选 |
||||
|
- 拖拽上传区域 + 点击上传 |
||||
|
- 上传进度条 |
||||
|
|
||||
|
### 预览弹窗 |
||||
|
|
||||
|
| 类型 | 实现 | |
||||
|
|------|------| |
||||
|
| 图片 (jpg/png/gif/webp) | `<img>` 直接显示 | |
||||
|
| 视频 (mp4/webm) | `<video>` HTML5 播放器 | |
||||
|
| PDF | `<iframe>` 嵌入查看 | |
||||
|
| 其他 | 文件信息卡片 + 下载按钮 | |
||||
|
|
||||
|
### 前端类型 |
||||
|
|
||||
|
```typescript |
||||
|
interface FileInfo { |
||||
|
id: number |
||||
|
name: string |
||||
|
key: string |
||||
|
size: number |
||||
|
mimeType: string |
||||
|
category: string |
||||
|
isPublic: boolean |
||||
|
userId: number |
||||
|
storageType: string |
||||
|
url: string |
||||
|
createdAt: string |
||||
|
updatedAt: string |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
## 文件变更清单 |
||||
|
|
||||
|
### 新建文件 |
||||
|
|
||||
|
**后端**: |
||||
|
- `backend/internal/storage/storage.go` — 接口定义 + 工厂函数 |
||||
|
- `backend/internal/storage/local.go` — 本地存储实现 |
||||
|
- `backend/internal/storage/oss.go` — 阿里云 OSS 实现 |
||||
|
- `backend/internal/storage/minio.go` — MinIO 实现 |
||||
|
- `backend/model/file_entity.go` — File GORM 模型 |
||||
|
- `backend/model/file_model.go` — File 数据访问方法 |
||||
|
- `backend/api/file.api` — API 定义 |
||||
|
- `backend/internal/handler/file/` — handlers (goctl 生成) |
||||
|
- `backend/internal/logic/file/` — 业务逻辑(6个) |
||||
|
|
||||
|
**前端**: |
||||
|
- `frontend/react-shadcn/pc/src/pages/FileManagementPage.tsx` — 文件管理页面 |
||||
|
|
||||
|
### 修改文件 |
||||
|
|
||||
|
**后端**: |
||||
|
- `backend/internal/config/config.go` — 新增 StorageConfig |
||||
|
- `backend/etc/base-api.yaml` — 新增 Storage 配置段 |
||||
|
- `backend/internal/svc/servicecontext.go` — 初始化 Storage + AutoMigrate File + 种子 Casbin |
||||
|
- `backend/base.api` — import file.api |
||||
|
- `backend/internal/types/types.go` — goctl 重新生成 |
||||
|
- `backend/internal/handler/routes.go` — goctl 重新生成 |
||||
|
- `backend/go.mod` / `go.sum` — 新增 OSS/MinIO SDK 依赖 |
||||
|
|
||||
|
**前端**: |
||||
|
- `frontend/react-shadcn/pc/src/types/index.ts` — 新增 File 类型 |
||||
|
- `frontend/react-shadcn/pc/src/services/api.ts` — 新增 file API 方法 |
||||
|
- `frontend/react-shadcn/pc/src/App.tsx` — 新增 /files 路由 |
||||
|
- `frontend/react-shadcn/pc/src/components/layout/Sidebar.tsx` — 新增导航项 |
||||
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue