Compare commits
20 Commits
c35a337695
...
5aaba71819
| Author | SHA1 | Date |
|---|---|---|
|
|
5aaba71819 | 1 month ago |
|
|
6f002f438b | 1 month ago |
|
|
5e4efc2a0e | 1 month ago |
|
|
899288180a | 1 month ago |
|
|
58a91f40ec | 1 month ago |
|
|
057cb12eba | 1 month ago |
|
|
80aa7c623a | 1 month ago |
|
|
208992d80a | 1 month ago |
|
|
45f614f61a | 1 month ago |
|
|
9be5893f6f | 1 month ago |
|
|
f1d1549595 | 1 month ago |
|
|
e672c8eb76 | 1 month ago |
|
|
0e3173189f | 1 month ago |
|
|
9f7466064c | 1 month ago |
|
|
3937c72d14 | 1 month ago |
|
|
82bfcb7592 | 1 month ago |
|
|
332f2cf59b | 1 month ago |
|
|
8663c768c2 | 1 month ago |
|
|
192b3803db | 1 month ago |
|
|
c1e1625ef2 | 1 month ago |
212 changed files with 30486 additions and 258 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,343 @@ |
|||||
|
syntax = "v1" |
||||
|
|
||||
|
// ========== AI Chat Types ========== |
||||
|
|
||||
|
type ( |
||||
|
AIChatMessage { |
||||
|
Role string `json:"role"` |
||||
|
Content string `json:"content"` |
||||
|
} |
||||
|
|
||||
|
AIChatCompletionRequest { |
||||
|
Model string `json:"model"` |
||||
|
Messages []AIChatMessage `json:"messages"` |
||||
|
Stream bool `json:"stream,optional"` |
||||
|
MaxTokens int `json:"max_tokens,optional"` |
||||
|
Temperature float64 `json:"temperature,optional"` |
||||
|
ConversationId int64 `json:"conversation_id,optional,string"` |
||||
|
} |
||||
|
|
||||
|
AIChatCompletionChoice { |
||||
|
Index int `json:"index"` |
||||
|
FinishReason string `json:"finish_reason"` |
||||
|
Message AIChatMessage `json:"message"` |
||||
|
} |
||||
|
|
||||
|
AIChatCompletionUsage { |
||||
|
PromptTokens int `json:"prompt_tokens"` |
||||
|
CompletionTokens int `json:"completion_tokens"` |
||||
|
TotalTokens int `json:"total_tokens"` |
||||
|
} |
||||
|
|
||||
|
AIChatCompletionResponse { |
||||
|
Id string `json:"id"` |
||||
|
Object string `json:"object"` |
||||
|
Model string `json:"model"` |
||||
|
Choices []AIChatCompletionChoice `json:"choices"` |
||||
|
Usage AIChatCompletionUsage `json:"usage"` |
||||
|
} |
||||
|
) |
||||
|
|
||||
|
// ========== Conversation Types ========== |
||||
|
|
||||
|
type ( |
||||
|
AIConversationInfo { |
||||
|
Id int64 `json:"id,string"` |
||||
|
Title string `json:"title"` |
||||
|
ModelId string `json:"modelId"` |
||||
|
ProviderId int64 `json:"providerId,string"` |
||||
|
TotalTokens int64 `json:"totalTokens"` |
||||
|
TotalCost float64 `json:"totalCost"` |
||||
|
IsArchived bool `json:"isArchived"` |
||||
|
CreatedAt string `json:"createdAt"` |
||||
|
UpdatedAt string `json:"updatedAt"` |
||||
|
} |
||||
|
|
||||
|
AIMessageInfo { |
||||
|
Id int64 `json:"id,string"` |
||||
|
ConversationId int64 `json:"conversationId,string"` |
||||
|
Role string `json:"role"` |
||||
|
Content string `json:"content"` |
||||
|
TokenCount int `json:"tokenCount"` |
||||
|
Cost float64 `json:"cost"` |
||||
|
ModelId string `json:"modelId"` |
||||
|
LatencyMs int `json:"latencyMs"` |
||||
|
CreatedAt string `json:"createdAt"` |
||||
|
} |
||||
|
|
||||
|
AIConversationListRequest { |
||||
|
Page int64 `form:"page,optional,default=1"` |
||||
|
PageSize int64 `form:"pageSize,optional,default=20"` |
||||
|
} |
||||
|
|
||||
|
AIConversationListResponse { |
||||
|
List []AIConversationInfo `json:"list"` |
||||
|
Total int64 `json:"total"` |
||||
|
} |
||||
|
|
||||
|
AIConversationCreateRequest { |
||||
|
Title string `json:"title,optional"` |
||||
|
ModelId string `json:"modelId,optional"` |
||||
|
} |
||||
|
|
||||
|
AIConversationGetRequest { |
||||
|
Id int64 `path:"id"` |
||||
|
} |
||||
|
|
||||
|
AIConversationDetailResponse { |
||||
|
Conversation AIConversationInfo `json:"conversation"` |
||||
|
Messages []AIMessageInfo `json:"messages"` |
||||
|
} |
||||
|
|
||||
|
AIConversationUpdateRequest { |
||||
|
Id int64 `path:"id"` |
||||
|
Title string `json:"title"` |
||||
|
} |
||||
|
|
||||
|
AIConversationDeleteRequest { |
||||
|
Id int64 `path:"id"` |
||||
|
} |
||||
|
) |
||||
|
|
||||
|
// ========== Provider Types ========== |
||||
|
|
||||
|
type ( |
||||
|
AIProviderInfo { |
||||
|
Id int64 `json:"id,string"` |
||||
|
Name string `json:"name"` |
||||
|
DisplayName string `json:"displayName"` |
||||
|
BaseUrl string `json:"baseUrl"` |
||||
|
SdkType string `json:"sdkType"` |
||||
|
Protocol string `json:"protocol"` |
||||
|
IsActive bool `json:"isActive"` |
||||
|
SortOrder int `json:"sortOrder"` |
||||
|
CreatedAt string `json:"createdAt"` |
||||
|
UpdatedAt string `json:"updatedAt"` |
||||
|
} |
||||
|
|
||||
|
AIProviderListRequest { |
||||
|
Page int64 `form:"page,optional,default=1"` |
||||
|
PageSize int64 `form:"pageSize,optional,default=50"` |
||||
|
} |
||||
|
|
||||
|
AIProviderListResponse { |
||||
|
List []AIProviderInfo `json:"list"` |
||||
|
Total int64 `json:"total"` |
||||
|
} |
||||
|
|
||||
|
AIProviderCreateRequest { |
||||
|
Name string `json:"name"` |
||||
|
DisplayName string `json:"displayName"` |
||||
|
BaseUrl string `json:"baseUrl"` |
||||
|
SdkType string `json:"sdkType"` |
||||
|
Protocol string `json:"protocol,optional,default=openai"` |
||||
|
SortOrder int `json:"sortOrder,optional"` |
||||
|
} |
||||
|
|
||||
|
AIProviderUpdateRequest { |
||||
|
Id int64 `path:"id"` |
||||
|
DisplayName string `json:"displayName,optional"` |
||||
|
BaseUrl string `json:"baseUrl,optional"` |
||||
|
SdkType string `json:"sdkType,optional"` |
||||
|
Protocol string `json:"protocol,optional"` |
||||
|
IsActive bool `json:"isActive,optional"` |
||||
|
SortOrder int `json:"sortOrder,optional"` |
||||
|
} |
||||
|
|
||||
|
AIProviderDeleteRequest { |
||||
|
Id int64 `path:"id"` |
||||
|
} |
||||
|
) |
||||
|
|
||||
|
// ========== Model Types ========== |
||||
|
|
||||
|
type ( |
||||
|
AIModelInfo { |
||||
|
Id int64 `json:"id,string"` |
||||
|
ProviderId int64 `json:"providerId,string"` |
||||
|
ProviderName string `json:"providerName"` |
||||
|
ModelId string `json:"modelId"` |
||||
|
DisplayName string `json:"displayName"` |
||||
|
InputPrice float64 `json:"inputPrice"` |
||||
|
OutputPrice float64 `json:"outputPrice"` |
||||
|
MaxTokens int `json:"maxTokens"` |
||||
|
ContextWindow int `json:"contextWindow"` |
||||
|
SupportsStream bool `json:"supportsStream"` |
||||
|
SupportsVision bool `json:"supportsVision"` |
||||
|
} |
||||
|
|
||||
|
AIModelListResponse { |
||||
|
List []AIModelInfo `json:"list"` |
||||
|
} |
||||
|
|
||||
|
AIModelCreateRequest { |
||||
|
ProviderId int64 `json:"providerId,string"` |
||||
|
ModelId string `json:"modelId"` |
||||
|
DisplayName string `json:"displayName"` |
||||
|
InputPrice float64 `json:"inputPrice"` |
||||
|
OutputPrice float64 `json:"outputPrice"` |
||||
|
MaxTokens int `json:"maxTokens,optional,default=4096"` |
||||
|
ContextWindow int `json:"contextWindow,optional,default=128000"` |
||||
|
SupportsStream bool `json:"supportsStream,optional"` |
||||
|
SupportsVision bool `json:"supportsVision,optional"` |
||||
|
} |
||||
|
|
||||
|
AIModelUpdateRequest { |
||||
|
Id int64 `path:"id"` |
||||
|
DisplayName string `json:"displayName,optional"` |
||||
|
InputPrice float64 `json:"inputPrice,optional"` |
||||
|
OutputPrice float64 `json:"outputPrice,optional"` |
||||
|
MaxTokens int `json:"maxTokens,optional"` |
||||
|
ContextWindow int `json:"contextWindow,optional"` |
||||
|
SupportsStream bool `json:"supportsStream,optional"` |
||||
|
SupportsVision bool `json:"supportsVision,optional"` |
||||
|
IsActive bool `json:"isActive,optional"` |
||||
|
} |
||||
|
|
||||
|
AIModelDeleteRequest { |
||||
|
Id int64 `path:"id"` |
||||
|
} |
||||
|
) |
||||
|
|
||||
|
// ========== API Key Types ========== |
||||
|
|
||||
|
type ( |
||||
|
AIApiKeyInfo { |
||||
|
Id int64 `json:"id,string"` |
||||
|
ProviderId int64 `json:"providerId,string"` |
||||
|
ProviderName string `json:"providerName"` |
||||
|
UserId int64 `json:"userId,string"` |
||||
|
KeyPreview string `json:"keyPreview"` |
||||
|
IsActive bool `json:"isActive"` |
||||
|
Remark string `json:"remark"` |
||||
|
CreatedAt string `json:"createdAt"` |
||||
|
} |
||||
|
|
||||
|
AIApiKeyListRequest { |
||||
|
Page int64 `form:"page,optional,default=1"` |
||||
|
PageSize int64 `form:"pageSize,optional,default=20"` |
||||
|
} |
||||
|
|
||||
|
AIApiKeyListResponse { |
||||
|
List []AIApiKeyInfo `json:"list"` |
||||
|
Total int64 `json:"total"` |
||||
|
} |
||||
|
|
||||
|
AIApiKeyCreateRequest { |
||||
|
ProviderId int64 `json:"providerId,string"` |
||||
|
KeyValue string `json:"keyValue"` |
||||
|
Remark string `json:"remark,optional"` |
||||
|
} |
||||
|
|
||||
|
AIApiKeyUpdateRequest { |
||||
|
Id int64 `path:"id"` |
||||
|
KeyValue string `json:"keyValue,optional"` |
||||
|
IsActive bool `json:"isActive,optional"` |
||||
|
Remark string `json:"remark,optional"` |
||||
|
} |
||||
|
|
||||
|
AIApiKeyDeleteRequest { |
||||
|
Id int64 `path:"id"` |
||||
|
} |
||||
|
) |
||||
|
|
||||
|
// ========== Quota Types ========== |
||||
|
|
||||
|
type ( |
||||
|
AIQuotaInfo { |
||||
|
Balance float64 `json:"balance"` |
||||
|
TotalRecharged float64 `json:"totalRecharged"` |
||||
|
TotalConsumed float64 `json:"totalConsumed"` |
||||
|
FrozenAmount float64 `json:"frozenAmount"` |
||||
|
} |
||||
|
|
||||
|
AIQuotaUserInfo { |
||||
|
UserId int64 `json:"userId,string"` |
||||
|
Username string `json:"username"` |
||||
|
Balance float64 `json:"balance"` |
||||
|
TotalRecharged float64 `json:"totalRecharged"` |
||||
|
TotalConsumed float64 `json:"totalConsumed"` |
||||
|
FrozenAmount float64 `json:"frozenAmount"` |
||||
|
} |
||||
|
|
||||
|
AIQuotaListRequest { |
||||
|
Page int64 `form:"page,optional,default=1"` |
||||
|
PageSize int64 `form:"pageSize,optional,default=20"` |
||||
|
} |
||||
|
|
||||
|
AIQuotaListResponse { |
||||
|
List []AIQuotaUserInfo `json:"list"` |
||||
|
Total int64 `json:"total"` |
||||
|
} |
||||
|
|
||||
|
AIQuotaRechargeRequest { |
||||
|
UserId int64 `json:"userId,string"` |
||||
|
Amount float64 `json:"amount"` |
||||
|
Remark string `json:"remark,optional"` |
||||
|
} |
||||
|
) |
||||
|
|
||||
|
// ========== Usage Record Types ========== |
||||
|
|
||||
|
type ( |
||||
|
AIUsageRecordInfo { |
||||
|
Id int64 `json:"id,string"` |
||||
|
UserId int64 `json:"userId,string"` |
||||
|
Username string `json:"username"` |
||||
|
ProviderId int64 `json:"providerId,string"` |
||||
|
ProviderName string `json:"providerName"` |
||||
|
ModelId string `json:"modelId"` |
||||
|
InputTokens int `json:"inputTokens"` |
||||
|
OutputTokens int `json:"outputTokens"` |
||||
|
Cost float64 `json:"cost"` |
||||
|
Status string `json:"status"` |
||||
|
LatencyMs int `json:"latencyMs"` |
||||
|
ErrorMessage string `json:"errorMessage"` |
||||
|
CreatedAt string `json:"createdAt"` |
||||
|
} |
||||
|
|
||||
|
AIUsageRecordListRequest { |
||||
|
Page int64 `form:"page,optional,default=1"` |
||||
|
PageSize int64 `form:"pageSize,optional,default=20"` |
||||
|
UserId int64 `form:"userId,optional"` |
||||
|
ModelId string `form:"modelId,optional"` |
||||
|
Status string `form:"status,optional"` |
||||
|
} |
||||
|
|
||||
|
AIUsageRecordListResponse { |
||||
|
List []AIUsageRecordInfo `json:"list"` |
||||
|
Total int64 `json:"total"` |
||||
|
} |
||||
|
) |
||||
|
|
||||
|
// ========== Stats Types ========== |
||||
|
|
||||
|
type ( |
||||
|
AIUsageStatsResponse { |
||||
|
TotalCalls int64 `json:"totalCalls"` |
||||
|
TotalTokens int64 `json:"totalTokens"` |
||||
|
TotalCost float64 `json:"totalCost"` |
||||
|
TotalUsers int64 `json:"totalUsers"` |
||||
|
ModelStats []AIModelStatItem `json:"modelStats"` |
||||
|
DailyStats []AIDailyStatItem `json:"dailyStats"` |
||||
|
} |
||||
|
|
||||
|
AIModelStatItem { |
||||
|
ModelId string `json:"modelId"` |
||||
|
Calls int64 `json:"calls"` |
||||
|
InputTokens int64 `json:"inputTokens"` |
||||
|
OutputTokens int64 `json:"outputTokens"` |
||||
|
TotalCost float64 `json:"totalCost"` |
||||
|
} |
||||
|
|
||||
|
AIDailyStatItem { |
||||
|
Date string `json:"date"` |
||||
|
Calls int64 `json:"calls"` |
||||
|
TotalTokens int64 `json:"totalTokens"` |
||||
|
TotalCost float64 `json:"totalCost"` |
||||
|
} |
||||
|
|
||||
|
AIUsageStatsRequest { |
||||
|
Days int `form:"days,optional,default=30"` |
||||
|
} |
||||
|
) |
||||
@ -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,50 @@ |
|||||
|
package billing |
||||
|
|
||||
|
import ( |
||||
|
"context" |
||||
|
|
||||
|
"github.com/youruser/base/model" |
||||
|
|
||||
|
"gorm.io/gorm" |
||||
|
) |
||||
|
|
||||
|
// QuotaService handles quota freeze/settle/unfreeze operations
|
||||
|
type QuotaService struct{} |
||||
|
|
||||
|
func NewQuotaService() *QuotaService { |
||||
|
return &QuotaService{} |
||||
|
} |
||||
|
|
||||
|
// CheckAndFreeze checks if user has sufficient balance and freezes the estimated cost.
|
||||
|
// Returns nil if successful, error if insufficient balance.
|
||||
|
// If apiKeyId > 0, it's a user-provided key — skip billing.
|
||||
|
func (s *QuotaService) CheckAndFreeze(ctx context.Context, db *gorm.DB, userId int64, estimatedCost float64, apiKeyId int64) error { |
||||
|
// User-provided keys skip billing
|
||||
|
if apiKeyId > 0 { |
||||
|
return nil |
||||
|
} |
||||
|
|
||||
|
// Ensure quota record exists
|
||||
|
_, err := model.AIUserQuotaEnsure(ctx, db, userId) |
||||
|
if err != nil { |
||||
|
return err |
||||
|
} |
||||
|
|
||||
|
return model.AIUserQuotaFreeze(ctx, db, userId, estimatedCost) |
||||
|
} |
||||
|
|
||||
|
// Settle finalizes billing: releases frozen amount, deducts actual cost, refunds difference.
|
||||
|
func (s *QuotaService) Settle(ctx context.Context, db *gorm.DB, userId int64, frozenAmount, actualCost float64, apiKeyId int64) error { |
||||
|
if apiKeyId > 0 { |
||||
|
return nil |
||||
|
} |
||||
|
return model.AIUserQuotaSettle(ctx, db, userId, frozenAmount, actualCost) |
||||
|
} |
||||
|
|
||||
|
// Unfreeze releases frozen amount back to balance (used on error).
|
||||
|
func (s *QuotaService) Unfreeze(ctx context.Context, db *gorm.DB, userId int64, amount float64, apiKeyId int64) error { |
||||
|
if apiKeyId > 0 { |
||||
|
return nil |
||||
|
} |
||||
|
return model.AIUserQuotaUnfreeze(ctx, db, userId, amount) |
||||
|
} |
||||
@ -0,0 +1,32 @@ |
|||||
|
package billing |
||||
|
|
||||
|
import ( |
||||
|
"context" |
||||
|
|
||||
|
"github.com/youruser/base/model" |
||||
|
|
||||
|
"gorm.io/gorm" |
||||
|
) |
||||
|
|
||||
|
// UsageService handles usage recording
|
||||
|
type UsageService struct{} |
||||
|
|
||||
|
func NewUsageService() *UsageService { |
||||
|
return &UsageService{} |
||||
|
} |
||||
|
|
||||
|
// Record inserts a usage record
|
||||
|
func (s *UsageService) Record(ctx context.Context, db *gorm.DB, record *model.AIUsageRecord) error { |
||||
|
_, err := model.AIUsageRecordInsert(ctx, db, record) |
||||
|
return err |
||||
|
} |
||||
|
|
||||
|
// UpdateConversationStats updates conversation token count and cost
|
||||
|
func (s *UsageService) UpdateConversationStats(ctx context.Context, db *gorm.DB, conversationId int64, tokens int64, cost float64) error { |
||||
|
return db.WithContext(ctx).Model(&model.AIConversation{}). |
||||
|
Where("id = ?", conversationId). |
||||
|
Updates(map[string]interface{}{ |
||||
|
"total_tokens": gorm.Expr("total_tokens + ?", tokens), |
||||
|
"total_cost": gorm.Expr("total_cost + ?", cost), |
||||
|
}).Error |
||||
|
} |
||||
@ -0,0 +1,216 @@ |
|||||
|
package provider |
||||
|
|
||||
|
import ( |
||||
|
"context" |
||||
|
"fmt" |
||||
|
"strings" |
||||
|
|
||||
|
"github.com/anthropics/anthropic-sdk-go" |
||||
|
"github.com/anthropics/anthropic-sdk-go/option" |
||||
|
) |
||||
|
|
||||
|
// AnthropicProvider implements AIProvider for Anthropic Claude models.
|
||||
|
type AnthropicProvider struct { |
||||
|
client anthropic.Client |
||||
|
name string |
||||
|
} |
||||
|
|
||||
|
// NewAnthropicProvider creates a new Anthropic provider.
|
||||
|
// baseUrl can be empty to use the default Anthropic endpoint.
|
||||
|
func NewAnthropicProvider(baseUrl, apiKey string) *AnthropicProvider { |
||||
|
opts := []option.RequestOption{ |
||||
|
option.WithAPIKey(apiKey), |
||||
|
} |
||||
|
if baseUrl != "" { |
||||
|
opts = append(opts, option.WithBaseURL(baseUrl)) |
||||
|
} |
||||
|
client := anthropic.NewClient(opts...) |
||||
|
return &AnthropicProvider{ |
||||
|
client: client, |
||||
|
name: "anthropic", |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// Name returns the provider name.
|
||||
|
func (p *AnthropicProvider) Name() string { |
||||
|
return p.name |
||||
|
} |
||||
|
|
||||
|
// Chat sends a synchronous message request to Anthropic.
|
||||
|
func (p *AnthropicProvider) Chat(ctx context.Context, req *ChatRequest) (*ChatResponse, error) { |
||||
|
systemPrompt, messages := convertToAnthropicMessages(req.Messages) |
||||
|
|
||||
|
maxTokens := int64(req.MaxTokens) |
||||
|
if maxTokens <= 0 { |
||||
|
maxTokens = 4096 |
||||
|
} |
||||
|
|
||||
|
params := anthropic.MessageNewParams{ |
||||
|
Model: anthropic.Model(req.Model), |
||||
|
Messages: messages, |
||||
|
MaxTokens: maxTokens, |
||||
|
} |
||||
|
|
||||
|
if req.Temperature > 0 { |
||||
|
params.Temperature = anthropic.Float(req.Temperature) |
||||
|
} |
||||
|
|
||||
|
if len(systemPrompt) > 0 { |
||||
|
params.System = systemPrompt |
||||
|
} |
||||
|
|
||||
|
resp, err := p.client.Messages.New(ctx, params) |
||||
|
if err != nil { |
||||
|
return nil, fmt.Errorf("anthropic message creation failed: %w", err) |
||||
|
} |
||||
|
|
||||
|
// Extract text content from response blocks
|
||||
|
content := extractTextContent(resp.Content) |
||||
|
|
||||
|
return &ChatResponse{ |
||||
|
Content: content, |
||||
|
Model: string(resp.Model), |
||||
|
InputTokens: int(resp.Usage.InputTokens), |
||||
|
OutputTokens: int(resp.Usage.OutputTokens), |
||||
|
FinishReason: string(resp.StopReason), |
||||
|
}, nil |
||||
|
} |
||||
|
|
||||
|
// ChatStream sends a streaming message request to Anthropic. It returns a
|
||||
|
// channel that delivers StreamChunk values. The channel is closed when
|
||||
|
// the stream ends.
|
||||
|
func (p *AnthropicProvider) ChatStream(ctx context.Context, req *ChatRequest) (<-chan *StreamChunk, error) { |
||||
|
systemPrompt, messages := convertToAnthropicMessages(req.Messages) |
||||
|
|
||||
|
maxTokens := int64(req.MaxTokens) |
||||
|
if maxTokens <= 0 { |
||||
|
maxTokens = 4096 |
||||
|
} |
||||
|
|
||||
|
params := anthropic.MessageNewParams{ |
||||
|
Model: anthropic.Model(req.Model), |
||||
|
Messages: messages, |
||||
|
MaxTokens: maxTokens, |
||||
|
} |
||||
|
|
||||
|
if req.Temperature > 0 { |
||||
|
params.Temperature = anthropic.Float(req.Temperature) |
||||
|
} |
||||
|
|
||||
|
if len(systemPrompt) > 0 { |
||||
|
params.System = systemPrompt |
||||
|
} |
||||
|
|
||||
|
stream := p.client.Messages.NewStreaming(ctx, params) |
||||
|
|
||||
|
ch := make(chan *StreamChunk, 64) |
||||
|
|
||||
|
go func() { |
||||
|
defer close(ch) |
||||
|
defer stream.Close() |
||||
|
|
||||
|
for stream.Next() { |
||||
|
event := stream.Current() |
||||
|
|
||||
|
switch event.Type { |
||||
|
case "content_block_delta": |
||||
|
// Text delta — the main content streaming event
|
||||
|
delta := event.Delta |
||||
|
if delta.Text != "" { |
||||
|
select { |
||||
|
case ch <- &StreamChunk{Content: delta.Text}: |
||||
|
case <-ctx.Done(): |
||||
|
return |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
case "message_delta": |
||||
|
// Message delta carries the stop reason and final usage
|
||||
|
chunk := &StreamChunk{} |
||||
|
if event.Delta.StopReason != "" { |
||||
|
chunk.FinishReason = string(event.Delta.StopReason) |
||||
|
} |
||||
|
if event.Usage.OutputTokens > 0 { |
||||
|
chunk.InputTokens = int(event.Usage.InputTokens) |
||||
|
chunk.OutputTokens = int(event.Usage.OutputTokens) |
||||
|
} |
||||
|
select { |
||||
|
case ch <- chunk: |
||||
|
case <-ctx.Done(): |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
case "message_stop": |
||||
|
// Stream is done
|
||||
|
select { |
||||
|
case ch <- &StreamChunk{Done: true}: |
||||
|
case <-ctx.Done(): |
||||
|
} |
||||
|
return |
||||
|
|
||||
|
// content_block_start, content_block_stop, message_start: no action needed
|
||||
|
default: |
||||
|
continue |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// Check for stream errors
|
||||
|
if err := stream.Err(); err != nil { |
||||
|
select { |
||||
|
case ch <- &StreamChunk{ |
||||
|
Content: fmt.Sprintf("[stream error: %v]", err), |
||||
|
Done: true, |
||||
|
FinishReason: "error", |
||||
|
}: |
||||
|
case <-ctx.Done(): |
||||
|
} |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
// If we exit the loop without message_stop, still signal done
|
||||
|
select { |
||||
|
case ch <- &StreamChunk{Done: true}: |
||||
|
case <-ctx.Done(): |
||||
|
} |
||||
|
}() |
||||
|
|
||||
|
return ch, nil |
||||
|
} |
||||
|
|
||||
|
// convertToAnthropicMessages separates system messages and converts the rest
|
||||
|
// to Anthropic MessageParam format. Anthropic does not support a "system" role
|
||||
|
// in messages; instead, system prompts are passed as a separate field.
|
||||
|
func convertToAnthropicMessages(messages []ChatMessage) ([]anthropic.TextBlockParam, []anthropic.MessageParam) { |
||||
|
var systemBlocks []anthropic.TextBlockParam |
||||
|
var result []anthropic.MessageParam |
||||
|
|
||||
|
for _, msg := range messages { |
||||
|
switch msg.Role { |
||||
|
case "system": |
||||
|
systemBlocks = append(systemBlocks, anthropic.TextBlockParam{ |
||||
|
Text: msg.Content, |
||||
|
}) |
||||
|
case "user": |
||||
|
result = append(result, anthropic.NewUserMessage( |
||||
|
anthropic.NewTextBlock(msg.Content), |
||||
|
)) |
||||
|
case "assistant": |
||||
|
result = append(result, anthropic.NewAssistantMessage( |
||||
|
anthropic.NewTextBlock(msg.Content), |
||||
|
)) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return systemBlocks, result |
||||
|
} |
||||
|
|
||||
|
// extractTextContent concatenates all text blocks from an Anthropic response.
|
||||
|
func extractTextContent(blocks []anthropic.ContentBlockUnion) string { |
||||
|
var parts []string |
||||
|
for _, block := range blocks { |
||||
|
if block.Type == "text" { |
||||
|
parts = append(parts, block.Text) |
||||
|
} |
||||
|
} |
||||
|
return strings.Join(parts, "") |
||||
|
} |
||||
@ -0,0 +1,21 @@ |
|||||
|
package provider |
||||
|
|
||||
|
import "fmt" |
||||
|
|
||||
|
// NewProvider creates a provider based on SDK type.
|
||||
|
// Supported sdkType values:
|
||||
|
// - "openai_compat": OpenAI-compatible APIs (OpenAI, Qwen, Zhipu, DeepSeek, etc.)
|
||||
|
// - "anthropic": Anthropic Claude models
|
||||
|
// - "wenxin": Baidu Wenxin (ERNIE) models
|
||||
|
func NewProvider(sdkType, baseUrl, apiKey string) (AIProvider, error) { |
||||
|
switch sdkType { |
||||
|
case "openai_compat": |
||||
|
return NewOpenAIProvider(baseUrl, apiKey), nil |
||||
|
case "anthropic": |
||||
|
return NewAnthropicProvider(baseUrl, apiKey), nil |
||||
|
case "wenxin": |
||||
|
return NewWenxinProvider(baseUrl, apiKey), nil |
||||
|
default: |
||||
|
return nil, fmt.Errorf("unsupported sdk_type: %s", sdkType) |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,155 @@ |
|||||
|
package provider |
||||
|
|
||||
|
import ( |
||||
|
"context" |
||||
|
"errors" |
||||
|
"fmt" |
||||
|
"io" |
||||
|
|
||||
|
openai "github.com/sashabaranov/go-openai" |
||||
|
) |
||||
|
|
||||
|
// OpenAIProvider implements AIProvider for OpenAI-compatible APIs
|
||||
|
// (OpenAI, Qwen, Zhipu, DeepSeek, etc.)
|
||||
|
type OpenAIProvider struct { |
||||
|
client *openai.Client |
||||
|
name string |
||||
|
} |
||||
|
|
||||
|
// NewOpenAIProvider creates a new OpenAI-compatible provider.
|
||||
|
// baseUrl can be empty to use the default OpenAI endpoint.
|
||||
|
func NewOpenAIProvider(baseUrl, apiKey string) *OpenAIProvider { |
||||
|
config := openai.DefaultConfig(apiKey) |
||||
|
if baseUrl != "" { |
||||
|
config.BaseURL = baseUrl |
||||
|
} |
||||
|
return &OpenAIProvider{ |
||||
|
client: openai.NewClientWithConfig(config), |
||||
|
name: "openai_compat", |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// Name returns the provider name.
|
||||
|
func (p *OpenAIProvider) Name() string { |
||||
|
return p.name |
||||
|
} |
||||
|
|
||||
|
// Chat sends a synchronous chat completion request.
|
||||
|
func (p *OpenAIProvider) Chat(ctx context.Context, req *ChatRequest) (*ChatResponse, error) { |
||||
|
messages := convertToOpenAIMessages(req.Messages) |
||||
|
|
||||
|
openaiReq := openai.ChatCompletionRequest{ |
||||
|
Model: req.Model, |
||||
|
Messages: messages, |
||||
|
MaxTokens: req.MaxTokens, |
||||
|
Temperature: float32(req.Temperature), |
||||
|
} |
||||
|
|
||||
|
resp, err := p.client.CreateChatCompletion(ctx, openaiReq) |
||||
|
if err != nil { |
||||
|
return nil, fmt.Errorf("openai chat completion failed: %w", err) |
||||
|
} |
||||
|
|
||||
|
if len(resp.Choices) == 0 { |
||||
|
return nil, fmt.Errorf("openai chat completion returned no choices") |
||||
|
} |
||||
|
|
||||
|
return &ChatResponse{ |
||||
|
Content: resp.Choices[0].Message.Content, |
||||
|
Model: resp.Model, |
||||
|
InputTokens: resp.Usage.PromptTokens, |
||||
|
OutputTokens: resp.Usage.CompletionTokens, |
||||
|
FinishReason: string(resp.Choices[0].FinishReason), |
||||
|
}, nil |
||||
|
} |
||||
|
|
||||
|
// ChatStream sends a streaming chat completion request. It returns a channel
|
||||
|
// that delivers StreamChunk values. The channel is closed when the stream
|
||||
|
// ends or an error occurs. The final chunk has Done=true and may include
|
||||
|
// token usage if the API provides it.
|
||||
|
func (p *OpenAIProvider) ChatStream(ctx context.Context, req *ChatRequest) (<-chan *StreamChunk, error) { |
||||
|
messages := convertToOpenAIMessages(req.Messages) |
||||
|
|
||||
|
openaiReq := openai.ChatCompletionRequest{ |
||||
|
Model: req.Model, |
||||
|
Messages: messages, |
||||
|
MaxTokens: req.MaxTokens, |
||||
|
Temperature: float32(req.Temperature), |
||||
|
Stream: true, |
||||
|
StreamOptions: &openai.StreamOptions{ |
||||
|
IncludeUsage: true, |
||||
|
}, |
||||
|
} |
||||
|
|
||||
|
stream, err := p.client.CreateChatCompletionStream(ctx, openaiReq) |
||||
|
if err != nil { |
||||
|
return nil, fmt.Errorf("openai stream creation failed: %w", err) |
||||
|
} |
||||
|
|
||||
|
ch := make(chan *StreamChunk, 64) |
||||
|
|
||||
|
go func() { |
||||
|
defer close(ch) |
||||
|
defer stream.Close() |
||||
|
|
||||
|
for { |
||||
|
response, err := stream.Recv() |
||||
|
if errors.Is(err, io.EOF) { |
||||
|
// Stream finished normally
|
||||
|
select { |
||||
|
case ch <- &StreamChunk{Done: true}: |
||||
|
case <-ctx.Done(): |
||||
|
} |
||||
|
return |
||||
|
} |
||||
|
if err != nil { |
||||
|
// Send error indication as the final chunk
|
||||
|
select { |
||||
|
case ch <- &StreamChunk{ |
||||
|
Content: fmt.Sprintf("[stream error: %v]", err), |
||||
|
Done: true, |
||||
|
FinishReason: "error", |
||||
|
}: |
||||
|
case <-ctx.Done(): |
||||
|
} |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
chunk := &StreamChunk{} |
||||
|
|
||||
|
// Extract content delta from choices
|
||||
|
if len(response.Choices) > 0 { |
||||
|
chunk.Content = response.Choices[0].Delta.Content |
||||
|
if response.Choices[0].FinishReason != "" { |
||||
|
chunk.FinishReason = string(response.Choices[0].FinishReason) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// Extract usage from the final usage chunk (when StreamOptions.IncludeUsage is true)
|
||||
|
if response.Usage != nil { |
||||
|
chunk.InputTokens = response.Usage.PromptTokens |
||||
|
chunk.OutputTokens = response.Usage.CompletionTokens |
||||
|
} |
||||
|
|
||||
|
select { |
||||
|
case ch <- chunk: |
||||
|
case <-ctx.Done(): |
||||
|
return |
||||
|
} |
||||
|
} |
||||
|
}() |
||||
|
|
||||
|
return ch, nil |
||||
|
} |
||||
|
|
||||
|
// convertToOpenAIMessages converts our unified ChatMessage slice to OpenAI format.
|
||||
|
func convertToOpenAIMessages(messages []ChatMessage) []openai.ChatCompletionMessage { |
||||
|
result := make([]openai.ChatCompletionMessage, 0, len(messages)) |
||||
|
for _, msg := range messages { |
||||
|
result = append(result, openai.ChatCompletionMessage{ |
||||
|
Role: msg.Role, |
||||
|
Content: msg.Content, |
||||
|
}) |
||||
|
} |
||||
|
return result |
||||
|
} |
||||
@ -0,0 +1,13 @@ |
|||||
|
package provider |
||||
|
|
||||
|
import "context" |
||||
|
|
||||
|
// AIProvider defines the interface for AI model providers
|
||||
|
type AIProvider interface { |
||||
|
// Chat sends a synchronous chat request
|
||||
|
Chat(ctx context.Context, req *ChatRequest) (*ChatResponse, error) |
||||
|
// ChatStream sends a streaming chat request, returning chunks via channel
|
||||
|
ChatStream(ctx context.Context, req *ChatRequest) (<-chan *StreamChunk, error) |
||||
|
// Name returns the provider name
|
||||
|
Name() string |
||||
|
} |
||||
@ -0,0 +1,34 @@ |
|||||
|
package provider |
||||
|
|
||||
|
// ChatMessage represents a single message in a conversation
|
||||
|
type ChatMessage struct { |
||||
|
Role string `json:"role"` // user, assistant, system
|
||||
|
Content string `json:"content"` |
||||
|
} |
||||
|
|
||||
|
// ChatRequest is the unified request format
|
||||
|
type ChatRequest struct { |
||||
|
Model string `json:"model"` |
||||
|
Messages []ChatMessage `json:"messages"` |
||||
|
MaxTokens int `json:"max_tokens,omitempty"` |
||||
|
Temperature float64 `json:"temperature,omitempty"` |
||||
|
Stream bool `json:"stream"` |
||||
|
} |
||||
|
|
||||
|
// ChatResponse is the unified non-streaming response
|
||||
|
type ChatResponse struct { |
||||
|
Content string `json:"content"` |
||||
|
Model string `json:"model"` |
||||
|
InputTokens int `json:"input_tokens"` |
||||
|
OutputTokens int `json:"output_tokens"` |
||||
|
FinishReason string `json:"finish_reason"` |
||||
|
} |
||||
|
|
||||
|
// StreamChunk represents a single SSE chunk
|
||||
|
type StreamChunk struct { |
||||
|
Content string `json:"content,omitempty"` |
||||
|
FinishReason string `json:"finish_reason,omitempty"` |
||||
|
InputTokens int `json:"input_tokens,omitempty"` |
||||
|
OutputTokens int `json:"output_tokens,omitempty"` |
||||
|
Done bool `json:"done"` |
||||
|
} |
||||
@ -0,0 +1,250 @@ |
|||||
|
package provider |
||||
|
|
||||
|
import ( |
||||
|
"bufio" |
||||
|
"bytes" |
||||
|
"context" |
||||
|
"encoding/json" |
||||
|
"fmt" |
||||
|
"io" |
||||
|
"net/http" |
||||
|
"strings" |
||||
|
) |
||||
|
|
||||
|
// WenxinProvider implements AIProvider for Baidu Wenxin (ERNIE) models.
|
||||
|
// Wenxin uses a unique API format that requires custom HTTP implementation.
|
||||
|
type WenxinProvider struct { |
||||
|
baseUrl string |
||||
|
apiKey string |
||||
|
} |
||||
|
|
||||
|
func NewWenxinProvider(baseUrl, apiKey string) *WenxinProvider { |
||||
|
if baseUrl == "" { |
||||
|
baseUrl = "https://aip.baidubce.com/rpc/2.0/ai_custom/v1/wenxinworkshop/chat" |
||||
|
} |
||||
|
return &WenxinProvider{ |
||||
|
baseUrl: baseUrl, |
||||
|
apiKey: apiKey, |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func (p *WenxinProvider) Name() string { |
||||
|
return "wenxin" |
||||
|
} |
||||
|
|
||||
|
// wenxinMessage is Wenxin's message format
|
||||
|
type wenxinMessage struct { |
||||
|
Role string `json:"role"` |
||||
|
Content string `json:"content"` |
||||
|
} |
||||
|
|
||||
|
// wenxinRequest is the request body for Wenxin API
|
||||
|
type wenxinRequest struct { |
||||
|
Messages []wenxinMessage `json:"messages"` |
||||
|
Stream bool `json:"stream"` |
||||
|
Temperature float64 `json:"temperature,omitempty"` |
||||
|
MaxOutputTokens int `json:"max_output_tokens,omitempty"` |
||||
|
} |
||||
|
|
||||
|
// wenxinResponse is the non-streaming response from Wenxin API
|
||||
|
type wenxinResponse struct { |
||||
|
Id string `json:"id"` |
||||
|
Result string `json:"result"` |
||||
|
IsTruncated bool `json:"is_truncated"` |
||||
|
NeedClearHistory bool `json:"need_clear_history"` |
||||
|
Usage struct { |
||||
|
PromptTokens int `json:"prompt_tokens"` |
||||
|
CompletionTokens int `json:"completion_tokens"` |
||||
|
TotalTokens int `json:"total_tokens"` |
||||
|
} `json:"usage"` |
||||
|
ErrorCode int `json:"error_code"` |
||||
|
ErrorMsg string `json:"error_msg"` |
||||
|
} |
||||
|
|
||||
|
// wenxinStreamResponse is a single SSE chunk from Wenxin API
|
||||
|
type wenxinStreamResponse struct { |
||||
|
Id string `json:"id"` |
||||
|
Result string `json:"result"` |
||||
|
IsEnd bool `json:"is_end"` |
||||
|
IsTruncated bool `json:"is_truncated"` |
||||
|
NeedClearHistory bool `json:"need_clear_history"` |
||||
|
Usage struct { |
||||
|
PromptTokens int `json:"prompt_tokens"` |
||||
|
CompletionTokens int `json:"completion_tokens"` |
||||
|
TotalTokens int `json:"total_tokens"` |
||||
|
} `json:"usage"` |
||||
|
ErrorCode int `json:"error_code"` |
||||
|
ErrorMsg string `json:"error_msg"` |
||||
|
} |
||||
|
|
||||
|
func (p *WenxinProvider) Chat(ctx context.Context, req *ChatRequest) (*ChatResponse, error) { |
||||
|
wenxinReq := p.buildRequest(req, false) |
||||
|
|
||||
|
body, err := json.Marshal(wenxinReq) |
||||
|
if err != nil { |
||||
|
return nil, fmt.Errorf("wenxin marshal request: %w", err) |
||||
|
} |
||||
|
|
||||
|
url := p.buildURL(req.Model) |
||||
|
httpReq, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body)) |
||||
|
if err != nil { |
||||
|
return nil, fmt.Errorf("wenxin create request: %w", err) |
||||
|
} |
||||
|
httpReq.Header.Set("Content-Type", "application/json") |
||||
|
httpReq.Header.Set("Authorization", "Bearer "+p.apiKey) |
||||
|
|
||||
|
resp, err := http.DefaultClient.Do(httpReq) |
||||
|
if err != nil { |
||||
|
return nil, fmt.Errorf("wenxin http request: %w", err) |
||||
|
} |
||||
|
defer resp.Body.Close() |
||||
|
|
||||
|
respBody, err := io.ReadAll(resp.Body) |
||||
|
if err != nil { |
||||
|
return nil, fmt.Errorf("wenxin read response: %w", err) |
||||
|
} |
||||
|
|
||||
|
var wenxinResp wenxinResponse |
||||
|
if err := json.Unmarshal(respBody, &wenxinResp); err != nil { |
||||
|
return nil, fmt.Errorf("wenxin unmarshal response: %w", err) |
||||
|
} |
||||
|
|
||||
|
if wenxinResp.ErrorCode != 0 { |
||||
|
return nil, fmt.Errorf("wenxin error %d: %s", wenxinResp.ErrorCode, wenxinResp.ErrorMsg) |
||||
|
} |
||||
|
|
||||
|
return &ChatResponse{ |
||||
|
Content: wenxinResp.Result, |
||||
|
Model: req.Model, |
||||
|
InputTokens: wenxinResp.Usage.PromptTokens, |
||||
|
OutputTokens: wenxinResp.Usage.CompletionTokens, |
||||
|
FinishReason: "stop", |
||||
|
}, nil |
||||
|
} |
||||
|
|
||||
|
func (p *WenxinProvider) ChatStream(ctx context.Context, req *ChatRequest) (<-chan *StreamChunk, error) { |
||||
|
wenxinReq := p.buildRequest(req, true) |
||||
|
|
||||
|
body, err := json.Marshal(wenxinReq) |
||||
|
if err != nil { |
||||
|
return nil, fmt.Errorf("wenxin marshal request: %w", err) |
||||
|
} |
||||
|
|
||||
|
url := p.buildURL(req.Model) |
||||
|
httpReq, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body)) |
||||
|
if err != nil { |
||||
|
return nil, fmt.Errorf("wenxin create request: %w", err) |
||||
|
} |
||||
|
httpReq.Header.Set("Content-Type", "application/json") |
||||
|
httpReq.Header.Set("Authorization", "Bearer "+p.apiKey) |
||||
|
|
||||
|
resp, err := http.DefaultClient.Do(httpReq) |
||||
|
if err != nil { |
||||
|
return nil, fmt.Errorf("wenxin http request: %w", err) |
||||
|
} |
||||
|
|
||||
|
if resp.StatusCode != http.StatusOK { |
||||
|
resp.Body.Close() |
||||
|
return nil, fmt.Errorf("wenxin stream returned status %d", resp.StatusCode) |
||||
|
} |
||||
|
|
||||
|
ch := make(chan *StreamChunk, 64) |
||||
|
|
||||
|
go func() { |
||||
|
defer close(ch) |
||||
|
defer resp.Body.Close() |
||||
|
|
||||
|
scanner := bufio.NewScanner(resp.Body) |
||||
|
for scanner.Scan() { |
||||
|
line := scanner.Text() |
||||
|
if !strings.HasPrefix(line, "data: ") { |
||||
|
continue |
||||
|
} |
||||
|
data := strings.TrimPrefix(line, "data: ") |
||||
|
if data == "" { |
||||
|
continue |
||||
|
} |
||||
|
|
||||
|
var streamResp wenxinStreamResponse |
||||
|
if err := json.Unmarshal([]byte(data), &streamResp); err != nil { |
||||
|
continue |
||||
|
} |
||||
|
|
||||
|
if streamResp.ErrorCode != 0 { |
||||
|
select { |
||||
|
case ch <- &StreamChunk{ |
||||
|
Content: fmt.Sprintf("[wenxin error: %s]", streamResp.ErrorMsg), |
||||
|
Done: true, |
||||
|
FinishReason: "error", |
||||
|
}: |
||||
|
case <-ctx.Done(): |
||||
|
} |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
chunk := &StreamChunk{ |
||||
|
Content: streamResp.Result, |
||||
|
} |
||||
|
|
||||
|
if streamResp.IsEnd { |
||||
|
chunk.Done = true |
||||
|
chunk.FinishReason = "stop" |
||||
|
chunk.InputTokens = streamResp.Usage.PromptTokens |
||||
|
chunk.OutputTokens = streamResp.Usage.CompletionTokens |
||||
|
} |
||||
|
|
||||
|
select { |
||||
|
case ch <- chunk: |
||||
|
case <-ctx.Done(): |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
if streamResp.IsEnd { |
||||
|
return |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// Ensure a final Done chunk
|
||||
|
select { |
||||
|
case ch <- &StreamChunk{Done: true}: |
||||
|
case <-ctx.Done(): |
||||
|
} |
||||
|
}() |
||||
|
|
||||
|
return ch, nil |
||||
|
} |
||||
|
|
||||
|
func (p *WenxinProvider) buildRequest(req *ChatRequest, stream bool) *wenxinRequest { |
||||
|
messages := make([]wenxinMessage, 0, len(req.Messages)) |
||||
|
for _, m := range req.Messages { |
||||
|
if m.Role == "system" { |
||||
|
continue // Wenxin doesn't support system role in messages
|
||||
|
} |
||||
|
messages = append(messages, wenxinMessage{ |
||||
|
Role: m.Role, |
||||
|
Content: m.Content, |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
wenxinReq := &wenxinRequest{ |
||||
|
Messages: messages, |
||||
|
Stream: stream, |
||||
|
} |
||||
|
if req.Temperature > 0 { |
||||
|
wenxinReq.Temperature = req.Temperature |
||||
|
} |
||||
|
if req.MaxTokens > 0 { |
||||
|
wenxinReq.MaxOutputTokens = req.MaxTokens |
||||
|
} |
||||
|
return wenxinReq |
||||
|
} |
||||
|
|
||||
|
func (p *WenxinProvider) buildURL(model string) string { |
||||
|
// Wenxin uses model-specific endpoints
|
||||
|
// The baseUrl should include the full path for the model
|
||||
|
// e.g., https://aip.baidubce.com/rpc/2.0/ai_custom/v1/wenxinworkshop/chat/ernie-4.0-8k
|
||||
|
if strings.Contains(p.baseUrl, model) { |
||||
|
return p.baseUrl |
||||
|
} |
||||
|
return strings.TrimRight(p.baseUrl, "/") + "/" + model |
||||
|
} |
||||
@ -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" |
||||
|
) |
||||
|
|
||||
|
// 添加API Key
|
||||
|
func AiApiKeyCreateHandler(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.NewAiApiKeyCreateLogic(r.Context(), svcCtx) |
||||
|
resp, err := l.AiApiKeyCreate(&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" |
||||
|
) |
||||
|
|
||||
|
// 删除API Key
|
||||
|
func AiApiKeyDeleteHandler(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.NewAiApiKeyDeleteLogic(r.Context(), svcCtx) |
||||
|
resp, err := l.AiApiKeyDelete(&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" |
||||
|
) |
||||
|
|
||||
|
// 获取我的API Key列表
|
||||
|
func AiApiKeyListHandler(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.NewAiApiKeyListLogic(r.Context(), svcCtx) |
||||
|
resp, err := l.AiApiKeyList(&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" |
||||
|
) |
||||
|
|
||||
|
// 更新API Key
|
||||
|
func AiApiKeyUpdateHandler(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.NewAiApiKeyUpdateLogic(r.Context(), svcCtx) |
||||
|
resp, err := l.AiApiKeyUpdate(&req) |
||||
|
if err != nil { |
||||
|
httpx.ErrorCtx(r.Context(), w, err) |
||||
|
} else { |
||||
|
httpx.OkJsonCtx(r.Context(), w, resp) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,62 @@ |
|||||
|
package ai |
||||
|
|
||||
|
import ( |
||||
|
"encoding/json" |
||||
|
"fmt" |
||||
|
"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 AiChatCompletionsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { |
||||
|
return func(w http.ResponseWriter, r *http.Request) { |
||||
|
var req types.AIChatCompletionRequest |
||||
|
if err := httpx.ParseJsonBody(r, &req); err != nil { |
||||
|
httpx.ErrorCtx(r.Context(), w, err) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
l := ai.NewAiChatCompletionsLogic(r.Context(), svcCtx) |
||||
|
|
||||
|
if req.Stream { |
||||
|
// SSE streaming mode
|
||||
|
w.Header().Set("Content-Type", "text/event-stream") |
||||
|
w.Header().Set("Cache-Control", "no-cache") |
||||
|
w.Header().Set("Connection", "keep-alive") |
||||
|
w.Header().Set("Access-Control-Allow-Origin", "*") |
||||
|
|
||||
|
flusher, ok := w.(http.Flusher) |
||||
|
if !ok { |
||||
|
http.Error(w, "streaming not supported", http.StatusInternalServerError) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
streamChan, err := l.ChatStream(&req) |
||||
|
if err != nil { |
||||
|
errData, _ := json.Marshal(map[string]string{"error": err.Error()}) |
||||
|
fmt.Fprintf(w, "data: %s\n\n", errData) |
||||
|
flusher.Flush() |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
for chunk := range streamChan { |
||||
|
data, _ := json.Marshal(chunk) |
||||
|
fmt.Fprintf(w, "data: %s\n\n", data) |
||||
|
flusher.Flush() |
||||
|
} |
||||
|
fmt.Fprintf(w, "data: [DONE]\n\n") |
||||
|
flusher.Flush() |
||||
|
} else { |
||||
|
// Normal (non-streaming) mode
|
||||
|
resp, err := l.Chat(&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 AiConversationCreateHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { |
||||
|
return func(w http.ResponseWriter, r *http.Request) { |
||||
|
var req types.AIConversationCreateRequest |
||||
|
if err := httpx.Parse(r, &req); err != nil { |
||||
|
httpx.ErrorCtx(r.Context(), w, err) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
l := ai.NewAiConversationCreateLogic(r.Context(), svcCtx) |
||||
|
resp, err := l.AiConversationCreate(&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 AiConversationDeleteHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { |
||||
|
return func(w http.ResponseWriter, r *http.Request) { |
||||
|
var req types.AIConversationDeleteRequest |
||||
|
if err := httpx.Parse(r, &req); err != nil { |
||||
|
httpx.ErrorCtx(r.Context(), w, err) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
l := ai.NewAiConversationDeleteLogic(r.Context(), svcCtx) |
||||
|
resp, err := l.AiConversationDelete(&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 AiConversationGetHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { |
||||
|
return func(w http.ResponseWriter, r *http.Request) { |
||||
|
var req types.AIConversationGetRequest |
||||
|
if err := httpx.Parse(r, &req); err != nil { |
||||
|
httpx.ErrorCtx(r.Context(), w, err) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
l := ai.NewAiConversationGetLogic(r.Context(), svcCtx) |
||||
|
resp, err := l.AiConversationGet(&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 AiConversationListHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { |
||||
|
return func(w http.ResponseWriter, r *http.Request) { |
||||
|
var req types.AIConversationListRequest |
||||
|
if err := httpx.Parse(r, &req); err != nil { |
||||
|
httpx.ErrorCtx(r.Context(), w, err) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
l := ai.NewAiConversationListLogic(r.Context(), svcCtx) |
||||
|
resp, err := l.AiConversationList(&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 AiConversationUpdateHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { |
||||
|
return func(w http.ResponseWriter, r *http.Request) { |
||||
|
var req types.AIConversationUpdateRequest |
||||
|
if err := httpx.Parse(r, &req); err != nil { |
||||
|
httpx.ErrorCtx(r.Context(), w, err) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
l := ai.NewAiConversationUpdateLogic(r.Context(), svcCtx) |
||||
|
resp, err := l.AiConversationUpdate(&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" |
||||
|
) |
||||
|
|
||||
|
// 创建AI模型
|
||||
|
func AiModelCreateHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { |
||||
|
return func(w http.ResponseWriter, r *http.Request) { |
||||
|
var req types.AIModelCreateRequest |
||||
|
if err := httpx.Parse(r, &req); err != nil { |
||||
|
httpx.ErrorCtx(r.Context(), w, err) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
l := ai.NewAiModelCreateLogic(r.Context(), svcCtx) |
||||
|
resp, err := l.AiModelCreate(&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" |
||||
|
) |
||||
|
|
||||
|
// 删除AI模型
|
||||
|
func AiModelDeleteHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { |
||||
|
return func(w http.ResponseWriter, r *http.Request) { |
||||
|
var req types.AIModelDeleteRequest |
||||
|
if err := httpx.Parse(r, &req); err != nil { |
||||
|
httpx.ErrorCtx(r.Context(), w, err) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
l := ai.NewAiModelDeleteLogic(r.Context(), svcCtx) |
||||
|
resp, err := l.AiModelDelete(&req) |
||||
|
if err != nil { |
||||
|
httpx.ErrorCtx(r.Context(), w, err) |
||||
|
} else { |
||||
|
httpx.OkJsonCtx(r.Context(), w, resp) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,25 @@ |
|||||
|
// 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/zeromicro/go-zero/rest/httpx" |
||||
|
) |
||||
|
|
||||
|
// 获取模型列表
|
||||
|
func AiModelListHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { |
||||
|
return func(w http.ResponseWriter, r *http.Request) { |
||||
|
l := ai.NewAiModelListLogic(r.Context(), svcCtx) |
||||
|
resp, err := l.AiModelList() |
||||
|
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" |
||||
|
) |
||||
|
|
||||
|
// 更新AI模型
|
||||
|
func AiModelUpdateHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { |
||||
|
return func(w http.ResponseWriter, r *http.Request) { |
||||
|
var req types.AIModelUpdateRequest |
||||
|
if err := httpx.Parse(r, &req); err != nil { |
||||
|
httpx.ErrorCtx(r.Context(), w, err) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
l := ai.NewAiModelUpdateLogic(r.Context(), svcCtx) |
||||
|
resp, err := l.AiModelUpdate(&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" |
||||
|
) |
||||
|
|
||||
|
// 创建AI平台
|
||||
|
func AiProviderCreateHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { |
||||
|
return func(w http.ResponseWriter, r *http.Request) { |
||||
|
var req types.AIProviderCreateRequest |
||||
|
if err := httpx.Parse(r, &req); err != nil { |
||||
|
httpx.ErrorCtx(r.Context(), w, err) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
l := ai.NewAiProviderCreateLogic(r.Context(), svcCtx) |
||||
|
resp, err := l.AiProviderCreate(&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" |
||||
|
) |
||||
|
|
||||
|
// 删除AI平台
|
||||
|
func AiProviderDeleteHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { |
||||
|
return func(w http.ResponseWriter, r *http.Request) { |
||||
|
var req types.AIProviderDeleteRequest |
||||
|
if err := httpx.Parse(r, &req); err != nil { |
||||
|
httpx.ErrorCtx(r.Context(), w, err) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
l := ai.NewAiProviderDeleteLogic(r.Context(), svcCtx) |
||||
|
resp, err := l.AiProviderDelete(&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" |
||||
|
) |
||||
|
|
||||
|
// 获取AI平台列表
|
||||
|
func AiProviderListHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { |
||||
|
return func(w http.ResponseWriter, r *http.Request) { |
||||
|
var req types.AIProviderListRequest |
||||
|
if err := httpx.Parse(r, &req); err != nil { |
||||
|
httpx.ErrorCtx(r.Context(), w, err) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
l := ai.NewAiProviderListLogic(r.Context(), svcCtx) |
||||
|
resp, err := l.AiProviderList(&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" |
||||
|
) |
||||
|
|
||||
|
// 更新AI平台
|
||||
|
func AiProviderUpdateHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { |
||||
|
return func(w http.ResponseWriter, r *http.Request) { |
||||
|
var req types.AIProviderUpdateRequest |
||||
|
if err := httpx.Parse(r, &req); err != nil { |
||||
|
httpx.ErrorCtx(r.Context(), w, err) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
l := ai.NewAiProviderUpdateLogic(r.Context(), svcCtx) |
||||
|
resp, err := l.AiProviderUpdate(&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 AiQuotaListHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { |
||||
|
return func(w http.ResponseWriter, r *http.Request) { |
||||
|
var req types.AIQuotaListRequest |
||||
|
if err := httpx.Parse(r, &req); err != nil { |
||||
|
httpx.ErrorCtx(r.Context(), w, err) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
l := ai.NewAiQuotaListLogic(r.Context(), svcCtx) |
||||
|
resp, err := l.AiQuotaList(&req) |
||||
|
if err != nil { |
||||
|
httpx.ErrorCtx(r.Context(), w, err) |
||||
|
} else { |
||||
|
httpx.OkJsonCtx(r.Context(), w, resp) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,25 @@ |
|||||
|
// 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/zeromicro/go-zero/rest/httpx" |
||||
|
) |
||||
|
|
||||
|
// 获取我的配额
|
||||
|
func AiQuotaMeHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { |
||||
|
return func(w http.ResponseWriter, r *http.Request) { |
||||
|
l := ai.NewAiQuotaMeLogic(r.Context(), svcCtx) |
||||
|
resp, err := l.AiQuotaMe() |
||||
|
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 AiQuotaRechargeHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { |
||||
|
return func(w http.ResponseWriter, r *http.Request) { |
||||
|
var req types.AIQuotaRechargeRequest |
||||
|
if err := httpx.Parse(r, &req); err != nil { |
||||
|
httpx.ErrorCtx(r.Context(), w, err) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
l := ai.NewAiQuotaRechargeLogic(r.Context(), svcCtx) |
||||
|
resp, err := l.AiQuotaRecharge(&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 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,26 @@ |
|||||
|
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" |
||||
|
) |
||||
|
|
||||
|
// 导出用量记录CSV
|
||||
|
func AiUsageExportHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { |
||||
|
return func(w http.ResponseWriter, r *http.Request) { |
||||
|
var req types.AIUsageRecordListRequest |
||||
|
if err := httpx.Parse(r, &req); err != nil { |
||||
|
httpx.ErrorCtx(r.Context(), w, err) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
l := ai.NewAiUsageExportLogic(r.Context(), svcCtx) |
||||
|
if err := l.AiUsageExport(&req, w); err != nil { |
||||
|
httpx.ErrorCtx(r.Context(), w, err) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -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 AiUsageRecordListHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { |
||||
|
return func(w http.ResponseWriter, r *http.Request) { |
||||
|
var req types.AIUsageRecordListRequest |
||||
|
if err := httpx.Parse(r, &req); err != nil { |
||||
|
httpx.ErrorCtx(r.Context(), w, err) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
l := ai.NewAiUsageRecordListLogic(r.Context(), svcCtx) |
||||
|
resp, err := l.AiUsageRecordList(&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" |
||||
|
) |
||||
|
|
||||
|
// 获取AI使用统计
|
||||
|
func AiUsageStatsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { |
||||
|
return func(w http.ResponseWriter, r *http.Request) { |
||||
|
var req types.AIUsageStatsRequest |
||||
|
if err := httpx.Parse(r, &req); err != nil { |
||||
|
httpx.ErrorCtx(r.Context(), w, err) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
l := ai.NewAiUsageStatsLogic(r.Context(), svcCtx) |
||||
|
resp, err := l.AiUsageStats(&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,73 @@ |
|||||
|
// Code scaffolded by goctl. Safe to edit.
|
||||
|
// goctl 1.9.2
|
||||
|
|
||||
|
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 AiApiKeyCreateLogic struct { |
||||
|
logx.Logger |
||||
|
ctx context.Context |
||||
|
svcCtx *svc.ServiceContext |
||||
|
} |
||||
|
|
||||
|
// 创建API Key
|
||||
|
func NewAiApiKeyCreateLogic(ctx context.Context, svcCtx *svc.ServiceContext) *AiApiKeyCreateLogic { |
||||
|
return &AiApiKeyCreateLogic{ |
||||
|
Logger: logx.WithContext(ctx), |
||||
|
ctx: ctx, |
||||
|
svcCtx: svcCtx, |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func (l *AiApiKeyCreateLogic) AiApiKeyCreate(req *types.AIApiKeyCreateRequest) (resp *types.AIApiKeyInfo, err error) { |
||||
|
userId, _ := l.ctx.Value("userId").(int64) |
||||
|
|
||||
|
apiKey := &model.AIApiKey{ |
||||
|
ProviderId: req.ProviderId, |
||||
|
UserId: userId, |
||||
|
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("创建API Key失败: %v", err) |
||||
|
} |
||||
|
|
||||
|
// Retrieve the inserted record to get timestamps
|
||||
|
created, err := model.AIApiKeyFindOne(l.ctx, l.svcCtx.DB, id) |
||||
|
if err != nil { |
||||
|
return nil, fmt.Errorf("查询API Key失败: %v", err) |
||||
|
} |
||||
|
|
||||
|
// Look up provider name
|
||||
|
providerName := "" |
||||
|
provider, provErr := model.AIProviderFindOne(l.ctx, l.svcCtx.DB, created.ProviderId) |
||||
|
if provErr == nil { |
||||
|
providerName = provider.DisplayName |
||||
|
} |
||||
|
|
||||
|
resp = &types.AIApiKeyInfo{ |
||||
|
Id: created.Id, |
||||
|
ProviderId: created.ProviderId, |
||||
|
ProviderName: providerName, |
||||
|
UserId: created.UserId, |
||||
|
KeyPreview: maskKey(created.KeyValue), |
||||
|
IsActive: created.IsActive, |
||||
|
Remark: created.Remark, |
||||
|
CreatedAt: created.CreatedAt.Format("2006-01-02 15:04:05"), |
||||
|
} |
||||
|
|
||||
|
return resp, nil |
||||
|
} |
||||
@ -0,0 +1,60 @@ |
|||||
|
// Code scaffolded by goctl. Safe to edit.
|
||||
|
// goctl 1.9.2
|
||||
|
|
||||
|
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 AiApiKeyDeleteLogic struct { |
||||
|
logx.Logger |
||||
|
ctx context.Context |
||||
|
svcCtx *svc.ServiceContext |
||||
|
} |
||||
|
|
||||
|
// 删除API Key
|
||||
|
func NewAiApiKeyDeleteLogic(ctx context.Context, svcCtx *svc.ServiceContext) *AiApiKeyDeleteLogic { |
||||
|
return &AiApiKeyDeleteLogic{ |
||||
|
Logger: logx.WithContext(ctx), |
||||
|
ctx: ctx, |
||||
|
svcCtx: svcCtx, |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func (l *AiApiKeyDeleteLogic) AiApiKeyDelete(req *types.AIApiKeyDeleteRequest) (resp *types.Response, err error) { |
||||
|
userId, _ := l.ctx.Value("userId").(int64) |
||||
|
|
||||
|
// Find existing key
|
||||
|
existing, err := model.AIApiKeyFindOne(l.ctx, l.svcCtx.DB, req.Id) |
||||
|
if err != nil { |
||||
|
if err == model.ErrNotFound { |
||||
|
return nil, fmt.Errorf("API Key不存在") |
||||
|
} |
||||
|
return nil, fmt.Errorf("查询API Key失败: %v", err) |
||||
|
} |
||||
|
|
||||
|
// Verify ownership
|
||||
|
if existing.UserId != userId && existing.UserId != 0 { |
||||
|
return nil, fmt.Errorf("无权操作此API Key") |
||||
|
} |
||||
|
|
||||
|
if err = model.AIApiKeyDelete(l.ctx, l.svcCtx.DB, req.Id); err != nil { |
||||
|
return nil, fmt.Errorf("删除API Key失败: %v", err) |
||||
|
} |
||||
|
|
||||
|
resp = &types.Response{ |
||||
|
Code: 0, |
||||
|
Message: "success", |
||||
|
Success: true, |
||||
|
} |
||||
|
|
||||
|
return resp, nil |
||||
|
} |
||||
@ -0,0 +1,90 @@ |
|||||
|
// Code scaffolded by goctl. Safe to edit.
|
||||
|
// goctl 1.9.2
|
||||
|
|
||||
|
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 AiApiKeyListLogic struct { |
||||
|
logx.Logger |
||||
|
ctx context.Context |
||||
|
svcCtx *svc.ServiceContext |
||||
|
} |
||||
|
|
||||
|
// 获取我的API Key列表
|
||||
|
func NewAiApiKeyListLogic(ctx context.Context, svcCtx *svc.ServiceContext) *AiApiKeyListLogic { |
||||
|
return &AiApiKeyListLogic{ |
||||
|
Logger: logx.WithContext(ctx), |
||||
|
ctx: ctx, |
||||
|
svcCtx: svcCtx, |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func (l *AiApiKeyListLogic) AiApiKeyList(req *types.AIApiKeyListRequest) (resp *types.AIApiKeyListResponse, err error) { |
||||
|
userId, _ := l.ctx.Value("userId").(int64) |
||||
|
|
||||
|
// Query keys belonging to current user or system keys (userId=0)
|
||||
|
var keys []model.AIApiKey |
||||
|
var total int64 |
||||
|
query := l.svcCtx.DB.WithContext(l.ctx).Model(&model.AIApiKey{}).Where("user_id = ? OR user_id = 0", userId) |
||||
|
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 |
||||
|
} |
||||
|
|
||||
|
// Build provider name cache to avoid repeated queries
|
||||
|
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: key.UserId, |
||||
|
KeyPreview: maskKey(key.KeyValue), |
||||
|
IsActive: key.IsActive, |
||||
|
Remark: key.Remark, |
||||
|
CreatedAt: key.CreatedAt.Format("2006-01-02 15:04:05"), |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
resp = &types.AIApiKeyListResponse{ |
||||
|
List: list, |
||||
|
Total: total, |
||||
|
} |
||||
|
|
||||
|
return resp, nil |
||||
|
} |
||||
|
|
||||
|
func maskKey(key string) string { |
||||
|
if len(key) <= 10 { |
||||
|
return "sk-***" |
||||
|
} |
||||
|
return key[:6] + "..." + key[len(key)-4:] |
||||
|
} |
||||
@ -0,0 +1,79 @@ |
|||||
|
// Code scaffolded by goctl. Safe to edit.
|
||||
|
// goctl 1.9.2
|
||||
|
|
||||
|
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 AiApiKeyUpdateLogic struct { |
||||
|
logx.Logger |
||||
|
ctx context.Context |
||||
|
svcCtx *svc.ServiceContext |
||||
|
} |
||||
|
|
||||
|
// 更新API Key
|
||||
|
func NewAiApiKeyUpdateLogic(ctx context.Context, svcCtx *svc.ServiceContext) *AiApiKeyUpdateLogic { |
||||
|
return &AiApiKeyUpdateLogic{ |
||||
|
Logger: logx.WithContext(ctx), |
||||
|
ctx: ctx, |
||||
|
svcCtx: svcCtx, |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func (l *AiApiKeyUpdateLogic) AiApiKeyUpdate(req *types.AIApiKeyUpdateRequest) (resp *types.AIApiKeyInfo, err error) { |
||||
|
userId, _ := l.ctx.Value("userId").(int64) |
||||
|
|
||||
|
// Find existing key
|
||||
|
existing, err := model.AIApiKeyFindOne(l.ctx, l.svcCtx.DB, req.Id) |
||||
|
if err != nil { |
||||
|
if err == model.ErrNotFound { |
||||
|
return nil, fmt.Errorf("API Key不存在") |
||||
|
} |
||||
|
return nil, fmt.Errorf("查询API Key失败: %v", err) |
||||
|
} |
||||
|
|
||||
|
// Verify ownership: must be the user's own key or a system key (userId=0)
|
||||
|
if existing.UserId != userId && existing.UserId != 0 { |
||||
|
return nil, fmt.Errorf("无权更新此API Key") |
||||
|
} |
||||
|
|
||||
|
// Update fields
|
||||
|
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("更新API Key失败: %v", err) |
||||
|
} |
||||
|
|
||||
|
// Look up provider name
|
||||
|
providerName := "" |
||||
|
provider, provErr := model.AIProviderFindOne(l.ctx, l.svcCtx.DB, existing.ProviderId) |
||||
|
if provErr == nil { |
||||
|
providerName = provider.DisplayName |
||||
|
} |
||||
|
|
||||
|
resp = &types.AIApiKeyInfo{ |
||||
|
Id: existing.Id, |
||||
|
ProviderId: existing.ProviderId, |
||||
|
ProviderName: providerName, |
||||
|
UserId: existing.UserId, |
||||
|
KeyPreview: maskKey(existing.KeyValue), |
||||
|
IsActive: existing.IsActive, |
||||
|
Remark: existing.Remark, |
||||
|
CreatedAt: existing.CreatedAt.Format("2006-01-02 15:04:05"), |
||||
|
} |
||||
|
|
||||
|
return resp, nil |
||||
|
} |
||||
@ -0,0 +1,347 @@ |
|||||
|
package ai |
||||
|
|
||||
|
import ( |
||||
|
"context" |
||||
|
"errors" |
||||
|
"fmt" |
||||
|
"time" |
||||
|
|
||||
|
"github.com/youruser/base/internal/ai/billing" |
||||
|
"github.com/youruser/base/internal/ai/provider" |
||||
|
"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 AiChatCompletionsLogic struct { |
||||
|
logx.Logger |
||||
|
ctx context.Context |
||||
|
svcCtx *svc.ServiceContext |
||||
|
} |
||||
|
|
||||
|
func NewAiChatCompletionsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *AiChatCompletionsLogic { |
||||
|
return &AiChatCompletionsLogic{ |
||||
|
Logger: logx.WithContext(ctx), |
||||
|
ctx: ctx, |
||||
|
svcCtx: svcCtx, |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// Chat handles non-streaming chat completions
|
||||
|
func (l *AiChatCompletionsLogic) Chat(req *types.AIChatCompletionRequest) (*types.AIChatCompletionResponse, error) { |
||||
|
startTime := time.Now() |
||||
|
|
||||
|
userId, _ := l.ctx.Value("userId").(int64) |
||||
|
if userId == 0 { |
||||
|
return nil, errors.New("unauthorized") |
||||
|
} |
||||
|
|
||||
|
// 1. Look up model
|
||||
|
aiModel, err := model.AIModelFindByModelId(l.ctx, l.svcCtx.DB, req.Model) |
||||
|
if err != nil { |
||||
|
return nil, fmt.Errorf("model not found: %s", req.Model) |
||||
|
} |
||||
|
|
||||
|
// 2. Look up provider
|
||||
|
aiProvider, err := model.AIProviderFindOne(l.ctx, l.svcCtx.DB, aiModel.ProviderId) |
||||
|
if err != nil { |
||||
|
return nil, fmt.Errorf("provider not found for model: %s", req.Model) |
||||
|
} |
||||
|
|
||||
|
// 3. Select API key
|
||||
|
apiKey, apiKeyId, err := l.selectApiKey(aiProvider.Id, userId) |
||||
|
if err != nil { |
||||
|
return nil, err |
||||
|
} |
||||
|
|
||||
|
// 4. Estimate cost and freeze
|
||||
|
estimatedCost := l.estimateCost(req, aiModel) |
||||
|
quotaSvc := billing.NewQuotaService() |
||||
|
if err := quotaSvc.CheckAndFreeze(l.ctx, l.svcCtx.DB, userId, estimatedCost, apiKeyId); err != nil { |
||||
|
return nil, fmt.Errorf("insufficient balance: %v", err) |
||||
|
} |
||||
|
|
||||
|
// 5. Build provider and call
|
||||
|
p, err := provider.NewProvider(aiProvider.SdkType, aiProvider.BaseUrl, apiKey) |
||||
|
if err != nil { |
||||
|
quotaSvc.Unfreeze(l.ctx, l.svcCtx.DB, userId, estimatedCost, apiKeyId) |
||||
|
return nil, err |
||||
|
} |
||||
|
|
||||
|
// Convert messages
|
||||
|
chatReq := l.buildChatRequest(req, aiModel) |
||||
|
chatResp, err := p.Chat(l.ctx, chatReq) |
||||
|
if err != nil { |
||||
|
quotaSvc.Unfreeze(l.ctx, l.svcCtx.DB, userId, estimatedCost, apiKeyId) |
||||
|
return nil, fmt.Errorf("AI request failed: %v", err) |
||||
|
} |
||||
|
|
||||
|
latencyMs := int(time.Since(startTime).Milliseconds()) |
||||
|
|
||||
|
// 6. Calculate actual cost and settle
|
||||
|
actualCost := l.calculateCost(chatResp.InputTokens, chatResp.OutputTokens, aiModel) |
||||
|
quotaSvc.Settle(l.ctx, l.svcCtx.DB, userId, estimatedCost, actualCost, apiKeyId) |
||||
|
|
||||
|
// 7. Record usage
|
||||
|
usageSvc := billing.NewUsageService() |
||||
|
usageSvc.Record(l.ctx, l.svcCtx.DB, &model.AIUsageRecord{ |
||||
|
UserId: userId, |
||||
|
ProviderId: aiProvider.Id, |
||||
|
ModelId: req.Model, |
||||
|
InputTokens: chatResp.InputTokens, |
||||
|
OutputTokens: chatResp.OutputTokens, |
||||
|
Cost: actualCost, |
||||
|
ApiKeyId: apiKeyId, |
||||
|
Status: "ok", |
||||
|
LatencyMs: latencyMs, |
||||
|
}) |
||||
|
|
||||
|
// 8. Save messages to conversation if conversation_id provided
|
||||
|
if req.ConversationId > 0 { |
||||
|
l.saveMessages(req, chatResp, aiModel, latencyMs) |
||||
|
} |
||||
|
|
||||
|
// 9. Build response
|
||||
|
return &types.AIChatCompletionResponse{ |
||||
|
Id: fmt.Sprintf("chatcmpl-%d", time.Now().UnixNano()), |
||||
|
Object: "chat.completion", |
||||
|
Model: req.Model, |
||||
|
Choices: []types.AIChatCompletionChoice{ |
||||
|
{ |
||||
|
Index: 0, |
||||
|
FinishReason: chatResp.FinishReason, |
||||
|
Message: types.AIChatMessage{ |
||||
|
Role: "assistant", |
||||
|
Content: chatResp.Content, |
||||
|
}, |
||||
|
}, |
||||
|
}, |
||||
|
Usage: types.AIChatCompletionUsage{ |
||||
|
PromptTokens: chatResp.InputTokens, |
||||
|
CompletionTokens: chatResp.OutputTokens, |
||||
|
TotalTokens: chatResp.InputTokens + chatResp.OutputTokens, |
||||
|
}, |
||||
|
}, nil |
||||
|
} |
||||
|
|
||||
|
// ChatStream handles streaming chat completions
|
||||
|
func (l *AiChatCompletionsLogic) ChatStream(req *types.AIChatCompletionRequest) (<-chan *provider.StreamChunk, error) { |
||||
|
userId, _ := l.ctx.Value("userId").(int64) |
||||
|
if userId == 0 { |
||||
|
return nil, errors.New("unauthorized") |
||||
|
} |
||||
|
|
||||
|
// 1. Look up model
|
||||
|
aiModel, err := model.AIModelFindByModelId(l.ctx, l.svcCtx.DB, req.Model) |
||||
|
if err != nil { |
||||
|
return nil, fmt.Errorf("model not found: %s", req.Model) |
||||
|
} |
||||
|
|
||||
|
// 2. Look up provider
|
||||
|
aiProvider, err := model.AIProviderFindOne(l.ctx, l.svcCtx.DB, aiModel.ProviderId) |
||||
|
if err != nil { |
||||
|
return nil, fmt.Errorf("provider not found for model: %s", req.Model) |
||||
|
} |
||||
|
|
||||
|
// 3. Select API key
|
||||
|
apiKey, apiKeyId, err := l.selectApiKey(aiProvider.Id, userId) |
||||
|
if err != nil { |
||||
|
return nil, err |
||||
|
} |
||||
|
|
||||
|
// 4. Estimate and freeze
|
||||
|
estimatedCost := l.estimateCost(req, aiModel) |
||||
|
quotaSvc := billing.NewQuotaService() |
||||
|
if err := quotaSvc.CheckAndFreeze(l.ctx, l.svcCtx.DB, userId, estimatedCost, apiKeyId); err != nil { |
||||
|
return nil, fmt.Errorf("insufficient balance: %v", err) |
||||
|
} |
||||
|
|
||||
|
// 5. Build provider
|
||||
|
p, err := provider.NewProvider(aiProvider.SdkType, aiProvider.BaseUrl, apiKey) |
||||
|
if err != nil { |
||||
|
quotaSvc.Unfreeze(l.ctx, l.svcCtx.DB, userId, estimatedCost, apiKeyId) |
||||
|
return nil, err |
||||
|
} |
||||
|
|
||||
|
// 6. Start stream
|
||||
|
chatReq := l.buildChatRequest(req, aiModel) |
||||
|
startTime := time.Now() |
||||
|
streamChan, err := p.ChatStream(l.ctx, chatReq) |
||||
|
if err != nil { |
||||
|
quotaSvc.Unfreeze(l.ctx, l.svcCtx.DB, userId, estimatedCost, apiKeyId) |
||||
|
return nil, fmt.Errorf("AI stream failed: %v", err) |
||||
|
} |
||||
|
|
||||
|
// 7. Wrap stream channel — accumulate content for post-processing
|
||||
|
outChan := make(chan *provider.StreamChunk, 100) |
||||
|
go func() { |
||||
|
defer close(outChan) |
||||
|
|
||||
|
var fullContent string |
||||
|
var inputTokens, outputTokens int |
||||
|
|
||||
|
for chunk := range streamChan { |
||||
|
fullContent += chunk.Content |
||||
|
if chunk.InputTokens > 0 { |
||||
|
inputTokens = chunk.InputTokens |
||||
|
} |
||||
|
if chunk.OutputTokens > 0 { |
||||
|
outputTokens = chunk.OutputTokens |
||||
|
} |
||||
|
outChan <- chunk |
||||
|
} |
||||
|
|
||||
|
latencyMs := int(time.Since(startTime).Milliseconds()) |
||||
|
|
||||
|
// Post-stream: calculate cost, settle billing, record usage
|
||||
|
actualCost := l.calculateCost(inputTokens, outputTokens, aiModel) |
||||
|
bgCtx := context.Background() |
||||
|
quotaSvc.Settle(bgCtx, l.svcCtx.DB, userId, estimatedCost, actualCost, apiKeyId) |
||||
|
|
||||
|
usageSvc := billing.NewUsageService() |
||||
|
usageSvc.Record(bgCtx, l.svcCtx.DB, &model.AIUsageRecord{ |
||||
|
UserId: userId, |
||||
|
ProviderId: aiProvider.Id, |
||||
|
ModelId: req.Model, |
||||
|
InputTokens: inputTokens, |
||||
|
OutputTokens: outputTokens, |
||||
|
Cost: actualCost, |
||||
|
ApiKeyId: apiKeyId, |
||||
|
Status: "ok", |
||||
|
LatencyMs: latencyMs, |
||||
|
}) |
||||
|
|
||||
|
// Save messages
|
||||
|
if req.ConversationId > 0 { |
||||
|
totalTokens := int64(inputTokens + outputTokens) |
||||
|
usageSvc.UpdateConversationStats(bgCtx, l.svcCtx.DB, req.ConversationId, totalTokens, actualCost) |
||||
|
|
||||
|
// Save user message (last one in req.Messages)
|
||||
|
if len(req.Messages) > 0 { |
||||
|
lastMsg := req.Messages[len(req.Messages)-1] |
||||
|
model.AIChatMessageInsert(bgCtx, l.svcCtx.DB, &model.AIChatMessage{ |
||||
|
ConversationId: req.ConversationId, |
||||
|
Role: lastMsg.Role, |
||||
|
Content: lastMsg.Content, |
||||
|
ModelId: req.Model, |
||||
|
}) |
||||
|
} |
||||
|
// Save assistant message
|
||||
|
model.AIChatMessageInsert(bgCtx, l.svcCtx.DB, &model.AIChatMessage{ |
||||
|
ConversationId: req.ConversationId, |
||||
|
Role: "assistant", |
||||
|
Content: fullContent, |
||||
|
TokenCount: outputTokens, |
||||
|
Cost: actualCost, |
||||
|
ModelId: req.Model, |
||||
|
LatencyMs: latencyMs, |
||||
|
}) |
||||
|
} |
||||
|
}() |
||||
|
|
||||
|
return outChan, nil |
||||
|
} |
||||
|
|
||||
|
// selectApiKey selects the best API key: user's own key first, then system key
|
||||
|
func (l *AiChatCompletionsLogic) selectApiKey(providerId, userId int64) (string, int64, error) { |
||||
|
// Try user's own key first
|
||||
|
userKeys, err := model.AIApiKeyFindByProviderAndUser(l.ctx, l.svcCtx.DB, providerId, userId) |
||||
|
if err == nil && len(userKeys) > 0 { |
||||
|
for _, key := range userKeys { |
||||
|
if key.IsActive { |
||||
|
return key.KeyValue, key.Id, nil |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// Fall back to system key (userId=0)
|
||||
|
systemKeys, err := model.AIApiKeyFindSystemKeys(l.ctx, l.svcCtx.DB, providerId) |
||||
|
if err != nil || len(systemKeys) == 0 { |
||||
|
return "", 0, fmt.Errorf("no API key available for this provider") |
||||
|
} |
||||
|
|
||||
|
return systemKeys[0].KeyValue, 0, nil |
||||
|
} |
||||
|
|
||||
|
// estimateCost estimates cost based on input message length
|
||||
|
func (l *AiChatCompletionsLogic) estimateCost(req *types.AIChatCompletionRequest, aiModel *model.AIModel) float64 { |
||||
|
// Rough estimation: ~4 chars per token for input, estimate 1000 output tokens
|
||||
|
totalChars := 0 |
||||
|
for _, msg := range req.Messages { |
||||
|
totalChars += len(msg.Content) |
||||
|
} |
||||
|
estimatedInputTokens := totalChars / 4 |
||||
|
estimatedOutputTokens := 1000 |
||||
|
if req.MaxTokens > 0 { |
||||
|
estimatedOutputTokens = req.MaxTokens |
||||
|
} |
||||
|
|
||||
|
cost := float64(estimatedInputTokens)/1000.0*aiModel.InputPrice + |
||||
|
float64(estimatedOutputTokens)/1000.0*aiModel.OutputPrice |
||||
|
return cost |
||||
|
} |
||||
|
|
||||
|
// calculateCost computes actual cost from real token counts
|
||||
|
func (l *AiChatCompletionsLogic) calculateCost(inputTokens, outputTokens int, aiModel *model.AIModel) float64 { |
||||
|
return float64(inputTokens)/1000.0*aiModel.InputPrice + |
||||
|
float64(outputTokens)/1000.0*aiModel.OutputPrice |
||||
|
} |
||||
|
|
||||
|
// buildChatRequest converts types request to provider request
|
||||
|
func (l *AiChatCompletionsLogic) buildChatRequest(req *types.AIChatCompletionRequest, aiModel *model.AIModel) *provider.ChatRequest { |
||||
|
messages := make([]provider.ChatMessage, len(req.Messages)) |
||||
|
for i, m := range req.Messages { |
||||
|
messages[i] = provider.ChatMessage{Role: m.Role, Content: m.Content} |
||||
|
} |
||||
|
|
||||
|
maxTokens := req.MaxTokens |
||||
|
if maxTokens <= 0 { |
||||
|
maxTokens = aiModel.MaxTokens |
||||
|
} |
||||
|
|
||||
|
temperature := req.Temperature |
||||
|
if temperature <= 0 { |
||||
|
temperature = 0.7 |
||||
|
} |
||||
|
|
||||
|
return &provider.ChatRequest{ |
||||
|
Model: req.Model, |
||||
|
Messages: messages, |
||||
|
MaxTokens: maxTokens, |
||||
|
Temperature: temperature, |
||||
|
Stream: req.Stream, |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// saveMessages saves user and assistant messages to conversation
|
||||
|
func (l *AiChatCompletionsLogic) saveMessages(req *types.AIChatCompletionRequest, resp *provider.ChatResponse, aiModel *model.AIModel, latencyMs int) { |
||||
|
actualCost := l.calculateCost(resp.InputTokens, resp.OutputTokens, aiModel) |
||||
|
|
||||
|
// Save user message
|
||||
|
if len(req.Messages) > 0 { |
||||
|
lastMsg := req.Messages[len(req.Messages)-1] |
||||
|
model.AIChatMessageInsert(l.ctx, l.svcCtx.DB, &model.AIChatMessage{ |
||||
|
ConversationId: req.ConversationId, |
||||
|
Role: lastMsg.Role, |
||||
|
Content: lastMsg.Content, |
||||
|
ModelId: req.Model, |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
// Save assistant message
|
||||
|
model.AIChatMessageInsert(l.ctx, l.svcCtx.DB, &model.AIChatMessage{ |
||||
|
ConversationId: req.ConversationId, |
||||
|
Role: "assistant", |
||||
|
Content: resp.Content, |
||||
|
TokenCount: resp.OutputTokens, |
||||
|
Cost: actualCost, |
||||
|
ModelId: req.Model, |
||||
|
LatencyMs: latencyMs, |
||||
|
}) |
||||
|
|
||||
|
// Update conversation stats
|
||||
|
totalTokens := int64(resp.InputTokens + resp.OutputTokens) |
||||
|
billing.NewUsageService().UpdateConversationStats(l.ctx, l.svcCtx.DB, req.ConversationId, totalTokens, actualCost) |
||||
|
} |
||||
@ -0,0 +1,70 @@ |
|||||
|
// Code scaffolded by goctl. Safe to edit.
|
||||
|
// goctl 1.9.2
|
||||
|
|
||||
|
package ai |
||||
|
|
||||
|
import ( |
||||
|
"context" |
||||
|
"errors" |
||||
|
|
||||
|
"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 AiConversationCreateLogic struct { |
||||
|
logx.Logger |
||||
|
ctx context.Context |
||||
|
svcCtx *svc.ServiceContext |
||||
|
} |
||||
|
|
||||
|
// 创建对话
|
||||
|
func NewAiConversationCreateLogic(ctx context.Context, svcCtx *svc.ServiceContext) *AiConversationCreateLogic { |
||||
|
return &AiConversationCreateLogic{ |
||||
|
Logger: logx.WithContext(ctx), |
||||
|
ctx: ctx, |
||||
|
svcCtx: svcCtx, |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func (l *AiConversationCreateLogic) AiConversationCreate(req *types.AIConversationCreateRequest) (resp *types.AIConversationInfo, err error) { |
||||
|
userId, _ := l.ctx.Value("userId").(int64) |
||||
|
if userId == 0 { |
||||
|
return nil, errors.New("unauthorized") |
||||
|
} |
||||
|
|
||||
|
title := req.Title |
||||
|
if title == "" { |
||||
|
title = "新对话" |
||||
|
} |
||||
|
|
||||
|
conv := &model.AIConversation{ |
||||
|
UserId: userId, |
||||
|
Title: title, |
||||
|
ModelId: req.ModelId, |
||||
|
} |
||||
|
|
||||
|
// Look up model to get provider ID
|
||||
|
if req.ModelId != "" { |
||||
|
aiModel, err := model.AIModelFindByModelId(l.ctx, l.svcCtx.DB, req.ModelId) |
||||
|
if err == nil { |
||||
|
conv.ProviderId = aiModel.ProviderId |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
_, err = model.AIConversationInsert(l.ctx, l.svcCtx.DB, conv) |
||||
|
if err != nil { |
||||
|
return nil, err |
||||
|
} |
||||
|
|
||||
|
return &types.AIConversationInfo{ |
||||
|
Id: conv.Id, |
||||
|
Title: conv.Title, |
||||
|
ModelId: conv.ModelId, |
||||
|
ProviderId: conv.ProviderId, |
||||
|
CreatedAt: conv.CreatedAt.Format("2006-01-02 15:04:05"), |
||||
|
UpdatedAt: conv.UpdatedAt.Format("2006-01-02 15:04:05"), |
||||
|
}, nil |
||||
|
} |
||||
@ -0,0 +1,51 @@ |
|||||
|
// Code scaffolded by goctl. Safe to edit.
|
||||
|
// goctl 1.9.2
|
||||
|
|
||||
|
package ai |
||||
|
|
||||
|
import ( |
||||
|
"context" |
||||
|
"errors" |
||||
|
|
||||
|
"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 AiConversationDeleteLogic struct { |
||||
|
logx.Logger |
||||
|
ctx context.Context |
||||
|
svcCtx *svc.ServiceContext |
||||
|
} |
||||
|
|
||||
|
// 删除对话
|
||||
|
func NewAiConversationDeleteLogic(ctx context.Context, svcCtx *svc.ServiceContext) *AiConversationDeleteLogic { |
||||
|
return &AiConversationDeleteLogic{ |
||||
|
Logger: logx.WithContext(ctx), |
||||
|
ctx: ctx, |
||||
|
svcCtx: svcCtx, |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func (l *AiConversationDeleteLogic) AiConversationDelete(req *types.AIConversationDeleteRequest) (resp *types.Response, err error) { |
||||
|
userId, _ := l.ctx.Value("userId").(int64) |
||||
|
if userId == 0 { |
||||
|
return nil, errors.New("unauthorized") |
||||
|
} |
||||
|
|
||||
|
conv, err := model.AIConversationFindOne(l.ctx, l.svcCtx.DB, req.Id) |
||||
|
if err != nil { |
||||
|
return nil, err |
||||
|
} |
||||
|
if conv.UserId != userId { |
||||
|
return nil, errors.New("forbidden") |
||||
|
} |
||||
|
|
||||
|
if err := model.AIConversationDelete(l.ctx, l.svcCtx.DB, req.Id); err != nil { |
||||
|
return nil, err |
||||
|
} |
||||
|
|
||||
|
return &types.Response{Success: true}, nil |
||||
|
} |
||||
@ -0,0 +1,81 @@ |
|||||
|
// Code scaffolded by goctl. Safe to edit.
|
||||
|
// goctl 1.9.2
|
||||
|
|
||||
|
package ai |
||||
|
|
||||
|
import ( |
||||
|
"context" |
||||
|
"errors" |
||||
|
|
||||
|
"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 AiConversationGetLogic struct { |
||||
|
logx.Logger |
||||
|
ctx context.Context |
||||
|
svcCtx *svc.ServiceContext |
||||
|
} |
||||
|
|
||||
|
// 获取对话详情
|
||||
|
func NewAiConversationGetLogic(ctx context.Context, svcCtx *svc.ServiceContext) *AiConversationGetLogic { |
||||
|
return &AiConversationGetLogic{ |
||||
|
Logger: logx.WithContext(ctx), |
||||
|
ctx: ctx, |
||||
|
svcCtx: svcCtx, |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func (l *AiConversationGetLogic) AiConversationGet(req *types.AIConversationGetRequest) (resp *types.AIConversationDetailResponse, err error) { |
||||
|
userId, _ := l.ctx.Value("userId").(int64) |
||||
|
if userId == 0 { |
||||
|
return nil, errors.New("unauthorized") |
||||
|
} |
||||
|
|
||||
|
conv, err := model.AIConversationFindOne(l.ctx, l.svcCtx.DB, req.Id) |
||||
|
if err != nil { |
||||
|
return nil, err |
||||
|
} |
||||
|
if conv.UserId != userId { |
||||
|
return nil, errors.New("forbidden") |
||||
|
} |
||||
|
|
||||
|
// Get messages
|
||||
|
messages, err := model.AIChatMessageFindByConversation(l.ctx, l.svcCtx.DB, conv.Id) |
||||
|
if err != nil { |
||||
|
return nil, err |
||||
|
} |
||||
|
|
||||
|
msgList := make([]types.AIMessageInfo, len(messages)) |
||||
|
for i, m := range messages { |
||||
|
msgList[i] = types.AIMessageInfo{ |
||||
|
Id: m.Id, |
||||
|
ConversationId: m.ConversationId, |
||||
|
Role: m.Role, |
||||
|
Content: m.Content, |
||||
|
TokenCount: m.TokenCount, |
||||
|
Cost: m.Cost, |
||||
|
ModelId: m.ModelId, |
||||
|
LatencyMs: m.LatencyMs, |
||||
|
CreatedAt: m.CreatedAt.Format("2006-01-02 15:04:05"), |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return &types.AIConversationDetailResponse{ |
||||
|
Conversation: types.AIConversationInfo{ |
||||
|
Id: conv.Id, |
||||
|
Title: conv.Title, |
||||
|
ModelId: conv.ModelId, |
||||
|
ProviderId: conv.ProviderId, |
||||
|
TotalTokens: conv.TotalTokens, |
||||
|
TotalCost: conv.TotalCost, |
||||
|
IsArchived: conv.IsArchived, |
||||
|
CreatedAt: conv.CreatedAt.Format("2006-01-02 15:04:05"), |
||||
|
UpdatedAt: conv.UpdatedAt.Format("2006-01-02 15:04:05"), |
||||
|
}, |
||||
|
Messages: msgList, |
||||
|
}, nil |
||||
|
} |
||||
@ -0,0 +1,59 @@ |
|||||
|
// Code scaffolded by goctl. Safe to edit.
|
||||
|
// goctl 1.9.2
|
||||
|
|
||||
|
package ai |
||||
|
|
||||
|
import ( |
||||
|
"context" |
||||
|
"errors" |
||||
|
|
||||
|
"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 AiConversationListLogic struct { |
||||
|
logx.Logger |
||||
|
ctx context.Context |
||||
|
svcCtx *svc.ServiceContext |
||||
|
} |
||||
|
|
||||
|
// 获取对话列表
|
||||
|
func NewAiConversationListLogic(ctx context.Context, svcCtx *svc.ServiceContext) *AiConversationListLogic { |
||||
|
return &AiConversationListLogic{ |
||||
|
Logger: logx.WithContext(ctx), |
||||
|
ctx: ctx, |
||||
|
svcCtx: svcCtx, |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func (l *AiConversationListLogic) AiConversationList(req *types.AIConversationListRequest) (resp *types.AIConversationListResponse, err error) { |
||||
|
userId, _ := l.ctx.Value("userId").(int64) |
||||
|
if userId == 0 { |
||||
|
return nil, errors.New("unauthorized") |
||||
|
} |
||||
|
|
||||
|
conversations, total, err := model.AIConversationFindByUser(l.ctx, l.svcCtx.DB, userId, req.Page, req.PageSize) |
||||
|
if err != nil { |
||||
|
return nil, err |
||||
|
} |
||||
|
|
||||
|
list := make([]types.AIConversationInfo, len(conversations)) |
||||
|
for i, c := range conversations { |
||||
|
list[i] = types.AIConversationInfo{ |
||||
|
Id: c.Id, |
||||
|
Title: c.Title, |
||||
|
ModelId: c.ModelId, |
||||
|
ProviderId: c.ProviderId, |
||||
|
TotalTokens: c.TotalTokens, |
||||
|
TotalCost: c.TotalCost, |
||||
|
IsArchived: c.IsArchived, |
||||
|
CreatedAt: c.CreatedAt.Format("2006-01-02 15:04:05"), |
||||
|
UpdatedAt: c.UpdatedAt.Format("2006-01-02 15:04:05"), |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return &types.AIConversationListResponse{List: list, Total: total}, nil |
||||
|
} |
||||
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue