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 版本更新:
|
||||
```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**
|
||||
|
@ -14,6 +14,11 @@ type GenerateInvitationForm struct {
|
||||
Number int `json:"number"`
|
||||
}
|
||||
|
||||
type GenerateRedeemForm struct {
|
||||
Quota float32 `json:"quota"`
|
||||
Number int `json:"number"`
|
||||
}
|
||||
|
||||
type QuotaOperationForm struct {
|
||||
Id int64 `json:"id"`
|
||||
Quota float32 `json:"quota"`
|
||||
@ -55,6 +60,11 @@ func ErrorAnalysisAPI(c *gin.Context) {
|
||||
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) {
|
||||
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))
|
||||
}
|
||||
|
||||
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) {
|
||||
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.POST("/admin/invitation/generate", GenerateInvitationAPI)
|
||||
|
||||
app.GET("/admin/redeem/list", RedeemListAPI)
|
||||
app.POST("/admin/redeem/generate", GenerateRedeemAPI)
|
||||
|
||||
app.GET("/admin/user/list", UserPaginationAPI)
|
||||
app.POST("/admin/user/quota", UserQuotaAPI)
|
||||
app.POST("/admin/user/subscription", UserSubscriptionAPI)
|
||||
|
@ -48,12 +48,24 @@ type InvitationData struct {
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
type RedeemData struct {
|
||||
Quota float32 `json:"quota"`
|
||||
Used float32 `json:"used"`
|
||||
Total float32 `json:"total"`
|
||||
}
|
||||
|
||||
type InvitationGenerateResponse struct {
|
||||
Status bool `json:"status"`
|
||||
Message string `json:"message"`
|
||||
Data []string `json:"data"`
|
||||
}
|
||||
|
||||
type RedeemGenerateResponse struct {
|
||||
Status bool `json:"status"`
|
||||
Message string `json:"message"`
|
||||
Data []string `json:"data"`
|
||||
}
|
||||
|
||||
type UserData struct {
|
||||
Id int64 `json:"id"`
|
||||
Username string `json:"username"`
|
||||
|
@ -6,6 +6,7 @@ import {
|
||||
InvitationGenerateResponse,
|
||||
InvitationResponse,
|
||||
ModelChartResponse,
|
||||
RedeemResponse,
|
||||
RequestChartResponse,
|
||||
UserResponse,
|
||||
} from "@/admin/types.ts";
|
||||
@ -94,6 +95,30 @@ export async function generateInvitation(
|
||||
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(
|
||||
page: number,
|
||||
search: string,
|
||||
|
@ -35,7 +35,7 @@ export const ChannelTypes: Record<string, string> = {
|
||||
bing: "New Bing",
|
||||
palm: "Google PaLM2",
|
||||
midjourney: "Midjourney",
|
||||
oneapi: "One API",
|
||||
oneapi: "Nio API",
|
||||
};
|
||||
|
||||
export const ChannelInfos: Record<string, ChannelInfo> = {
|
||||
|
@ -52,12 +52,26 @@ export type InvitationResponse = {
|
||||
total: number;
|
||||
};
|
||||
|
||||
export type Redeem = {
|
||||
quota: number;
|
||||
used: boolean;
|
||||
total: number;
|
||||
};
|
||||
|
||||
export type RedeemResponse = Redeem[];
|
||||
|
||||
export type InvitationGenerateResponse = {
|
||||
status: boolean;
|
||||
data: string[];
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type RedeemGenerateResponse = {
|
||||
status: boolean;
|
||||
data: string[];
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type UserData = {
|
||||
id: number;
|
||||
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,
|
||||
.redeem-row,
|
||||
.invitation-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
@ -58,6 +59,7 @@
|
||||
}
|
||||
|
||||
.user-action,
|
||||
.redeem-action,
|
||||
.invitation-action {
|
||||
display: flex;
|
||||
margin-top: 1rem;
|
||||
|
@ -8,14 +8,15 @@ import { HelpCircle } from "lucide-react";
|
||||
|
||||
type TipsProps = {
|
||||
content: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function Tips({ content }: TipsProps) {
|
||||
function Tips({ content, className }: TipsProps) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<HelpCircle className={`tips-icon`} />
|
||||
<HelpCircle className={`tips-icon ${className}`} />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{content}</p>
|
||||
|
@ -158,7 +158,7 @@ function InvitationTable() {
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<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.type")}</TableHead>
|
||||
<TableHead>{t("admin.used")}</TableHead>
|
||||
|
@ -53,7 +53,7 @@ function MenuBar() {
|
||||
icon={<LayoutDashboard />}
|
||||
path={"/"}
|
||||
/>
|
||||
<MenuItem title={t("admin.users")} icon={<Users />} path={"/users"} />
|
||||
<MenuItem title={t("admin.user")} icon={<Users />} path={"/users"} />
|
||||
<MenuItem
|
||||
title={t("admin.broadcast")}
|
||||
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())}>
|
||||
<Gift className={`h-4 w-4 mr-1`} />
|
||||
{t("invitation.title")}
|
||||
{t("invitation.invitation")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => dispatch(openSharingDialog())}>
|
||||
<ListStart className={`h-4 w-4 mr-1`} />
|
||||
|
@ -26,7 +26,7 @@ function InvitationDialog() {
|
||||
<Dialog open={open} onOpenChange={(open) => dispatch(setDialog(open))}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("invitation.title")}</DialogTitle>
|
||||
<DialogTitle>{t("invitation.invitation")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
<Input
|
||||
value={code}
|
||||
|
@ -40,6 +40,7 @@ import { useEffectAsync } from "@/utils/hook.ts";
|
||||
import { selectAuthenticated } from "@/store/auth.ts";
|
||||
import { ToastAction } from "@/components/ui/toast.tsx";
|
||||
import { deeptrainEndpoint, docsEndpoint, useDeeptrain } from "@/utils/env.ts";
|
||||
import { useRedeem } from "@/api/redeem.ts";
|
||||
|
||||
type AmountComponentProps = {
|
||||
amount: number;
|
||||
@ -82,6 +83,8 @@ function QuotaDialog() {
|
||||
|
||||
const sub = useSelector(subDialogSelector);
|
||||
|
||||
const [redeem, setRedeem] = useState("");
|
||||
|
||||
const dispatch = useDispatch();
|
||||
useEffectAsync(async () => {
|
||||
if (!auth) return;
|
||||
@ -205,7 +208,7 @@ function QuotaDialog() {
|
||||
<Button
|
||||
variant={`default`}
|
||||
className={`buy-button`}
|
||||
disabled={amount === 0}
|
||||
disabled={amount === 0 || !useDeeptrain}
|
||||
>
|
||||
<Plus className={`h-4 w-4 mr-2`} />
|
||||
{t("buy.buy", { amount })}
|
||||
@ -269,6 +272,43 @@ function QuotaDialog() {
|
||||
</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`}>
|
||||
<Button variant={`outline`} asChild>
|
||||
<a href={docsEndpoint} target={`_blank`}>
|
||||
|
@ -144,8 +144,14 @@
|
||||
"dialog-buy": "购买",
|
||||
"success": "购买成功",
|
||||
"success-prompt": "您已成功购买 {{amount}} 点数。",
|
||||
"redeem": "兑换",
|
||||
"redeem-placeholder": "请输入兑换码",
|
||||
"exchange-success": "兑换成功",
|
||||
"exchange-success-prompt": "您已成功兑换 {{amount}} 点数。",
|
||||
"failed": "购买失败",
|
||||
"failed-prompt": "购买点数失败,请确保您有足够的余额。",
|
||||
"exchange-failed": "兑换失败",
|
||||
"exchange-failed-prompt": "兑换失败,原因:{{reason}}",
|
||||
"gpt4-tip": "提示:web 联网版功能可能会带来更多的输入点数消耗",
|
||||
"go": "前往"
|
||||
},
|
||||
@ -294,6 +300,7 @@
|
||||
},
|
||||
"invitation": {
|
||||
"title": "兑换码",
|
||||
"invitation": "邀请码",
|
||||
"input-placeholder": "请输入兑换码",
|
||||
"cancel": "取消",
|
||||
"check": "验证",
|
||||
@ -331,6 +338,7 @@
|
||||
"admin": {
|
||||
"dashboard": "仪表盘",
|
||||
"users": "后台管理",
|
||||
"user": "用户管理",
|
||||
"broadcast": "公告管理",
|
||||
"channel": "渠道设置",
|
||||
"settings": "系统设置",
|
||||
@ -350,6 +358,10 @@
|
||||
"confirm": "确认",
|
||||
"invitation": "兑换码管理",
|
||||
"code": "兑换码",
|
||||
"invitation-code": "邀请码",
|
||||
"invitation-manage": "邀请码管理",
|
||||
"invitation-tips": "邀请码用于兑换点数,每一类邀请码一个用户只能使用一次(可作宣传使用)",
|
||||
"redeem-tips": "兑换码用于兑换点数,可用于支付发卡等",
|
||||
"quota": "点数",
|
||||
"type": "类型",
|
||||
"used": "状态",
|
||||
@ -388,6 +400,11 @@
|
||||
"generate": "批量生成",
|
||||
"generate-result": "生成结果",
|
||||
"error": "请求失败",
|
||||
"redeem": {
|
||||
"quota": "点数",
|
||||
"used": "已用个数",
|
||||
"total": "总个数"
|
||||
},
|
||||
"channels": {
|
||||
"id": "渠道 ID",
|
||||
"name": "名称",
|
||||
|
@ -108,7 +108,13 @@
|
||||
"failed": "Purchase failed",
|
||||
"failed-prompt": "Failed to purchase points, please make sure you have enough balance.",
|
||||
"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": {
|
||||
"title": "Packages",
|
||||
@ -260,7 +266,8 @@
|
||||
"check": "Check",
|
||||
"check-success": "Redeem Success",
|
||||
"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": {
|
||||
"title": "Contact Us"
|
||||
@ -418,6 +425,16 @@
|
||||
"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).",
|
||||
"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": {
|
||||
@ -464,4 +481,4 @@
|
||||
},
|
||||
"reset": "Reset",
|
||||
"request-error": "Request failed for {{reason}}"
|
||||
}
|
||||
}
|
@ -108,7 +108,13 @@
|
||||
"failed": "購入できませんでした",
|
||||
"failed-prompt": "クレジットの購入に失敗しました。十分な残高があることを確認。",
|
||||
"gpt4-tip": "ヒント:ウェブに接続された機能により、より多くの入力ポイントが消費される可能性があります",
|
||||
"go": "行く"
|
||||
"go": "行く",
|
||||
"redeem": "交換する",
|
||||
"redeem-placeholder": "引き換えコードを入力してください",
|
||||
"exchange-success": "交換成功",
|
||||
"exchange-success-prompt": "{{amount}}クレジットを正常に引き換えました。",
|
||||
"exchange-failed": "引き換えに失敗しました",
|
||||
"exchange-failed-prompt": "{{reason}}のため、引き換えに失敗しました"
|
||||
},
|
||||
"pkg": {
|
||||
"title": "パック",
|
||||
@ -260,7 +266,8 @@
|
||||
"check": "検証",
|
||||
"check-success": "交換成功",
|
||||
"check-success-description": "正常に引き換えられました! AIの旅を始めるために{{amount}}クレジットを獲得しました!",
|
||||
"check-failed": "引き換えに失敗しました"
|
||||
"check-failed": "引き換えに失敗しました",
|
||||
"invitation": "招待コード"
|
||||
},
|
||||
"contact": {
|
||||
"title": "お問い合わせ"
|
||||
@ -418,6 +425,16 @@
|
||||
"searchQuery": "検索結果の最大数",
|
||||
"searchTip": "DuckDuckGoは、入力せずにWebPilotやNew Bing Reverse Searchなどのアクセスポイントを自動的に検索します。\\ nDuckDuckGo APIプロジェクトビルド:[ duckduckgo - api ]( https://github.com/binjie09/duckduckgo-api )。",
|
||||
"mailFrom": "発信元"
|
||||
},
|
||||
"user": "ユーザー管理",
|
||||
"invitation-code": "招待コード",
|
||||
"invitation-manage": "招待コードの管理",
|
||||
"invitation-tips": "招待コードはポイントの引き換えに使用されます。各タイプの招待コードは1人のユーザーが1回のみ使用できます(宣伝に使用できます)",
|
||||
"redeem-tips": "引き換えコードはクレジットの引き換えに使用され、カード発行などの支払いに使用できます。",
|
||||
"redeem": {
|
||||
"quota": "Whirlies",
|
||||
"used": "使用数",
|
||||
"total": "合計"
|
||||
}
|
||||
},
|
||||
"mask": {
|
||||
@ -464,4 +481,4 @@
|
||||
},
|
||||
"reset": "リセット",
|
||||
"request-error": "{{reason}}のためにリクエストできませんでした"
|
||||
}
|
||||
}
|
@ -108,7 +108,13 @@
|
||||
"failed": "Покупка не удалась",
|
||||
"failed-prompt": "Не удалось приобрести очки. Пожалуйста, убедитесь, что у вас достаточно баланса.",
|
||||
"gpt4-tip": "Совет: функция веб-поиска может потреблять больше входных очков",
|
||||
"go": "Перейти к"
|
||||
"go": "Перейти к",
|
||||
"redeem": "Обмен валюты",
|
||||
"redeem-placeholder": "Введите код погашения",
|
||||
"exchange-success": "Успешный обмен",
|
||||
"exchange-success-prompt": "Вы успешно использовали {{amount}} кредита (-ов).",
|
||||
"exchange-failed": "Сбой обмена",
|
||||
"exchange-failed-prompt": "Не удалось погасить по {{reason}}"
|
||||
},
|
||||
"pkg": {
|
||||
"title": "Пакеты",
|
||||
@ -260,7 +266,8 @@
|
||||
"check": "Проверить",
|
||||
"check-success": "Успешно",
|
||||
"check-success-description": "Успешно! Вы получили {{amount}} очков, начните свое путешествие в мир AI!",
|
||||
"check-failed": "Не удалось"
|
||||
"check-failed": "Не удалось",
|
||||
"invitation": "Код приглашения"
|
||||
},
|
||||
"contact": {
|
||||
"title": "Связаться с нами"
|
||||
@ -418,6 +425,16 @@
|
||||
"searchQuery": "Максимальное количество результатов поиска",
|
||||
"searchTip": "Конечная точка поиска DuckDuckGo, если она не заполнена, по умолчанию используется функция обратного поиска WebPilot и New Bing.\nСборка проекта DuckDuckGo API: [duckduckgo-api](https://github.com/binjie09/duckduckgo-api).",
|
||||
"mailFrom": "От"
|
||||
},
|
||||
"user": "Управление пользователями",
|
||||
"invitation-code": "Код приглашения",
|
||||
"invitation-manage": "Управление кодом приглашения",
|
||||
"invitation-tips": "Пригласительные коды используются для погашения баллов. Каждый тип пригласительного кода может использоваться только один раз одним пользователем (может использоваться для рекламы)",
|
||||
"redeem-tips": "Коды погашения используются для погашения кредитов и могут быть использованы для оплаты выпуска карт и т. д.",
|
||||
"redeem": {
|
||||
"quota": "Вихри",
|
||||
"used": "Количество использованных",
|
||||
"total": "Итого"
|
||||
}
|
||||
},
|
||||
"mask": {
|
||||
@ -464,4 +481,4 @@
|
||||
},
|
||||
"reset": "сброс",
|
||||
"request-error": "Запрос не выполнен по {{reason}}"
|
||||
}
|
||||
}
|
@ -8,6 +8,8 @@ import { useTranslation } from "react-i18next";
|
||||
import InvitationTable from "@/components/admin/InvitationTable.tsx";
|
||||
import UserTable from "@/components/admin/UserTable.tsx";
|
||||
import { mobile } from "@/utils/device.ts";
|
||||
import Tips from "@/components/Tips.tsx";
|
||||
import RedeemTable from "@/components/admin/RedeemTable.tsx";
|
||||
|
||||
function Users() {
|
||||
const { t } = useTranslation();
|
||||
@ -16,7 +18,7 @@ function Users() {
|
||||
<div className={`user-interface ${mobile ? "mobile" : ""}`}>
|
||||
<Card>
|
||||
<CardHeader className={`select-none`}>
|
||||
<CardTitle>{t("admin.users")}</CardTitle>
|
||||
<CardTitle>{t("admin.user")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<UserTable />
|
||||
@ -24,12 +26,32 @@ function Users() {
|
||||
</Card>
|
||||
<Card>
|
||||
<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>
|
||||
<CardContent>
|
||||
<InvitationTable />
|
||||
</CardContent>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
@ -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.POST("/subscribe", SubscribeAPI)
|
||||
app.GET("/invite", InviteAPI)
|
||||
app.GET("/redeem", RedeemAPI)
|
||||
}
|
||||
|
@ -26,8 +26,14 @@ func ConnectRedis() *redis.Client {
|
||||
DB: viper.GetInt("redis.db"),
|
||||
})
|
||||
|
||||
if pingRedis(Cache) != nil {
|
||||
log.Println(fmt.Sprintf("[connection] failed to connect to redis host: %s, will retry in 5 seconds", viper.GetString("redis.host")))
|
||||
if err := pingRedis(Cache); err != nil {
|
||||
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 {
|
||||
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"),
|
||||
))
|
||||
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)
|
||||
db.Close()
|
||||
@ -48,6 +53,7 @@ func ConnectMySQL() *sql.DB {
|
||||
CreateSubscriptionTable(db)
|
||||
CreateApiKeyTable(db)
|
||||
CreateInvitationTable(db)
|
||||
CreateRedeemTable(db)
|
||||
CreateBroadcastTable(db)
|
||||
|
||||
DB = db
|
||||
@ -119,8 +125,8 @@ func CreateQuotaTable(db *sql.DB) {
|
||||
CREATE TABLE IF NOT EXISTS quota (
|
||||
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||
user_id INT UNIQUE,
|
||||
quota DECIMAL(10, 4),
|
||||
used DECIMAL(10, 4),
|
||||
quota DECIMAL(16, 4),
|
||||
used DECIMAL(16, 4),
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES auth(id)
|
||||
@ -206,7 +212,7 @@ func CreateInvitationTable(db *sql.DB) {
|
||||
CREATE TABLE IF NOT EXISTS invitation (
|
||||
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||
code VARCHAR(255) UNIQUE,
|
||||
quota DECIMAL(6, 4),
|
||||
quota DECIMAL(12, 4),
|
||||
type VARCHAR(255),
|
||||
used BOOLEAN DEFAULT FALSE,
|
||||
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) {
|
||||
_, err := db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS broadcast (
|
||||
|
@ -35,6 +35,7 @@ var limits = map[string]Limiter{
|
||||
"/chat": {Duration: 1, Count: 5},
|
||||
"/conversation": {Duration: 1, Count: 5},
|
||||
"/invite": {Duration: 7200, Count: 20},
|
||||
"/redeem": {Duration: 1200, Count: 60},
|
||||
"/v1": {Duration: 1, Count: 600},
|
||||
"/dashboard": {Duration: 1, Count: 5},
|
||||
"/card": {Duration: 1, Count: 5},
|
||||
|
@ -30,16 +30,14 @@ func ReadConf() {
|
||||
}
|
||||
|
||||
func NewEngine() *gin.Engine {
|
||||
engine := gin.New()
|
||||
|
||||
if viper.GetBool("debug") {
|
||||
engine.Use(gin.Logger())
|
||||
} else {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
return gin.Default()
|
||||
}
|
||||
|
||||
engine.Use(gin.Recovery())
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
|
||||
engine := gin.New()
|
||||
engine.Use(gin.Recovery())
|
||||
return engine
|
||||
}
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user