diff --git a/model/option.go b/model/option.go index bed8d4c37d..a62bd5b489 100644 --- a/model/option.go +++ b/model/option.go @@ -95,6 +95,9 @@ func SyncOptions(frequency int) { for { time.Sleep(time.Duration(frequency) * time.Second) logger.SysLog("syncing options from database") + if config.IsMasterNode { + checkAndDowngradeUsers() + } loadOptionsFromDatabase() } } diff --git a/model/user.go b/model/user.go index 924d72f940..0b06116962 100644 --- a/model/user.go +++ b/model/user.go @@ -10,7 +10,9 @@ import ( "github.com/songquanpeng/one-api/common/logger" "github.com/songquanpeng/one-api/common/random" "gorm.io/gorm" + "log" "strings" + "time" ) const ( @@ -47,6 +49,7 @@ type User struct { Group string `json:"group" gorm:"type:varchar(32);default:'default'"` AffCode string `json:"aff_code" gorm:"type:varchar(32);column:aff_code;uniqueIndex"` InviterId int `json:"inviter_id" gorm:"type:int;column:inviter_id;index"` + ExpirationDate int64 `json:"expiration_date" gorm:"column:expiration_date"` // Expiration date of the user's subscription or account. } func GetMaxUserId() int { @@ -210,6 +213,25 @@ func (user *User) ValidateAndFill() (err error) { if !okay || user.Status != UserStatusEnabled { return errors.New("用户名或密码错误,或用户已被封禁") } + // 校验用户是不是非default,如果是非default,判断到期时间如果过期了降级为default + if user.ExpirationDate > 0 { + // 将时间戳转换为 time.Time 类型 + expirationTime := time.Unix(user.ExpirationDate, 0) + // 获取当前时间 + currentTime := time.Now() + + // 比较当前时间和到期时间 + if expirationTime.Before(currentTime) { + // 降级为 default + user.Group = "default" + err := DB.Model(user).Updates(user).Error + if err != nil { + fmt.Printf("用户: %s, 降级为 default 时发生错误: %v\n", user.Username, err) + return err + } + fmt.Printf("用户: %s, 特权组过期降为 default\n", user.Username) + } + } return nil } @@ -435,3 +457,40 @@ func GetUsernameById(id int) (username string) { DB.Model(&User{}).Where("id = ?", id).Select("username").Find(&username) return username } + +func checkAndDowngradeUsers() { + var users []User + + // 查询所有 Group 不为 "default" 的用户 + // 构建查询条件 + query := DB.Where("`Group` <> ?", "default"). // Group 不等于 "default" + Where("`username` <> ?", "root"). // username 不等于 "root" + Where("`expiration_date` IS NOT NULL"). // expiration_date 不为空 + Where("`expiration_date` != ?", -1) // expiration_date 不等于 -1 + + // 执行查询并处理错误 + if err := query.Find(&users).Error; err != nil { + log.Printf("查询用户失败: %v", err) + return + } + + currentTime := time.Now() + + for _, user := range users { + if user.Group != "default" { + // 将时间戳转换为 time.Time 类型 + expirationTime := time.Unix(user.ExpirationDate, 0) + + // 比较当前时间和到期时间 + if expirationTime.Before(currentTime) { + // 降级为 default + user.Group = "default" + if err := DB.Model(&user).Updates(user).Error; err != nil { + log.Printf("更新用户 %s 失败: %v", user.Username, err) + } else { + fmt.Printf("用户: %s, 特权组过期降为 default\n", user.Username) + } + } + } + } +} diff --git a/web/berry/package.json b/web/berry/package.json index f8265ef7bb..f82aa0897c 100644 --- a/web/berry/package.json +++ b/web/berry/package.json @@ -17,6 +17,7 @@ "@tabler/icons-react": "^2.44.0", "apexcharts": "3.35.3", "axios": "^0.27.2", + "date-fns": "^3.6.0", "dayjs": "^1.11.10", "formik": "^2.2.9", "framer-motion": "^6.3.16", @@ -27,6 +28,7 @@ "prop-types": "^15.8.1", "react": "^18.2.0", "react-apexcharts": "1.4.0", + "react-datepicker": "^7.3.0", "react-device-detect": "^2.2.2", "react-dom": "^18.2.0", "react-perfect-scrollbar": "^1.5.8", diff --git a/web/berry/src/views/User/component/EditModal.js b/web/berry/src/views/User/component/EditModal.js index f6b533e2d1..17ee1eaacd 100644 --- a/web/berry/src/views/User/component/EditModal.js +++ b/web/berry/src/views/User/component/EditModal.js @@ -2,7 +2,9 @@ import PropTypes from 'prop-types'; import * as Yup from 'yup'; import { Formik } from 'formik'; import { useTheme } from '@mui/material/styles'; -import { useState, useEffect } from 'react'; +import { useState, useEffect, useCallback } from 'react'; +import { format } from 'date-fns'; + import { Dialog, DialogTitle, @@ -17,7 +19,11 @@ import { Select, MenuItem, IconButton, - FormHelperText + FormHelperText, + TextField, + Typography, + Switch, + FormControlLabel } from '@mui/material'; import Visibility from '@mui/icons-material/Visibility'; @@ -44,6 +50,17 @@ const validationSchema = Yup.object().shape({ is: false, then: Yup.number().min(0, '额度 不能小于 0'), otherwise: Yup.number() + }), + expiration_date: Yup.mixed().when('group', { + is: (group) => group !== 'default', + then: Yup.mixed().test( + 'expiration_date-required', + '到期时间 不能为空', + function (value) { + const { expiration_date } = this.parent; + return expiration_date === -1 || !!expiration_date; + } + ), }) }); @@ -53,7 +70,8 @@ const originInputs = { display_name: '', password: '', group: 'default', - quota: 0 + quota: 0, + expiration_date: null }; const EditModal = ({ open, userId, onCancel, onOk }) => { @@ -65,6 +83,12 @@ const EditModal = ({ open, userId, onCancel, onOk }) => { const submit = async (values, { setErrors, setStatus, setSubmitting }) => { setSubmitting(true); + // 将到期时间转换为 Unix 时间戳 + if (values.expiration_date && values.expiration_date !== -1) { + const date = new Date(values.expiration_date); + values.expiration_date = Math.floor(date.getTime() / 1000); // 转换为秒级的 Unix 时间戳 + } + let res; if (values.is_edit) { res = await API.put(`/api/user/`, { ...values, id: parseInt(userId) }); @@ -95,16 +119,23 @@ const EditModal = ({ open, userId, onCancel, onOk }) => { event.preventDefault(); }; - const loadUser = async () => { + const loadUser = useCallback(async () => { let res = await API.get(`/api/user/${userId}`); const { success, message, data } = res.data; if (success) { data.is_edit = true; + + // 将 Unix 时间戳转换为日期字符串 + if (data.expiration_date && data.expiration_date !== -1) { + const date = new Date(data.expiration_date * 1000); // 转换为毫秒级的时间戳 + data.expiration_date = format(date, 'yyyy-MM-dd\'T\'HH:mm:ss'); // 格式化为 datetime-local 格式 + } + setInputs(data); } else { showError(message); } - }; + }, [userId]); const fetchGroups = async () => { try { @@ -122,159 +153,203 @@ const EditModal = ({ open, userId, onCancel, onOk }) => { } else { setInputs(originInputs); } - }, [userId]); + }, [userId, loadUser]); return ( - - - {userId ? '编辑用户' : '新建用户'} - - - - - {({ errors, handleBlur, handleChange, handleSubmit, touched, values, isSubmitting }) => ( -
- - 用户名 - - {touched.username && errors.username && ( - - {errors.username} - - )} - - - - 显示名称 - - {touched.display_name && errors.display_name && ( - - {errors.display_name} - - )} - - - - 密码 - - - {showPassword ? : } - - - } - aria-describedby="helper-text-channel-password-label" - /> - {touched.password && errors.password && ( - - {errors.password} - - )} - - - {values.is_edit && ( - <> - - 额度 + + + {userId ? '编辑用户' : '新建用户'} + + + + + {({ errors, handleBlur, handleChange, handleSubmit, setFieldValue, touched, values, isSubmitting }) => ( + + + 用户名 {renderQuotaWithPrompt(values.quota)}} - onBlur={handleBlur} - onChange={handleChange} - aria-describedby="helper-text-channel-quota-label" - disabled={values.unlimited_quota} + id="channel-username-label" + label="用户名" + type="text" + value={values.username} + name="username" + onBlur={handleBlur} + onChange={handleChange} + inputProps={{ autoComplete: 'username' }} + aria-describedby="helper-text-channel-username-label" /> + {touched.username && errors.username && ( + + {errors.username} + + )} + - {touched.quota && errors.quota && ( - - {errors.quota} - + + 显示名称 + + {touched.display_name && errors.display_name && ( + + {errors.display_name} + )} - - 分组 - - {touched.group && errors.group && ( - - {errors.group} - + aria-describedby="helper-text-channel-password-label" + /> + {touched.password && errors.password && ( + + {errors.password} + )} - - )} - - - - - - )} - - - + + {values.is_edit && ( + <> + + 额度 + {renderQuotaWithPrompt(values.quota)}} + onBlur={handleBlur} + onChange={handleChange} + aria-describedby="helper-text-channel-quota-label" + disabled={values.unlimited_quota} + /> + + {touched.quota && errors.quota && ( + + {errors.quota} + + )} + + + + 分组 + + {touched.group && errors.group && ( + + {errors.group} + + )} + + + )} + + {values.group !== 'default' && ( + + + {touched.expiration_date && errors.expiration_date && ( + + {errors.expiration_date} + + )} + setFieldValue('expiration_date', e.target.checked ? -1 : '')} + name="permanent" + color="primary" + /> + } + label="永不过期" + /> + + )} + + + + + + + )} +
+
+
); }; @@ -285,4 +360,4 @@ EditModal.propTypes = { userId: PropTypes.number, onCancel: PropTypes.func, onOk: PropTypes.func -}; +}; \ No newline at end of file