add redeem feature

This commit is contained in:
Zhang Minghan 2023-12-27 00:20:20 +08:00
parent 13c12956af
commit d278f242a5
29 changed files with 687 additions and 32 deletions

View File

@ -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**

View File

@ -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
View 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
}

View File

@ -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)

View File

@ -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"`

View File

@ -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,

View File

@ -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> = {

View File

@ -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
View 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,
};
}
}

View File

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

View File

@ -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>

View File

@ -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>

View File

@ -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 />}

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

View File

@ -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`} />

View File

@ -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}

View File

@ -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`}>

View File

@ -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": "名称",

View File

@ -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}}"
} }

View File

@ -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}}のためにリクエストできませんでした"
} }

View File

@ -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}}"
} }

View File

@ -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>
); );
} }

View File

@ -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
View 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
}
}

View File

@ -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)
} }

View File

@ -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")))
} }

View File

@ -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 (

View File

@ -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},

View File

@ -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
} }