mirror of
https://github.com/coaidev/coai.git
synced 2025-05-19 13:00:14 +09:00
add redeem feature
This commit is contained in:
parent
13c12956af
commit
d278f242a5
@ -109,7 +109,9 @@
|
|||||||
|
|
||||||
chatnio 版本更新:
|
chatnio 版本更新:
|
||||||
```shell
|
```shell
|
||||||
docker-compose pull chatnio # pull latest image
|
docker-compose down
|
||||||
|
docker-compose pull # pull latest image
|
||||||
|
docker-compose up -d # start service in background
|
||||||
```
|
```
|
||||||
|
|
||||||
> - MySQL 数据库挂载目录项目 ~/**db**
|
> - MySQL 数据库挂载目录项目 ~/**db**
|
||||||
|
@ -14,6 +14,11 @@ type GenerateInvitationForm struct {
|
|||||||
Number int `json:"number"`
|
Number int `json:"number"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type GenerateRedeemForm struct {
|
||||||
|
Quota float32 `json:"quota"`
|
||||||
|
Number int `json:"number"`
|
||||||
|
}
|
||||||
|
|
||||||
type QuotaOperationForm struct {
|
type QuotaOperationForm struct {
|
||||||
Id int64 `json:"id"`
|
Id int64 `json:"id"`
|
||||||
Quota float32 `json:"quota"`
|
Quota float32 `json:"quota"`
|
||||||
@ -55,6 +60,11 @@ func ErrorAnalysisAPI(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, GetErrorData(cache))
|
c.JSON(http.StatusOK, GetErrorData(cache))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func RedeemListAPI(c *gin.Context) {
|
||||||
|
db := utils.GetDBFromContext(c)
|
||||||
|
c.JSON(http.StatusOK, GetRedeemData(db))
|
||||||
|
}
|
||||||
|
|
||||||
func InvitationPaginationAPI(c *gin.Context) {
|
func InvitationPaginationAPI(c *gin.Context) {
|
||||||
db := utils.GetDBFromContext(c)
|
db := utils.GetDBFromContext(c)
|
||||||
|
|
||||||
@ -77,6 +87,21 @@ func GenerateInvitationAPI(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, GenerateInvitations(db, form.Number, form.Quota, form.Type))
|
c.JSON(http.StatusOK, GenerateInvitations(db, form.Number, form.Quota, form.Type))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func GenerateRedeemAPI(c *gin.Context) {
|
||||||
|
db := utils.GetDBFromContext(c)
|
||||||
|
|
||||||
|
var form GenerateRedeemForm
|
||||||
|
if err := c.ShouldBindJSON(&form); err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"status": false,
|
||||||
|
"message": err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, GenerateRedeemCodes(db, form.Number, form.Quota))
|
||||||
|
}
|
||||||
|
|
||||||
func UserPaginationAPI(c *gin.Context) {
|
func UserPaginationAPI(c *gin.Context) {
|
||||||
db := utils.GetDBFromContext(c)
|
db := utils.GetDBFromContext(c)
|
||||||
|
|
||||||
|
67
admin/redeem.go
Normal file
67
admin/redeem.go
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
package admin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"chat/utils"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func GetRedeemData(db *sql.DB) []RedeemData {
|
||||||
|
var data []RedeemData
|
||||||
|
|
||||||
|
rows, err := db.Query(`
|
||||||
|
SELECT quota, COUNT(*) AS total, SUM(IF(used = 0, 0, 1)) AS used
|
||||||
|
FROM redeem
|
||||||
|
GROUP BY quota
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var d RedeemData
|
||||||
|
if err := rows.Scan(&d.Quota, &d.Total, &d.Used); err != nil {
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
data = append(data, d)
|
||||||
|
}
|
||||||
|
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
func GenerateRedeemCodes(db *sql.DB, num int, quota float32) RedeemGenerateResponse {
|
||||||
|
arr := make([]string, 0)
|
||||||
|
idx := 0
|
||||||
|
for idx < num {
|
||||||
|
code, err := CreateRedeemCode(db, quota)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return RedeemGenerateResponse{
|
||||||
|
Status: false,
|
||||||
|
Message: err.Error(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
arr = append(arr, code)
|
||||||
|
idx++
|
||||||
|
}
|
||||||
|
|
||||||
|
return RedeemGenerateResponse{
|
||||||
|
Status: true,
|
||||||
|
Data: arr,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func CreateRedeemCode(db *sql.DB, quota float32) (string, error) {
|
||||||
|
code := fmt.Sprintf("nio-%s", utils.GenerateChar(32))
|
||||||
|
_, err := db.Exec(`
|
||||||
|
INSERT INTO redeem (code, quota) VALUES (?, ?)
|
||||||
|
`, code, quota)
|
||||||
|
|
||||||
|
if err != nil && strings.Contains(err.Error(), "Duplicate entry") {
|
||||||
|
// code name is duplicate
|
||||||
|
return CreateRedeemCode(db, quota)
|
||||||
|
}
|
||||||
|
|
||||||
|
return code, err
|
||||||
|
}
|
@ -17,6 +17,9 @@ func Register(app *gin.RouterGroup) {
|
|||||||
app.GET("/admin/invitation/list", InvitationPaginationAPI)
|
app.GET("/admin/invitation/list", InvitationPaginationAPI)
|
||||||
app.POST("/admin/invitation/generate", GenerateInvitationAPI)
|
app.POST("/admin/invitation/generate", GenerateInvitationAPI)
|
||||||
|
|
||||||
|
app.GET("/admin/redeem/list", RedeemListAPI)
|
||||||
|
app.POST("/admin/redeem/generate", GenerateRedeemAPI)
|
||||||
|
|
||||||
app.GET("/admin/user/list", UserPaginationAPI)
|
app.GET("/admin/user/list", UserPaginationAPI)
|
||||||
app.POST("/admin/user/quota", UserQuotaAPI)
|
app.POST("/admin/user/quota", UserQuotaAPI)
|
||||||
app.POST("/admin/user/subscription", UserSubscriptionAPI)
|
app.POST("/admin/user/subscription", UserSubscriptionAPI)
|
||||||
|
@ -48,12 +48,24 @@ type InvitationData struct {
|
|||||||
UpdatedAt string `json:"updated_at"`
|
UpdatedAt string `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type RedeemData struct {
|
||||||
|
Quota float32 `json:"quota"`
|
||||||
|
Used float32 `json:"used"`
|
||||||
|
Total float32 `json:"total"`
|
||||||
|
}
|
||||||
|
|
||||||
type InvitationGenerateResponse struct {
|
type InvitationGenerateResponse struct {
|
||||||
Status bool `json:"status"`
|
Status bool `json:"status"`
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
Data []string `json:"data"`
|
Data []string `json:"data"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type RedeemGenerateResponse struct {
|
||||||
|
Status bool `json:"status"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Data []string `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
type UserData struct {
|
type UserData struct {
|
||||||
Id int64 `json:"id"`
|
Id int64 `json:"id"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
|
@ -6,6 +6,7 @@ import {
|
|||||||
InvitationGenerateResponse,
|
InvitationGenerateResponse,
|
||||||
InvitationResponse,
|
InvitationResponse,
|
||||||
ModelChartResponse,
|
ModelChartResponse,
|
||||||
|
RedeemResponse,
|
||||||
RequestChartResponse,
|
RequestChartResponse,
|
||||||
UserResponse,
|
UserResponse,
|
||||||
} from "@/admin/types.ts";
|
} from "@/admin/types.ts";
|
||||||
@ -94,6 +95,30 @@ export async function generateInvitation(
|
|||||||
return response.data as InvitationGenerateResponse;
|
return response.data as InvitationGenerateResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getRedeemList(): Promise<RedeemResponse> {
|
||||||
|
const response = await axios.get("/admin/redeem/list");
|
||||||
|
if (response.status !== 200) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.data as RedeemResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateRedeem(
|
||||||
|
quota: number,
|
||||||
|
number: number,
|
||||||
|
): Promise<InvitationGenerateResponse> {
|
||||||
|
const response = await axios.post("/admin/redeem/generate", {
|
||||||
|
quota,
|
||||||
|
number,
|
||||||
|
});
|
||||||
|
if (response.status !== 200) {
|
||||||
|
return { status: false, data: [], message: "" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.data as InvitationGenerateResponse;
|
||||||
|
}
|
||||||
|
|
||||||
export async function getUserList(
|
export async function getUserList(
|
||||||
page: number,
|
page: number,
|
||||||
search: string,
|
search: string,
|
||||||
|
@ -35,7 +35,7 @@ export const ChannelTypes: Record<string, string> = {
|
|||||||
bing: "New Bing",
|
bing: "New Bing",
|
||||||
palm: "Google PaLM2",
|
palm: "Google PaLM2",
|
||||||
midjourney: "Midjourney",
|
midjourney: "Midjourney",
|
||||||
oneapi: "One API",
|
oneapi: "Nio API",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ChannelInfos: Record<string, ChannelInfo> = {
|
export const ChannelInfos: Record<string, ChannelInfo> = {
|
||||||
|
@ -52,12 +52,26 @@ export type InvitationResponse = {
|
|||||||
total: number;
|
total: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type Redeem = {
|
||||||
|
quota: number;
|
||||||
|
used: boolean;
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RedeemResponse = Redeem[];
|
||||||
|
|
||||||
export type InvitationGenerateResponse = {
|
export type InvitationGenerateResponse = {
|
||||||
status: boolean;
|
status: boolean;
|
||||||
data: string[];
|
data: string[];
|
||||||
message: string;
|
message: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type RedeemGenerateResponse = {
|
||||||
|
status: boolean;
|
||||||
|
data: string[];
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type UserData = {
|
export type UserData = {
|
||||||
id: number;
|
id: number;
|
||||||
username: string;
|
username: string;
|
||||||
|
22
app/src/api/redeem.ts
Normal file
22
app/src/api/redeem.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import axios from "axios";
|
||||||
|
import { getErrorMessage } from "@/utils/base.ts";
|
||||||
|
|
||||||
|
export type RedeemResponse = {
|
||||||
|
status: boolean;
|
||||||
|
error: string;
|
||||||
|
quota: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function useRedeem(code: string): Promise<RedeemResponse> {
|
||||||
|
try {
|
||||||
|
const resp = await axios.get(`/redeem?code=${code}`);
|
||||||
|
return resp.data as RedeemResponse;
|
||||||
|
} catch (e) {
|
||||||
|
console.debug(e);
|
||||||
|
return {
|
||||||
|
status: false,
|
||||||
|
error: `network error: ${getErrorMessage(e)}`,
|
||||||
|
quota: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
@ -46,6 +46,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.user-row,
|
.user-row,
|
||||||
|
.redeem-row,
|
||||||
.invitation-row {
|
.invitation-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
@ -58,6 +59,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.user-action,
|
.user-action,
|
||||||
|
.redeem-action,
|
||||||
.invitation-action {
|
.invitation-action {
|
||||||
display: flex;
|
display: flex;
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
|
@ -8,14 +8,15 @@ import { HelpCircle } from "lucide-react";
|
|||||||
|
|
||||||
type TipsProps = {
|
type TipsProps = {
|
||||||
content: string;
|
content: string;
|
||||||
|
className?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
function Tips({ content }: TipsProps) {
|
function Tips({ content, className }: TipsProps) {
|
||||||
return (
|
return (
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<HelpCircle className={`tips-icon`} />
|
<HelpCircle className={`tips-icon ${className}`} />
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<p>{content}</p>
|
<p>{content}</p>
|
||||||
|
@ -158,7 +158,7 @@ function InvitationTable() {
|
|||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow className={`select-none whitespace-nowrap`}>
|
<TableRow className={`select-none whitespace-nowrap`}>
|
||||||
<TableHead>{t("admin.code")}</TableHead>
|
<TableHead>{t("admin.invitation-code")}</TableHead>
|
||||||
<TableHead>{t("admin.quota")}</TableHead>
|
<TableHead>{t("admin.quota")}</TableHead>
|
||||||
<TableHead>{t("admin.type")}</TableHead>
|
<TableHead>{t("admin.type")}</TableHead>
|
||||||
<TableHead>{t("admin.used")}</TableHead>
|
<TableHead>{t("admin.used")}</TableHead>
|
||||||
|
@ -53,7 +53,7 @@ function MenuBar() {
|
|||||||
icon={<LayoutDashboard />}
|
icon={<LayoutDashboard />}
|
||||||
path={"/"}
|
path={"/"}
|
||||||
/>
|
/>
|
||||||
<MenuItem title={t("admin.users")} icon={<Users />} path={"/users"} />
|
<MenuItem title={t("admin.user")} icon={<Users />} path={"/users"} />
|
||||||
<MenuItem
|
<MenuItem
|
||||||
title={t("admin.broadcast")}
|
title={t("admin.broadcast")}
|
||||||
icon={<Radio />}
|
icon={<Radio />}
|
||||||
|
178
app/src/components/admin/RedeemTable.tsx
Normal file
178
app/src/components/admin/RedeemTable.tsx
Normal file
@ -0,0 +1,178 @@
|
|||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table.tsx";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog.tsx";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { RedeemResponse } from "@/admin/types.ts";
|
||||||
|
import { Button } from "@/components/ui/button.tsx";
|
||||||
|
import { Download, RotateCw } from "lucide-react";
|
||||||
|
import { generateRedeem, getRedeemList } from "@/admin/api/chart.ts";
|
||||||
|
import { Input } from "@/components/ui/input.tsx";
|
||||||
|
import { useToast } from "@/components/ui/use-toast.ts";
|
||||||
|
import { Textarea } from "@/components/ui/textarea.tsx";
|
||||||
|
import { saveAsFile } from "@/utils/dom.ts";
|
||||||
|
import { useEffectAsync } from "@/utils/hook.ts";
|
||||||
|
|
||||||
|
function GenerateDialog() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [open, setOpen] = useState<boolean>(false);
|
||||||
|
const [quota, setQuota] = useState<string>("5");
|
||||||
|
const [number, setNumber] = useState<string>("1");
|
||||||
|
const [data, setData] = useState<string>("");
|
||||||
|
|
||||||
|
function getNumber(value: string): string {
|
||||||
|
return value.replace(/[^\d.]/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateCode() {
|
||||||
|
const data = await generateRedeem(Number(quota), Number(number));
|
||||||
|
if (data.status) setData(data.data.join("\n"));
|
||||||
|
else
|
||||||
|
toast({
|
||||||
|
title: t("admin.error"),
|
||||||
|
description: data.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
setQuota("5");
|
||||||
|
setNumber("1");
|
||||||
|
|
||||||
|
setOpen(false);
|
||||||
|
setData("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadCode() {
|
||||||
|
return saveAsFile("code.txt", data);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button>{t("admin.generate")}</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t("admin.generate")}</DialogTitle>
|
||||||
|
<DialogDescription className={`pt-2`}>
|
||||||
|
<div className={`invitation-row`}>
|
||||||
|
<p className={`mr-4`}>{t("admin.quota")}</p>
|
||||||
|
<Input
|
||||||
|
value={quota}
|
||||||
|
onChange={(e) => setQuota(getNumber(e.target.value))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={`invitation-row`}>
|
||||||
|
<p className={`mr-4`}>{t("admin.number")}</p>
|
||||||
|
<Input
|
||||||
|
value={number}
|
||||||
|
onChange={(e) => setNumber(getNumber(e.target.value))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant={`outline`} onClick={() => setOpen(false)}>
|
||||||
|
{t("admin.cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button variant={`default`} loading={true} onClick={generateCode}>
|
||||||
|
{t("admin.confirm")}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
<Dialog
|
||||||
|
open={data !== ""}
|
||||||
|
onOpenChange={(state: boolean) => {
|
||||||
|
if (!state) close();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t("admin.generate-result")}</DialogTitle>
|
||||||
|
<DialogDescription className={`pt-4`}>
|
||||||
|
<Textarea value={data} rows={12} readOnly />
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant={`outline`} onClick={close}>
|
||||||
|
{t("close")}
|
||||||
|
</Button>
|
||||||
|
<Button variant={`default`} onClick={downloadCode}>
|
||||||
|
<Download className={`h-4 w-4 mr-2`} />
|
||||||
|
{t("download")}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RedeemTable() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [data, setData] = useState<RedeemResponse>([]);
|
||||||
|
|
||||||
|
const sync = async () => {
|
||||||
|
const resp = await getRedeemList();
|
||||||
|
setData(resp ?? []);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffectAsync(sync, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`redeem-table`}>
|
||||||
|
{data.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow className={`select-none whitespace-nowrap`}>
|
||||||
|
<TableHead>{t("admin.redeem.quota")}</TableHead>
|
||||||
|
<TableHead>{t("admin.redeem.total")}</TableHead>
|
||||||
|
<TableHead>{t("admin.redeem.used")}</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{data.map((redeem, idx) => (
|
||||||
|
<TableRow key={idx} className={`whitespace-nowrap`}>
|
||||||
|
<TableCell>{redeem.quota}</TableCell>
|
||||||
|
<TableCell>{redeem.total}</TableCell>
|
||||||
|
<TableCell>{redeem.used}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className={`empty`}>
|
||||||
|
<p>{t("admin.empty")}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className={`redeem-action`}>
|
||||||
|
<div className={`grow`} />
|
||||||
|
<Button variant={`outline`} size={`icon`} onClick={sync}>
|
||||||
|
<RotateCw className={`h-4 w-4`} />
|
||||||
|
</Button>
|
||||||
|
<GenerateDialog />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default RedeemTable;
|
@ -73,7 +73,7 @@ function MenuBar({ children, className }: MenuBarProps) {
|
|||||||
)}
|
)}
|
||||||
<DropdownMenuItem onClick={() => dispatch(openInvitationDialog())}>
|
<DropdownMenuItem onClick={() => dispatch(openInvitationDialog())}>
|
||||||
<Gift className={`h-4 w-4 mr-1`} />
|
<Gift className={`h-4 w-4 mr-1`} />
|
||||||
{t("invitation.title")}
|
{t("invitation.invitation")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={() => dispatch(openSharingDialog())}>
|
<DropdownMenuItem onClick={() => dispatch(openSharingDialog())}>
|
||||||
<ListStart className={`h-4 w-4 mr-1`} />
|
<ListStart className={`h-4 w-4 mr-1`} />
|
||||||
|
@ -26,7 +26,7 @@ function InvitationDialog() {
|
|||||||
<Dialog open={open} onOpenChange={(open) => dispatch(setDialog(open))}>
|
<Dialog open={open} onOpenChange={(open) => dispatch(setDialog(open))}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{t("invitation.title")}</DialogTitle>
|
<DialogTitle>{t("invitation.invitation")}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
<Input
|
<Input
|
||||||
value={code}
|
value={code}
|
||||||
|
@ -40,6 +40,7 @@ import { useEffectAsync } from "@/utils/hook.ts";
|
|||||||
import { selectAuthenticated } from "@/store/auth.ts";
|
import { selectAuthenticated } from "@/store/auth.ts";
|
||||||
import { ToastAction } from "@/components/ui/toast.tsx";
|
import { ToastAction } from "@/components/ui/toast.tsx";
|
||||||
import { deeptrainEndpoint, docsEndpoint, useDeeptrain } from "@/utils/env.ts";
|
import { deeptrainEndpoint, docsEndpoint, useDeeptrain } from "@/utils/env.ts";
|
||||||
|
import { useRedeem } from "@/api/redeem.ts";
|
||||||
|
|
||||||
type AmountComponentProps = {
|
type AmountComponentProps = {
|
||||||
amount: number;
|
amount: number;
|
||||||
@ -82,6 +83,8 @@ function QuotaDialog() {
|
|||||||
|
|
||||||
const sub = useSelector(subDialogSelector);
|
const sub = useSelector(subDialogSelector);
|
||||||
|
|
||||||
|
const [redeem, setRedeem] = useState("");
|
||||||
|
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
useEffectAsync(async () => {
|
useEffectAsync(async () => {
|
||||||
if (!auth) return;
|
if (!auth) return;
|
||||||
@ -205,7 +208,7 @@ function QuotaDialog() {
|
|||||||
<Button
|
<Button
|
||||||
variant={`default`}
|
variant={`default`}
|
||||||
className={`buy-button`}
|
className={`buy-button`}
|
||||||
disabled={amount === 0}
|
disabled={amount === 0 || !useDeeptrain}
|
||||||
>
|
>
|
||||||
<Plus className={`h-4 w-4 mr-2`} />
|
<Plus className={`h-4 w-4 mr-2`} />
|
||||||
{t("buy.buy", { amount })}
|
{t("buy.buy", { amount })}
|
||||||
@ -269,6 +272,43 @@ function QuotaDialog() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{!useDeeptrain && (
|
||||||
|
<div className={`flex flex-row px-4 py-2`}>
|
||||||
|
<Input
|
||||||
|
className={`redeem-input mr-2 text-center`}
|
||||||
|
placeholder={t("buy.redeem-placeholder")}
|
||||||
|
value={redeem}
|
||||||
|
onChange={(e) => setRedeem(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
loading={true}
|
||||||
|
className={`whitespace-nowrap`}
|
||||||
|
onClick={async () => {
|
||||||
|
if (redeem.trim() === "") return;
|
||||||
|
const res = await useRedeem(redeem.trim());
|
||||||
|
if (res.status) {
|
||||||
|
toast({
|
||||||
|
title: t("buy.exchange-success"),
|
||||||
|
description: t("buy.exchange-success-prompt", {
|
||||||
|
amount: res.quota,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
setRedeem("");
|
||||||
|
await refreshQuota(dispatch);
|
||||||
|
} else {
|
||||||
|
toast({
|
||||||
|
title: t("buy.exchange-failed"),
|
||||||
|
description: t("buy.exchange-failed-prompt", {
|
||||||
|
reason: res.error,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("buy.redeem")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className={`tip`}>
|
<div className={`tip`}>
|
||||||
<Button variant={`outline`} asChild>
|
<Button variant={`outline`} asChild>
|
||||||
<a href={docsEndpoint} target={`_blank`}>
|
<a href={docsEndpoint} target={`_blank`}>
|
||||||
|
@ -144,8 +144,14 @@
|
|||||||
"dialog-buy": "购买",
|
"dialog-buy": "购买",
|
||||||
"success": "购买成功",
|
"success": "购买成功",
|
||||||
"success-prompt": "您已成功购买 {{amount}} 点数。",
|
"success-prompt": "您已成功购买 {{amount}} 点数。",
|
||||||
|
"redeem": "兑换",
|
||||||
|
"redeem-placeholder": "请输入兑换码",
|
||||||
|
"exchange-success": "兑换成功",
|
||||||
|
"exchange-success-prompt": "您已成功兑换 {{amount}} 点数。",
|
||||||
"failed": "购买失败",
|
"failed": "购买失败",
|
||||||
"failed-prompt": "购买点数失败,请确保您有足够的余额。",
|
"failed-prompt": "购买点数失败,请确保您有足够的余额。",
|
||||||
|
"exchange-failed": "兑换失败",
|
||||||
|
"exchange-failed-prompt": "兑换失败,原因:{{reason}}",
|
||||||
"gpt4-tip": "提示:web 联网版功能可能会带来更多的输入点数消耗",
|
"gpt4-tip": "提示:web 联网版功能可能会带来更多的输入点数消耗",
|
||||||
"go": "前往"
|
"go": "前往"
|
||||||
},
|
},
|
||||||
@ -294,6 +300,7 @@
|
|||||||
},
|
},
|
||||||
"invitation": {
|
"invitation": {
|
||||||
"title": "兑换码",
|
"title": "兑换码",
|
||||||
|
"invitation": "邀请码",
|
||||||
"input-placeholder": "请输入兑换码",
|
"input-placeholder": "请输入兑换码",
|
||||||
"cancel": "取消",
|
"cancel": "取消",
|
||||||
"check": "验证",
|
"check": "验证",
|
||||||
@ -331,6 +338,7 @@
|
|||||||
"admin": {
|
"admin": {
|
||||||
"dashboard": "仪表盘",
|
"dashboard": "仪表盘",
|
||||||
"users": "后台管理",
|
"users": "后台管理",
|
||||||
|
"user": "用户管理",
|
||||||
"broadcast": "公告管理",
|
"broadcast": "公告管理",
|
||||||
"channel": "渠道设置",
|
"channel": "渠道设置",
|
||||||
"settings": "系统设置",
|
"settings": "系统设置",
|
||||||
@ -350,6 +358,10 @@
|
|||||||
"confirm": "确认",
|
"confirm": "确认",
|
||||||
"invitation": "兑换码管理",
|
"invitation": "兑换码管理",
|
||||||
"code": "兑换码",
|
"code": "兑换码",
|
||||||
|
"invitation-code": "邀请码",
|
||||||
|
"invitation-manage": "邀请码管理",
|
||||||
|
"invitation-tips": "邀请码用于兑换点数,每一类邀请码一个用户只能使用一次(可作宣传使用)",
|
||||||
|
"redeem-tips": "兑换码用于兑换点数,可用于支付发卡等",
|
||||||
"quota": "点数",
|
"quota": "点数",
|
||||||
"type": "类型",
|
"type": "类型",
|
||||||
"used": "状态",
|
"used": "状态",
|
||||||
@ -388,6 +400,11 @@
|
|||||||
"generate": "批量生成",
|
"generate": "批量生成",
|
||||||
"generate-result": "生成结果",
|
"generate-result": "生成结果",
|
||||||
"error": "请求失败",
|
"error": "请求失败",
|
||||||
|
"redeem": {
|
||||||
|
"quota": "点数",
|
||||||
|
"used": "已用个数",
|
||||||
|
"total": "总个数"
|
||||||
|
},
|
||||||
"channels": {
|
"channels": {
|
||||||
"id": "渠道 ID",
|
"id": "渠道 ID",
|
||||||
"name": "名称",
|
"name": "名称",
|
||||||
|
@ -108,7 +108,13 @@
|
|||||||
"failed": "Purchase failed",
|
"failed": "Purchase failed",
|
||||||
"failed-prompt": "Failed to purchase points, please make sure you have enough balance.",
|
"failed-prompt": "Failed to purchase points, please make sure you have enough balance.",
|
||||||
"gpt4-tip": "Tip: web searching feature may consume more input points",
|
"gpt4-tip": "Tip: web searching feature may consume more input points",
|
||||||
"go": "Go"
|
"go": "Go",
|
||||||
|
"redeem": "Exchange",
|
||||||
|
"redeem-placeholder": "Please enter the Redeem code",
|
||||||
|
"exchange-success": "Redeem Successfully",
|
||||||
|
"exchange-success-prompt": "You have successfully redeemed {{amount}} credits.",
|
||||||
|
"exchange-failed": "Failed",
|
||||||
|
"exchange-failed-prompt": "Redemption failed for {{reason}}"
|
||||||
},
|
},
|
||||||
"pkg": {
|
"pkg": {
|
||||||
"title": "Packages",
|
"title": "Packages",
|
||||||
@ -260,7 +266,8 @@
|
|||||||
"check": "Check",
|
"check": "Check",
|
||||||
"check-success": "Redeem Success",
|
"check-success": "Redeem Success",
|
||||||
"check-success-description": "Redeem Success! You have received {{amount}} points, start your AI journey!",
|
"check-success-description": "Redeem Success! You have received {{amount}} points, start your AI journey!",
|
||||||
"check-failed": "Redeem Failed"
|
"check-failed": "Redeem Failed",
|
||||||
|
"invitation": "Invitation Code"
|
||||||
},
|
},
|
||||||
"contact": {
|
"contact": {
|
||||||
"title": "Contact Us"
|
"title": "Contact Us"
|
||||||
@ -418,6 +425,16 @@
|
|||||||
"searchQuery": "Max Search Results",
|
"searchQuery": "Max Search Results",
|
||||||
"searchTip": "DuckDuckGo search endpoint, if not filled in, use WebPilot and New Bing reverse search function by default.\nDuckDuckGo API project build: [duckduckgo-api](https://github.com/binjie09/duckduckgo-api).",
|
"searchTip": "DuckDuckGo search endpoint, if not filled in, use WebPilot and New Bing reverse search function by default.\nDuckDuckGo API project build: [duckduckgo-api](https://github.com/binjie09/duckduckgo-api).",
|
||||||
"mailFrom": "Sender"
|
"mailFrom": "Sender"
|
||||||
|
},
|
||||||
|
"user": "User Management",
|
||||||
|
"invitation-code": "Invitation Code",
|
||||||
|
"invitation-manage": "Invitation code management",
|
||||||
|
"invitation-tips": "Invitation codes are used to redeem points. Each type of invitation code can only be used once by one user (can be used for publicity)",
|
||||||
|
"redeem-tips": "Redemption codes are used to redeem credits and can be used to pay for card issuance, etc.",
|
||||||
|
"redeem": {
|
||||||
|
"quota": "Number of points",
|
||||||
|
"used": "Used Count",
|
||||||
|
"total": "Total"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"mask": {
|
"mask": {
|
||||||
@ -464,4 +481,4 @@
|
|||||||
},
|
},
|
||||||
"reset": "Reset",
|
"reset": "Reset",
|
||||||
"request-error": "Request failed for {{reason}}"
|
"request-error": "Request failed for {{reason}}"
|
||||||
}
|
}
|
@ -108,7 +108,13 @@
|
|||||||
"failed": "購入できませんでした",
|
"failed": "購入できませんでした",
|
||||||
"failed-prompt": "クレジットの購入に失敗しました。十分な残高があることを確認。",
|
"failed-prompt": "クレジットの購入に失敗しました。十分な残高があることを確認。",
|
||||||
"gpt4-tip": "ヒント:ウェブに接続された機能により、より多くの入力ポイントが消費される可能性があります",
|
"gpt4-tip": "ヒント:ウェブに接続された機能により、より多くの入力ポイントが消費される可能性があります",
|
||||||
"go": "行く"
|
"go": "行く",
|
||||||
|
"redeem": "交換する",
|
||||||
|
"redeem-placeholder": "引き換えコードを入力してください",
|
||||||
|
"exchange-success": "交換成功",
|
||||||
|
"exchange-success-prompt": "{{amount}}クレジットを正常に引き換えました。",
|
||||||
|
"exchange-failed": "引き換えに失敗しました",
|
||||||
|
"exchange-failed-prompt": "{{reason}}のため、引き換えに失敗しました"
|
||||||
},
|
},
|
||||||
"pkg": {
|
"pkg": {
|
||||||
"title": "パック",
|
"title": "パック",
|
||||||
@ -260,7 +266,8 @@
|
|||||||
"check": "検証",
|
"check": "検証",
|
||||||
"check-success": "交換成功",
|
"check-success": "交換成功",
|
||||||
"check-success-description": "正常に引き換えられました! AIの旅を始めるために{{amount}}クレジットを獲得しました!",
|
"check-success-description": "正常に引き換えられました! AIの旅を始めるために{{amount}}クレジットを獲得しました!",
|
||||||
"check-failed": "引き換えに失敗しました"
|
"check-failed": "引き換えに失敗しました",
|
||||||
|
"invitation": "招待コード"
|
||||||
},
|
},
|
||||||
"contact": {
|
"contact": {
|
||||||
"title": "お問い合わせ"
|
"title": "お問い合わせ"
|
||||||
@ -418,6 +425,16 @@
|
|||||||
"searchQuery": "検索結果の最大数",
|
"searchQuery": "検索結果の最大数",
|
||||||
"searchTip": "DuckDuckGoは、入力せずにWebPilotやNew Bing Reverse Searchなどのアクセスポイントを自動的に検索します。\\ nDuckDuckGo APIプロジェクトビルド:[ duckduckgo - api ]( https://github.com/binjie09/duckduckgo-api )。",
|
"searchTip": "DuckDuckGoは、入力せずにWebPilotやNew Bing Reverse Searchなどのアクセスポイントを自動的に検索します。\\ nDuckDuckGo APIプロジェクトビルド:[ duckduckgo - api ]( https://github.com/binjie09/duckduckgo-api )。",
|
||||||
"mailFrom": "発信元"
|
"mailFrom": "発信元"
|
||||||
|
},
|
||||||
|
"user": "ユーザー管理",
|
||||||
|
"invitation-code": "招待コード",
|
||||||
|
"invitation-manage": "招待コードの管理",
|
||||||
|
"invitation-tips": "招待コードはポイントの引き換えに使用されます。各タイプの招待コードは1人のユーザーが1回のみ使用できます(宣伝に使用できます)",
|
||||||
|
"redeem-tips": "引き換えコードはクレジットの引き換えに使用され、カード発行などの支払いに使用できます。",
|
||||||
|
"redeem": {
|
||||||
|
"quota": "Whirlies",
|
||||||
|
"used": "使用数",
|
||||||
|
"total": "合計"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"mask": {
|
"mask": {
|
||||||
@ -464,4 +481,4 @@
|
|||||||
},
|
},
|
||||||
"reset": "リセット",
|
"reset": "リセット",
|
||||||
"request-error": "{{reason}}のためにリクエストできませんでした"
|
"request-error": "{{reason}}のためにリクエストできませんでした"
|
||||||
}
|
}
|
@ -108,7 +108,13 @@
|
|||||||
"failed": "Покупка не удалась",
|
"failed": "Покупка не удалась",
|
||||||
"failed-prompt": "Не удалось приобрести очки. Пожалуйста, убедитесь, что у вас достаточно баланса.",
|
"failed-prompt": "Не удалось приобрести очки. Пожалуйста, убедитесь, что у вас достаточно баланса.",
|
||||||
"gpt4-tip": "Совет: функция веб-поиска может потреблять больше входных очков",
|
"gpt4-tip": "Совет: функция веб-поиска может потреблять больше входных очков",
|
||||||
"go": "Перейти к"
|
"go": "Перейти к",
|
||||||
|
"redeem": "Обмен валюты",
|
||||||
|
"redeem-placeholder": "Введите код погашения",
|
||||||
|
"exchange-success": "Успешный обмен",
|
||||||
|
"exchange-success-prompt": "Вы успешно использовали {{amount}} кредита (-ов).",
|
||||||
|
"exchange-failed": "Сбой обмена",
|
||||||
|
"exchange-failed-prompt": "Не удалось погасить по {{reason}}"
|
||||||
},
|
},
|
||||||
"pkg": {
|
"pkg": {
|
||||||
"title": "Пакеты",
|
"title": "Пакеты",
|
||||||
@ -260,7 +266,8 @@
|
|||||||
"check": "Проверить",
|
"check": "Проверить",
|
||||||
"check-success": "Успешно",
|
"check-success": "Успешно",
|
||||||
"check-success-description": "Успешно! Вы получили {{amount}} очков, начните свое путешествие в мир AI!",
|
"check-success-description": "Успешно! Вы получили {{amount}} очков, начните свое путешествие в мир AI!",
|
||||||
"check-failed": "Не удалось"
|
"check-failed": "Не удалось",
|
||||||
|
"invitation": "Код приглашения"
|
||||||
},
|
},
|
||||||
"contact": {
|
"contact": {
|
||||||
"title": "Связаться с нами"
|
"title": "Связаться с нами"
|
||||||
@ -418,6 +425,16 @@
|
|||||||
"searchQuery": "Максимальное количество результатов поиска",
|
"searchQuery": "Максимальное количество результатов поиска",
|
||||||
"searchTip": "Конечная точка поиска DuckDuckGo, если она не заполнена, по умолчанию используется функция обратного поиска WebPilot и New Bing.\nСборка проекта DuckDuckGo API: [duckduckgo-api](https://github.com/binjie09/duckduckgo-api).",
|
"searchTip": "Конечная точка поиска DuckDuckGo, если она не заполнена, по умолчанию используется функция обратного поиска WebPilot и New Bing.\nСборка проекта DuckDuckGo API: [duckduckgo-api](https://github.com/binjie09/duckduckgo-api).",
|
||||||
"mailFrom": "От"
|
"mailFrom": "От"
|
||||||
|
},
|
||||||
|
"user": "Управление пользователями",
|
||||||
|
"invitation-code": "Код приглашения",
|
||||||
|
"invitation-manage": "Управление кодом приглашения",
|
||||||
|
"invitation-tips": "Пригласительные коды используются для погашения баллов. Каждый тип пригласительного кода может использоваться только один раз одним пользователем (может использоваться для рекламы)",
|
||||||
|
"redeem-tips": "Коды погашения используются для погашения кредитов и могут быть использованы для оплаты выпуска карт и т. д.",
|
||||||
|
"redeem": {
|
||||||
|
"quota": "Вихри",
|
||||||
|
"used": "Количество использованных",
|
||||||
|
"total": "Итого"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"mask": {
|
"mask": {
|
||||||
@ -464,4 +481,4 @@
|
|||||||
},
|
},
|
||||||
"reset": "сброс",
|
"reset": "сброс",
|
||||||
"request-error": "Запрос не выполнен по {{reason}}"
|
"request-error": "Запрос не выполнен по {{reason}}"
|
||||||
}
|
}
|
@ -8,6 +8,8 @@ import { useTranslation } from "react-i18next";
|
|||||||
import InvitationTable from "@/components/admin/InvitationTable.tsx";
|
import InvitationTable from "@/components/admin/InvitationTable.tsx";
|
||||||
import UserTable from "@/components/admin/UserTable.tsx";
|
import UserTable from "@/components/admin/UserTable.tsx";
|
||||||
import { mobile } from "@/utils/device.ts";
|
import { mobile } from "@/utils/device.ts";
|
||||||
|
import Tips from "@/components/Tips.tsx";
|
||||||
|
import RedeemTable from "@/components/admin/RedeemTable.tsx";
|
||||||
|
|
||||||
function Users() {
|
function Users() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@ -16,7 +18,7 @@ function Users() {
|
|||||||
<div className={`user-interface ${mobile ? "mobile" : ""}`}>
|
<div className={`user-interface ${mobile ? "mobile" : ""}`}>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className={`select-none`}>
|
<CardHeader className={`select-none`}>
|
||||||
<CardTitle>{t("admin.users")}</CardTitle>
|
<CardTitle>{t("admin.user")}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<UserTable />
|
<UserTable />
|
||||||
@ -24,12 +26,32 @@ function Users() {
|
|||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className={`select-none`}>
|
<CardHeader className={`select-none`}>
|
||||||
<CardTitle>{t("admin.invitation")}</CardTitle>
|
<CardTitle className={`flex items-center`}>
|
||||||
|
{t("admin.invitation-manage")}
|
||||||
|
<Tips
|
||||||
|
content={t("admin.invitation-tips")}
|
||||||
|
className={`ml-2 h-6 w-6 translate-y-0.5`}
|
||||||
|
/>
|
||||||
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<InvitationTable />
|
<InvitationTable />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className={`select-none`}>
|
||||||
|
<CardTitle className={`flex items-center`}>
|
||||||
|
{t("admin.invitation")}
|
||||||
|
<Tips
|
||||||
|
content={t("admin.redeem-tips")}
|
||||||
|
className={`ml-2 h-6 w-6 translate-y-0.5`}
|
||||||
|
/>
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<RedeemTable />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -431,3 +431,37 @@ func InviteAPI(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func RedeemAPI(c *gin.Context) {
|
||||||
|
user := GetUserByCtx(c)
|
||||||
|
if user == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
db := utils.GetDBFromContext(c)
|
||||||
|
cache := utils.GetCacheFromContext(c)
|
||||||
|
code := strings.TrimSpace(c.Query("code"))
|
||||||
|
if len(code) == 0 {
|
||||||
|
c.JSON(200, gin.H{
|
||||||
|
"status": false,
|
||||||
|
"error": "invalid code",
|
||||||
|
"quota": 0.,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if quota, err := user.UseRedeem(db, cache, code); err != nil {
|
||||||
|
c.JSON(200, gin.H{
|
||||||
|
"status": false,
|
||||||
|
"error": err.Error(),
|
||||||
|
"quota": 0.,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
c.JSON(200, gin.H{
|
||||||
|
"status": true,
|
||||||
|
"error": "success",
|
||||||
|
"quota": quota,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
112
auth/redeem.go
Normal file
112
auth/redeem.go
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"chat/admin"
|
||||||
|
"chat/utils"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"github.com/go-redis/redis/v8"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Redeem struct {
|
||||||
|
Id int64 `json:"id"`
|
||||||
|
Code string `json:"code"`
|
||||||
|
Quota float32 `json:"quota"`
|
||||||
|
Used bool `json:"used"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func GenerateRedeemCodes(db *sql.DB, num int, quota float32) ([]string, error) {
|
||||||
|
arr := make([]string, 0)
|
||||||
|
idx := 0
|
||||||
|
for idx < num {
|
||||||
|
code := fmt.Sprintf("nio-%s", utils.GenerateChar(32))
|
||||||
|
if err := CreateRedeemCode(db, code, quota); err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("failed to generate code: %w", err)
|
||||||
|
}
|
||||||
|
arr = append(arr, code)
|
||||||
|
idx++
|
||||||
|
}
|
||||||
|
|
||||||
|
return arr, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func CreateRedeemCode(db *sql.DB, code string, quota float32) error {
|
||||||
|
_, err := db.Exec(`
|
||||||
|
INSERT INTO redeem (code, quota) VALUES (?, ?)
|
||||||
|
`, code, quota)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetRedeemCode(db *sql.DB, code string) (*Redeem, error) {
|
||||||
|
row := db.QueryRow(`
|
||||||
|
SELECT id, code, quota, used
|
||||||
|
FROM redeem
|
||||||
|
WHERE code = ?
|
||||||
|
`, code)
|
||||||
|
var redeem Redeem
|
||||||
|
err := row.Scan(&redeem.Id, &redeem.Code, &redeem.Quota, &redeem.Used)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, fmt.Errorf("redeem code not found")
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("failed to get redeem code: %w", err)
|
||||||
|
}
|
||||||
|
return &redeem, nil
|
||||||
|
}
|
||||||
|
func (r *Redeem) IsUsed() bool {
|
||||||
|
return r.Used
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Redeem) Use(db *sql.DB) error {
|
||||||
|
_, err := db.Exec(`
|
||||||
|
UPDATE redeem SET used = TRUE WHERE id = ? AND used = FALSE
|
||||||
|
`, r.Id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Redeem) GetQuota() float32 {
|
||||||
|
return r.Quota
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Redeem) UseRedeem(db *sql.DB, user *User) error {
|
||||||
|
if r.IsUsed() {
|
||||||
|
return fmt.Errorf("this redeem code has been used")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := r.Use(db); err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return fmt.Errorf("redeem code not found")
|
||||||
|
} else if errors.Is(err, sql.ErrTxDone) {
|
||||||
|
return fmt.Errorf("transaction has been closed")
|
||||||
|
}
|
||||||
|
return fmt.Errorf("failed to use redeem code: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !user.IncreaseQuota(db, r.GetQuota()) {
|
||||||
|
return fmt.Errorf("failed to increase quota for user")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *User) UseRedeem(db *sql.DB, cache *redis.Client, code string) (float32, error) {
|
||||||
|
if useDeeptrain() {
|
||||||
|
return 0, errors.New("redeem code is not available in deeptrain mode")
|
||||||
|
}
|
||||||
|
|
||||||
|
if redeem, err := GetRedeemCode(db, code); err != nil {
|
||||||
|
return 0, err
|
||||||
|
} else {
|
||||||
|
if err := redeem.UseRedeem(db, u); err != nil {
|
||||||
|
return 0, fmt.Errorf("failed to use redeem code: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
admin.IncrBillingRequest(cache, int64(redeem.GetQuota()*10))
|
||||||
|
return redeem.GetQuota(), nil
|
||||||
|
}
|
||||||
|
}
|
@ -15,4 +15,5 @@ func Register(app *gin.RouterGroup) {
|
|||||||
app.GET("/subscription", SubscriptionAPI)
|
app.GET("/subscription", SubscriptionAPI)
|
||||||
app.POST("/subscribe", SubscribeAPI)
|
app.POST("/subscribe", SubscribeAPI)
|
||||||
app.GET("/invite", InviteAPI)
|
app.GET("/invite", InviteAPI)
|
||||||
|
app.GET("/redeem", RedeemAPI)
|
||||||
}
|
}
|
||||||
|
@ -26,8 +26,14 @@ func ConnectRedis() *redis.Client {
|
|||||||
DB: viper.GetInt("redis.db"),
|
DB: viper.GetInt("redis.db"),
|
||||||
})
|
})
|
||||||
|
|
||||||
if pingRedis(Cache) != nil {
|
if err := pingRedis(Cache); err != nil {
|
||||||
log.Println(fmt.Sprintf("[connection] failed to connect to redis host: %s, will retry in 5 seconds", viper.GetString("redis.host")))
|
log.Println(
|
||||||
|
fmt.Sprintf(
|
||||||
|
"[connection] failed to connect to redis host: %s (message: %s), will retry in 5 seconds",
|
||||||
|
viper.GetString("redis.host"),
|
||||||
|
err.Error(),
|
||||||
|
),
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
log.Println(fmt.Sprintf("[connection] connected to redis (host: %s)", viper.GetString("redis.host")))
|
log.Println(fmt.Sprintf("[connection] connected to redis (host: %s)", viper.GetString("redis.host")))
|
||||||
}
|
}
|
||||||
|
@ -30,7 +30,12 @@ func ConnectMySQL() *sql.DB {
|
|||||||
viper.GetString("mysql.db"),
|
viper.GetString("mysql.db"),
|
||||||
))
|
))
|
||||||
if err != nil || db.Ping() != nil {
|
if err != nil || db.Ping() != nil {
|
||||||
log.Println(fmt.Sprintf("[connection] failed to connect to mysql server: %s, will retry in 5 seconds", viper.GetString("mysql.host")))
|
log.Println(
|
||||||
|
fmt.Sprintf("[connection] failed to connect to mysql server: %s (message: %s), will retry in 5 seconds",
|
||||||
|
viper.GetString("mysql.host"),
|
||||||
|
utils.GetError(err), // err.Error() may contain nil pointer
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
utils.Sleep(5000)
|
utils.Sleep(5000)
|
||||||
db.Close()
|
db.Close()
|
||||||
@ -48,6 +53,7 @@ func ConnectMySQL() *sql.DB {
|
|||||||
CreateSubscriptionTable(db)
|
CreateSubscriptionTable(db)
|
||||||
CreateApiKeyTable(db)
|
CreateApiKeyTable(db)
|
||||||
CreateInvitationTable(db)
|
CreateInvitationTable(db)
|
||||||
|
CreateRedeemTable(db)
|
||||||
CreateBroadcastTable(db)
|
CreateBroadcastTable(db)
|
||||||
|
|
||||||
DB = db
|
DB = db
|
||||||
@ -119,8 +125,8 @@ func CreateQuotaTable(db *sql.DB) {
|
|||||||
CREATE TABLE IF NOT EXISTS quota (
|
CREATE TABLE IF NOT EXISTS quota (
|
||||||
id INT PRIMARY KEY AUTO_INCREMENT,
|
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||||
user_id INT UNIQUE,
|
user_id INT UNIQUE,
|
||||||
quota DECIMAL(10, 4),
|
quota DECIMAL(16, 4),
|
||||||
used DECIMAL(10, 4),
|
used DECIMAL(16, 4),
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
FOREIGN KEY (user_id) REFERENCES auth(id)
|
FOREIGN KEY (user_id) REFERENCES auth(id)
|
||||||
@ -206,7 +212,7 @@ func CreateInvitationTable(db *sql.DB) {
|
|||||||
CREATE TABLE IF NOT EXISTS invitation (
|
CREATE TABLE IF NOT EXISTS invitation (
|
||||||
id INT PRIMARY KEY AUTO_INCREMENT,
|
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||||
code VARCHAR(255) UNIQUE,
|
code VARCHAR(255) UNIQUE,
|
||||||
quota DECIMAL(6, 4),
|
quota DECIMAL(12, 4),
|
||||||
type VARCHAR(255),
|
type VARCHAR(255),
|
||||||
used BOOLEAN DEFAULT FALSE,
|
used BOOLEAN DEFAULT FALSE,
|
||||||
used_id INT,
|
used_id INT,
|
||||||
@ -221,6 +227,22 @@ func CreateInvitationTable(db *sql.DB) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func CreateRedeemTable(db *sql.DB) {
|
||||||
|
_, err := db.Exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS redeem (
|
||||||
|
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
code VARCHAR(255) UNIQUE,
|
||||||
|
quota DECIMAL(12, 4),
|
||||||
|
used BOOLEAN DEFAULT FALSE,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func CreateBroadcastTable(db *sql.DB) {
|
func CreateBroadcastTable(db *sql.DB) {
|
||||||
_, err := db.Exec(`
|
_, err := db.Exec(`
|
||||||
CREATE TABLE IF NOT EXISTS broadcast (
|
CREATE TABLE IF NOT EXISTS broadcast (
|
||||||
|
@ -35,6 +35,7 @@ var limits = map[string]Limiter{
|
|||||||
"/chat": {Duration: 1, Count: 5},
|
"/chat": {Duration: 1, Count: 5},
|
||||||
"/conversation": {Duration: 1, Count: 5},
|
"/conversation": {Duration: 1, Count: 5},
|
||||||
"/invite": {Duration: 7200, Count: 20},
|
"/invite": {Duration: 7200, Count: 20},
|
||||||
|
"/redeem": {Duration: 1200, Count: 60},
|
||||||
"/v1": {Duration: 1, Count: 600},
|
"/v1": {Duration: 1, Count: 600},
|
||||||
"/dashboard": {Duration: 1, Count: 5},
|
"/dashboard": {Duration: 1, Count: 5},
|
||||||
"/card": {Duration: 1, Count: 5},
|
"/card": {Duration: 1, Count: 5},
|
||||||
|
@ -30,16 +30,14 @@ func ReadConf() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func NewEngine() *gin.Engine {
|
func NewEngine() *gin.Engine {
|
||||||
engine := gin.New()
|
|
||||||
|
|
||||||
if viper.GetBool("debug") {
|
if viper.GetBool("debug") {
|
||||||
engine.Use(gin.Logger())
|
return gin.Default()
|
||||||
} else {
|
|
||||||
gin.SetMode(gin.ReleaseMode)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
engine.Use(gin.Recovery())
|
gin.SetMode(gin.ReleaseMode)
|
||||||
|
|
||||||
|
engine := gin.New()
|
||||||
|
engine.Use(gin.Recovery())
|
||||||
return engine
|
return engine
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user