mirror of
https://github.com/coaidev/coai.git
synced 2025-05-19 13:00:14 +09:00
update user operation and fix api connection
This commit is contained in:
parent
b40c60edd8
commit
38dad633ee
@ -14,6 +14,16 @@ type GenerateInvitationForm struct {
|
||||
Number int `json:"number"`
|
||||
}
|
||||
|
||||
type QuotaOperationForm struct {
|
||||
Id int64 `json:"id"`
|
||||
Quota float32 `json:"quota"`
|
||||
}
|
||||
|
||||
type SubscriptionOperationForm struct {
|
||||
Id int64 `json:"id"`
|
||||
Month int64 `json:"month"`
|
||||
}
|
||||
|
||||
func InfoAPI(c *gin.Context) {
|
||||
db := utils.GetDBFromContext(c)
|
||||
cache := utils.GetCacheFromContext(c)
|
||||
@ -74,3 +84,55 @@ func UserPaginationAPI(c *gin.Context) {
|
||||
search := strings.TrimSpace(c.Query("search"))
|
||||
c.JSON(http.StatusOK, GetUserPagination(db, int64(page), search))
|
||||
}
|
||||
|
||||
func UserQuotaAPI(c *gin.Context) {
|
||||
db := utils.GetDBFromContext(c)
|
||||
|
||||
var form QuotaOperationForm
|
||||
if err := c.ShouldBindJSON(&form); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"status": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
err := QuotaOperation(db, form.Id, form.Quota)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"status": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"status": true,
|
||||
})
|
||||
}
|
||||
|
||||
func UserSubscriptionAPI(c *gin.Context) {
|
||||
db := utils.GetDBFromContext(c)
|
||||
|
||||
var form SubscriptionOperationForm
|
||||
if err := c.ShouldBindJSON(&form); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"status": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
err := SubscriptionOperation(db, form.Id, form.Month)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"status": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"status": true,
|
||||
})
|
||||
}
|
||||
|
@ -13,4 +13,6 @@ func Register(app *gin.Engine) {
|
||||
app.POST("/admin/invitation/generate", GenerateInvitationAPI)
|
||||
|
||||
app.GET("/admin/user/list", UserPaginationAPI)
|
||||
app.POST("/admin/user/quota", UserQuotaAPI)
|
||||
app.POST("/admin/user/subscription", UserSubscriptionAPI)
|
||||
}
|
||||
|
@ -21,7 +21,6 @@ func IncrRequest(cache *redis.Client) {
|
||||
}
|
||||
|
||||
func IncrModelRequest(cache *redis.Client, model string, tokens int64) {
|
||||
IncrRequest(cache)
|
||||
utils.IncrWithExpire(cache, getModelFormat(getDay(), model), tokens, time.Hour*24*7*2)
|
||||
}
|
||||
|
||||
|
@ -32,7 +32,7 @@ func GetUserPagination(db *sql.DB, page int64, search string) PaginationForm {
|
||||
LEFT JOIN quota ON quota.user_id = auth.id
|
||||
LEFT JOIN subscription ON subscription.user_id = auth.id
|
||||
WHERE auth.username LIKE ?
|
||||
ORDER BY auth.id DESC LIMIT ? OFFSET ?
|
||||
ORDER BY auth.id LIMIT ? OFFSET ?
|
||||
`, "%"+search+"%", pagination, page*pagination)
|
||||
if err != nil {
|
||||
return PaginationForm{
|
||||
@ -79,3 +79,29 @@ func GetUserPagination(db *sql.DB, page int64, search string) PaginationForm {
|
||||
Data: users,
|
||||
}
|
||||
}
|
||||
|
||||
func QuotaOperation(db *sql.DB, id int64, quota float32) error {
|
||||
// if quota is negative, then decrease quota
|
||||
// if quota is positive, then increase quota
|
||||
|
||||
_, err := db.Exec(`
|
||||
INSERT INTO quota (user_id, quota, used) VALUES (?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE quota = quota + ?
|
||||
`, id, quota, 0., quota)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func SubscriptionOperation(db *sql.DB, id int64, month int64) error {
|
||||
// if month is negative, then decrease month
|
||||
// if month is positive, then increase month
|
||||
|
||||
expireAt := time.Now().AddDate(0, int(month), 0)
|
||||
|
||||
_, err := db.Exec(`
|
||||
INSERT INTO subscription (user_id, total_month, expired_at) VALUES (?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE total_month = total_month + ?, expired_at = DATE_ADD(expired_at, INTERVAL ? MONTH)
|
||||
`, id, month, expireAt, month, month)
|
||||
|
||||
return err
|
||||
}
|
||||
|
@ -1,5 +1,6 @@
|
||||
import {
|
||||
BillingChartResponse,
|
||||
CommonResponse,
|
||||
ErrorChartResponse,
|
||||
InfoResponse,
|
||||
InvitationGenerateResponse,
|
||||
@ -111,3 +112,27 @@ export async function getUserList(
|
||||
|
||||
return response.data as UserResponse;
|
||||
}
|
||||
|
||||
export async function quotaOperation(
|
||||
id: number,
|
||||
quota: number,
|
||||
): Promise<CommonResponse> {
|
||||
const response = await axios.post("/admin/user/quota", { id, quota });
|
||||
if (response.status !== 200) {
|
||||
return { status: false, message: "" };
|
||||
}
|
||||
|
||||
return response.data as CommonResponse;
|
||||
}
|
||||
|
||||
export async function subscriptionOperation(
|
||||
id: number,
|
||||
month: number,
|
||||
): Promise<CommonResponse> {
|
||||
const response = await axios.post("/admin/user/subscription", { id, month });
|
||||
if (response.status !== 200) {
|
||||
return { status: false, message: "" };
|
||||
}
|
||||
|
||||
return response.data as CommonResponse;
|
||||
}
|
||||
|
@ -1,3 +1,8 @@
|
||||
export type CommonResponse = {
|
||||
status: boolean;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type InfoResponse = {
|
||||
billing_today: number;
|
||||
billing_month: number;
|
||||
@ -54,7 +59,7 @@ export type InvitationGenerateResponse = {
|
||||
};
|
||||
|
||||
export type UserData = {
|
||||
id: string;
|
||||
id: number;
|
||||
username: string;
|
||||
is_admin: boolean;
|
||||
quota: number;
|
||||
|
79
app/src/components/PopupDialog.tsx
Normal file
79
app/src/components/PopupDialog.tsx
Normal file
@ -0,0 +1,79 @@
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import { Input } from "@/components/ui/input.tsx";
|
||||
import { useState } from "react";
|
||||
|
||||
export type PopupDialogProps = {
|
||||
title: string;
|
||||
description?: string;
|
||||
name: string;
|
||||
defaultValue?: string;
|
||||
onValueChange?: (value: string) => string;
|
||||
onSubmit?: (value: string) => Promise<boolean>;
|
||||
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
};
|
||||
|
||||
function PopupDialog({
|
||||
title,
|
||||
description,
|
||||
name,
|
||||
defaultValue,
|
||||
onValueChange,
|
||||
onSubmit,
|
||||
open,
|
||||
setOpen,
|
||||
}: PopupDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [value, setValue] = useState<string>(defaultValue || "");
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription className={`pt-1.5`}>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className={`pt-1 flex flex-row items-center justify-center`}>
|
||||
<span className={`mr-4 whitespace-nowrap`}>{name}</span>
|
||||
<Input
|
||||
onChange={(e) => {
|
||||
setValue(
|
||||
onValueChange ? onValueChange(e.target.value) : e.target.value,
|
||||
);
|
||||
}}
|
||||
value={value}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant={`outline`} onClick={() => setOpen(false)}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
onSubmit && onSubmit(value).then((success) => {
|
||||
if (success) {
|
||||
setOpen(false);
|
||||
setValue(defaultValue || "");
|
||||
}
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t("confirm")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default PopupDialog;
|
@ -155,8 +155,8 @@ function InvitationTable() {
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.data.map((invitation, idx) => (
|
||||
<TableRow key={idx}>
|
||||
{(data.data || []).map((invitation, idx) => (
|
||||
<TableRow key={idx} className={`whitespace-nowrap`}>
|
||||
<TableCell>{invitation.code}</TableCell>
|
||||
<TableCell>{invitation.quota}</TableCell>
|
||||
<TableCell>{invitation.type}</TableCell>
|
||||
|
@ -1,8 +1,8 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useToast } from "@/components/ui/use-toast.ts";
|
||||
import { useState } from "react";
|
||||
import { UserForm, UserResponse } from "@/admin/types.ts";
|
||||
import { getUserList } from "@/admin/api.ts";
|
||||
import {CommonResponse, UserForm, UserResponse} from "@/admin/types.ts";
|
||||
import {getUserList, quotaOperation, subscriptionOperation} from "@/admin/api.ts";
|
||||
import { useEffectAsync } from "@/utils/hook.ts";
|
||||
import {
|
||||
Table,
|
||||
@ -12,15 +12,93 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table.tsx";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu.tsx";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import {
|
||||
CalendarClock,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
CloudCog,
|
||||
MoreHorizontal,
|
||||
RotateCw,
|
||||
Search,
|
||||
} from "lucide-react";
|
||||
import { Input } from "@/components/ui/input.tsx";
|
||||
import PopupDialog from "@/components/PopupDialog.tsx";
|
||||
import {getNumber, parseNumber} from "@/utils/base.ts";
|
||||
|
||||
type OperationMenuProps = {
|
||||
id: number;
|
||||
};
|
||||
|
||||
function doToast(t: any, toast: any, resp: CommonResponse) {
|
||||
if (!resp.status) toast({
|
||||
title: t("admin.operate-failed"),
|
||||
description: t("admin.operate-failed-prompt", { reason: resp.message }),
|
||||
});
|
||||
else toast({
|
||||
title: t("admin.operate-success"),
|
||||
description: t("admin.operate-success-prompt"),
|
||||
});
|
||||
}
|
||||
|
||||
function OperationMenu({ id }: OperationMenuProps) {
|
||||
const { t } = useTranslation();
|
||||
const { toast } = useToast();
|
||||
const [quotaOpen, setQuotaOpen] = useState<boolean>(false);
|
||||
const [subscriptionOpen, setSubscriptionOpen] = useState<boolean>(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PopupDialog
|
||||
title={t("admin.quota-action")} name={t("admin.quota")}
|
||||
description={t("admin.quota-action-desc")}
|
||||
defaultValue={"0"} onValueChange={getNumber}
|
||||
open={quotaOpen} setOpen={setQuotaOpen}
|
||||
onSubmit={async (value) => {
|
||||
const quota = parseNumber(value);
|
||||
const resp = await quotaOperation(id, quota);
|
||||
doToast(t, toast, resp);
|
||||
return resp.status;
|
||||
}}
|
||||
/>
|
||||
<PopupDialog
|
||||
title={t("admin.subscription-action")} name={t("admin.month")}
|
||||
description={t("admin.subscription-action-desc")}
|
||||
defaultValue={"0"} onValueChange={getNumber}
|
||||
open={subscriptionOpen} setOpen={setSubscriptionOpen}
|
||||
onSubmit={async (value) => {
|
||||
const month = parseNumber(value);
|
||||
const resp = await subscriptionOperation(id, month);
|
||||
doToast(t, toast, resp);
|
||||
return resp.status;
|
||||
}}
|
||||
/>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<Button variant={`outline`} size={`icon`}>
|
||||
<MoreHorizontal className={`h-4 w-4`} />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem onClick={() => setQuotaOpen(true)}>
|
||||
<CloudCog className={`h-4 w-4 mr-2`} />
|
||||
{t("admin.quota-action")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setSubscriptionOpen(true)}>
|
||||
<CalendarClock className={`h-4 w-4 mr-2`} />
|
||||
{t("admin.subscription-action")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function UserTable() {
|
||||
const { t } = useTranslation();
|
||||
@ -73,7 +151,7 @@ function UserTable() {
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.data.map((user, idx) => (
|
||||
{(data.data || []).map((user, idx) => (
|
||||
<TableRow key={idx}>
|
||||
<TableCell>{user.id}</TableCell>
|
||||
<TableCell>{user.username}</TableCell>
|
||||
@ -84,9 +162,7 @@ function UserTable() {
|
||||
<TableCell>{t(user.enterprise.toString())}</TableCell>
|
||||
<TableCell>{t(user.is_admin.toString())}</TableCell>
|
||||
<TableCell>
|
||||
<Button variant={`outline`} size={`icon`}>
|
||||
<MoreHorizontal className={`h-4 w-4`} />
|
||||
</Button>
|
||||
<OperationMenu id={user.id} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
|
@ -9,6 +9,7 @@ import {
|
||||
FolderKanban,
|
||||
Link,
|
||||
Newspaper,
|
||||
Shield,
|
||||
Users2,
|
||||
} from "lucide-react";
|
||||
import router from "@/router.tsx";
|
||||
@ -20,8 +21,10 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog.tsx";
|
||||
import { getLanguage } from "@/i18n.ts";
|
||||
import { selectAdmin } from "@/store/auth.ts";
|
||||
|
||||
function ChatSpace() {
|
||||
const admin = useSelector(selectAdmin);
|
||||
const [open, setOpen] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
const subscription = useSelector(isSubscribedSelector);
|
||||
@ -82,6 +85,12 @@ function ChatSpace() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<div className={`space-footer`}>
|
||||
{admin && (
|
||||
<p>
|
||||
<Shield className={`h-3 w-3 mr-1`} />
|
||||
<a onClick={() => router.navigate("/admin")}>{t("admin.users")}</a>
|
||||
</p>
|
||||
)}
|
||||
<p>
|
||||
<Link className={`h-3 w-3 mr-1`} />
|
||||
<a
|
||||
|
@ -29,7 +29,6 @@ export class Connection {
|
||||
public constructor(id: number, callback?: StreamCallback) {
|
||||
this.state = false;
|
||||
this.id = id;
|
||||
this.init();
|
||||
this.callback && this.setCallback(callback);
|
||||
}
|
||||
|
||||
@ -62,6 +61,7 @@ export class Connection {
|
||||
|
||||
public send(data: Record<string, string | boolean | number>): boolean {
|
||||
if (!this.state || !this.connection) {
|
||||
if (this.connection === undefined) this.init();
|
||||
console.debug("[connection] connection not ready, retrying in 500ms...");
|
||||
return false;
|
||||
}
|
||||
|
@ -267,6 +267,7 @@ const resources = {
|
||||
used: "状态",
|
||||
number: "数量",
|
||||
username: "用户名",
|
||||
month: "月数",
|
||||
"is-admin": "管理员",
|
||||
"used-quota": "已用点数",
|
||||
"is-subscribed": "是否订阅",
|
||||
@ -276,7 +277,7 @@ const resources = {
|
||||
"search-username": "搜索用户名",
|
||||
"quota-action": "点数变更",
|
||||
"quota-action-desc": "请输入点数变更值(正数为增加,负数为减少)",
|
||||
"subscription-action": "赠送订阅",
|
||||
"subscription-action": "订阅管理",
|
||||
"subscription-action-desc": "请输入赠送的订阅月数",
|
||||
"operate-success": "操作成功",
|
||||
"operate-success-prompt": "您的操作已成功执行。",
|
||||
@ -567,6 +568,7 @@ const resources = {
|
||||
used: "Status",
|
||||
number: "Number",
|
||||
username: "Username",
|
||||
month: "Month",
|
||||
"is-admin": "Admin",
|
||||
"used-quota": "Used Quota",
|
||||
"is-subscribed": "Subscribed",
|
||||
@ -577,7 +579,7 @@ const resources = {
|
||||
"quota-action": "Quota Change",
|
||||
"quota-action-desc":
|
||||
"Please enter the quota change value (positive for increase, negative for decrease)",
|
||||
"subscription-action": "Subscription Gift",
|
||||
"subscription-action": "Subscription Management",
|
||||
"subscription-action-desc": "Please enter the gift subscription months",
|
||||
"operate-success": "Operate Success",
|
||||
"operate-success-prompt":
|
||||
@ -870,6 +872,7 @@ const resources = {
|
||||
used: "Статус",
|
||||
number: "Количество",
|
||||
username: "Имя пользователя",
|
||||
month: "Месяц",
|
||||
"is-admin": "Админ",
|
||||
"used-quota": "Использовано",
|
||||
"is-subscribed": "Подписан",
|
||||
@ -880,7 +883,7 @@ const resources = {
|
||||
"quota-action": "Изменение квоты",
|
||||
"quota-action-desc":
|
||||
"Пожалуйста, введите значение изменения квоты (положительное для увеличения, отрицательное для уменьшения)",
|
||||
"subscription-action": "Подарок подписки",
|
||||
"subscription-action": "Управление подпиской",
|
||||
"subscription-action-desc":
|
||||
"Пожалуйста, введите количество месяцев подарочной подписки",
|
||||
"operate-success": "Успешно",
|
||||
|
@ -26,3 +26,14 @@ export function asyncCaller<T>(fn: (...args: any[]) => Promise<T>) {
|
||||
return promise;
|
||||
};
|
||||
}
|
||||
|
||||
export function getNumber(value: string, supportNegative = true): string {
|
||||
return value.replace(
|
||||
supportNegative ? /[^-0-9.]/g : /[^0-9.]/g,
|
||||
""
|
||||
);
|
||||
}
|
||||
|
||||
export function parseNumber(value: string): number {
|
||||
return parseFloat(getNumber(value));
|
||||
}
|
||||
|
@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"github.com/gin-gonic/gin"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type WebsocketAuthForm struct {
|
||||
@ -15,6 +16,23 @@ type WebsocketAuthForm struct {
|
||||
Ref string `json:"ref"`
|
||||
}
|
||||
|
||||
func ParseAuth(c *gin.Context, token string) *auth.User {
|
||||
token = strings.TrimSpace(token)
|
||||
if token == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if strings.HasPrefix(token, "Bearer ") {
|
||||
token = token[7:]
|
||||
}
|
||||
|
||||
if strings.HasPrefix(token, "sk-") {
|
||||
return auth.ParseApiKey(c, token)
|
||||
}
|
||||
|
||||
return auth.ParseToken(c, token)
|
||||
}
|
||||
|
||||
func ChatAPI(c *gin.Context) {
|
||||
var conn *utils.WebSocket
|
||||
if conn = utils.NewWebsocket(c, false); conn == nil {
|
||||
@ -28,7 +46,7 @@ func ChatAPI(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
user := auth.ParseToken(c, form.Token)
|
||||
user := ParseAuth(c, form.Token)
|
||||
authenticated := user != nil
|
||||
|
||||
id := auth.GetId(db, user)
|
||||
|
@ -1,6 +1,7 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"chat/admin"
|
||||
"chat/utils"
|
||||
"fmt"
|
||||
"github.com/gin-gonic/gin"
|
||||
@ -58,6 +59,7 @@ func ThrottleMiddleware() gin.HandlerFunc {
|
||||
ip := c.ClientIP()
|
||||
path := c.Request.URL.Path
|
||||
cache := utils.GetCacheFromContext(c)
|
||||
admin.IncrRequest(cache)
|
||||
limiter := GetPrefixMap[Limiter](path, limits)
|
||||
if limiter != nil && limiter.RateLimit(c, cache, ip, path) {
|
||||
c.JSON(200, gin.H{"status": false, "reason": "You have sent too many requests. Please try again later."})
|
||||
|
Loading…
Reference in New Issue
Block a user