feat: 令牌分组

This commit is contained in:
CalciumIon 2024-09-18 05:19:10 +08:00
parent 5f3798053f
commit 052bc2075b
13 changed files with 248 additions and 120 deletions

23
common/user_groups.go Normal file
View File

@ -0,0 +1,23 @@
package common
import (
"encoding/json"
)
var UserUsableGroups = map[string]string{
"default": "默认分组",
"vip": "vip分组",
}
func UserUsableGroups2JSONString() string {
jsonBytes, err := json.Marshal(UserUsableGroups)
if err != nil {
SysError("error marshalling user groups: " + err.Error())
}
return string(jsonBytes)
}
func UpdateUserUsableGroupsByJSONString(jsonStr string) error {
UserUsableGroups = make(map[string]string)
return json.Unmarshal([]byte(jsonStr), &UserUsableGroups)
}

View File

@ -17,3 +17,18 @@ func GetGroups(c *gin.Context) {
"data": groupNames, "data": groupNames,
}) })
} }
func GetUserGroups(c *gin.Context) {
usableGroups := make(map[string]string)
for groupName, _ := range common.GroupRatio {
// UserUsableGroups contains the groups that the user can use
if _, ok := common.UserUsableGroups[groupName]; ok {
usableGroups[groupName] = common.UserUsableGroups[groupName]
}
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": usableGroups,
})
}

View File

@ -135,6 +135,7 @@ func AddToken(c *gin.Context) {
ModelLimitsEnabled: token.ModelLimitsEnabled, ModelLimitsEnabled: token.ModelLimitsEnabled,
ModelLimits: token.ModelLimits, ModelLimits: token.ModelLimits,
AllowIps: token.AllowIps, AllowIps: token.AllowIps,
Group: token.Group,
} }
err = cleanToken.Insert() err = cleanToken.Insert()
if err != nil { if err != nil {
@ -223,6 +224,7 @@ func UpdateToken(c *gin.Context) {
cleanToken.ModelLimitsEnabled = token.ModelLimitsEnabled cleanToken.ModelLimitsEnabled = token.ModelLimitsEnabled
cleanToken.ModelLimits = token.ModelLimits cleanToken.ModelLimits = token.ModelLimits
cleanToken.AllowIps = token.AllowIps cleanToken.AllowIps = token.AllowIps
cleanToken.Group = token.Group
} }
err = cleanToken.Update() err = cleanToken.Update()
if err != nil { if err != nil {

View File

@ -176,6 +176,7 @@ func TokenAuth() func(c *gin.Context) {
c.Set("token_model_limit_enabled", false) c.Set("token_model_limit_enabled", false)
} }
c.Set("allow_ips", token.GetIpLimitsMap()) c.Set("allow_ips", token.GetIpLimitsMap())
c.Set("token_group", token.Group)
if len(parts) > 1 { if len(parts) > 1 {
if model.IsAdmin(token.UserId) { if model.IsAdmin(token.UserId) {
c.Set("specific_channel_id", parts[1]) c.Set("specific_channel_id", parts[1])

View File

@ -39,6 +39,15 @@ func Distribute() func(c *gin.Context) {
return return
} }
userGroup, _ := model.CacheGetUserGroup(userId) userGroup, _ := model.CacheGetUserGroup(userId)
tokenGroup := c.GetString("token_group")
if tokenGroup != "" {
// check group in common.GroupRatio
if _, ok := common.GroupRatio[tokenGroup]; !ok {
abortWithOpenAiMessage(c, http.StatusForbidden, fmt.Sprintf("分组 %s 已被禁用", tokenGroup))
return
}
userGroup = tokenGroup
}
c.Set("group", userGroup) c.Set("group", userGroup)
if ok { if ok {
id, err := strconv.Atoi(channelId.(string)) id, err := strconv.Atoi(channelId.(string))

View File

@ -86,6 +86,7 @@ func InitOptionMap() {
common.OptionMap["ModelRatio"] = common.ModelRatio2JSONString() common.OptionMap["ModelRatio"] = common.ModelRatio2JSONString()
common.OptionMap["ModelPrice"] = common.ModelPrice2JSONString() common.OptionMap["ModelPrice"] = common.ModelPrice2JSONString()
common.OptionMap["GroupRatio"] = common.GroupRatio2JSONString() common.OptionMap["GroupRatio"] = common.GroupRatio2JSONString()
common.OptionMap["UserUsableGroups"] = common.UserUsableGroups2JSONString()
common.OptionMap["CompletionRatio"] = common.CompletionRatio2JSONString() common.OptionMap["CompletionRatio"] = common.CompletionRatio2JSONString()
common.OptionMap["TopUpLink"] = common.TopUpLink common.OptionMap["TopUpLink"] = common.TopUpLink
common.OptionMap["ChatLink"] = common.ChatLink common.OptionMap["ChatLink"] = common.ChatLink
@ -303,6 +304,8 @@ func updateOptionMap(key string, value string) (err error) {
err = common.UpdateModelRatioByJSONString(value) err = common.UpdateModelRatioByJSONString(value)
case "GroupRatio": case "GroupRatio":
err = common.UpdateGroupRatioByJSONString(value) err = common.UpdateGroupRatioByJSONString(value)
case "UserUsableGroups":
err = common.UpdateUserUsableGroupsByJSONString(value)
case "CompletionRatio": case "CompletionRatio":
err = common.UpdateCompletionRatioByJSONString(value) err = common.UpdateCompletionRatioByJSONString(value)
case "ModelPrice": case "ModelPrice":

View File

@ -25,6 +25,7 @@ type Token struct {
ModelLimits string `json:"model_limits" gorm:"type:varchar(1024);default:''"` ModelLimits string `json:"model_limits" gorm:"type:varchar(1024);default:''"`
AllowIps *string `json:"allow_ips" gorm:"default:''"` AllowIps *string `json:"allow_ips" gorm:"default:''"`
UsedQuota int `json:"used_quota" gorm:"default:0"` // used quota UsedQuota int `json:"used_quota" gorm:"default:0"` // used quota
Group string `json:"group" gorm:"default:''"`
DeletedAt gorm.DeletedAt `gorm:"index"` DeletedAt gorm.DeletedAt `gorm:"index"`
} }
@ -153,7 +154,8 @@ func (token *Token) Insert() error {
// Update Make sure your token's fields is completed, because this will update non-zero values // Update Make sure your token's fields is completed, because this will update non-zero values
func (token *Token) Update() error { func (token *Token) Update() error {
var err error var err error
err = DB.Model(token).Select("name", "status", "expired_time", "remain_quota", "unlimited_quota", "model_limits_enabled", "model_limits", "allow_ips").Updates(token).Error err = DB.Model(token).Select("name", "status", "expired_time", "remain_quota", "unlimited_quota",
"model_limits_enabled", "model_limits", "allow_ips", "group").Updates(token).Error
return err return err
} }

View File

@ -39,6 +39,7 @@ func SetApiRouter(router *gin.Engine) {
//userRoute.POST("/tokenlog", middleware.CriticalRateLimit(), controller.TokenLog) //userRoute.POST("/tokenlog", middleware.CriticalRateLimit(), controller.TokenLog)
userRoute.GET("/logout", controller.Logout) userRoute.GET("/logout", controller.Logout)
userRoute.GET("/epay/notify", controller.EpayNotify) userRoute.GET("/epay/notify", controller.EpayNotify)
userRoute.GET("/groups", controller.GetUserGroups)
selfRoute := userRoute.Group("/") selfRoute := userRoute.Group("/")
selfRoute.Use(middleware.UserAuth()) selfRoute.Use(middleware.UserAuth())

View File

@ -23,6 +23,7 @@ const OperationSetting = () => {
CompletionRatio: '', CompletionRatio: '',
ModelPrice: '', ModelPrice: '',
GroupRatio: '', GroupRatio: '',
UserUsableGroups: '',
TopUpLink: '', TopUpLink: '',
ChatLink: '', ChatLink: '',
ChatLink2: '', // 添加的新状态变量 ChatLink2: '', // 添加的新状态变量
@ -62,6 +63,7 @@ const OperationSetting = () => {
if ( if (
item.key === 'ModelRatio' || item.key === 'ModelRatio' ||
item.key === 'GroupRatio' || item.key === 'GroupRatio' ||
item.key === 'UserUsableGroups' ||
item.key === 'CompletionRatio' || item.key === 'CompletionRatio' ||
item.key === 'ModelPrice' item.key === 'ModelPrice'
) { ) {

View File

@ -8,14 +8,14 @@ import {
} from '../helpers'; } from '../helpers';
import { ITEMS_PER_PAGE } from '../constants'; import { ITEMS_PER_PAGE } from '../constants';
import { renderQuota } from '../helpers/render'; import {renderGroup, renderQuota} from '../helpers/render';
import { import {
Button, Button,
Dropdown, Dropdown,
Form, Form,
Modal, Modal,
Popconfirm, Popconfirm,
Popover, Popover, Space,
SplitButtonGroup, SplitButtonGroup,
Table, Table,
Tag, Tag,
@ -119,7 +119,12 @@ const TokensTable = () => {
dataIndex: 'status', dataIndex: 'status',
key: 'status', key: 'status',
render: (text, record, index) => { render: (text, record, index) => {
return <div>{renderStatus(text, record.model_limits_enabled)}</div>; return <div>
<Space>
{renderStatus(text, record.model_limits_enabled)}
{renderGroup(record.group)}
</Space>
</div>;
}, },
}, },
{ {

View File

@ -15,8 +15,8 @@ export function renderText(text, limit) {
export function renderGroup(group) { export function renderGroup(group) {
if (group === '') { if (group === '') {
return ( return (
<Tag size='large' key='default'> <Tag size='large' key='default' color={stringToColor('default')}>
unknown default
</Tag> </Tag>
); );
} }

View File

@ -16,7 +16,8 @@ export default function SettingsMagnification(props) {
ModelPrice: '', ModelPrice: '',
ModelRatio: '', ModelRatio: '',
CompletionRatio: '', CompletionRatio: '',
GroupRatio: '' GroupRatio: '',
UserUsableGroups: ''
}); });
const refForm = useRef(); const refForm = useRef();
const [inputsRow, setInputsRow] = useState(inputs); const [inputsRow, setInputsRow] = useState(inputs);
@ -213,6 +214,33 @@ export default function SettingsMagnification(props) {
/> />
</Col> </Col>
</Row> </Row>
<Row gutter={16}>
<Col span={16}>
<Form.TextArea
label={'用户可选分组'}
extraText={''}
placeholder={'为一个 JSON 文本,键为分组名称,值为倍率'}
field={'UserUsableGroups'}
autosize={{ minRows: 6, maxRows: 12 }}
trigger='blur'
stopValidateWithError
rules={[
{
validator: (rule, value) => {
return verifyJSON(value);
},
message: '不是合法的 JSON 字符串'
}
]}
onChange={(value) =>
setInputs({
...inputs,
UserUsableGroups: value
})
}
/>
</Col>
</Row>
</Form.Section> </Form.Section>
</Form> </Form>
<Space> <Space>

View File

@ -35,6 +35,7 @@ const EditToken = (props) => {
model_limits_enabled: false, model_limits_enabled: false,
model_limits: [], model_limits: [],
allow_ips: '', allow_ips: '',
group: '',
}; };
const [inputs, setInputs] = useState(originInputs); const [inputs, setInputs] = useState(originInputs);
const { const {
@ -44,10 +45,12 @@ const EditToken = (props) => {
unlimited_quota, unlimited_quota,
model_limits_enabled, model_limits_enabled,
model_limits, model_limits,
allow_ips allow_ips,
group
} = inputs; } = inputs;
// const [visible, setVisible] = useState(false); // const [visible, setVisible] = useState(false);
const [models, setModels] = useState({}); const [models, setModels] = useState({});
const [groups, setGroups] = useState([]);
const navigate = useNavigate(); const navigate = useNavigate();
const handleInputChange = (name, value) => { const handleInputChange = (name, value) => {
setInputs((inputs) => ({ ...inputs, [name]: value })); setInputs((inputs) => ({ ...inputs, [name]: value }));
@ -88,6 +91,22 @@ const EditToken = (props) => {
} }
}; };
const loadGroups = async () => {
let res = await API.get(`/api/user/groups`);
const { success, message, data } = res.data;
if (success) {
// return data is a map, key is group name, value is group description
// label is group description, value is group name
let localGroupOptions = Object.keys(data).map((group) => ({
label: data[group],
value: group,
}));
setGroups(localGroupOptions);
} else {
showError(message);
}
};
const loadToken = async () => { const loadToken = async () => {
setLoading(true); setLoading(true);
let res = await API.get(`/api/token/${props.editingToken.id}`); let res = await API.get(`/api/token/${props.editingToken.id}`);
@ -120,6 +139,7 @@ const EditToken = (props) => {
}); });
} }
loadModels(); loadModels();
loadGroups();
}, [isEdit]); }, [isEdit]);
// 新增 state 变量 tokenCount 来记录用户想要创建的令牌数量,默认为 1 // 新增 state 变量 tokenCount 来记录用户想要创建的令牌数量,默认为 1
@ -253,7 +273,7 @@ const EditToken = (props) => {
> >
<Spin spinning={loading}> <Spin spinning={loading}>
<Input <Input
style={{ marginTop: 20 }} style={{marginTop: 20}}
label='名称' label='名称'
name='name' name='name'
placeholder={'请输入名称'} placeholder={'请输入名称'}
@ -262,7 +282,7 @@ const EditToken = (props) => {
autoComplete='new-password' autoComplete='new-password'
required={!isEdit} required={!isEdit}
/> />
<Divider /> <Divider/>
<DatePicker <DatePicker
label='过期时间' label='过期时间'
name='expired_time' name='expired_time'
@ -272,7 +292,7 @@ const EditToken = (props) => {
autoComplete='new-password' autoComplete='new-password'
type='dateTime' type='dateTime'
/> />
<div style={{ marginTop: 20 }}> <div style={{marginTop: 20}}>
<Space> <Space>
<Button <Button
type={'tertiary'} type={'tertiary'}
@ -309,18 +329,18 @@ const EditToken = (props) => {
</Space> </Space>
</div> </div>
<Divider /> <Divider/>
<Banner <Banner
type={'warning'} type={'warning'}
description={ description={
'注意,令牌的额度仅用于限制令牌本身的最大额度使用量,实际的使用受到账户的剩余额度限制。' '注意,令牌的额度仅用于限制令牌本身的最大额度使用量,实际的使用受到账户的剩余额度限制。'
} }
></Banner> ></Banner>
<div style={{ marginTop: 20 }}> <div style={{marginTop: 20}}>
<Typography.Text>{`额度${renderQuotaWithPrompt(remain_quota)}`}</Typography.Text> <Typography.Text>{`额度${renderQuotaWithPrompt(remain_quota)}`}</Typography.Text>
</div> </div>
<AutoComplete <AutoComplete
style={{ marginTop: 8 }} style={{marginTop: 8}}
name='remain_quota' name='remain_quota'
placeholder={'请输入额度'} placeholder={'请输入额度'}
onChange={(value) => handleInputChange('remain_quota', value)} onChange={(value) => handleInputChange('remain_quota', value)}
@ -329,23 +349,23 @@ const EditToken = (props) => {
type='number' type='number'
// position={'top'} // position={'top'}
data={[ data={[
{ value: 500000, label: '1$' }, {value: 500000, label: '1$'},
{ value: 5000000, label: '10$' }, {value: 5000000, label: '10$'},
{ value: 25000000, label: '50$' }, {value: 25000000, label: '50$'},
{ value: 50000000, label: '100$' }, {value: 50000000, label: '100$'},
{ value: 250000000, label: '500$' }, {value: 250000000, label: '500$'},
{ value: 500000000, label: '1000$' }, {value: 500000000, label: '1000$'},
]} ]}
disabled={unlimited_quota} disabled={unlimited_quota}
/> />
{!isEdit && ( {!isEdit && (
<> <>
<div style={{ marginTop: 20 }}> <div style={{marginTop: 20}}>
<Typography.Text>新建数量</Typography.Text> <Typography.Text>新建数量</Typography.Text>
</div> </div>
<AutoComplete <AutoComplete
style={{ marginTop: 8 }} style={{marginTop: 8}}
label='数量' label='数量'
placeholder={'请选择或输入创建令牌的数量'} placeholder={'请选择或输入创建令牌的数量'}
onChange={(value) => handleTokenCountChange(value)} onChange={(value) => handleTokenCountChange(value)}
@ -354,10 +374,10 @@ const EditToken = (props) => {
autoComplete='off' autoComplete='off'
type='number' type='number'
data={[ data={[
{ value: 10, label: '10个' }, {value: 10, label: '10个'},
{ value: 20, label: '20个' }, {value: 20, label: '20个'},
{ value: 30, label: '30个' }, {value: 30, label: '30个'},
{ value: 100, label: '100个' }, {value: 100, label: '100个'},
]} ]}
disabled={unlimited_quota} disabled={unlimited_quota}
/> />
@ -366,7 +386,7 @@ const EditToken = (props) => {
<div> <div>
<Button <Button
style={{ marginTop: 8 }} style={{marginTop: 8}}
type={'warning'} type={'warning'}
onClick={() => { onClick={() => {
setUnlimitedQuota(); setUnlimitedQuota();
@ -375,8 +395,8 @@ const EditToken = (props) => {
{unlimited_quota ? '取消无限额度' : '设为无限额度'} {unlimited_quota ? '取消无限额度' : '设为无限额度'}
</Button> </Button>
</div> </div>
<Divider /> <Divider/>
<div style={{ marginTop: 10 }}> <div style={{marginTop: 10}}>
<Typography.Text>IP白名单请勿过度信任此功能</Typography.Text> <Typography.Text>IP白名单请勿过度信任此功能</Typography.Text>
</div> </div>
<TextArea <TextArea
@ -387,9 +407,9 @@ const EditToken = (props) => {
handleInputChange('allow_ips', value); handleInputChange('allow_ips', value);
}} }}
value={inputs.allow_ips} value={inputs.allow_ips}
style={{ fontFamily: 'JetBrains Mono, Consolas' }} style={{fontFamily: 'JetBrains Mono, Consolas'}}
/> />
<div style={{ marginTop: 10, display: 'flex' }}> <div style={{marginTop: 10, display: 'flex'}}>
<Space> <Space>
<Checkbox <Checkbox
name='model_limits_enabled' name='model_limits_enabled'
@ -405,7 +425,7 @@ const EditToken = (props) => {
</div> </div>
<Select <Select
style={{ marginTop: 8 }} style={{marginTop: 8}}
placeholder={'请选择该渠道所支持的模型'} placeholder={'请选择该渠道所支持的模型'}
name='models' name='models'
required required
@ -419,6 +439,23 @@ const EditToken = (props) => {
optionList={models} optionList={models}
disabled={!model_limits_enabled} disabled={!model_limits_enabled}
/> />
<div style={{marginTop: 10}}>
<Typography.Text>令牌分组不选为默认分组</Typography.Text>
</div>
<Select
style={{marginTop: 8}}
placeholder={'令牌分组,不选为默认分组'}
name='gruop'
required
selection
onChange={(value) => {
handleInputChange('group', value);
}}
value={inputs.group}
autoComplete='new-password'
optionList={groups}
/>
</Spin> </Spin>
</SideSheet> </SideSheet>
</> </>