feat: support custom subscription config (#24)

This commit is contained in:
Zhang Minghan 2024-01-18 18:47:56 +08:00
parent aacc6b9c12
commit ed74b6b975
27 changed files with 1205 additions and 114 deletions

View File

@ -29,6 +29,7 @@
"@radix-ui/react-tabs": "^1.0.4",
"@radix-ui/react-toast": "^1.1.4",
"@radix-ui/react-toggle": "^1.0.3",
"@radix-ui/react-toggle-group": "^1.0.4",
"@radix-ui/react-tooltip": "^1.0.6",
"@reduxjs/toolkit": "^1.9.5",
"@tanem/react-nprogress": "^5.0.51",

30
app/pnpm-lock.yaml generated
View File

@ -56,6 +56,9 @@ dependencies:
'@radix-ui/react-toggle':
specifier: ^1.0.3
version: 1.0.3(@types/react-dom@18.2.14)(@types/react@18.2.33)(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-toggle-group':
specifier: ^1.0.4
version: 1.0.4(@types/react-dom@18.2.14)(@types/react@18.2.33)(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-tooltip':
specifier: ^1.0.6
version: 1.0.7(@types/react-dom@18.2.14)(@types/react@18.2.33)(react-dom@18.2.0)(react@18.2.0)
@ -1588,6 +1591,33 @@ packages:
react-dom: 18.2.0(react@18.2.0)
dev: false
/@radix-ui/react-toggle-group@1.0.4(@types/react-dom@18.2.14)(@types/react@18.2.33)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-Uaj/M/cMyiyT9Bx6fOZO0SAG4Cls0GptBWiBmBxofmDbNVnYYoyRWj/2M/6VCi/7qcXFWnHhRUfdfZFvvkuu8A==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0
react-dom: ^16.8 || ^17.0 || ^18.0
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
dependencies:
'@babel/runtime': 7.23.2
'@radix-ui/primitive': 1.0.1
'@radix-ui/react-context': 1.0.1(@types/react@18.2.33)(react@18.2.0)
'@radix-ui/react-direction': 1.0.1(@types/react@18.2.33)(react@18.2.0)
'@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.14)(@types/react@18.2.33)(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-roving-focus': 1.0.4(@types/react-dom@18.2.14)(@types/react@18.2.33)(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-toggle': 1.0.3(@types/react-dom@18.2.14)(@types/react@18.2.33)(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-use-controllable-state': 1.0.1(@types/react@18.2.33)(react@18.2.0)
'@types/react': 18.2.33
'@types/react-dom': 18.2.14
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
dev: false
/@radix-ui/react-toggle@1.0.3(@types/react-dom@18.2.14)(@types/react@18.2.33)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-Pkqg3+Bc98ftZGsl60CLANXQBBQ4W3mTFS9EJvNxKMZ7magklKV69/id1mlAlOFDDfHvlCms0fx8fA4CMKDJHg==}
peerDependencies:

View File

@ -8,7 +8,7 @@
},
"package": {
"productName": "chatnio",
"version": "3.8.6"
"version": "3.9.0"
},
"tauri": {
"allowlist": {

36
app/src/admin/api/plan.ts Normal file
View File

@ -0,0 +1,36 @@
import { Plan } from "@/api/types";
import axios from "axios";
import { CommonResponse } from "@/admin/utils.ts";
import { getErrorMessage } from "@/utils/base.ts";
export type PlanConfig = {
enabled: boolean;
plans: Plan[];
};
export async function getPlanConfig(): Promise<PlanConfig> {
try {
const response = await axios.get("/admin/plan/view");
const conf = response.data as PlanConfig;
conf.plans = (conf.plans || []).filter((item) => item.level > 0);
if (conf.plans.length === 0)
conf.plans = [1, 2, 3].map(
(level) => ({ level, price: 0, items: [] }) as Plan,
);
return conf;
} catch (e) {
console.warn(e);
return { enabled: false, plans: [] };
}
}
export async function setPlanConfig(
config: PlanConfig,
): Promise<CommonResponse> {
try {
const response = await axios.post(`/admin/plan/update`, config);
return response.data as CommonResponse;
} catch (e) {
return { status: false, error: getErrorMessage(e) };
}
}

View File

@ -3,7 +3,7 @@ import { getMemory } from "@/utils/memory.ts";
import { getErrorMessage } from "@/utils/base.ts";
export const endpoint = `${websocketEndpoint}/chat`;
export const maxRetry = 5;
export const maxRetry = 30; // 15s max websocket retry
export type StreamMessage = {
conversation?: number;

View File

@ -11,3 +11,127 @@
min-height: 20vh;
}
}
.plan-config {
display: flex;
flex-direction: column;
margin-top: 0.25rem;
& > * {
margin-bottom: 1rem;
&:last-child {
margin-bottom: 0;
}
}
.plan-config-row {
display: flex;
flex-direction: row;
align-items: center;
}
.plan-config-card {
display: flex;
flex-direction: column;
padding: 1rem;
border-radius: var(--radius);
border: 1px solid hsl(var(--border));
.plan-config-title {
display: flex;
flex-direction: row;
align-items: center;
white-space: nowrap;
user-select: none;
margin-bottom: 0.75rem;
&:before {
display: inline-block;
content: '';
margin-right: 0.5rem;
height: 1.25rem;
width: 2px;
border-radius: 1px;
background: hsl(var(--text-secondary));
transition: .25s;
}
}
.plan-items-action {
display: flex;
flex-direction: row;
gap: 1rem;
align-items: center;
flex-wrap: wrap;
margin-top: 1rem;
}
.plan-items-wrapper {
display: flex;
flex-direction: column;
width: 100%;
height: max-content;
margin-top: 1rem;
.plan-item {
padding: 1rem;
border-radius: var(--radius);
border: 1px solid hsl(var(--border));
display: flex;
flex-direction: column;
.plan-editor-row > p {
min-width: 4.25rem;
}
& > * {
margin-bottom: 1rem;
&:last-child {
margin-bottom: 0;
}
}
}
& > * {
margin-bottom: 0.5rem;
&:last-child {
margin-bottom: 0;
}
}
}
.plan-editor-row {
display: flex;
flex-direction: row;
align-items: center;
.plan-editor-label {
display: flex;
flex-direction: row;
align-items: center;
white-space: nowrap;
margin-right: 0.5rem;
svg {
display: inline-block;
flex-shrink: 0;
}
}
& > p {
white-space: nowrap;
}
}
& > * {
margin-bottom: 0.25rem;
&:last-child {
margin-bottom: 0;
}
}
}
}

View File

@ -0,0 +1,94 @@
import React from "react";
import { cn } from "@/components/ui/lib/utils";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Check, ChevronsUpDown } from "lucide-react";
import { useTranslation } from "react-i18next";
type MultiComboBoxProps = {
value: string[];
onChange: (value: string[]) => void;
list: string[];
placeholder?: string;
searchPlaceholder?: string;
defaultOpen?: boolean;
className?: string;
align?: "start" | "end" | "center" | undefined;
};
export function MultiCombobox({
value,
onChange,
list,
placeholder,
searchPlaceholder,
defaultOpen,
className,
align,
}: MultiComboBoxProps) {
const { t } = useTranslation();
const [open, setOpen] = React.useState(defaultOpen ?? false);
const valueList = React.useMemo((): string[] => {
// list set
const set = new Set(list);
return [...set];
}, [list]);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className={cn("w-[320px] max-w-[60vw] justify-between", className)}
>
<Check className="mr-2 h-4 w-4 shrink-0 opacity-50" />
{placeholder ?? `${value.length} Items Selected`}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[320px] max-w-[60vw] p-0" align={align}>
<Command>
<CommandInput placeholder={searchPlaceholder} />
<CommandEmpty>{t("admin.empty")}</CommandEmpty>
<CommandList>
{valueList.map((key) => (
<CommandItem
key={key}
value={key}
onSelect={(current) => {
if (value.includes(current)) {
onChange(value.filter((item) => item !== current));
} else {
onChange([...value, current]);
}
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
value.includes(key) ? "opacity-100" : "opacity-0",
)}
/>
{key}
</CommandItem>
))}
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}

View File

@ -37,8 +37,8 @@ const NumberInput = React.forwardRef<HTMLInputElement, NumberInputProps>(
return v.match(exp)?.join("") || "";
}
// replace -0124.5 to -124.5, 0043 to 43
const exp = /^[-+]?0+(?=[1-9])|(?<=\.)0+(?=[1-9])|(?<=\.)0+$/g;
// replace -0124.5 to -124.5, 0043 to 43, 2.000 to 2.000
const exp = /^[-+]?0+(?=[0-9]+(\.[0-9]+)?$)/;
v = v.replace(exp, "");
const raw = getNumber(v, props.acceptNegative);

View File

@ -0,0 +1,59 @@
import * as React from "react";
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group";
import { VariantProps } from "class-variance-authority";
import { cn } from "@/components/ui/lib/utils";
import { toggleVariants } from "@/components/ui/toggle";
const ToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants>
>({
size: "default",
variant: "default",
});
const ToggleGroup = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root> &
VariantProps<typeof toggleVariants>
>(({ className, variant, size, children, ...props }, ref) => (
<ToggleGroupPrimitive.Root
ref={ref}
className={cn("flex items-center justify-center gap-1", className)}
{...props}
>
<ToggleGroupContext.Provider value={{ variant, size }}>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root>
));
ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName;
const ToggleGroupItem = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Item> &
VariantProps<typeof toggleVariants>
>(({ className, children, variant, size, ...props }, ref) => {
const context = React.useContext(ToggleGroupContext);
return (
<ToggleGroupPrimitive.Item
ref={ref}
className={cn(
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
className,
)}
{...props}
>
{children}
</ToggleGroupPrimitive.Item>
);
});
ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName;
export { ToggleGroup, ToggleGroupItem };

View File

@ -9,7 +9,7 @@ import { syncSiteInfo } from "@/admin/api/info.ts";
import { getOfflineModels, loadPreferenceModels } from "@/conf/storage.ts";
import { setAxiosConfig } from "@/conf/api.ts";
export const version = "3.8.6"; // version of the current build
export const version = "3.9.0"; // version of the current build
export const dev: boolean = getDev(); // is in development mode (for debugging, in localhost origin)
export const deploy: boolean = true; // is production environment (for api endpoint)
export const tokenField = getTokenField(deploy); // token field name for storing token

View File

@ -5,6 +5,9 @@ import {
ImagePlus,
Video,
AudioLines,
Container,
Archive,
Flame,
} from "lucide-react";
import React, { useMemo } from "react";
import { Plan, Plans } from "@/api/types.ts";
@ -17,8 +20,13 @@ export const subscriptionIcons: Record<string, React.ReactElement> = {
booktext: <BookText />,
video: <Video />,
audio: <AudioLines />,
flame: <Flame />,
archive: <Archive />,
container: <Container />,
};
export const subscriptionIconsList: string[] = Object.keys(subscriptionIcons);
export const subscriptionType: Record<number, string> = {
1: "basic",
2: "standard",

View File

@ -38,6 +38,11 @@
"download-fatal-log": "下载错误日志",
"fatal-tips": "请您先检查您的网络,浏览器兼容性,尝试清除浏览器缓存并刷新页面。如果问题仍然存在,请将日志提供给开发者以便我们排查问题。",
"request-error": "请求失败,原因:{{reason}}",
"delete": "删除",
"remove": "移除",
"upward": "上移",
"downward": "下移",
"save": "保存",
"auth": {
"username": "用户名",
"username-placeholder": "请输入用户名",
@ -422,6 +427,25 @@
"used": "已用个数",
"total": "总个数"
},
"plan": {
"enable": "启用订阅",
"price": "价格",
"price-tip": "一月订阅价格 (单位:元)",
"item-id": "ID",
"item-id-placeholder": "请输入实体 ID (Item ID 不能多次使用gpt-4)",
"item-name": "名称",
"item-name-placeholder": "请输入实体名称 (Item Name 用于显示在订阅列表中的实体名GPT-4)",
"item-value": "配额",
"item-value-tip": "每月配额 (单位:次)",
"item-icon": "图标",
"item-icon-tip": "实体图标 (Item Icon 用于显示在订阅列表中的图标)",
"item-models": "模型",
"item-models-tip": "实体涵盖的模型 (Item Models 用于显示在订阅列表中的模型)",
"item-models-search-placeholder": "搜索模型 ID",
"item-models-placeholder": "已选 {{length}} 个模型",
"add-item": "添加",
"import-item": "导入"
},
"channels": {
"id": "渠道 ID",
"name": "名称",

View File

@ -473,6 +473,25 @@
"title": "service log",
"console": "Console",
"consoleLength": "Number of log entries"
},
"plan": {
"enable": "Enable subscriptions",
"price": "Price",
"price-tip": "January subscription price (unit: yuan)",
"item-id": "ID",
"item-id-placeholder": "Please enter Entity ID (Item ID cannot be used more than once, ex: gpt-4)",
"item-name": "Name",
"item-name-placeholder": "Please enter the entity name (Item Name is used to display the entity name in the subscription list, e.g. GPT-4)",
"item-value": "quota",
"item-value-tip": "Monthly quota (unit: times)",
"item-icon": "Icon",
"item-icon-tip": "Entity icons (icons used by Item Icons to appear in the subscription list)",
"item-models": "Model",
"item-models-tip": "The models covered by the entity (Item Models are used to display the models in the subscription list)",
"item-models-search-placeholder": "Search Model ID",
"item-models-placeholder": "{{length}} models selected",
"add-item": "add",
"import-item": "Import"
}
},
"mask": {
@ -519,5 +538,10 @@
},
"reset": "Reset",
"request-error": "Request failed for {{reason}}",
"update": "Updated"
"update": "Updated",
"delete": "Delete",
"remove": "remove",
"upward": "Top",
"downward": "Move down",
"save": "Save"
}

View File

@ -473,6 +473,25 @@
"title": "サービスログ",
"console": "コンソール",
"consoleLength": "ログエントリの数"
},
"plan": {
"enable": "サブスクリプションを有効にする",
"price": "価格",
"price-tip": "1月のサブスクリプション価格単位",
"item-id": "ID",
"item-id-placeholder": "エンティティIDを入力してくださいアイテムIDは複数回使用することはできません。例 gpt -4 ",
"item-name": "名称",
"item-name-placeholder": "エンティティ名を入力してください(アイテム名はサブスクリプションリストにエンティティ名を表示するために使用されます。例: GPT -4 ",
"item-value": "クォータ",
"item-value-tip": "月間クォータ(単位:回)",
"item-icon": "を使って「",
"item-icon-tip": "エンティティアイコン(サブスクリプションリストに表示されるアイテムアイコンで使用されるアイコン)",
"item-models": "モデル",
"item-models-tip": "エンティティがカバーするモデル(アイテムモデルは、サブスクリプションリストにモデルを表示するために使用されます)",
"item-models-search-placeholder": "モデルIDを検索",
"item-models-placeholder": "{{length}}モデルが選択されました",
"add-item": "登録",
"import-item": "導入"
}
},
"mask": {
@ -519,5 +538,10 @@
},
"reset": "リセット",
"request-error": "{{reason}}のためにリクエストできませんでした",
"update": "更新"
"update": "更新",
"delete": "削除",
"remove": "追放",
"upward": "上へ移動",
"downward": "下へ移動",
"save": "保存"
}

View File

@ -473,6 +473,25 @@
"title": "Журнал обслуживания",
"console": "Консоль",
"consoleLength": "Количество записей в журнале"
},
"plan": {
"enable": "Включить подписку",
"price": "Цена",
"price-tip": "Цена подписки за январь (единица: юань)",
"item-id": "Айди",
"item-id-placeholder": "Введите идентификатор организации (идентификатор позиции не может использоваться более одного раза, например: gpt-4)",
"item-name": "",
"item-name-placeholder": "Пожалуйста, введите название объекта (название объекта используется для отображения названия объекта в списке подписки, например GPT-4)",
"item-value": "Всего",
"item-value-tip": "Ежемесячная квота (единица измерения: раз)",
"item-icon": "Иконка",
"item-icon-tip": "Значки сущностей (значки, используемые значками элементов для отображения в списке подписки)",
"item-models": "Модели",
"item-models-tip": "Модели, охватываемые сущностью (модели элементов используются для отображения моделей в списке подписки)",
"item-models-search-placeholder": "Поиск по идентификатору модели",
"item-models-placeholder": "Выбрано моделей: {{length}}",
"add-item": "Добавить",
"import-item": "Импорт"
}
},
"mask": {
@ -519,5 +538,10 @@
},
"reset": "сброс",
"request-error": "Запрос не выполнен по {{reason}}",
"update": "Обновить"
"update": "Обновить",
"delete": "удалять",
"remove": "Убрать",
"upward": "Выше",
"downward": "Ниже",
"save": "Сохранить"
}

View File

@ -119,7 +119,7 @@ function Logger() {
const { t } = useTranslation();
return (
<div className={`logger`}>
<Card className={`logger-card`}>
<Card className={`admin-card logger-card`}>
<CardHeader className={`select-none`}>
<CardTitle>{t("admin.logger.title")}</CardTitle>
</CardHeader>

View File

@ -5,16 +5,598 @@ import {
CardTitle,
} from "@/components/ui/card.tsx";
import { useTranslation } from "react-i18next";
import { useMemo, useReducer, useState } from "react";
import { getPlanConfig, PlanConfig, setPlanConfig } from "@/admin/api/plan.ts";
import { useEffectAsync } from "@/utils/hook.ts";
import { Switch } from "@/components/ui/switch.tsx";
import {
BookDashed,
ChevronDown,
ChevronUp,
Loader2,
Plus,
Trash,
} from "lucide-react";
import {
getPlanName,
SubscriptionIcon,
subscriptionIconsList,
} from "@/conf/subscription.tsx";
import { Plan, PlanItem } from "@/api/types.ts";
import Tips from "@/components/Tips.tsx";
import { NumberInput } from "@/components/ui/number-input.tsx";
import { Input } from "@/components/ui/input.tsx";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group.tsx";
import { MultiCombobox } from "@/components/ui/multi-combobox.tsx";
import { channelModels } from "@/admin/channel.ts";
import { Button } from "@/components/ui/button.tsx";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu.tsx";
import { toastState } from "@/admin/utils.ts";
import { useToast } from "@/components/ui/use-toast.ts";
import { dispatchSubscriptionData } from "@/store/globals.ts";
import { useDispatch } from "react-redux";
const planInitialConfig: PlanConfig = {
enabled: false,
plans: [],
};
function reducer(state: PlanConfig, action: Record<string, any>): PlanConfig {
switch (action.type) {
case "set":
return action.payload;
case "set-enabled":
return {
...state,
enabled: action.payload,
};
case "set-price":
return {
...state,
plans: state.plans.map((plan: Plan) => {
if (plan.level === action.payload.level) {
return {
...plan,
price: action.payload.price,
};
}
return plan;
}),
};
case "set-item-id":
return {
...state,
plans: state.plans.map((plan: Plan) => {
if (plan.level === action.payload.level) {
return {
...plan,
items: plan.items.map((item: PlanItem, index: number) => {
if (index === action.payload.index) {
return {
...item,
id: action.payload.id,
};
}
return item;
}),
};
}
return plan;
}),
};
case "set-item-name":
return {
...state,
plans: state.plans.map((plan: Plan) => {
if (plan.level === action.payload.level) {
return {
...plan,
items: plan.items.map((item: PlanItem, index: number) => {
if (index === action.payload.index) {
return {
...item,
name: action.payload.name,
};
}
return item;
}),
};
}
return plan;
}),
};
case "set-item-value":
return {
...state,
plans: state.plans.map((plan: Plan) => {
if (plan.level === action.payload.level) {
return {
...plan,
items: plan.items.map((item: PlanItem, index: number) => {
if (index === action.payload.index) {
return {
...item,
value: action.payload.value,
};
}
return item;
}),
};
}
return plan;
}),
};
case "set-item-icon":
return {
...state,
plans: state.plans.map((plan: Plan) => {
if (plan.level === action.payload.level) {
return {
...plan,
items: plan.items.map((item: PlanItem, index: number) => {
if (index === action.payload.index) {
return {
...item,
icon: action.payload.icon,
};
}
return item;
}),
};
}
return plan;
}),
};
case "add-item":
return {
...state,
plans: state.plans.map((plan: Plan) => {
if (plan.level === action.payload.level) {
return {
...plan,
items: [
...plan.items,
{
id: "",
name: "",
value: 0,
icon: subscriptionIconsList[0],
models: [],
},
],
};
}
return plan;
}),
};
case "set-item-models":
return {
...state,
plans: state.plans.map((plan: Plan) => {
if (plan.level === action.payload.level) {
return {
...plan,
items: plan.items.map((item: PlanItem, index: number) => {
if (index === action.payload.index) {
return {
...item,
models: action.payload.models,
};
}
return item;
}),
};
}
return plan;
}),
};
case "remove-item":
return {
...state,
plans: state.plans.map((plan: Plan) => {
if (plan.level === action.payload.level) {
return {
...plan,
items: plan.items.filter(
(_: PlanItem, index: number) => index !== action.payload.index,
),
};
}
return plan;
}),
};
case "upward-item":
return {
...state,
plans: state.plans.map((plan: Plan) => {
if (plan.level === action.payload.level) {
const items = plan.items;
const index = action.payload.index;
if (index > 0) {
const tmp = items[index];
items[index] = items[index - 1];
items[index - 1] = tmp;
}
return {
...plan,
items,
};
}
return plan;
}),
};
case "downward-item":
return {
...state,
plans: state.plans.map((plan: Plan) => {
if (plan.level === action.payload.level) {
const items = plan.items;
const index = action.payload.index;
if (index < items.length - 1) {
const tmp = items[index];
items[index] = items[index + 1];
items[index + 1] = tmp;
}
return {
...plan,
items,
};
}
return plan;
}),
};
case "import-item":
const { level, id, target } = action.payload;
const plan = state.plans.find((p: Plan) => p.level === level);
const item = plan?.items.find((i: PlanItem) => i.id === id);
if (!plan || !item) return state;
return {
...state,
plans: state.plans.map((p: Plan) => {
if (p.level === target) {
const items = p.items;
items.push(item);
return {
...p,
items,
};
}
return p;
}),
};
default:
throw new Error();
}
}
type ItemIconEditorProps = {
value: string;
onValueChange: (value: string) => void;
};
function ItemIconEditor({ value, onValueChange }: ItemIconEditorProps) {
return (
<ToggleGroup
variant={`outline`}
type={`single`}
value={value}
onValueChange={onValueChange}
className={`flex-wrap`}
>
{subscriptionIconsList.map((key, index) => (
<ToggleGroupItem value={key} key={index}>
<SubscriptionIcon type={key} className={`h-4 w-4`} />
</ToggleGroupItem>
))}
</ToggleGroup>
);
}
type ImportActionProps = {
plans: Plan[];
level: number;
dispatch: (action: Record<string, any>) => void;
};
type ImportActionItem = {
item: PlanItem;
level: number;
};
function ImportAction({ plans, level, dispatch }: ImportActionProps) {
const { t } = useTranslation();
const usableItems = useMemo((): ImportActionItem[] => {
const raw = plans.filter((p: Plan) => p.level !== level);
return raw
.map((p: Plan) =>
p.items.map((item: PlanItem) => ({ level: p.level, item })),
)
.flat();
}, [plans, level]);
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant={`outline`}>
<BookDashed className={`h-4 w-4 mr-1`} />
{t("admin.plan.import-item")}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
{usableItems.map(
({ level: from, item }: ImportActionItem, index: number) => (
<DropdownMenuItem
key={index}
onClick={() => {
dispatch({
type: "import-item",
payload: { level: from, id: item.id, target: level },
});
}}
>
{t(`sub.${getPlanName(from)}`)} - {item.name} ({item.id})
</DropdownMenuItem>
),
)}
</DropdownMenuContent>
</DropdownMenu>
);
}
function PlanConfig() {
const { t } = useTranslation();
const [form, formDispatch] = useReducer(reducer, planInitialConfig);
const [loading, setLoading] = useState<boolean>(false);
const dispatch = useDispatch();
const { toast } = useToast();
useEffectAsync(async () => {
setLoading(true);
const res = await getPlanConfig();
formDispatch({ type: "set", payload: res });
setLoading(false);
}, []);
const save = async () => {
const res = await setPlanConfig(form);
toastState(toast, t, res, true);
if (res.status)
dispatchSubscriptionData(dispatch, form.enabled ? form.plans : []);
};
return (
<div className={`plan-config`}>
<div className={`plan-config-row`}>
<p>
{t("admin.plan.enable")}
{loading && (
<Loader2 className={`h-4 w-4 ml-1 inline-block animate-spin`} />
)}
</p>
<div className={`grow`} />
<Switch
checked={form.enabled}
onCheckedChange={(checked: boolean) =>
formDispatch({ type: "set-enabled", payload: checked })
}
/>
</div>
{form.enabled &&
form.plans.map((plan: Plan, index: number) => (
<div className={`plan-config-card`} key={index}>
<p className={`plan-config-title`}>
{t(`sub.${getPlanName(plan.level)}`)}
</p>
<div className={`plan-editor-row`}>
<p className={`select-none flex flex-row items-center mr-2`}>
{t("admin.plan.price")}
<Tips
className={`inline-block translate-y-[2px]`}
content={t("admin.plan.price-tip")}
/>
</p>
<NumberInput
value={plan.price}
onValueChange={(value: number) => {
formDispatch({
type: "set-price",
payload: { level: plan.level, price: value },
});
}}
/>
</div>
<div className={`plan-items-wrapper`}>
{plan.items.map((item: PlanItem, index: number) => (
<div className={`plan-item`} key={index}>
<div className={`plan-editor-row`}>
<p className={`plan-editor-label mr-2`}>
{t(`admin.plan.item-id`)}
<Tips content={t("admin.plan.item-id-placeholder")} />
</p>
<Input
value={item.id}
onChange={(e) => {
formDispatch({
type: "set-item-id",
payload: {
level: plan.level,
id: e.target.value,
index,
},
});
}}
placeholder={t(`admin.plan.item-id-placeholder`)}
/>
</div>
<div className={`plan-editor-row`}>
<p className={`plan-editor-label mr-2`}>
{t(`admin.plan.item-name`)}
<Tips content={t("admin.plan.item-name-placeholder")} />
</p>
<Input
value={item.name}
onChange={(e) => {
formDispatch({
type: "set-item-name",
payload: {
level: plan.level,
name: e.target.value,
index,
},
});
}}
placeholder={t(`admin.plan.item-name-placeholder`)}
/>
</div>
<div className={`plan-editor-row`}>
<p className={`plan-editor-label mr-2`}>
{t(`admin.plan.item-value`)}
<Tips content={t("admin.plan.item-value-tip")} />
</p>
<NumberInput
value={item.value}
onValueChange={(value: number) => {
formDispatch({
type: "set-item-value",
payload: { level: plan.level, value, index },
});
}}
/>
</div>
<div className={`plan-editor-row`}>
<p className={`plan-editor-label mr-2`}>
{t(`admin.plan.item-models`)}
<Tips content={t("admin.plan.item-models-tip")} />
</p>
<MultiCombobox
align={`start`}
value={item.models}
onChange={(value: string[]) => {
formDispatch({
type: "set-item-models",
payload: { level: plan.level, models: value, index },
});
}}
placeholder={t(`admin.plan.item-models-placeholder`, {
length: item.models.length,
})}
searchPlaceholder={t(
`admin.plan.item-models-search-placeholder`,
)}
list={channelModels}
className={`w-full max-w-full`}
/>
</div>
<div className={`plan-editor-row`}>
<p className={`plan-editor-label mr-2`}>
{t(`admin.plan.item-icon`)}
<Tips content={t("admin.plan.item-icon-tip")} />
</p>
<div className={`grow`} />
<ItemIconEditor
value={item.icon}
onValueChange={(value: string) => {
formDispatch({
type: "set-item-icon",
payload: { level: plan.level, icon: value, index },
});
}}
/>
</div>
<div className={`flex flex-row flex-wrap gap-1`}>
<div className={`grow`} />
<Button
variant={`outline`}
onClick={() => {
formDispatch({
type: "upward-item",
payload: { level: plan.level, index },
});
}}
disabled={index === 0}
>
<ChevronUp className={`h-4 w-4 mr-1`} />
{t("upward")}
</Button>
<Button
variant={`outline`}
onClick={() => {
formDispatch({
type: "downward-item",
payload: { level: plan.level, index },
});
}}
disabled={index === plan.items.length - 1}
>
<ChevronDown className={`h-4 w-4 mr-1`} />
{t("downward")}
</Button>
<Button
variant={`default`}
onClick={() => {
formDispatch({
type: "remove-item",
payload: { level: plan.level, index },
});
}}
>
<Trash className={`h-4 w-4 mr-1`} />
{t("remove")}
</Button>
</div>
</div>
))}
</div>
<div className={`plan-items-action`}>
<div className={`grow`} />
<ImportAction
plans={form.plans}
level={plan.level}
dispatch={formDispatch}
/>
<Button
variant={`default`}
onClick={() => {
formDispatch({
type: "add-item",
payload: { level: plan.level },
});
}}
>
<Plus className={`h-4 w-4 mr-1`} />
{t("admin.plan.add-item")}
</Button>
</div>
</div>
))}
<div className={`flex flex-row flex-wrap gap-1`}>
<div className={`grow`} />
<Button loading={true} onClick={save}>
{t("save")}
</Button>
</div>
</div>
);
}
function Subscription() {
const { t } = useTranslation();
return (
<div className={`admin-subscription`}>
<Card className={`sub-card`}>
<Card className={`admin-card sub-card`}>
<CardHeader className={`select-none`}>
<CardTitle>{t("admin.subscription")}</CardTitle>
</CardHeader>
<CardContent>in development</CardContent>
<CardContent>
<PlanConfig />
</CardContent>
</Card>
</div>
);

View File

@ -1,6 +1,7 @@
package auth
import (
"chat/channel"
"chat/utils"
"github.com/gin-gonic/gin"
"net/http"
@ -346,6 +347,18 @@ func SubscriptionAPI(c *gin.Context) {
db := utils.GetDBFromContext(c)
cache := utils.GetCacheFromContext(c)
if disableSubscription() {
c.JSON(200, gin.H{
"status": true,
"level": 0,
"is_subscribed": false,
"enterprise": false,
"expired": 0,
"usage": channel.UsageMap{},
})
}
c.JSON(200, gin.H{
"status": true,
"level": user.GetSubscriptionLevel(db),
@ -381,7 +394,7 @@ func SubscribeAPI(c *gin.Context) {
return
}
if BuySubscription(db, cache, user, form.Level, form.Month) {
if err := BuySubscription(db, cache, user, form.Level, form.Month); err == nil {
c.JSON(200, gin.H{
"status": true,
"error": "success",
@ -389,7 +402,7 @@ func SubscribeAPI(c *gin.Context) {
} else {
c.JSON(200, gin.H{
"status": false,
"error": "not enough money",
"error": err.Error(),
})
}
}

View File

@ -73,6 +73,10 @@ func (u *User) GetID(db *sql.DB) int64 {
return u.ID
}
func (u *User) HitID() int64 {
return u.ID
}
func (u *User) GetEmail(db *sql.DB) string {
if len(u.Email) > 0 {
return u.Email

View File

@ -1,14 +1,20 @@
package auth
import (
"chat/channel"
"chat/utils"
"database/sql"
"errors"
"fmt"
"github.com/go-redis/redis/v8"
"math"
"time"
)
func disableSubscription() bool {
return !channel.PlanInstance.IsEnabled()
}
func (u *User) GetSubscription(db *sql.DB) (time.Time, int) {
if u.Subscription != nil && u.Subscription.Unix() > 0 {
return *u.Subscription, u.Level
@ -31,8 +37,8 @@ func (u *User) GetSubscriptionLevel(db *sql.DB) int {
return level
}
func (u *User) GetPlan(db *sql.DB) Plan {
return GetLevel(u.GetSubscriptionLevel(db))
func (u *User) GetPlan(db *sql.DB) channel.Plan {
return channel.PlanInstance.GetPlan(u.GetSubscriptionLevel(db))
}
func (u *User) GetSubscriptionExpiredAt(db *sql.DB) time.Time {
@ -89,7 +95,7 @@ func (u *User) DowngradePlan(db *sql.DB, target int) error {
}
now := time.Now()
weight := GetLevel(current).Price / GetLevel(target).Price
weight := channel.PlanInstance.GetPlan(current).Price / channel.PlanInstance.GetPlan(target).Price
stamp := float32(expired.Unix()-now.Unix()) * weight
// ceil expired time
@ -102,7 +108,7 @@ func (u *User) DowngradePlan(db *sql.DB, target int) error {
func (u *User) CountUpgradePrice(db *sql.DB, target int) float32 {
expired := u.GetSubscriptionExpiredAt(db)
weight := GetLevel(target).Price - u.GetPlan(db).Price
weight := channel.PlanInstance.GetPlan(target).Price - u.GetPlan(db).Price
if weight < 0 {
return 0
}
@ -117,7 +123,7 @@ func (u *User) SetSubscriptionLevel(db *sql.DB, level int) bool {
}
func CountSubscriptionPrize(level int, month int) float32 {
plan := GetLevel(level)
plan := channel.PlanInstance.GetPlan(level)
base := plan.Price * float32(month)
if month >= 36 {
return base * 0.7
@ -129,9 +135,13 @@ func CountSubscriptionPrize(level int, month int) float32 {
return base
}
func BuySubscription(db *sql.DB, cache *redis.Client, user *User, level int, month int) bool {
if month < 1 || month > 999 || !InLevel(level) {
return false
func BuySubscription(db *sql.DB, cache *redis.Client, user *User, level int, month int) error {
if disableSubscription() {
return errors.New("subscription feature does not enable of this site")
}
if month < 1 || month > 999 || !channel.IsValidPlan(level) {
return errors.New("invalid subscription params")
}
before := user.GetSubscriptionLevel(db)
@ -152,34 +162,35 @@ func BuySubscription(db *sql.DB, cache *redis.Client, user *User, level int, mon
}
}
return true
return nil
}
} else if before > level {
// downgrade subscription
err := user.DowngradePlan(db, level)
if err != nil {
fmt.Println(err)
}
return err == nil
return user.DowngradePlan(db, level)
} else {
// upgrade subscription
money := user.CountUpgradePrice(db, level)
if user.Pay(db, cache, money) {
user.SetSubscriptionLevel(db, level)
return true
return nil
}
}
return false
return errors.New("not enough money")
}
func HandleSubscriptionUsage(db *sql.DB, cache *redis.Client, user *User, model string) bool {
if disableSubscription() {
return false
}
plan := user.GetPlan(db)
return plan.IncreaseUsage(user, cache, model)
}
func RevertSubscriptionUsage(db *sql.DB, cache *redis.Client, user *User, model string) bool {
if disableSubscription() {
return false
}
plan := user.GetPlan(db)
return plan.DecreaseUsage(user, cache, model)
}

12
auth/usage.go Normal file
View File

@ -0,0 +1,12 @@
package auth
import (
"chat/channel"
"database/sql"
"github.com/go-redis/redis/v8"
)
func (u *User) GetSubscriptionUsage(db *sql.DB, cache *redis.Client) channel.UsageMap {
plan := u.GetPlan(db)
return plan.GetUsage(u, db, cache)
}

View File

@ -151,3 +151,24 @@ func UpdateConfig(c *gin.Context) {
"error": utils.GetError(state),
})
}
func GetPlanConfig(c *gin.Context) {
c.JSON(http.StatusOK, PlanInstance)
}
func UpdatePlanConfig(c *gin.Context) {
var config PlanManager
if err := c.ShouldBindJSON(&config); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"status": false,
"error": err.Error(),
})
return
}
state := PlanInstance.UpdateConfig(&config)
c.JSON(http.StatusOK, gin.H{
"status": state == nil,
"error": utils.GetError(state),
})
}

View File

@ -9,11 +9,13 @@ import (
var ConduitInstance *Manager
var ChargeInstance *ChargeManager
var SystemInstance *SystemConfig
var PlanInstance *PlanManager
func InitManager() {
ConduitInstance = NewChannelManager()
ChargeInstance = NewChargeManager()
SystemInstance = NewSystemConfig()
PlanInstance = NewPlanManager()
}
func NewChannelManager() *Manager {

View File

@ -1,4 +1,4 @@
package auth
package channel
import (
"chat/globals"
@ -7,10 +7,16 @@ import (
"errors"
"fmt"
"github.com/go-redis/redis/v8"
"github.com/spf13/viper"
"strings"
"time"
)
type PlanManager struct {
Enabled bool `json:"enabled" mapstructure:"enabled"`
Plans []Plan `json:"plans" mapstructure:"plans"`
}
type Plan struct {
Level int `json:"level" mapstructure:"level"`
Price float32 `json:"price" mapstructure:"price"`
@ -31,67 +37,64 @@ type Usage struct {
}
type UsageMap map[string]Usage
var GPT4Array = []string{
globals.GPT4, globals.GPT40314, globals.GPT40613, globals.GPT41106Preview, globals.GPT41106VisionPreview,
globals.GPT4Vision, globals.GPT4Dalle, globals.GPT4All,
}
var ClaudeProArray = []string{
globals.Claude1100k, globals.Claude2100k, globals.Claude2200k,
}
var MidjourneyArray = []string{
globals.MidjourneyFast,
}
var Plans = []Plan{
{
Level: 0,
Price: 0,
Items: []PlanItem{},
},
{
Level: 1,
Price: 42,
Items: []PlanItem{
{Id: "gpt-4", Value: 150, Models: GPT4Array, Name: "GPT-4", Icon: "compass"},
{Id: "midjourney", Value: 50, Models: MidjourneyArray, Name: "Midjourney", Icon: "image-plus"},
{Id: "claude-100k", Value: 300, Models: ClaudeProArray, Name: "Claude 100k", Icon: "book-text"},
},
},
{
Level: 2,
Price: 76,
Items: []PlanItem{
{Id: "gpt-4", Value: 300, Models: GPT4Array, Name: "GPT-4", Icon: "compass"},
{Id: "midjourney", Value: 100, Models: MidjourneyArray, Name: "Midjourney", Icon: "image-plus"},
{Id: "claude-100k", Value: 600, Models: ClaudeProArray, Name: "Claude 100k", Icon: "book-text"},
},
},
{
Level: 3,
Price: 148,
Items: []PlanItem{
{Id: "gpt-4", Value: 600, Models: GPT4Array, Name: "GPT-4", Icon: "compass"},
{Id: "midjourney", Value: 200, Models: MidjourneyArray, Name: "Midjourney", Icon: "image-plus"},
{Id: "claude-100k", Value: 1200, Models: ClaudeProArray, Name: "Claude 100k", Icon: "book-text"},
},
},
}
var planExp int64 = 0
func NewPlanManager() *PlanManager {
manager := &PlanManager{}
if err := viper.UnmarshalKey("subscription", manager); err != nil {
panic(err)
}
return manager
}
func (c *PlanManager) SaveConfig() error {
viper.Set("subscription", c)
return viper.WriteConfig()
}
func (c *PlanManager) UpdateConfig(data *PlanManager) error {
c.Enabled = data.Enabled
c.Plans = data.Plans
return c.SaveConfig()
}
func (c *PlanManager) GetPlan(level int) Plan {
for _, plan := range c.Plans {
if plan.Level == level {
return plan
}
}
return Plan{}
}
func (c *PlanManager) GetPlans() []Plan {
if c.Enabled {
return c.Plans
}
return []Plan{}
}
func (c *PlanManager) GetRawPlans() []Plan {
return c.Plans
}
func (c *PlanManager) IsEnabled() bool {
return c.Enabled
}
func getOffsetFormat(offset time.Time, usage int64) string {
return fmt.Sprintf("%s/%d", offset.Format("2006-01-02:15:04:05"), usage)
}
func GetSubscriptionUsage(cache *redis.Client, user *User, t string) (usage int64, offset time.Time) {
func GetSubscriptionUsage(cache *redis.Client, user globals.AuthLike, t string) (usage int64, offset time.Time) {
// example cache value: 2021-09-01:19:00:00/100
// if date is longer than 1 month, reset usage
offset = time.Now()
key := globals.GetSubscriptionLimitFormat(t, user.ID)
key := globals.GetSubscriptionLimitFormat(t, user.HitID())
v, err := utils.GetCache(cache, key)
if (err != nil && errors.Is(err, redis.Nil)) || len(v) == 0 {
usage = 0
@ -141,8 +144,8 @@ func GetSubscriptionUsage(cache *redis.Client, user *User, t string) (usage int6
return
}
func IncreaseSubscriptionUsage(cache *redis.Client, user *User, t string, limit int64) bool {
key := globals.GetSubscriptionLimitFormat(t, user.ID)
func IncreaseSubscriptionUsage(cache *redis.Client, user globals.AuthLike, t string, limit int64) bool {
key := globals.GetSubscriptionLimitFormat(t, user.HitID())
usage, offset := GetSubscriptionUsage(cache, user, t)
usage += 1
@ -155,8 +158,8 @@ func IncreaseSubscriptionUsage(cache *redis.Client, user *User, t string, limit
return err == nil
}
func DecreaseSubscriptionUsage(cache *redis.Client, user *User, t string) bool {
key := globals.GetSubscriptionLimitFormat(t, user.ID)
func DecreaseSubscriptionUsage(cache *redis.Client, user globals.AuthLike, t string) bool {
key := globals.GetSubscriptionLimitFormat(t, user.HitID())
usage, offset := GetSubscriptionUsage(cache, user, t)
usage -= 1
@ -169,35 +172,35 @@ func DecreaseSubscriptionUsage(cache *redis.Client, user *User, t string) bool {
return err == nil
}
func (p *Plan) GetUsage(user *User, db *sql.DB, cache *redis.Client) UsageMap {
func (p *Plan) GetUsage(user globals.AuthLike, db *sql.DB, cache *redis.Client) UsageMap {
return utils.EachObject[PlanItem, Usage](p.Items, func(usage PlanItem) (string, Usage) {
return usage.Id, usage.GetUsageForm(user, db, cache)
})
}
func (p *PlanItem) GetUsage(user *User, db *sql.DB, cache *redis.Client) int64 {
func (p *PlanItem) GetUsage(user globals.AuthLike, db *sql.DB, cache *redis.Client) int64 {
// preflight check
user.GetID(db)
usage, _ := GetSubscriptionUsage(cache, user, p.Id)
return usage
}
func (p *PlanItem) ResetUsage(user *User, cache *redis.Client) bool {
key := globals.GetSubscriptionLimitFormat(p.Id, user.ID)
func (p *PlanItem) ResetUsage(user globals.AuthLike, cache *redis.Client) bool {
key := globals.GetSubscriptionLimitFormat(p.Id, user.HitID())
_, offset := GetSubscriptionUsage(cache, user, p.Id)
err := utils.SetCache(cache, key, getOffsetFormat(offset, 0), planExp)
return err == nil
}
func (p *PlanItem) CreateUsage(user *User, cache *redis.Client) bool {
key := globals.GetSubscriptionLimitFormat(p.Id, user.ID)
func (p *PlanItem) CreateUsage(user globals.AuthLike, cache *redis.Client) bool {
key := globals.GetSubscriptionLimitFormat(p.Id, user.HitID())
err := utils.SetCache(cache, key, getOffsetFormat(time.Now(), 0), planExp)
return err == nil
}
func (p *PlanItem) GetUsageForm(user *User, db *sql.DB, cache *redis.Client) Usage {
func (p *PlanItem) GetUsageForm(user globals.AuthLike, db *sql.DB, cache *redis.Client) Usage {
return Usage{
Used: p.GetUsage(user, db, cache),
Total: p.Value,
@ -208,30 +211,25 @@ func (p *PlanItem) IsInfinity() bool {
return p.Value == -1
}
func (p *PlanItem) IsExceeded(user *User, db *sql.DB, cache *redis.Client) bool {
func (p *PlanItem) IsExceeded(user globals.AuthLike, db *sql.DB, cache *redis.Client) bool {
return p.IsInfinity() || p.GetUsage(user, db, cache) < p.Value
}
func (p *PlanItem) Increase(user *User, cache *redis.Client) bool {
func (p *PlanItem) Increase(user globals.AuthLike, cache *redis.Client) bool {
if p.Value == -1 {
return true
}
return IncreaseSubscriptionUsage(cache, user, p.Id, p.Value)
}
func (p *PlanItem) Decrease(user *User, cache *redis.Client) bool {
func (p *PlanItem) Decrease(user globals.AuthLike, cache *redis.Client) bool {
if p.Value == -1 {
return true
}
return DecreaseSubscriptionUsage(cache, user, p.Id)
}
func (u *User) GetSubscriptionUsage(db *sql.DB, cache *redis.Client) UsageMap {
plan := u.GetPlan(db)
return plan.GetUsage(u, db, cache)
}
func (p *Plan) IncreaseUsage(user *User, cache *redis.Client, model string) bool {
func (p *Plan) IncreaseUsage(user globals.AuthLike, cache *redis.Client, model string) bool {
for _, usage := range p.Items {
if utils.Contains(model, usage.Models) {
return usage.Increase(user, cache)
@ -241,7 +239,7 @@ func (p *Plan) IncreaseUsage(user *User, cache *redis.Client, model string) bool
return false
}
func (p *Plan) DecreaseUsage(user *User, cache *redis.Client, model string) bool {
func (p *Plan) DecreaseUsage(user globals.AuthLike, cache *redis.Client, model string) bool {
for _, usage := range p.Items {
if utils.Contains(model, usage.Models) {
return usage.Decrease(user, cache)
@ -251,15 +249,6 @@ func (p *Plan) DecreaseUsage(user *User, cache *redis.Client, model string) bool
return false
}
func GetLevel(level int) Plan {
for _, plan := range Plans {
if plan.Level == level {
return plan
}
}
return Plan{}
}
func InLevel(level int) bool {
func IsValidPlan(level int) bool {
return utils.InRange(level, 1, 3)
}

View File

@ -19,4 +19,7 @@ func Register(app *gin.RouterGroup) {
app.GET("/admin/config/view", GetConfig)
app.POST("/admin/config/update", UpdateConfig)
app.GET("/admin/plan/view", GetPlanConfig)
app.POST("/admin/plan/update", UpdatePlanConfig)
}

View File

@ -1,5 +1,7 @@
package globals
import "database/sql"
type ChannelConfig interface {
GetType() string
GetModelReflect(model string) string
@ -9,3 +11,8 @@ type ChannelConfig interface {
GetEndpoint() string
ProcessError(err error) error
}
type AuthLike interface {
GetID(db *sql.DB) int64
HitID() int64
}

View File

@ -2,7 +2,6 @@ package manager
import (
"chat/admin"
"chat/auth"
"chat/channel"
"github.com/gin-gonic/gin"
"net/http"
@ -21,7 +20,7 @@ func ChargeAPI(c *gin.Context) {
}
func PlanAPI(c *gin.Context) {
c.JSON(http.StatusOK, auth.Plans)
c.JSON(http.StatusOK, channel.PlanInstance.GetPlans())
}
func sendErrorResponse(c *gin.Context, err error, types ...string) {