update user operation and fix api connection

This commit is contained in:
Zhang Minghan 2023-11-08 18:05:58 +08:00
parent b40c60edd8
commit 38dad633ee
15 changed files with 333 additions and 16 deletions

View File

@ -14,6 +14,16 @@ type GenerateInvitationForm struct {
Number int `json:"number"` 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) { func InfoAPI(c *gin.Context) {
db := utils.GetDBFromContext(c) db := utils.GetDBFromContext(c)
cache := utils.GetCacheFromContext(c) cache := utils.GetCacheFromContext(c)
@ -74,3 +84,55 @@ func UserPaginationAPI(c *gin.Context) {
search := strings.TrimSpace(c.Query("search")) search := strings.TrimSpace(c.Query("search"))
c.JSON(http.StatusOK, GetUserPagination(db, int64(page), 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,
})
}

View File

@ -13,4 +13,6 @@ func Register(app *gin.Engine) {
app.POST("/admin/invitation/generate", GenerateInvitationAPI) app.POST("/admin/invitation/generate", GenerateInvitationAPI)
app.GET("/admin/user/list", UserPaginationAPI) app.GET("/admin/user/list", UserPaginationAPI)
app.POST("/admin/user/quota", UserQuotaAPI)
app.POST("/admin/user/subscription", UserSubscriptionAPI)
} }

View File

@ -21,7 +21,6 @@ func IncrRequest(cache *redis.Client) {
} }
func IncrModelRequest(cache *redis.Client, model string, tokens int64) { func IncrModelRequest(cache *redis.Client, model string, tokens int64) {
IncrRequest(cache)
utils.IncrWithExpire(cache, getModelFormat(getDay(), model), tokens, time.Hour*24*7*2) utils.IncrWithExpire(cache, getModelFormat(getDay(), model), tokens, time.Hour*24*7*2)
} }

View File

@ -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 quota ON quota.user_id = auth.id
LEFT JOIN subscription ON subscription.user_id = auth.id LEFT JOIN subscription ON subscription.user_id = auth.id
WHERE auth.username LIKE ? WHERE auth.username LIKE ?
ORDER BY auth.id DESC LIMIT ? OFFSET ? ORDER BY auth.id LIMIT ? OFFSET ?
`, "%"+search+"%", pagination, page*pagination) `, "%"+search+"%", pagination, page*pagination)
if err != nil { if err != nil {
return PaginationForm{ return PaginationForm{
@ -79,3 +79,29 @@ func GetUserPagination(db *sql.DB, page int64, search string) PaginationForm {
Data: users, 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
}

View File

@ -1,5 +1,6 @@
import { import {
BillingChartResponse, BillingChartResponse,
CommonResponse,
ErrorChartResponse, ErrorChartResponse,
InfoResponse, InfoResponse,
InvitationGenerateResponse, InvitationGenerateResponse,
@ -111,3 +112,27 @@ export async function getUserList(
return response.data as UserResponse; 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;
}

View File

@ -1,3 +1,8 @@
export type CommonResponse = {
status: boolean;
message: string;
};
export type InfoResponse = { export type InfoResponse = {
billing_today: number; billing_today: number;
billing_month: number; billing_month: number;
@ -54,7 +59,7 @@ export type InvitationGenerateResponse = {
}; };
export type UserData = { export type UserData = {
id: string; id: number;
username: string; username: string;
is_admin: boolean; is_admin: boolean;
quota: number; quota: number;

View 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;

View File

@ -155,8 +155,8 @@ function InvitationTable() {
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{data.data.map((invitation, idx) => ( {(data.data || []).map((invitation, idx) => (
<TableRow key={idx}> <TableRow key={idx} className={`whitespace-nowrap`}>
<TableCell>{invitation.code}</TableCell> <TableCell>{invitation.code}</TableCell>
<TableCell>{invitation.quota}</TableCell> <TableCell>{invitation.quota}</TableCell>
<TableCell>{invitation.type}</TableCell> <TableCell>{invitation.type}</TableCell>

View File

@ -1,8 +1,8 @@
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useToast } from "@/components/ui/use-toast.ts"; import { useToast } from "@/components/ui/use-toast.ts";
import { useState } from "react"; import { useState } from "react";
import { UserForm, UserResponse } from "@/admin/types.ts"; import {CommonResponse, UserForm, UserResponse} from "@/admin/types.ts";
import { getUserList } from "@/admin/api.ts"; import {getUserList, quotaOperation, subscriptionOperation} from "@/admin/api.ts";
import { useEffectAsync } from "@/utils/hook.ts"; import { useEffectAsync } from "@/utils/hook.ts";
import { import {
Table, Table,
@ -12,15 +12,93 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from "@/components/ui/table.tsx"; } from "@/components/ui/table.tsx";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu.tsx";
import { Button } from "@/components/ui/button.tsx"; import { Button } from "@/components/ui/button.tsx";
import { import {
CalendarClock,
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
CloudCog,
MoreHorizontal, MoreHorizontal,
RotateCw, RotateCw,
Search, Search,
} from "lucide-react"; } from "lucide-react";
import { Input } from "@/components/ui/input.tsx"; 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() { function UserTable() {
const { t } = useTranslation(); const { t } = useTranslation();
@ -73,7 +151,7 @@ function UserTable() {
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{data.data.map((user, idx) => ( {(data.data || []).map((user, idx) => (
<TableRow key={idx}> <TableRow key={idx}>
<TableCell>{user.id}</TableCell> <TableCell>{user.id}</TableCell>
<TableCell>{user.username}</TableCell> <TableCell>{user.username}</TableCell>
@ -84,9 +162,7 @@ function UserTable() {
<TableCell>{t(user.enterprise.toString())}</TableCell> <TableCell>{t(user.enterprise.toString())}</TableCell>
<TableCell>{t(user.is_admin.toString())}</TableCell> <TableCell>{t(user.is_admin.toString())}</TableCell>
<TableCell> <TableCell>
<Button variant={`outline`} size={`icon`}> <OperationMenu id={user.id} />
<MoreHorizontal className={`h-4 w-4`} />
</Button>
</TableCell> </TableCell>
</TableRow> </TableRow>
))} ))}

View File

@ -9,6 +9,7 @@ import {
FolderKanban, FolderKanban,
Link, Link,
Newspaper, Newspaper,
Shield,
Users2, Users2,
} from "lucide-react"; } from "lucide-react";
import router from "@/router.tsx"; import router from "@/router.tsx";
@ -20,8 +21,10 @@ import {
DialogTitle, DialogTitle,
} from "@/components/ui/dialog.tsx"; } from "@/components/ui/dialog.tsx";
import { getLanguage } from "@/i18n.ts"; import { getLanguage } from "@/i18n.ts";
import { selectAdmin } from "@/store/auth.ts";
function ChatSpace() { function ChatSpace() {
const admin = useSelector(selectAdmin);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const { t } = useTranslation(); const { t } = useTranslation();
const subscription = useSelector(isSubscribedSelector); const subscription = useSelector(isSubscribedSelector);
@ -82,6 +85,12 @@ function ChatSpace() {
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<div className={`space-footer`}> <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> <p>
<Link className={`h-3 w-3 mr-1`} /> <Link className={`h-3 w-3 mr-1`} />
<a <a

View File

@ -29,7 +29,6 @@ export class Connection {
public constructor(id: number, callback?: StreamCallback) { public constructor(id: number, callback?: StreamCallback) {
this.state = false; this.state = false;
this.id = id; this.id = id;
this.init();
this.callback && this.setCallback(callback); this.callback && this.setCallback(callback);
} }
@ -62,6 +61,7 @@ export class Connection {
public send(data: Record<string, string | boolean | number>): boolean { public send(data: Record<string, string | boolean | number>): boolean {
if (!this.state || !this.connection) { if (!this.state || !this.connection) {
if (this.connection === undefined) this.init();
console.debug("[connection] connection not ready, retrying in 500ms..."); console.debug("[connection] connection not ready, retrying in 500ms...");
return false; return false;
} }

View File

@ -267,6 +267,7 @@ const resources = {
used: "状态", used: "状态",
number: "数量", number: "数量",
username: "用户名", username: "用户名",
month: "月数",
"is-admin": "管理员", "is-admin": "管理员",
"used-quota": "已用点数", "used-quota": "已用点数",
"is-subscribed": "是否订阅", "is-subscribed": "是否订阅",
@ -276,7 +277,7 @@ const resources = {
"search-username": "搜索用户名", "search-username": "搜索用户名",
"quota-action": "点数变更", "quota-action": "点数变更",
"quota-action-desc": "请输入点数变更值(正数为增加,负数为减少)", "quota-action-desc": "请输入点数变更值(正数为增加,负数为减少)",
"subscription-action": "赠送订阅", "subscription-action": "订阅管理",
"subscription-action-desc": "请输入赠送的订阅月数", "subscription-action-desc": "请输入赠送的订阅月数",
"operate-success": "操作成功", "operate-success": "操作成功",
"operate-success-prompt": "您的操作已成功执行。", "operate-success-prompt": "您的操作已成功执行。",
@ -567,6 +568,7 @@ const resources = {
used: "Status", used: "Status",
number: "Number", number: "Number",
username: "Username", username: "Username",
month: "Month",
"is-admin": "Admin", "is-admin": "Admin",
"used-quota": "Used Quota", "used-quota": "Used Quota",
"is-subscribed": "Subscribed", "is-subscribed": "Subscribed",
@ -577,7 +579,7 @@ const resources = {
"quota-action": "Quota Change", "quota-action": "Quota Change",
"quota-action-desc": "quota-action-desc":
"Please enter the quota change value (positive for increase, negative for decrease)", "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", "subscription-action-desc": "Please enter the gift subscription months",
"operate-success": "Operate Success", "operate-success": "Operate Success",
"operate-success-prompt": "operate-success-prompt":
@ -870,6 +872,7 @@ const resources = {
used: "Статус", used: "Статус",
number: "Количество", number: "Количество",
username: "Имя пользователя", username: "Имя пользователя",
month: "Месяц",
"is-admin": "Админ", "is-admin": "Админ",
"used-quota": "Использовано", "used-quota": "Использовано",
"is-subscribed": "Подписан", "is-subscribed": "Подписан",
@ -880,7 +883,7 @@ const resources = {
"quota-action": "Изменение квоты", "quota-action": "Изменение квоты",
"quota-action-desc": "quota-action-desc":
"Пожалуйста, введите значение изменения квоты (положительное для увеличения, отрицательное для уменьшения)", "Пожалуйста, введите значение изменения квоты (положительное для увеличения, отрицательное для уменьшения)",
"subscription-action": "Подарок подписки", "subscription-action": "Управление подпиской",
"subscription-action-desc": "subscription-action-desc":
"Пожалуйста, введите количество месяцев подарочной подписки", "Пожалуйста, введите количество месяцев подарочной подписки",
"operate-success": "Успешно", "operate-success": "Успешно",

View File

@ -26,3 +26,14 @@ export function asyncCaller<T>(fn: (...args: any[]) => Promise<T>) {
return promise; 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));
}

View File

@ -7,6 +7,7 @@ import (
"fmt" "fmt"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"strconv" "strconv"
"strings"
) )
type WebsocketAuthForm struct { type WebsocketAuthForm struct {
@ -15,6 +16,23 @@ type WebsocketAuthForm struct {
Ref string `json:"ref"` 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) { func ChatAPI(c *gin.Context) {
var conn *utils.WebSocket var conn *utils.WebSocket
if conn = utils.NewWebsocket(c, false); conn == nil { if conn = utils.NewWebsocket(c, false); conn == nil {
@ -28,7 +46,7 @@ func ChatAPI(c *gin.Context) {
return return
} }
user := auth.ParseToken(c, form.Token) user := ParseAuth(c, form.Token)
authenticated := user != nil authenticated := user != nil
id := auth.GetId(db, user) id := auth.GetId(db, user)

View File

@ -1,6 +1,7 @@
package middleware package middleware
import ( import (
"chat/admin"
"chat/utils" "chat/utils"
"fmt" "fmt"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@ -58,6 +59,7 @@ func ThrottleMiddleware() gin.HandlerFunc {
ip := c.ClientIP() ip := c.ClientIP()
path := c.Request.URL.Path path := c.Request.URL.Path
cache := utils.GetCacheFromContext(c) cache := utils.GetCacheFromContext(c)
admin.IncrRequest(cache)
limiter := GetPrefixMap[Limiter](path, limits) limiter := GetPrefixMap[Limiter](path, limits)
if limiter != nil && limiter.RateLimit(c, cache, ip, path) { 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."}) c.JSON(200, gin.H{"status": false, "reason": "You have sent too many requests. Please try again later."})