diff --git a/common/constants.go b/common/constants.go index 1298789e..523bf07d 100644 --- a/common/constants.go +++ b/common/constants.go @@ -93,6 +93,12 @@ const ( TokenStatusExhausted = 4 ) +const ( + RedemptionCodeStatusEnabled = 1 // don't use 0, 0 is the default value! + RedemptionCodeStatusDisabled = 2 // also don't use 0 + RedemptionCodeStatusUsed = 3 // also don't use 0 +) + const ( ChannelStatusUnknown = 0 ChannelStatusEnabled = 1 // don't use 0, 0 is the default value! diff --git a/controller/redemption.go b/controller/redemption.go new file mode 100644 index 00000000..0f656be0 --- /dev/null +++ b/controller/redemption.go @@ -0,0 +1,192 @@ +package controller + +import ( + "github.com/gin-gonic/gin" + "net/http" + "one-api/common" + "one-api/model" + "strconv" +) + +func GetAllRedemptions(c *gin.Context) { + p, _ := strconv.Atoi(c.Query("p")) + if p < 0 { + p = 0 + } + redemptions, err := model.GetAllRedemptions(p*common.ItemsPerPage, common.ItemsPerPage) + 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": redemptions, + }) + return +} + +func SearchRedemptions(c *gin.Context) { + keyword := c.Query("keyword") + redemptions, err := model.SearchRedemptions(keyword) + 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": redemptions, + }) + return +} + +func GetRedemption(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 + } + redemption, err := model.GetRedemptionById(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": redemption, + }) + return +} + +func AddRedemption(c *gin.Context) { + redemption := model.Redemption{} + err := c.ShouldBindJSON(&redemption) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + if len(redemption.Name) == 0 || len(redemption.Name) > 20 { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "兑换码名称长度必须在1-20之间", + }) + return + } + if redemption.Count <= 0 { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "兑换码个数必须大于0", + }) + return + } + if redemption.Count > 100 { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "一次兑换码批量生成的个数不能大于 100", + }) + return + } + var keys []string + for i := 0; i < redemption.Count; i++ { + key := common.GetUUID() + cleanRedemption := model.Redemption{ + UserId: c.GetInt("id"), + Name: redemption.Name, + Key: key, + CreatedTime: common.GetTimestamp(), + Quota: redemption.Quota, + } + err = cleanRedemption.Insert() + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + "data": keys, + }) + return + } + keys = append(keys, key) + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": keys, + }) + return +} + +func DeleteRedemption(c *gin.Context) { + id, _ := strconv.Atoi(c.Param("id")) + err := model.DeleteRedemptionById(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": "", + }) + return +} + +func UpdateRedemption(c *gin.Context) { + statusOnly := c.Query("status_only") + redemption := model.Redemption{} + err := c.ShouldBindJSON(&redemption) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + cleanRedemption, err := model.GetRedemptionById(redemption.Id) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + if statusOnly != "" { + cleanRedemption.Status = redemption.Status + } else { + // If you add more fields, please also update redemption.Update() + cleanRedemption.Name = redemption.Name + cleanRedemption.Quota = redemption.Quota + } + err = cleanRedemption.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": cleanRedemption, + }) + return +} diff --git a/controller/token.go b/controller/token.go index f08c9911..b12d2e2d 100644 --- a/controller/token.go +++ b/controller/token.go @@ -201,3 +201,34 @@ func UpdateToken(c *gin.Context) { }) return } + +type topUpRequest struct { + Id int `json:"id"` + Key string `json:"key"` +} + +func TopUp(c *gin.Context) { + req := topUpRequest{} + err := c.ShouldBindJSON(&req) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + quota, err := model.Redeem(req.Key, req.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": quota, + }) + return +} diff --git a/model/main.go b/model/main.go index 8d739cf2..85b72acc 100644 --- a/model/main.go +++ b/model/main.go @@ -69,6 +69,10 @@ func InitDB() (err error) { if err != nil { return err } + err = db.AutoMigrate(&Redemption{}) + if err != nil { + return err + } err = createRootAccountIfNeed() return err } else { diff --git a/model/redemption.go b/model/redemption.go new file mode 100644 index 00000000..f0b80c88 --- /dev/null +++ b/model/redemption.go @@ -0,0 +1,107 @@ +package model + +import ( + "errors" + _ "gorm.io/driver/sqlite" + "one-api/common" +) + +type Redemption struct { + Id int `json:"id"` + UserId int `json:"user_id"` + Key string `json:"key" gorm:"uniqueIndex"` + Status int `json:"status" gorm:"default:1"` + Name string `json:"name" gorm:"index"` + Quota int `json:"quota" gorm:"default:100"` + CreatedTime int64 `json:"created_time" gorm:"bigint"` + RedeemedTime int64 `json:"redeemed_time" gorm:"bigint"` + Count int `json:"count" gorm:"-:all"` // only for api request +} + +func GetAllRedemptions(startIdx int, num int) ([]*Redemption, error) { + var redemptions []*Redemption + var err error + err = DB.Order("id desc").Limit(num).Offset(startIdx).Find(&redemptions).Error + return redemptions, err +} + +func SearchRedemptions(keyword string) (redemptions []*Redemption, err error) { + err = DB.Where("id = ? or name LIKE ?", keyword, keyword+"%").Find(&redemptions).Error + return redemptions, err +} + +func GetRedemptionById(id int) (*Redemption, error) { + if id == 0 { + return nil, errors.New("id 为空!") + } + redemption := Redemption{Id: id} + var err error = nil + err = DB.First(&redemption, "id = ?", id).Error + return &redemption, err +} + +func Redeem(key string, tokenId int) (quota int, err error) { + if key == "" { + return 0, errors.New("未提供兑换码") + } + if tokenId == 0 { + return 0, errors.New("未提供 token id") + } + redemption := &Redemption{} + err = DB.Where("key = ?", key).First(redemption).Error + if err != nil { + return 0, errors.New("无效的兑换码") + } + if redemption.Status != common.RedemptionCodeStatusEnabled { + return 0, errors.New("该兑换码已被使用") + } + err = TopUpToken(tokenId, redemption.Quota) + if err != nil { + return 0, err + } + go func() { + redemption.RedeemedTime = common.GetTimestamp() + redemption.Status = common.RedemptionCodeStatusUsed + err := redemption.SelectUpdate() + if err != nil { + common.SysError("更新兑换码状态失败:" + err.Error()) + } + }() + return redemption.Quota, nil +} + +func (redemption *Redemption) Insert() error { + var err error + err = DB.Create(redemption).Error + return err +} + +func (redemption *Redemption) SelectUpdate() error { + // This can update zero values + return DB.Model(redemption).Select("redeemed_time", "status").Updates(redemption).Error +} + +// Update Make sure your token's fields is completed, because this will update non-zero values +func (redemption *Redemption) Update() error { + var err error + err = DB.Model(redemption).Select("name", "status", "redeemed_time").Updates(redemption).Error + return err +} + +func (redemption *Redemption) Delete() error { + var err error + err = DB.Delete(redemption).Error + return err +} + +func DeleteRedemptionById(id int) (err error) { + if id == 0 { + return errors.New("id 为空!") + } + redemption := Redemption{Id: id} + err = DB.Where(redemption).First(&redemption).Error + if err != nil { + return err + } + return redemption.Delete() +} diff --git a/model/token.go b/model/token.go index c3182067..4488288f 100644 --- a/model/token.go +++ b/model/token.go @@ -123,3 +123,8 @@ func DecreaseTokenRemainTimesById(id int) (err error) { err = DB.Model(&Token{}).Where("id = ?", id).Update("remain_times", gorm.Expr("remain_times - ?", 1)).Error return err } + +func TopUpToken(id int, times int) (err error) { + err = DB.Model(&Token{}).Where("id = ?", id).Update("remain_times", gorm.Expr("remain_times + ?", times)).Error + return err +} diff --git a/router/api-router.go b/router/api-router.go index 662115a7..b90a4105 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -70,10 +70,21 @@ func SetApiRouter(router *gin.Engine) { { tokenRoute.GET("/", controller.GetAllTokens) tokenRoute.GET("/search", controller.SearchTokens) + tokenRoute.POST("/topup", controller.TopUp) tokenRoute.GET("/:id", controller.GetToken) tokenRoute.POST("/", controller.AddToken) tokenRoute.PUT("/", controller.UpdateToken) tokenRoute.DELETE("/:id", controller.DeleteToken) } + redemptionRoute := apiRouter.Group("/redemption") + redemptionRoute.Use(middleware.AdminAuth()) + { + redemptionRoute.GET("/", controller.GetAllRedemptions) + redemptionRoute.GET("/search", controller.SearchRedemptions) + redemptionRoute.GET("/:id", controller.GetRedemption) + redemptionRoute.POST("/", controller.AddRedemption) + redemptionRoute.PUT("/", controller.UpdateRedemption) + redemptionRoute.DELETE("/:id", controller.DeleteRedemption) + } } } diff --git a/web/src/App.js b/web/src/App.js index b5ce5601..0c060662 100644 --- a/web/src/App.js +++ b/web/src/App.js @@ -20,6 +20,8 @@ import Token from './pages/Token'; import EditToken from './pages/Token/EditToken'; import EditChannel from './pages/Channel/EditChannel'; import AddChannel from './pages/Channel/AddChannel'; +import Redemption from './pages/Redemption'; +import EditRedemption from './pages/Redemption/EditRedemption'; const Home = lazy(() => import('./pages/Home')); const About = lazy(() => import('./pages/About')); @@ -119,6 +121,30 @@ function App() { } /> + + + + } + /> + }> + + + } + /> + }> + + + } + /> + {timestamp2string(timestamp)} + + ); +} + +function renderStatus(status) { + switch (status) { + case 1: + return ; + case 2: + return ; + case 3: + return ; + default: + return ; + } +} + +const RedemptionsTable = () => { + const [redemptions, setRedemptions] = useState([]); + const [loading, setLoading] = useState(true); + const [activePage, setActivePage] = useState(1); + const [searchKeyword, setSearchKeyword] = useState(''); + const [searching, setSearching] = useState(false); + + const loadRedemptions = async (startIdx) => { + const res = await API.get(`/api/redemption/?p=${startIdx}`); + const { success, message, data } = res.data; + if (success) { + if (startIdx === 0) { + setRedemptions(data); + } else { + let newRedemptions = redemptions; + newRedemptions.push(...data); + setRedemptions(newRedemptions); + } + } else { + showError(message); + } + setLoading(false); + }; + + const onPaginationChange = (e, { activePage }) => { + (async () => { + if (activePage === Math.ceil(redemptions.length / ITEMS_PER_PAGE) + 1) { + // In this case we have to load more data and then append them. + await loadRedemptions(activePage - 1); + } + setActivePage(activePage); + })(); + }; + + useEffect(() => { + loadRedemptions(0) + .then() + .catch((reason) => { + showError(reason); + }); + }, []); + + const manageRedemption = async (id, action, idx) => { + let data = { id }; + let res; + switch (action) { + case 'delete': + res = await API.delete(`/api/redemption/${id}/`); + break; + case 'enable': + data.status = 1; + res = await API.put('/api/redemption/?status_only=true', data); + break; + case 'disable': + data.status = 2; + res = await API.put('/api/redemption/?status_only=true', data); + break; + } + const { success, message } = res.data; + if (success) { + showSuccess('操作成功完成!'); + let redemption = res.data.data; + let newRedemptions = [...redemptions]; + let realIdx = (activePage - 1) * ITEMS_PER_PAGE + idx; + if (action === 'delete') { + newRedemptions[realIdx].deleted = true; + } else { + newRedemptions[realIdx].status = redemption.status; + } + setRedemptions(newRedemptions); + } else { + showError(message); + } + }; + + const searchRedemptions = async () => { + if (searchKeyword === '') { + // if keyword is blank, load files instead. + await loadRedemptions(0); + setActivePage(1); + return; + } + setSearching(true); + const res = await API.get(`/api/redemption/search?keyword=${searchKeyword}`); + const { success, message, data } = res.data; + if (success) { + setRedemptions(data); + setActivePage(1); + } else { + showError(message); + } + setSearching(false); + }; + + const handleKeywordChange = async (e, { value }) => { + setSearchKeyword(value.trim()); + }; + + const sortRedemption = (key) => { + if (redemptions.length === 0) return; + setLoading(true); + let sortedRedemptions = [...redemptions]; + sortedRedemptions.sort((a, b) => { + return ('' + a[key]).localeCompare(b[key]); + }); + if (sortedRedemptions[0].id === redemptions[0].id) { + sortedRedemptions.reverse(); + } + setRedemptions(sortedRedemptions); + setLoading(false); + }; + + return ( + <> +
+ + + + + + + { + sortRedemption('id'); + }} + > + ID + + { + sortRedemption('name'); + }} + > + 名称 + + { + sortRedemption('status'); + }} + > + 状态 + + { + sortRedemption('quota'); + }} + > + 额度 + + { + sortRedemption('created_time'); + }} + > + 创建时间 + + { + sortRedemption('redeemed_time'); + }} + > + 兑换时间 + + 操作 + + + + + {redemptions + .slice( + (activePage - 1) * ITEMS_PER_PAGE, + activePage * ITEMS_PER_PAGE + ) + .map((redemption, idx) => { + if (redemption.deleted) return <>; + return ( + + {redemption.id} + {redemption.name ? redemption.name : '无'} + {renderStatus(redemption.status)} + {redemption.quota} + {renderTimestamp(redemption.created_time)} + {redemption.redeemed_time ? renderTimestamp(redemption.redeemed_time) : "尚未兑换"} + +
+ + + + +
+
+
+ ); + })} +
+ + + + + + + + + +
+ + ); +}; + +export default RedemptionsTable; diff --git a/web/src/components/TokensTable.js b/web/src/components/TokensTable.js index 4ac27687..795ea080 100644 --- a/web/src/components/TokensTable.js +++ b/web/src/components/TokensTable.js @@ -1,7 +1,7 @@ import React, { useEffect, useState } from 'react'; -import { Button, Form, Label, Message, Pagination, Table } from 'semantic-ui-react'; +import { Button, Form, Label, Modal, Pagination, Table } from 'semantic-ui-react'; import { Link } from 'react-router-dom'; -import { API, copy, showError, showInfo, showSuccess, showWarning, timestamp2string } from '../helpers'; +import { API, copy, showError, showSuccess, showWarning, timestamp2string } from '../helpers'; import { ITEMS_PER_PAGE } from '../constants'; @@ -34,6 +34,9 @@ const TokensTable = () => { const [activePage, setActivePage] = useState(1); const [searchKeyword, setSearchKeyword] = useState(''); const [searching, setSearching] = useState(false); + const [showTopUpModal, setShowTopUpModal] = useState(false); + const [targetTokenIdx, setTargetTokenIdx] = useState(0); + const [redemptionCode, setRedemptionCode] = useState(''); const loadTokens = async (startIdx) => { const res = await API.get(`/api/token/?p=${startIdx}`); @@ -140,6 +143,28 @@ const TokensTable = () => { setLoading(false); }; + const topUp = async () => { + if (redemptionCode === '') { + return; + } + const res = await API.post('/api/token/topup/', { + id: tokens[targetTokenIdx].id, + key: redemptionCode + }); + const { success, message, data } = res.data; + if (success) { + showSuccess('充值成功!'); + let newTokens = [...tokens]; + let realIdx = (activePage - 1) * ITEMS_PER_PAGE + targetTokenIdx; + newTokens[realIdx].remain_times += data; + setTokens(newTokens); + setRedemptionCode(''); + setShowTopUpModal(false); + } else { + showError(message); + } + } + return ( <>
@@ -197,14 +222,6 @@ const TokensTable = () => { > 创建时间 - { - sortToken('accessed_time'); - }} - > - 访问时间 - { @@ -230,10 +247,9 @@ const TokensTable = () => { {token.id} {token.name ? token.name : '无'} {renderStatus(token.status)} - {token.unlimited_times ? "无限制" : token.remain_times} + {token.unlimited_times ? '无限制' : token.remain_times} {renderTimestamp(token.created_time)} - {renderTimestamp(token.accessed_time)} - {token.expired_time === -1 ? "永不过期" : renderTimestamp(token.expired_time)} + {token.expired_time === -1 ? '永不过期' : renderTimestamp(token.expired_time)}
+ + + + + ); }; diff --git a/web/src/pages/Redemption/EditRedemption.js b/web/src/pages/Redemption/EditRedemption.js new file mode 100644 index 00000000..5a3285f7 --- /dev/null +++ b/web/src/pages/Redemption/EditRedemption.js @@ -0,0 +1,114 @@ +import React, { useEffect, useState } from 'react'; +import { Button, Form, Header, Segment } from 'semantic-ui-react'; +import { useParams } from 'react-router-dom'; +import { API, isAdmin, showError, showSuccess, quotatamp2string } from '../../helpers'; + +const EditRedemption = () => { + const params = useParams(); + const redemptionId = params.id; + const isEdit = redemptionId !== undefined; + const [loading, setLoading] = useState(isEdit); + const originInputs = { + name: '', + quota: 100, + count: 1, + }; + const [inputs, setInputs] = useState(originInputs); + const { name, quota, count } = inputs; + + const handleInputChange = (e, { name, value }) => { + setInputs((inputs) => ({ ...inputs, [name]: value })); + }; + + const loadRedemption = async () => { + let res = await API.get(`/api/redemption/${redemptionId}`); + const { success, message, data } = res.data; + if (success) { + setInputs(data); + } else { + showError(message); + } + setLoading(false); + }; + useEffect(() => { + if (isEdit) { + loadRedemption().then(); + } + }, []); + + const submit = async () => { + if (!isEdit && inputs.name === '') return; + let localInputs = inputs; + localInputs.count = parseInt(localInputs.count); + localInputs.quota = parseInt(localInputs.quota); + let res; + if (isEdit) { + res = await API.put(`/api/redemption/`, { ...localInputs, id: parseInt(redemptionId) }); + } else { + res = await API.post(`/api/redemption/`, { + ...localInputs, + }); + } + const { success, message } = res.data; + if (success) { + if (isEdit) { + showSuccess('兑换码更新成功!'); + } else { + showSuccess('兑换码创建成功!'); + setInputs(originInputs); + } + } else { + showError(message); + } + }; + + return ( + <> + +
{isEdit ? '更新兑换码信息' : '创建新的兑换码'}
+
+ + + + + + + { + !isEdit && <> + + + + + } + +
+
+ + ); +}; + +export default EditRedemption; diff --git a/web/src/pages/Redemption/index.js b/web/src/pages/Redemption/index.js new file mode 100644 index 00000000..c0649412 --- /dev/null +++ b/web/src/pages/Redemption/index.js @@ -0,0 +1,14 @@ +import React from 'react'; +import { Segment, Header } from 'semantic-ui-react'; +import RedemptionsTable from '../../components/RedemptionsTable'; + +const Redemption = () => ( + <> + +
管理兑换码
+ +
+ +); + +export default Redemption;