feat: API key management improvements

- Rename routes: /dashboard/tokens → /dashboard/apikeys, /dashboard/manager/keys → /dashboard/manager/channels
- Add KeyPlain field to store plaintext API keys for re-viewing
- API key list shows masked key (sk-ot-123456****abcd) with eye toggle to reveal
- Copy button with 2s feedback on key list
- TokenNew shows full key + copy after creation
- Increase key prefix display to 12 characters
- Fix SQLite driver: replace gorm.io/driver/sqlite with ncruces/go-sqlite3/gormlite
- Fix user.status === 'active' checks across frontend views
- Add channel store for new channels API
- Update Makefile frontend build to work reliably

BREAKING CHANGE: Existing API keys created before this change will not show their plaintext value (only prefix visible).
This commit is contained in:
Sakurasan
2026-08-30 15:21:49 +08:00
parent 96e4853d6e
commit 094230a293
26 changed files with 1141 additions and 912 deletions
+11 -9
View File
@@ -1,6 +1,6 @@
module opencatd-open
go 1.23.2
go 1.25.0
require (
github.com/gin-contrib/cors v1.7.2
@@ -12,15 +12,15 @@ require (
github.com/google/wire v0.6.0
github.com/joho/godotenv v1.5.1
github.com/lib/pq v1.10.9
github.com/ncruces/go-sqlite3/gormlite v0.34.0
github.com/pkoukk/tiktoken-go v0.1.7
github.com/sashabaranov/go-openai v1.42.0
github.com/spf13/cobra v1.9.1
golang.org/x/crypto v0.37.0
golang.org/x/crypto v0.50.0
golang.org/x/time v0.10.0
gorm.io/driver/mysql v1.5.7
gorm.io/driver/postgres v1.5.11
gorm.io/driver/sqlite v1.6.0
gorm.io/gorm v1.30.0
gorm.io/gorm v1.31.1
)
require (
@@ -52,20 +52,22 @@ require (
github.com/kr/text v0.2.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v1.14.22 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/ncruces/go-sqlite3 v0.34.0 // indirect
github.com/ncruces/go-sqlite3-wasm/v2 v2.1.35300 // indirect
github.com/ncruces/julianday v1.0.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
github.com/spf13/pflag v1.0.6 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
github.com/x448/float16 v0.8.4 // indirect
golang.org/x/arch v0.16.0 // indirect
golang.org/x/net v0.39.0 // indirect
golang.org/x/sync v0.13.0 // indirect
golang.org/x/sys v0.32.0 // indirect
golang.org/x/text v0.24.0 // indirect
golang.org/x/net v0.52.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.43.0 // indirect
golang.org/x/text v0.36.0 // indirect
google.golang.org/protobuf v1.36.6 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+20 -16
View File
@@ -91,8 +91,6 @@ github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -100,6 +98,14 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/ncruces/go-sqlite3 v0.34.0 h1:q2I6wHTLWIoz6ehYkKdG5dGQc66eJv7ZGnekhvuMfK8=
github.com/ncruces/go-sqlite3 v0.34.0/go.mod h1:qpBxsSdGPnO9K5OExuv5GEsrGQ7Rk6JsJFH6wn2DwwU=
github.com/ncruces/go-sqlite3-wasm/v2 v2.1.35300 h1:cRdxCt3BDfMu0vfSdoqaAPD+dzIXPkGREjqyZMLN2Ak=
github.com/ncruces/go-sqlite3-wasm/v2 v2.1.35300/go.mod h1:R2kJLPoSA/GBX/b8x7zwOq/KLAw6rLMY1l3Hi76SQIo=
github.com/ncruces/go-sqlite3/gormlite v0.34.0 h1:QLlOy/i7OabsFUQ+d5KyXmq2hw9sMh/CRW435+eQMRY=
github.com/ncruces/go-sqlite3/gormlite v0.34.0/go.mod h1:CMv+6YhqLmPBXYACiQtrWA0q/JLIMTKB4E65SUfLgF0=
github.com/ncruces/julianday v1.0.0 h1:fH0OKwa7NWvniGQtxdJRxAgkBMolni2BjDHaWTxqt7M=
github.com/ncruces/julianday v1.0.0/go.mod h1:Dusn2KvZrrovOMJuOt0TNXL6tB7U2E8kvza5fFc9G7g=
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
github.com/pkoukk/tiktoken-go v0.1.7 h1:qOBHXX4PHtvIvmOtyg1EeKlwFRiMKAcoMp4Q+bLQDmw=
@@ -141,8 +147,8 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
@@ -154,15 +160,15 @@ golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -173,8 +179,8 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
@@ -188,8 +194,8 @@ golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/time v0.10.0 h1:3usCWA8tQn0L8+hFJQNgzpWbd89begxN66o1Ojdn5L4=
golang.org/x/time v0.10.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -212,9 +218,7 @@ gorm.io/driver/mysql v1.5.7 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo=
gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM=
gorm.io/driver/postgres v1.5.11 h1:ubBVAfbKEUld/twyKZ0IYn9rSQh448EdelLYk9Mv314=
gorm.io/driver/postgres v1.5.11/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI=
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
gorm.io/gorm v1.30.0 h1:qbT5aPv1UH8gI99OsRlvDToLxW5zR7FzS9acZDOZcgs=
gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
+336 -18
View File
@@ -37,13 +37,33 @@ func NewHandler(db *gorm.DB) *Handler {
}
}
// --- Helpers ---
func userToResponse(user *store.User) gin.H {
roleNum := 1
if user.Role == store.RoleAdmin {
roleNum = 10
}
return gin.H{
"id": user.ID,
"username": user.Username,
"email": user.Email,
"role": roleNum,
"status": user.Status,
"balance": user.Balance,
"created_at": user.CreatedAt,
"updated_at": user.UpdatedAt,
"last_login_at": user.LastLoginAt,
}
}
// --- Auth ---
func (h *Handler) Register(c *gin.Context) {
var req struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
Email string `json:"email" binding:"required,email"`
Email string `json:"email" binding:"omitempty,email"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
@@ -60,9 +80,13 @@ func (h *Handler) Register(c *gin.Context) {
}
hash := crypto.Sha256Hex(req.Password)
email := req.Email
if email == "" {
email = req.Username + "@placeholder.local"
}
user := &store.User{
Username: req.Username,
Email: req.Email,
Email: email,
PasswordHash: hash,
Role: role,
Status: store.UserStatusActive,
@@ -127,34 +151,121 @@ func (h *Handler) Me(c *gin.Context) {
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
return
}
// Map role string to number for frontend compatibility
roleNum := 1 // default user
if user.Role == store.RoleAdmin {
roleNum = 10
}
c.JSON(http.StatusOK, gin.H{
"code": 200,
"data": gin.H{
"id": user.ID,
"username": user.Username,
"email": user.Email,
"role": roleNum,
"status": user.Status,
},
"data": userToResponse(user),
})
}
func (h *Handler) UpdateProfile(c *gin.Context) {
userID, _ := c.Get("user_id")
user, err := h.userDAO.GetByID(userID.(uint64))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
return
}
var req struct {
Email string `json:"email"`
Name string `json:"name"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if req.Email != "" {
user.Email = req.Email
}
if err := h.userDAO.Update(user); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "profile updated"})
}
func (h *Handler) UpdatePassword(c *gin.Context) {
userID, _ := c.Get("user_id")
user, err := h.userDAO.GetByID(userID.(uint64))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
return
}
var req struct {
Password string `json:"password" binding:"required"`
NewPassword string `json:"newpassword" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Verify old password
oldHash := crypto.Sha256Hex(req.Password)
if user.PasswordHash != oldHash {
c.JSON(http.StatusBadRequest, gin.H{"error": "incorrect password"})
return
}
// Update to new password
user.PasswordHash = crypto.Sha256Hex(req.NewPassword)
if err := h.userDAO.Update(user); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "password updated"})
}
// --- Users ---
func (h *Handler) ListUsers(c *gin.Context) {
// Support both limit/offset and pageSize/page parameters
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
// If pageSize/page are provided, use them instead
if pageSize := c.Query("pageSize"); pageSize != "" {
if size, err := strconv.Atoi(pageSize); err == nil && size > 0 {
limit = size
}
}
if page := c.Query("page"); page != "" {
if p, err := strconv.Atoi(page); err == nil && p > 0 {
offset = (p - 1) * limit
}
}
users, total, err := h.userDAO.List(limit, offset)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"data": users, "total": total})
data := make([]gin.H, len(users))
for i, u := range users {
data[i] = userToResponse(u)
}
c.JSON(http.StatusOK, gin.H{"data": data, "total": total})
}
func (h *Handler) GetUser(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
user, err := h.userDAO.GetByID(id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
return
}
c.JSON(http.StatusOK, gin.H{
"data": userToResponse(user),
})
}
func (h *Handler) CreateUser(c *gin.Context) {
@@ -188,7 +299,87 @@ func (h *Handler) CreateUser(c *gin.Context) {
return
}
c.JSON(http.StatusOK, user)
c.JSON(http.StatusOK, userToResponse(user))
}
func (h *Handler) UpdateUser(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
user, err := h.userDAO.GetByID(id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
return
}
var req struct {
Username string `json:"username"`
Email string `json:"email"`
Password string `json:"password"`
Role string `json:"role"`
Status string `json:"status"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if req.Username != "" {
user.Username = req.Username
}
if req.Email != "" {
user.Email = req.Email
}
if req.Password != "" {
user.PasswordHash = crypto.Sha256Hex(req.Password)
}
if req.Role != "" {
user.Role = req.Role
}
if req.Status != "" {
user.Status = req.Status
}
if err := h.userDAO.Update(user); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, userToResponse(user))
}
func (h *Handler) BatchUsers(c *gin.Context) {
option := c.Param("option")
var req struct {
IDs []uint64 `json:"ids"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
for _, id := range req.IDs {
user, err := h.userDAO.GetByID(id)
if err != nil {
continue
}
switch option {
case "enable":
user.Status = store.UserStatusActive
case "disable":
user.Status = store.UserStatusDisabled
case "delete":
h.userDAO.Delete(id)
continue
}
h.userDAO.Update(user)
}
c.JSON(http.StatusOK, gin.H{"message": "batch operation completed"})
}
func (h *Handler) DeleteUser(c *gin.Context) {
@@ -208,8 +399,22 @@ func (h *Handler) DeleteUser(c *gin.Context) {
func (h *Handler) ListApiKeys(c *gin.Context) {
userID, _ := c.Get("user_id")
// Support both limit/offset and pageSize/page parameters
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
// If pageSize/page are provided, use them instead
if pageSize := c.Query("pageSize"); pageSize != "" {
if size, err := strconv.Atoi(pageSize); err == nil && size > 0 {
limit = size
}
}
if page := c.Query("page"); page != "" {
if p, err := strconv.Atoi(page); err == nil && p > 0 {
offset = (p - 1) * limit
}
}
keys, total, err := h.apiKeyDAO.ListByUserID(userID.(uint64), limit, offset)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
@@ -218,6 +423,20 @@ func (h *Handler) ListApiKeys(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"data": keys, "total": total})
}
func (h *Handler) GetApiKey(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
key, err := h.apiKeyDAO.GetByID(id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "key not found"})
return
}
c.JSON(http.StatusOK, gin.H{"data": key})
}
func (h *Handler) CreateApiKey(c *gin.Context) {
var req struct {
Name string `json:"name" binding:"required"`
@@ -236,7 +455,8 @@ func (h *Handler) CreateApiKey(c *gin.Context) {
UserID: userID.(uint64),
Name: req.Name,
KeyHash: apikey.Hash(keyValue),
KeyPrefix: keyValue[:8],
KeyPlain: keyValue,
KeyPrefix: keyValue[:12],
QuotaTokensPerDay: req.QuotaTokensPerDay,
QuotaRequestsPerDay: req.QuotaRequestsPerDay,
Status: store.KeyStatusActive,
@@ -249,10 +469,80 @@ func (h *Handler) CreateApiKey(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"key": keyValue,
"id": key.ID,
"data": key,
})
}
func (h *Handler) UpdateApiKey(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
key, err := h.apiKeyDAO.GetByID(id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "key not found"})
return
}
var req struct {
Name string `json:"name"`
QuotaTokensPerDay *int64 `json:"quota_tokens_per_day"`
QuotaRequestsPerDay *int `json:"quota_requests_per_day"`
AllowedModels []string `json:"allowed_models"`
Status string `json:"status"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if req.Name != "" {
key.Name = req.Name
}
if req.QuotaTokensPerDay != nil {
key.QuotaTokensPerDay = req.QuotaTokensPerDay
}
if req.QuotaRequestsPerDay != nil {
key.QuotaRequestsPerDay = req.QuotaRequestsPerDay
}
if req.AllowedModels != nil {
key.AllowedModels = req.AllowedModels
}
if req.Status != "" {
key.Status = req.Status
}
if err := h.apiKeyDAO.Update(key); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"data": key})
}
func (h *Handler) BatchApiKeys(c *gin.Context) {
option := c.Param("option")
var req struct {
IDs []uint64 `json:"ids"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
switch option {
case "delete":
if err := h.apiKeyDAO.BatchDelete(req.IDs); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
}
c.JSON(http.StatusOK, gin.H{"message": "batch operation completed"})
}
func (h *Handler) DeleteApiKey(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
@@ -269,8 +559,22 @@ func (h *Handler) DeleteApiKey(c *gin.Context) {
// --- Channels ---
func (h *Handler) ListChannels(c *gin.Context) {
// Support both limit/offset and pageSize/page parameters
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
// If pageSize/page are provided, use them instead
if pageSize := c.Query("pageSize"); pageSize != "" {
if size, err := strconv.Atoi(pageSize); err == nil && size > 0 {
limit = size
}
}
if page := c.Query("page"); page != "" {
if p, err := strconv.Atoi(page); err == nil && p > 0 {
offset = (p - 1) * limit
}
}
channels, total, err := h.channelDAO.List(limit, offset)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
@@ -401,8 +705,22 @@ func (h *Handler) DeleteChannel(c *gin.Context) {
// --- Models ---
func (h *Handler) ListModels(c *gin.Context) {
// Support both limit/offset and pageSize/page parameters
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
// If pageSize/page are provided, use them instead
if pageSize := c.Query("pageSize"); pageSize != "" {
if size, err := strconv.Atoi(pageSize); err == nil && size > 0 {
limit = size
}
}
if page := c.Query("page"); page != "" {
if p, err := strconv.Atoi(page); err == nil && p > 0 {
offset = (p - 1) * limit
}
}
models, total, err := h.modelDAO.List(limit, offset)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+2 -2
View File
@@ -6,9 +6,9 @@ import (
"opencatd-open/pkg/config"
_ "github.com/lib/pq"
"github.com/ncruces/go-sqlite3/gormlite"
"gorm.io/driver/mysql"
"gorm.io/driver/postgres"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
@@ -52,7 +52,7 @@ func sqliteDialector(dsn string) gorm.Dialector {
if dsn == "" {
dsn = "opencatd.db"
}
return sqlite.Open(dsn)
return gormlite.Open(dsn)
}
func postgresDialector(dsn string) gorm.Dialector {
+1
View File
@@ -58,6 +58,7 @@ type APIKey struct {
UserID uint64 `gorm:"index;not null" json:"user_id"`
Name string `gorm:"size:64;not null" json:"name"`
KeyHash string `gorm:"uniqueIndex;size:64;not null" json:"-"`
KeyPlain string `gorm:"size:255;not null" json:"key_plain"`
KeyPrefix string `gorm:"size:32;not null" json:"key_prefix"`
QuotaTokensPerDay *int64 `json:"quota_tokens_per_day,omitempty"`
QuotaRequestsPerDay *int `json:"quota_requests_per_day,omitempty"`
+8
View File
@@ -80,16 +80,24 @@ func SetRouter(cfg *config.Config, db *gorm.DB, web *embed.FS) {
// User profile
apiGroup.GET("/me", apiHandler.Me)
apiGroup.GET("/profile", apiHandler.Me)
apiGroup.POST("/profile/update", apiHandler.UpdateProfile)
apiGroup.POST("/profile/update/password", apiHandler.UpdatePassword)
// User management (admin)
apiGroup.GET("/users", apiHandler.ListUsers)
apiGroup.GET("/users/:id", apiHandler.GetUser)
apiGroup.POST("/users", apiHandler.CreateUser)
apiGroup.PUT("/users/:id", apiHandler.UpdateUser)
apiGroup.DELETE("/users/:id", apiHandler.DeleteUser)
apiGroup.POST("/users/batch/:option", apiHandler.BatchUsers)
// API Key management
apiGroup.GET("/keys", apiHandler.ListApiKeys)
apiGroup.GET("/keys/:id", apiHandler.GetApiKey)
apiGroup.POST("/keys", apiHandler.CreateApiKey)
apiGroup.PUT("/keys/:id", apiHandler.UpdateApiKey)
apiGroup.DELETE("/keys/:id", apiHandler.DeleteApiKey)
apiGroup.POST("/keys/batch/:option", apiHandler.BatchApiKeys)
// Channel management
apiGroup.GET("/channels", apiHandler.ListChannels)