diff --git a/SERVER_FEATURE_IMPLEMENTATION.md b/SERVER_FEATURE_IMPLEMENTATION.md new file mode 100644 index 0000000..7fb49f1 --- /dev/null +++ b/SERVER_FEATURE_IMPLEMENTATION.md @@ -0,0 +1,219 @@ +# 服务器售卖功能实现说明 + +## 功能概述 + +本次更新为项目添加了完整的服务器售卖功能,包括管理员端和用户端的所有必要功能。 + +## 实现的功能 + +### 1. 后端实现 + +#### 1.1 数据库模型 + +**Server 模型** (`model/server.go`) +- 服务器配置表,用于管理可售卖的服务器 +- 字段: + - `id`: 主键 + - `title`: 服务器标题 + - `config`: 配置信息(文本) + - `price`: 价格(元) + - `duration`: 有效时长(天) + - `status`: 状态(1-启用,2-禁用) + - `description`: 服务器描述 + - `stock`: 库存数量(-1表示无限) + - `created_time`: 创建时间 + - `updated_time`: 更新时间 + +**PurchasedServer 模型** (`model/purchased_server.go`) +- 已购服务器表,记录用户购买的服务器 +- 字段: + - `id`: 主键 + - `user_id`: 用户ID + - `server_id`: 服务器配置ID + - `server_title`: 服务器标题(冗余字段) + - `server_config`: 服务器配置(冗余字段) + - `price`: 购买价格 + - `duration`: 购买时长(天) + - `purchase_time`: 购买时间 + - `expire_time`: 到期时间 + - `status`: 状态(1-正常,2-已过期) + - `trade_no`: 交易单号 + +#### 1.2 控制器 (`controller/server.go`) + +**管理员功能:** +- `GetAllServers`: 获取所有服务器配置(分页) +- `SearchServers`: 搜索服务器配置 +- `GetServer`: 获取单个服务器配置 +- `AddServer`: 添加服务器配置 +- `UpdateServer`: 更新服务器配置 +- `DeleteServer`: 删除服务器配置 +- `GetAllPurchasedServers`: 获取所有已购服务器(分页,含用户名) +- `SearchPurchasedServers`: 搜索已购服务器 +- `DeletePurchasedServer`: 删除已购服务器记录 + +**用户功能:** +- `GetEnabledServers`: 获取启用的服务器列表 +- `PurchaseServer`: 购买服务器(扣除用户额度) +- `GetUserPurchasedServers`: 获取用户已购服务器列表 + +#### 1.3 API 路由 (`router/api-router.go`) + +``` +/api/server/ + - GET / # 获取所有服务器(管理员) + - GET /search # 搜索服务器(管理员) + - GET /:id # 获取单个服务器(管理员) + - POST / # 添加服务器(管理员) + - PUT / # 更新服务器(管理员) + - DELETE /:id # 删除服务器(管理员) + - GET /enabled # 获取启用的服务器(用户) + - POST /purchase # 购买服务器(用户) + - GET /purchased/self # 获取用户已购服务器(用户) + - GET /purchased # 获取所有已购服务器(管理员) + - GET /purchased/search # 搜索已购服务器(管理员) + - DELETE /purchased/:id # 删除已购服务器(管理员) +``` + +### 2. 前端实现 + +#### 2.1 管理员页面 + +**服务器管理页面** (`web/src/pages/Server/index.js`) +- 组件:`ServersTable` (`web/src/components/table/ServersTable.js`) +- 功能: + - 查看所有服务器配置 + - 搜索服务器 + - 添加新服务器 + - 编辑服务器配置 + - 删除服务器 + - 显示服务器状态、价格、库存等信息 + +**已售服务器列表** (`web/src/pages/PurchasedServer/index.js`) +- 组件:`PurchasedServersTable` (`web/src/components/table/PurchasedServersTable.js`) +- 功能: + - 查看所有已售服务器 + - 搜索已购服务器(按用户ID或服务器标题) + - 查看服务器配置 + - 显示用户名、购买时间、到期时间等 + - 删除已购服务器记录 + +#### 2.2 用户页面 + +**服务器购买页面** (`web/src/pages/ServerShop/index.js`) +- 功能: + - 展示所有可购买的服务器(卡片式布局) + - 显示服务器标题、价格、有效期、库存 + - 显示服务器配置预览 + - 购买确认对话框 + - 购买成功后显示完整配置信息 + - 自动扣除用户额度 + +**我的服务器页面** (`web/src/pages/MyServers/index.js`) +- 功能: + - 查看用户已购买的所有服务器 + - 显示购买时间、到期时间、剩余天数 + - 状态标识(正常/已过期) + - 点击查看完整服务器配置 + - 到期提醒(7天内显示橙色标签) + +#### 2.3 路由和导航 + +**路由配置** (`web/src/App.js`) +- `/console/server`: 服务器管理(管理员) +- `/console/purchased-server`: 已售服务器列表(管理员) +- `/console/server-shop`: 服务器购买(用户) +- `/console/my-servers`: 我的服务器(用户) + +**侧边栏导航** (`web/src/components/layout/SiderBar.js`) +- 管理员菜单: + - 服务器管理 + - 已售服务器 +- 个人中心菜单: + - 服务器购买 + - 我的服务器 + +**图标配置** (`web/src/helpers/render.js`) +- 使用 Lucide React 的 Server 图标 +- 紫色主题色 (#8B5CF6) + +## 业务逻辑 + +### 购买流程 + +1. 用户在"服务器购买"页面浏览可用服务器 +2. 点击"立即购买"按钮 +3. 系统显示确认对话框,包含价格和有效期信息 +4. 用户确认后: + - 检查服务器状态和库存 + - 计算需要扣除的额度(价格 × QuotaPerUnit) + - 检查用户额度是否足够 + - 扣除用户额度 + - 创建已购服务器记录 + - 更新库存(如果不是无限库存) + - 记录操作日志 +5. 购买成功后显示服务器配置信息 + +### 额度计算 + +- 服务器价格以人民币(元)为单位 +- 购买时将价格转换为系统额度:`额度 = 价格 × QuotaPerUnit` +- 使用 `decimal` 包进行精确的浮点数计算 + +### 数据冗余 + +- 已购服务器表中保存了服务器标题和配置的副本 +- 即使原服务器配置被删除或修改,用户已购买的服务器信息仍然保留 + +## 数据库迁移 + +已在 `model/main.go` 中添加了自动迁移: +- `Server` 表 +- `PurchasedServer` 表 + +系统启动时会自动创建这两张表。 + +## 安全性 + +- 所有管理员接口都使用 `middleware.AdminAuth()` 保护 +- 所有用户接口都使用 `middleware.UserAuth()` 保护 +- 购买时进行额度检查和库存检查 +- 使用事务保证数据一致性(在额度扣除失败时回滚) + +## UI/UX 特性 + +- 响应式设计,支持不同屏幕尺寸 +- 使用 Semi Design 组件库,保持与项目整体风格一致 +- 卡片式布局展示服务器,直观美观 +- 实时显示剩余天数和过期状态 +- 搜索和分页功能,方便管理大量数据 +- 友好的确认对话框和成功提示 + +## 扩展性 + +该实现具有良好的扩展性,可以轻松添加: +- 服务器分类 +- 优惠券/折扣功能 +- 自动续费 +- 服务器到期提醒(邮件/站内信) +- 更多的服务器配置字段 +- 批量操作功能 + +## 测试建议 + +1. 测试管理员创建、编辑、删除服务器配置 +2. 测试用户购买服务器(正常流程) +3. 测试额度不足时的购买失败 +4. 测试库存为0时无法购买 +5. 测试已禁用服务器无法购买 +6. 测试服务器配置的查看功能 +7. 测试搜索和分页功能 +8. 测试过期服务器的状态显示 + +## 注意事项 + +1. 确保 `common.QuotaPerUnit` 配置正确 +2. 购买时会记录操作日志,可在日志页面查看 +3. 服务器配置可以包含任意文本,建议使用 JSON 或 YAML 格式 +4. 库存设置为 -1 表示无限库存 +5. 到期时间自动计算:购买时间 + 时长(天)× 86400 秒 diff --git a/controller/server.go b/controller/server.go new file mode 100644 index 0000000..c57a412 --- /dev/null +++ b/controller/server.go @@ -0,0 +1,468 @@ +package controller + +import ( + "fmt" + "net/http" + "one-api/common" + "one-api/model" + "strconv" + "time" + + "github.com/gin-gonic/gin" + "github.com/shopspring/decimal" +) + +// GetAllServers 获取所有服务器配置(管理员) +func GetAllServers(c *gin.Context) { + p, _ := strconv.Atoi(c.Query("p")) + if p < 0 { + p = 0 + } + pageSize, _ := strconv.Atoi(c.Query("page_size")) + if pageSize <= 0 { + pageSize = common.ItemsPerPage + } + + pageInfo := &common.PageInfo{ + Page: p, + PageSize: pageSize, + } + + servers, total, err := model.GetAllServers(pageInfo) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": servers, + "total": total, + }) +} + +// SearchServers 搜索服务器配置(管理员) +func SearchServers(c *gin.Context) { + keyword := c.Query("keyword") + p, _ := strconv.Atoi(c.Query("p")) + if p < 0 { + p = 0 + } + pageSize, _ := strconv.Atoi(c.Query("page_size")) + if pageSize <= 0 { + pageSize = common.ItemsPerPage + } + + servers, total, err := model.SearchServers(keyword, p*pageSize, pageSize) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": servers, + "total": total, + }) +} + +// GetServer 获取单个服务器配置(管理员) +func GetServer(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + server, err := model.GetServerById(id) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": server, + }) +} + +// AddServer 添加服务器配置(管理员) +func AddServer(c *gin.Context) { + server := model.Server{} + err := c.ShouldBindJSON(&server) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + err = server.Insert() + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": server, + }) +} + +// UpdateServer 更新服务器配置(管理员) +func UpdateServer(c *gin.Context) { + server := model.Server{} + err := c.ShouldBindJSON(&server) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + err = server.Update() + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": server, + }) +} + +// DeleteServer 删除服务器配置(管理员) +func DeleteServer(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + err = model.DeleteServerById(id) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + }) +} + +// GetEnabledServers 获取启用的服务器列表(用户) +func GetEnabledServers(c *gin.Context) { + servers, err := model.GetEnabledServers() + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": servers, + }) +} + +// GetAllPurchasedServers 获取所有已购服务器(管理员) +func GetAllPurchasedServers(c *gin.Context) { + p, _ := strconv.Atoi(c.Query("p")) + if p < 0 { + p = 0 + } + pageSize, _ := strconv.Atoi(c.Query("page_size")) + if pageSize <= 0 { + pageSize = common.ItemsPerPage + } + + pageInfo := &common.PageInfo{ + Page: p, + PageSize: pageSize, + } + + servers, total, err := model.GetAllPurchasedServers(pageInfo) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + + // 获取用户名 + type PurchasedServerWithUsername struct { + model.PurchasedServer + Username string `json:"username"` + } + + serversWithUsername := make([]PurchasedServerWithUsername, len(servers)) + for i, server := range servers { + username, _ := model.GetUsernameById(server.UserId, false) + serversWithUsername[i] = PurchasedServerWithUsername{ + PurchasedServer: *server, + Username: username, + } + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": serversWithUsername, + "total": total, + }) +} + +// SearchPurchasedServers 搜索已购服务器(管理员) +func SearchPurchasedServers(c *gin.Context) { + keyword := c.Query("keyword") + p, _ := strconv.Atoi(c.Query("p")) + if p < 0 { + p = 0 + } + pageSize, _ := strconv.Atoi(c.Query("page_size")) + if pageSize <= 0 { + pageSize = common.ItemsPerPage + } + + servers, total, err := model.SearchPurchasedServers(keyword, p*pageSize, pageSize) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + + // 获取用户名 + type PurchasedServerWithUsername struct { + model.PurchasedServer + Username string `json:"username"` + } + + serversWithUsername := make([]PurchasedServerWithUsername, len(servers)) + for i, server := range servers { + username, _ := model.GetUsernameById(server.UserId, false) + serversWithUsername[i] = PurchasedServerWithUsername{ + PurchasedServer: *server, + Username: username, + } + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": serversWithUsername, + "total": total, + }) +} + +// GetUserPurchasedServers 获取用户已购服务器列表 +func GetUserPurchasedServers(c *gin.Context) { + userId := c.GetInt("id") + p, _ := strconv.Atoi(c.Query("p")) + if p < 0 { + p = 0 + } + pageSize, _ := strconv.Atoi(c.Query("page_size")) + if pageSize <= 0 { + pageSize = common.ItemsPerPage + } + + pageInfo := &common.PageInfo{ + Page: p, + PageSize: pageSize, + } + + servers, total, err := model.GetUserPurchasedServers(userId, pageInfo) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": servers, + "total": total, + }) +} + +type PurchaseServerRequest struct { + ServerId int `json:"server_id" binding:"required"` + TradeNo string `json:"trade_no"` +} + +// PurchaseServer 购买服务器 +func PurchaseServer(c *gin.Context) { + var req PurchaseServerRequest + err := c.ShouldBindJSON(&req) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "参数错误", + }) + return + } + + userId := c.GetInt("id") + + // 获取服务器配置 + server, err := model.GetServerById(req.ServerId) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "服务器配置不存在", + }) + return + } + + if server.Status != 1 { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "服务器已下架", + }) + return + } + + // 检查库存 + if server.Stock == 0 { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "服务器库存不足", + }) + return + } + + // 计算需要扣除的额度 + dPrice := decimal.NewFromFloat(server.Price) + dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit) + quotaNeeded := int(dPrice.Mul(dQuotaPerUnit).IntPart()) + + // 检查用户额度 + userQuota, err := model.GetUserQuota(userId, true) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "获取用户额度失败", + }) + return + } + + if userQuota < quotaNeeded { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": fmt.Sprintf("额度不足,需要 %s,当前额度 %s", common.LogQuota(quotaNeeded), common.LogQuota(userQuota)), + }) + return + } + + // 扣除用户额度 + err = model.DecreaseUserQuota(userId, quotaNeeded) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "扣除额度失败", + }) + return + } + + // 创建已购服务器记录 + now := time.Now().Unix() + expireTime := now + int64(server.Duration*24*3600) + + tradeNo := req.TradeNo + if tradeNo == "" { + tradeNo = fmt.Sprintf("SRV%dNO%s", userId, fmt.Sprintf("%s%d", common.GetRandomString(6), time.Now().Unix())) + } + + purchasedServer := &model.PurchasedServer{ + UserId: userId, + ServerId: server.Id, + ServerTitle: server.Title, + ServerConfig: server.Config, + Price: server.Price, + Duration: server.Duration, + PurchaseTime: now, + ExpireTime: expireTime, + Status: 1, + TradeNo: tradeNo, + } + + err = purchasedServer.Insert() + if err != nil { + // 回滚额度 + _ = model.IncreaseUserQuota(userId, quotaNeeded, true) + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "创建订单失败", + }) + return + } + + // 更新库存 + if server.Stock > 0 { + server.Stock-- + _ = server.Update() + } + + // 记录日志 + model.RecordLog(userId, model.LogTypeSystem, fmt.Sprintf("购买服务器 %s,消耗额度 %s,有效期 %d 天", server.Title, common.LogQuota(quotaNeeded), server.Duration)) + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "购买成功", + "data": purchasedServer, + }) +} + +// DeletePurchasedServer 删除已购服务器记录(管理员) +func DeletePurchasedServer(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + err = model.DeletePurchasedServerById(id) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + }) +} diff --git a/model/main.go b/model/main.go index 013beac..922a540 100644 --- a/model/main.go +++ b/model/main.go @@ -251,6 +251,8 @@ func migrateDB() error { &QuotaData{}, &Task{}, &Setup{}, + &Server{}, + &PurchasedServer{}, ) if err != nil { return err @@ -277,6 +279,8 @@ func migrateDBFast() error { {&QuotaData{}, "QuotaData"}, {&Task{}, "Task"}, {&Setup{}, "Setup"}, + {&Server{}, "Server"}, + {&PurchasedServer{}, "PurchasedServer"}, } // 动态计算migration数量,确保errChan缓冲区足够大 errChan := make(chan error, len(migrations)) diff --git a/model/purchased_server.go b/model/purchased_server.go new file mode 100644 index 0000000..6e8c087 --- /dev/null +++ b/model/purchased_server.go @@ -0,0 +1,158 @@ +package model + +import ( + "errors" + "one-api/common" +) + +// PurchasedServer 已购服务器表 +type PurchasedServer struct { + Id int `json:"id"` + UserId int `json:"user_id" gorm:"index"` // 用户ID + ServerId int `json:"server_id" gorm:"index"` // 服务器配置ID + ServerTitle string `json:"server_title" gorm:"type:varchar(255)"` // 服务器标题(冗余字段,防止配置被删除) + ServerConfig string `json:"server_config" gorm:"type:text"` // 服务器配置(冗余字段) + Price float64 `json:"price"` // 购买价格 + Duration int `json:"duration"` // 购买时长(天) + PurchaseTime int64 `json:"purchase_time" gorm:"bigint;index"` // 购买时间 + ExpireTime int64 `json:"expire_time" gorm:"bigint;index"` // 到期时间 + Status int `json:"status" gorm:"default:1"` // 状态:1-正常,2-已过期 + TradeNo string `json:"trade_no" gorm:"type:varchar(255);index"` // 交易单号 +} + +func (ps *PurchasedServer) Insert() error { + return DB.Create(ps).Error +} + +func (ps *PurchasedServer) Update() error { + return DB.Save(ps).Error +} + +func GetPurchasedServerById(id int) (*PurchasedServer, error) { + if id == 0 { + return nil, errors.New("id 为空") + } + ps := PurchasedServer{Id: id} + err := DB.First(&ps, "id = ?", id).Error + return &ps, err +} + +func GetPurchasedServerByTradeNo(tradeNo string) (*PurchasedServer, error) { + var ps PurchasedServer + err := DB.Where("trade_no = ?", tradeNo).First(&ps).Error + if err != nil { + return nil, err + } + return &ps, nil +} + +func GetAllPurchasedServers(pageInfo *common.PageInfo) (servers []*PurchasedServer, total int64, err error) { + tx := DB.Begin() + if tx.Error != nil { + return nil, 0, tx.Error + } + defer func() { + if r := recover(); r != nil { + tx.Rollback() + } + }() + + err = tx.Model(&PurchasedServer{}).Count(&total).Error + if err != nil { + tx.Rollback() + return nil, 0, err + } + + err = tx.Order("id desc").Limit(pageInfo.GetPageSize()).Offset(pageInfo.GetStartIdx()).Find(&servers).Error + if err != nil { + tx.Rollback() + return nil, 0, err + } + + if err = tx.Commit().Error; err != nil { + return nil, 0, err + } + + return servers, total, nil +} + +func GetUserPurchasedServers(userId int, pageInfo *common.PageInfo) (servers []*PurchasedServer, total int64, err error) { + tx := DB.Begin() + if tx.Error != nil { + return nil, 0, tx.Error + } + defer func() { + if r := recover(); r != nil { + tx.Rollback() + } + }() + + err = tx.Model(&PurchasedServer{}).Where("user_id = ?", userId).Count(&total).Error + if err != nil { + tx.Rollback() + return nil, 0, err + } + + err = tx.Where("user_id = ?", userId).Order("id desc").Limit(pageInfo.GetPageSize()).Offset(pageInfo.GetStartIdx()).Find(&servers).Error + if err != nil { + tx.Rollback() + return nil, 0, err + } + + if err = tx.Commit().Error; err != nil { + return nil, 0, err + } + + return servers, total, nil +} + +func SearchPurchasedServers(keyword string, startIdx int, num int) (servers []*PurchasedServer, total int64, err error) { + tx := DB.Begin() + if tx.Error != nil { + return nil, 0, tx.Error + } + defer func() { + if r := recover(); r != nil { + tx.Rollback() + } + }() + + query := tx.Model(&PurchasedServer{}) + if keyword != "" { + // 支持按用户ID、服务器标题搜索 + query = query.Where("server_title LIKE ? OR user_id = ?", "%"+keyword+"%", keyword) + } + + err = query.Count(&total).Error + if err != nil { + tx.Rollback() + return nil, 0, err + } + + err = query.Order("id desc").Limit(num).Offset(startIdx).Find(&servers).Error + if err != nil { + tx.Rollback() + return nil, 0, err + } + + if err = tx.Commit().Error; err != nil { + return nil, 0, err + } + + return servers, total, nil +} + +func DeletePurchasedServerById(id int) error { + if id == 0 { + return errors.New("id 为空") + } + return DB.Delete(&PurchasedServer{}, "id = ?", id).Error +} + +// UpdateExpiredServers 更新过期服务器状态 +func UpdateExpiredServers() error { + now := common.GetTimestamp() + return DB.Model(&PurchasedServer{}). + Where("expire_time < ? AND status = ?", now, 1). + Update("status", 2).Error +} diff --git a/model/server.go b/model/server.go new file mode 100644 index 0000000..84b5eff --- /dev/null +++ b/model/server.go @@ -0,0 +1,121 @@ +package model + +import ( + "errors" + "one-api/common" +) + +// Server 服务器配置表 +type Server struct { + Id int `json:"id"` + Title string `json:"title" gorm:"type:varchar(255);not null"` // 服务器标题 + Config string `json:"config" gorm:"type:text"` // 配置信息 + Price float64 `json:"price" gorm:"not null"` // 价格(单位:元) + Duration int `json:"duration" gorm:"not null"` // 时长(单位:天) + Status int `json:"status" gorm:"default:1"` // 状态:1-启用,2-禁用 + CreatedTime int64 `json:"created_time" gorm:"bigint"` + UpdatedTime int64 `json:"updated_time" gorm:"bigint"` + Description string `json:"description" gorm:"type:text"` // 服务器描述 + Stock int `json:"stock" gorm:"default:-1"` // 库存数量,-1表示无限 +} + +func (server *Server) Insert() error { + server.CreatedTime = common.GetTimestamp() + server.UpdatedTime = common.GetTimestamp() + return DB.Create(server).Error +} + +func (server *Server) Update() error { + server.UpdatedTime = common.GetTimestamp() + return DB.Save(server).Error +} + +func GetServerById(id int) (*Server, error) { + if id == 0 { + return nil, errors.New("id 为空") + } + server := Server{Id: id} + err := DB.First(&server, "id = ?", id).Error + return &server, err +} + +func GetAllServers(pageInfo *common.PageInfo) (servers []*Server, total int64, err error) { + tx := DB.Begin() + if tx.Error != nil { + return nil, 0, tx.Error + } + defer func() { + if r := recover(); r != nil { + tx.Rollback() + } + }() + + err = tx.Model(&Server{}).Count(&total).Error + if err != nil { + tx.Rollback() + return nil, 0, err + } + + err = tx.Order("id desc").Limit(pageInfo.GetPageSize()).Offset(pageInfo.GetStartIdx()).Find(&servers).Error + if err != nil { + tx.Rollback() + return nil, 0, err + } + + if err = tx.Commit().Error; err != nil { + return nil, 0, err + } + + return servers, total, nil +} + +func GetEnabledServers() ([]*Server, error) { + var servers []*Server + err := DB.Where("status = ?", 1).Order("id asc").Find(&servers).Error + return servers, err +} + +func DeleteServerById(id int) error { + if id == 0 { + return errors.New("id 为空") + } + return DB.Delete(&Server{}, "id = ?", id).Error +} + +func SearchServers(keyword string, startIdx int, num int) ([]*Server, int64, error) { + var servers []*Server + var total int64 + + tx := DB.Begin() + if tx.Error != nil { + return nil, 0, tx.Error + } + defer func() { + if r := recover(); r != nil { + tx.Rollback() + } + }() + + query := tx.Model(&Server{}) + if keyword != "" { + query = query.Where("title LIKE ? OR description LIKE ?", "%"+keyword+"%", "%"+keyword+"%") + } + + err := query.Count(&total).Error + if err != nil { + tx.Rollback() + return nil, 0, err + } + + err = query.Order("id desc").Limit(num).Offset(startIdx).Find(&servers).Error + if err != nil { + tx.Rollback() + return nil, 0, err + } + + if err = tx.Commit().Error; err != nil { + return nil, 0, err + } + + return servers, total, nil +} diff --git a/router/api-router.go b/router/api-router.go index bc49803..cf3a9ec 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -175,5 +175,26 @@ func SetApiRouter(router *gin.Engine) { taskRoute.GET("/self", middleware.UserAuth(), controller.GetUserTask) taskRoute.GET("/", middleware.AdminAuth(), controller.GetAllTask) } + + serverRoute := apiRouter.Group("/server") + { + // 管理员路由 + serverRoute.GET("/", middleware.AdminAuth(), controller.GetAllServers) + serverRoute.GET("/search", middleware.AdminAuth(), controller.SearchServers) + serverRoute.GET("/:id", middleware.AdminAuth(), controller.GetServer) + serverRoute.POST("/", middleware.AdminAuth(), controller.AddServer) + serverRoute.PUT("/", middleware.AdminAuth(), controller.UpdateServer) + serverRoute.DELETE("/:id", middleware.AdminAuth(), controller.DeleteServer) + + // 用户路由 + serverRoute.GET("/enabled", middleware.UserAuth(), controller.GetEnabledServers) + serverRoute.POST("/purchase", middleware.UserAuth(), controller.PurchaseServer) + serverRoute.GET("/purchased/self", middleware.UserAuth(), controller.GetUserPurchasedServers) + + // 已购服务器管理(管理员) + serverRoute.GET("/purchased", middleware.AdminAuth(), controller.GetAllPurchasedServers) + serverRoute.GET("/purchased/search", middleware.AdminAuth(), controller.SearchPurchasedServers) + serverRoute.DELETE("/purchased/:id", middleware.AdminAuth(), controller.DeletePurchasedServer) + } } } diff --git a/web/package.json b/web/package.json index 4186f6f..40786f9 100644 --- a/web/package.json +++ b/web/package.json @@ -82,5 +82,11 @@ "singleQuote": true, "jsxSingleQuote": true }, - "proxy": "http://localhost:3000" + "proxy": "http://localhost:3000", + "overrides": { + "@lobehub/icons": { + "react": "$react", + "react-dom": "$react-dom" + } + } } diff --git a/web/src/App.js b/web/src/App.js index bdd7047..c010d68 100644 --- a/web/src/App.js +++ b/web/src/App.js @@ -28,6 +28,10 @@ import OAuth2Callback from './components/auth/OAuth2Callback.js'; import PersonalSetting from './components/settings/PersonalSetting.js'; import Setup from './pages/Setup/index.js'; import SetupCheck from './components/layout/SetupCheck.js'; +import Server from './pages/Server'; +import PurchasedServer from './pages/PurchasedServer'; +import ServerShop from './pages/ServerShop'; +import MyServers from './pages/MyServers'; const Home = lazy(() => import('./pages/Home')); const Detail = lazy(() => import('./pages/Detail')); @@ -273,6 +277,38 @@ function App() { } /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> { } }) => { @@ -110,6 +114,16 @@ const SiderBar = ({ onNavigate = () => { } }) => { itemKey: 'topup', to: '/topup', }, + { + text: t('服务器购买'), + itemKey: 'server-shop', + to: '/server-shop', + }, + { + text: t('我的服务器'), + itemKey: 'my-servers', + to: '/my-servers', + }, { text: t('个人设置'), itemKey: 'personal', @@ -145,6 +159,18 @@ const SiderBar = ({ onNavigate = () => { } }) => { to: '/invoice-management', className: isAdmin() ? '' : 'tableHiddle', }, + { + text: t('服务器管理'), + itemKey: 'server', + to: '/server', + className: isAdmin() ? '' : 'tableHiddle', + }, + { + text: t('已售服务器'), + itemKey: 'purchased-server', + to: '/purchased-server', + className: isAdmin() ? '' : 'tableHiddle', + }, { text: t('系统设置'), itemKey: 'setting', @@ -262,6 +288,10 @@ const SiderBar = ({ onNavigate = () => { } }) => { case 'user': case 'personal': return sidebarIconColors.user; case 'setting': return sidebarIconColors.settings; + case 'server': + case 'purchased-server': + case 'server-shop': + case 'my-servers': return sidebarIconColors.server; default: // 处理聊天项 if (itemKey && itemKey.startsWith('chat')) return sidebarIconColors.message; diff --git a/web/src/components/table/PurchasedServersTable.js b/web/src/components/table/PurchasedServersTable.js new file mode 100644 index 0000000..365115d --- /dev/null +++ b/web/src/components/table/PurchasedServersTable.js @@ -0,0 +1,301 @@ +import React, { useEffect, useState } from 'react'; +import { + API, + showError, + showSuccess, + timestamp2string, +} from '../../helpers'; + +import { Server } from 'lucide-react'; + +import { ITEMS_PER_PAGE } from '../../constants'; +import { + Button, + Card, + Dropdown, + Empty, + Modal, + Space, + Table, + Tag, + Typography, + Input, +} from '@douyinfe/semi-ui'; +import { + IllustrationNoResult, + IllustrationNoResultDark, +} from '@douyinfe/semi-illustrations'; +import { IconSearch, IconMore } from '@douyinfe/semi-icons'; +import { useTranslation } from 'react-i18next'; +import { useTableCompactMode } from '../../hooks/useTableCompactMode'; + +const { Text } = Typography; + +function renderTimestamp(timestamp) { + return <>{timestamp2string(timestamp)}; +} + +const PurchasedServersTable = () => { + const { t } = useTranslation(); + const { isCompactMode } = useTableCompactMode(); + + const columns = [ + { + title: t('ID'), + dataIndex: 'id', + width: 60, + }, + { + title: t('用户名'), + dataIndex: 'username', + width: 120, + }, + { + title: t('用户ID'), + dataIndex: 'user_id', + width: 80, + }, + { + title: t('服务器标题'), + dataIndex: 'server_title', + }, + { + title: t('价格'), + dataIndex: 'price', + render: (text) => { + return ¥{text}; + }, + }, + { + title: t('购买时间'), + dataIndex: 'purchase_time', + render: (text) => { + return
{renderTimestamp(text)}
; + }, + }, + { + title: t('到期时间'), + dataIndex: 'expire_time', + render: (text) => { + const now = Math.floor(Date.now() / 1000); + const isExpired = text < now; + return ( +
+ {renderTimestamp(text)} + {isExpired && {t('已过期')}} +
+ ); + }, + }, + { + title: t('状态'), + dataIndex: 'status', + width: 80, + render: (text) => { + return text === 1 ? ( + {t('正常')} + ) : ( + {t('已过期')} + ); + }, + }, + { + title: '', + dataIndex: 'operate', + fixed: 'right', + width: 150, + render: (text, record) => { + const moreMenuItems = [ + { + node: 'item', + name: t('查看配置'), + onClick: () => { + Modal.info({ + title: t('服务器配置'), + content: ( +
+ {record.server_config} +
+ ), + style: { width: 600 }, + }); + }, + }, + { + node: 'item', + name: t('删除'), + type: 'danger', + onClick: () => { + Modal.confirm({ + title: t('确定是否要删除此记录?'), + content: t('此修改将不可逆'), + onOk: () => { + (async () => { + await deletePurchasedServer(record.id); + await refresh(); + })(); + }, + }); + }, + }, + ]; + + return ( + + + + + } + > + } + darkModeImage={} + description={t('暂无数据')} + /> + } + /> + + ); +}; + +export default PurchasedServersTable; diff --git a/web/src/components/table/ServersTable.js b/web/src/components/table/ServersTable.js new file mode 100644 index 0000000..2903f85 --- /dev/null +++ b/web/src/components/table/ServersTable.js @@ -0,0 +1,411 @@ +import React, { useEffect, useState } from 'react'; +import { + API, + showError, + showSuccess, + timestamp2string, +} from '../../helpers'; + +import { Server } from 'lucide-react'; + +import { ITEMS_PER_PAGE } from '../../constants'; +import { + Button, + Card, + Divider, + Dropdown, + Empty, + Form, + Modal, + Space, + Table, + Tag, + Typography, + Input, + InputNumber, + TextArea, + Select, +} from '@douyinfe/semi-ui'; +import { + IllustrationNoResult, + IllustrationNoResultDark, +} from '@douyinfe/semi-illustrations'; +import { IconSearch, IconMore } from '@douyinfe/semi-icons'; +import { useTranslation } from 'react-i18next'; +import { useTableCompactMode } from '../../hooks/useTableCompactMode'; + +const { Text } = Typography; + +function renderTimestamp(timestamp) { + return <>{timestamp2string(timestamp)}; +} + +const ServersTable = () => { + const { t } = useTranslation(); + const { isCompactMode } = useTableCompactMode(); + + const columns = [ + { + title: t('ID'), + dataIndex: 'id', + width: 60, + }, + { + title: t('标题'), + dataIndex: 'title', + }, + { + title: t('价格'), + dataIndex: 'price', + render: (text) => { + return ¥{text}; + }, + }, + { + title: t('时长(天)'), + dataIndex: 'duration', + width: 100, + }, + { + title: t('库存'), + dataIndex: 'stock', + width: 80, + render: (text) => { + if (text === -1) { + return {t('无限')}; + } + return 0 ? 'green' : 'red'}>{text}; + }, + }, + { + title: t('状态'), + dataIndex: 'status', + width: 80, + render: (text) => { + return text === 1 ? ( + {t('启用')} + ) : ( + {t('禁用')} + ); + }, + }, + { + title: t('创建时间'), + dataIndex: 'created_time', + render: (text) => { + return
{renderTimestamp(text)}
; + }, + }, + { + title: '', + dataIndex: 'operate', + fixed: 'right', + width: 150, + render: (text, record) => { + const moreMenuItems = [ + { + node: 'item', + name: t('删除'), + type: 'danger', + onClick: () => { + Modal.confirm({ + title: t('确定是否要删除此服务器配置?'), + content: t('此修改将不可逆'), + onOk: () => { + (async () => { + await deleteServer(record.id); + await refresh(); + })(); + }, + }); + }, + }, + ]; + + return ( + + + + + + + } + > +
} + darkModeImage={} + description={t('暂无数据')} + /> + } + /> + + + + {editingServer && ( +
+ + + + + + + + {t('启用')} + {t('禁用')} + + + + + + + )} +
+ + ); +}; + +export default ServersTable; diff --git a/web/src/helpers/render.js b/web/src/helpers/render.js index 34ba78d..49d4937 100644 --- a/web/src/helpers/render.js +++ b/web/src/helpers/render.js @@ -50,6 +50,7 @@ import { User, Settings, CircleUser, + Server as ServerIcon, } from 'lucide-react'; // 侧边栏图标颜色映射 @@ -66,6 +67,7 @@ export const sidebarIconColors = { gift: '#F43F5E', // 玫红色 user: '#10B981', // 绿色 settings: '#F97316', // 橙色 + server: '#8B5CF6', // 紫色 }; // 获取侧边栏Lucide图标组件 @@ -165,6 +167,16 @@ export function getLucideIcon(key, selected = false) { color={selected ? sidebarIconColors.settings : 'currentColor'} /> ); + case 'server': + case 'purchased-server': + case 'server-shop': + case 'my-servers': + return ( + + ); default: return ( {timestamp2string(timestamp)}; +} + +const MyServers = () => { + const { t } = useTranslation(); + const { isCompactMode } = useTableCompactMode(); + + const columns = [ + { + title: t('ID'), + dataIndex: 'id', + width: 60, + }, + { + title: t('服务器标题'), + dataIndex: 'server_title', + }, + { + title: t('价格'), + dataIndex: 'price', + render: (text) => { + return ¥{text}; + }, + }, + { + title: t('购买时间'), + dataIndex: 'purchase_time', + render: (text) => { + return
{renderTimestamp(text)}
; + }, + }, + { + title: t('到期时间'), + dataIndex: 'expire_time', + render: (text) => { + const now = Math.floor(Date.now() / 1000); + const isExpired = text < now; + const daysLeft = Math.ceil((text - now) / 86400); + + return ( +
+ {renderTimestamp(text)} + {isExpired ? ( + {t('已过期')} + ) : daysLeft <= 7 ? ( + + {t('剩余')} {daysLeft} {t('天')} + + ) : ( + + {t('剩余')} {daysLeft} {t('天')} + + )} +
+ ); + }, + }, + { + title: t('状态'), + dataIndex: 'status', + width: 80, + render: (text) => { + return text === 1 ? ( + {t('正常')} + ) : ( + {t('已过期')} + ); + }, + }, + { + title: '', + dataIndex: 'operate', + fixed: 'right', + width: 100, + render: (text, record) => { + return ( + + ); + }, + }, + ]; + + const [servers, setServers] = useState([]); + const [loading, setLoading] = useState(true); + const [activePage, setActivePage] = useState(1); + const [total, setTotal] = useState(0); + + const loadServers = async (page = 1) => { + setLoading(true); + try { + const res = await API.get(`/api/server/purchased/self?p=${page - 1}&page_size=${ITEMS_PER_PAGE}`); + const { success, message, data, total } = res.data; + if (success) { + if (page === 1) { + setServers(data || []); + } else { + let newServers = [...servers]; + newServers.splice((page - 1) * ITEMS_PER_PAGE, data.length, ...data); + setServers(newServers); + } + setTotal(total); + } else { + showError(message); + } + } catch (error) { + showError(error.message); + } + setLoading(false); + }; + + useEffect(() => { + loadServers(); + }, []); + + const showConfig = (server) => { + Modal.info({ + title: ( + + + {server.server_title} - {t('配置信息')} + + ), + content: ( +
+
+ + {t('购买时间')}: {timestamp2string(server.purchase_time)} + {t('到期时间')}: {timestamp2string(server.expire_time)} + +
+
+ {server.server_config} +
+
+ ), + style: { width: 700 }, + }); + }; + + const handlePageChange = (page) => { + setActivePage(page); + loadServers(page); + }; + + return ( +
+ + + + {t('我的服务器')} + + + } + > +
} + darkModeImage={} + description={t('您还没有购买任何服务器')} + /> + } + /> + + + ); +}; + +export default MyServers; diff --git a/web/src/pages/PurchasedServer/index.js b/web/src/pages/PurchasedServer/index.js new file mode 100644 index 0000000..207a028 --- /dev/null +++ b/web/src/pages/PurchasedServer/index.js @@ -0,0 +1,12 @@ +import React from 'react'; +import PurchasedServersTable from '../../components/table/PurchasedServersTable'; + +const PurchasedServer = () => { + return ( +
+ +
+ ); +}; + +export default PurchasedServer; diff --git a/web/src/pages/Server/index.js b/web/src/pages/Server/index.js new file mode 100644 index 0000000..c84bcb5 --- /dev/null +++ b/web/src/pages/Server/index.js @@ -0,0 +1,12 @@ +import React from 'react'; +import ServersTable from '../../components/table/ServersTable'; + +const Server = () => { + return ( +
+ +
+ ); +}; + +export default Server; diff --git a/web/src/pages/ServerShop/index.js b/web/src/pages/ServerShop/index.js new file mode 100644 index 0000000..e6341f2 --- /dev/null +++ b/web/src/pages/ServerShop/index.js @@ -0,0 +1,208 @@ +import React, { useEffect, useState } from 'react'; +import { API, showError, showSuccess, renderQuota } from '../../helpers'; +import { + Card, + Col, + Row, + Button, + Typography, + Space, + Tag, + Modal, + Spin, + Empty, +} from '@douyinfe/semi-ui'; +import { Server, ShoppingCart } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations'; + +const { Text, Title, Paragraph } = Typography; + +const ServerShop = () => { + const { t } = useTranslation(); + const [servers, setServers] = useState([]); + const [loading, setLoading] = useState(true); + const [purchasing, setPurchasing] = useState(false); + + const loadServers = async () => { + setLoading(true); + try { + const res = await API.get('/api/server/enabled'); + const { success, message, data } = res.data; + if (success) { + setServers(data || []); + } else { + showError(message); + } + } catch (error) { + showError(error.message); + } + setLoading(false); + }; + + useEffect(() => { + loadServers(); + }, []); + + const handlePurchase = (server) => { + Modal.confirm({ + title: t('确认购买'), + content: ( +
+

{t('服务器')}: {server.title}

+

{t('价格')}: ¥{server.price}

+

{t('有效期')}: {server.duration} {t('天')}

+

+ {t('购买后将从您的账户额度中扣除相应金额')} +

+
+ ), + onOk: async () => { + setPurchasing(true); + try { + const res = await API.post('/api/server/purchase', { + server_id: server.id, + }); + const { success, message, data } = res.data; + if (success) { + showSuccess(t('购买成功')); + Modal.success({ + title: t('购买成功'), + content: ( +
+

{t('服务器配置')}:

+
+ {data.server_config} +
+
+ ), + style: { width: 600 }, + }); + } else { + showError(message); + } + } catch (error) { + showError(error.message); + } + setPurchasing(false); + }, + }); + }; + + if (loading) { + return ( +
+
+ +
+
+ ); + } + + return ( +
+ + + + + {t('服务器购买')} + + {t('选择您需要的服务器配置进行购买')} + + + + {servers.length === 0 ? ( + + } + darkModeImage={} + description={t('暂无可购买的服务器')} + /> + + ) : ( + + {servers.map((server) => ( +
+ + + {server.title} + + } + headerExtraContent={ + + ¥{server.price} + + } + bodyStyle={{ minHeight: 200 }} + > + + {server.description && ( +
+ {server.description} +
+ )} + +
+ + {t('有效期')}: {server.duration} {t('天')} + {server.stock === -1 ? ( + {t('库存充足')} + ) : ( + 0 ? 'orange' : 'red'}> + {t('剩余')}: {server.stock} + + )} + +
+ + {server.config && ( +
+ {t('配置预览')}: +
+ {server.config.length > 200 + ? server.config.substring(0, 200) + '...' + : server.config} +
+
+ )} + + +
+
+ + ))} + + )} + + ); +}; + +export default ServerShop;