feat: 商业版支持邀请返佣功能

This commit is contained in:
xiaojunnuo
2026-05-18 13:25:35 +08:00
parent 1bdcfe646f
commit f9a310b6c3
33 changed files with 1317 additions and 14 deletions
+12
View File
@@ -35,6 +35,8 @@ Certd 是一个支持私有化部署的 SSL/TLS 证书自动化管理平台。
- `packages/ui/certd-server/`:后端服务 - `packages/ui/certd-server/`:后端服务
- `packages/ui/certd-client/`:前端 Web 管理台 - `packages/ui/certd-client/`:前端 Web 管理台
`packages/pro/` 是独立 Git 工作区,使用 `packages/pro/.git` 管理。根仓库的 `git status` / `git diff` 默认看不到这里的实际改动;修改商业版代码后,要在 `packages/pro` 目录内单独执行 `git status` / `git diff` 检查。
## 后端 ## 后端
主要后端包:`packages/ui/certd-server` 主要后端包:`packages/ui/certd-server`
@@ -108,6 +110,14 @@ Certd 是一个支持私有化部署的 SSL/TLS 证书自动化管理平台。
- 不要运行前端 `pnpm tsc` / `vue-tsc`:当前依赖组合中 `vue-tsc@1.8.27` 会直接抛内部错误 `Search string not found: "/supportedTSExtensions = .*(?=;)/"`,不是有效的项目类型检查结果。 - 不要运行前端 `pnpm tsc` / `vue-tsc`:当前依赖组合中 `vue-tsc@1.8.27` 会直接抛内部错误 `Search string not found: "/supportedTSExtensions = .*(?=;)/"`,不是有效的项目类型检查结果。
- 前端暂不跑单元测试;当前 `test:unit` 只是占位脚本 - 前端暂不跑单元测试;当前 `test:unit` 只是占位脚本
前端列表管理页面约定:
- 列表管理、后台管理、记录查询、CRUD 表格类页面,默认优先使用 Fast Crud(`@fast-crud/fast-crud``fs-crud``useFs``createCrudOptions`)实现。
- 只有轻量只读展示、强交互自定义界面或已有页面模式明确不适合 Fast Crud 时,才手写 `a-table` / 自定义列表,并在回复中说明原因。
- 开发或重构这类页面前,先读取 `.trae/skills/fast-crud-page-dev/SKILL.md`,按仓库内 Fast Crud 页面拆分与验证方式实现。
- 前端对话框里只做纯确认时可以使用 `Modal.confirm`;只要对话框里有字段输入、表单校验或提交字段,统一使用 `useFormDialog` / `openFormDialog`,不要在 `Modal.confirm``content` 里手写输入框。
- 页面内嵌 Fast Crud 表格时,要显式给外层容器稳定高度或 `flex: 1; min-height: 0` 的撑满链路;Fast Crud 依赖外部元素高度,不能只依赖表格默认高度。
## 流水线与插件模型 ## 流水线与插件模型
项目最关键的架构概念是证书流水线。 项目最关键的架构概念是证书流水线。
@@ -168,6 +178,7 @@ Certd 是一个支持私有化部署的 SSL/TLS 证书自动化管理平台。
- `packages/ui/certd-server/data/``logs/`、生成的 metadata/dist 等通常视为运行时或构建产物,除非任务明确要求处理它们。 - `packages/ui/certd-server/data/``logs/`、生成的 metadata/dist 等通常视为运行时或构建产物,除非任务明确要求处理它们。
- 注意本地数据和配置里可能包含凭据、证书材料等敏感信息。 - 注意本地数据和配置里可能包含凭据、证书材料等敏感信息。
- 本仓库代码注释优先使用中文,尤其是解释业务规则、兼容逻辑、协议细节和隐藏风险时;除非文件已有明确英文注释风格或引用外部英文术语,否则不要新增英文说明性注释。 - 本仓库代码注释优先使用中文,尤其是解释业务规则、兼容逻辑、协议细节和隐藏风险时;除非文件已有明确英文注释风格或引用外部英文术语,否则不要新增英文说明性注释。
- 代码可读性优先于短写法。遇到包含业务分支的复杂三元表达式、内联对象、链式调用或条件组合时,优先拆成命名清晰的中间变量、独立分支或小函数,让读代码的人能一眼看出业务意图;不要为了少写几行把逻辑压成难读的一坨。
## 插件开发技能 ## 插件开发技能
@@ -182,6 +193,7 @@ Certd 是一个支持私有化部署的 SSL/TLS 证书自动化管理平台。
- `access-plugin-dev`:开发 Access 授权插件 - `access-plugin-dev`:开发 Access 授权插件
- `dns-provider-dev`:开发 DNS Provider 插件 - `dns-provider-dev`:开发 DNS Provider 插件
- `fast-crud-page-dev`:开发或重构前端 Fast Crud 列表管理页面
- `task-plugin-dev`:开发 Task 部署任务插件 - `task-plugin-dev`:开发 Task 部署任务插件
- `plugin-converter`:将插件转换为 YAML 配置 - `plugin-converter`:将插件转换为 YAML 配置
@@ -22,6 +22,8 @@ export default {
mySuite: "我的套餐", mySuite: "我的套餐",
suiteBuy: "套餐购买", suiteBuy: "套餐购买",
myTrade: "我的订单", myTrade: "我的订单",
myWallet: "我的钱包",
inviteCommission: "邀请返佣",
paymentReturn: "支付返回", paymentReturn: "支付返回",
source: "源码", source: "源码",
github: "github", github: "github",
@@ -46,6 +48,8 @@ export default {
suiteSetting: "套餐设置", suiteSetting: "套餐设置",
orderManager: "订单管理", orderManager: "订单管理",
userSuites: "用户套餐", userSuites: "用户套餐",
inviteCommissionSetting: "邀请返佣设置",
inviteWithdraw: "提现申请记录",
netTest: "网络测试", netTest: "网络测试",
enterpriseManager: "企业管理设置", enterpriseManager: "企业管理设置",
projectManager: "项目管理", projectManager: "项目管理",
+2
View File
@@ -12,9 +12,11 @@ import plugin from "./plugin/";
import { setupVben } from "./vben"; import { setupVben } from "./vben";
import { util } from "/@/utils"; import { util } from "/@/utils";
import { initPreferences } from "/@/vben/preferences"; import { initPreferences } from "/@/vben/preferences";
import { inviteUtils } from "/@/utils/util.invite";
// import "./components/code-editor/import-works"; // import "./components/code-editor/import-works";
// @ts-ignore // @ts-ignore
async function bootstrap() { async function bootstrap() {
inviteUtils.captureFromLocation();
const app = createApp(App); const app = createApp(App);
// app.use(Antd); // app.use(Antd);
app.use(Antd); app.use(Antd);
@@ -324,7 +324,7 @@ export const certdResources = [
meta: { meta: {
show: () => { show: () => {
const settingStore = useSettingStore(); const settingStore = useSettingStore();
return settingStore.isComm; return settingStore.isInviteCommissionEnabled;
}, },
icon: "ion:gift-outline", icon: "ion:gift-outline",
auth: true, auth: true,
@@ -359,6 +359,36 @@ export const certdResources = [
keepAlive: true, keepAlive: true,
}, },
}, },
{
title: "certd.myWallet",
name: "MyWallet",
path: "/certd/wallet",
component: "/certd/wallet/index.vue",
meta: {
show: () => {
const settingStore = useSettingStore();
return settingStore.isComm;
},
icon: "ion:wallet-outline",
auth: true,
keepAlive: true,
},
},
{
title: "certd.inviteCommission",
name: "InviteCommission",
path: "/certd/invite",
component: "/certd/invite/index.vue",
meta: {
show: () => {
const settingStore = useSettingStore();
return settingStore.isComm;
},
icon: "ion:gift-outline",
auth: true,
keepAlive: true,
},
},
{ {
title: "certd.paymentReturn", title: "certd.paymentReturn",
name: "PaymentReturn", name: "PaymentReturn",
@@ -286,6 +286,38 @@ export const sysResources = [
keepAlive: true, keepAlive: true,
}, },
}, },
{
title: "certd.sysResources.inviteCommissionSetting",
name: "SysInviteCommissionSetting",
path: "/sys/suite/invite/setting",
component: "/sys/suite/invite/setting.vue",
meta: {
show: () => {
const settingStore = useSettingStore();
return settingStore.isComm;
},
icon: "ion:gift-outline",
permission: "sys:settings:edit",
auth: true,
keepAlive: true,
},
},
{
title: "certd.sysResources.inviteWithdraw",
name: "SysInviteWithdraw",
path: "/sys/suite/invite/withdraw",
component: "/sys/suite/invite/withdraw.vue",
meta: {
show: () => {
const settingStore = useSettingStore();
return settingStore.isComm;
},
icon: "ion:cash-outline",
permission: "sys:settings:edit",
auth: true,
keepAlive: true,
},
},
], ],
}, },
{ {
@@ -97,6 +97,9 @@ export type SysPublicSetting = {
export type SuiteSetting = { export type SuiteSetting = {
enabled?: boolean; enabled?: boolean;
}; };
export type InviteSetting = {
enabled?: boolean;
};
export type SysPrivateSetting = { export type SysPrivateSetting = {
httpProxy?: string; httpProxy?: string;
httpsProxy?: string; httpsProxy?: string;
@@ -142,6 +145,7 @@ export type AllSettings = {
siteEnv: SiteEnv; siteEnv: SiteEnv;
headerMenus: HeaderMenus; headerMenus: HeaderMenus;
suiteSetting: SuiteSetting; suiteSetting: SuiteSetting;
inviteSetting: InviteSetting;
app: AppInfo; app: AppInfo;
}; };
@@ -30,6 +30,9 @@ export interface SettingState {
headerMenus?: HeaderMenus; headerMenus?: HeaderMenus;
inited?: boolean; inited?: boolean;
suiteSetting?: SuiteSetting; suiteSetting?: SuiteSetting;
inviteSetting?: {
enabled?: boolean;
};
app: { app: {
version?: string; version?: string;
time?: number; time?: number;
@@ -102,6 +105,7 @@ export const useSettingStore = defineStore({
menus: [], menus: [],
}, },
suiteSetting: { enabled: false }, suiteSetting: { enabled: false },
inviteSetting: { enabled: false },
inited: false, inited: false,
app: { app: {
version: "", version: "",
@@ -196,6 +200,9 @@ export const useSettingStore = defineStore({
// @ts-ignore // @ts-ignore
return this.suiteSetting?.enabled === true; return this.suiteSetting?.enabled === true;
}, },
isInviteCommissionEnabled(): boolean {
return this.isComm && this.inviteSetting?.enabled === true;
},
}, },
actions: { actions: {
checkPlus() { checkPlus() {
@@ -215,6 +222,7 @@ export const useSettingStore = defineStore({
merge(this.plusInfo, allSettings.plusInfo || {}); merge(this.plusInfo, allSettings.plusInfo || {});
merge(this.headerMenus, allSettings.headerMenus || {}); merge(this.headerMenus, allSettings.headerMenus || {});
merge(this.suiteSetting, allSettings.suiteSetting || {}); merge(this.suiteSetting, allSettings.suiteSetting || {});
merge(this.inviteSetting, allSettings.inviteSetting || {});
//@ts-ignore //@ts-ignore
this.initSiteInfo(allSettings.siteInfo || {}); this.initSiteInfo(allSettings.siteInfo || {});
this.initAppInfo(allSettings.app || {}); this.initAppInfo(allSettings.app || {});
@@ -4,6 +4,7 @@ export interface RegisterReq {
username: string; username: string;
password: string; password: string;
confirmPassword: string; confirmPassword: string;
inviteCode?: string;
} }
/** /**
* @description: Login interface parameters * @description: Login interface parameters
@@ -18,6 +19,7 @@ export interface SmsLoginReq {
phoneCode: string; phoneCode: string;
smsCode: string; smsCode: string;
randomStr: string; randomStr: string;
inviteCode?: string;
} }
export interface ForgotPasswordReq { export interface ForgotPasswordReq {
@@ -0,0 +1,54 @@
const INVITE_STORAGE_KEY = "certd_invite_code";
const INVITE_TTL = 3 * 24 * 60 * 60 * 1000;
export type InviteCache = {
code: string;
expiresAt: number;
};
function normalizeInviteCode(code?: string | null) {
return code?.trim().toUpperCase();
}
export const inviteUtils = {
save(code?: string | null) {
const normalized = normalizeInviteCode(code);
if (!normalized) {
return;
}
const cache: InviteCache = {
code: normalized,
expiresAt: Date.now() + INVITE_TTL,
};
localStorage.setItem(INVITE_STORAGE_KEY, JSON.stringify(cache));
},
get() {
const text = localStorage.getItem(INVITE_STORAGE_KEY);
if (!text) {
return "";
}
try {
const cache = JSON.parse(text) as InviteCache;
if (!cache.code || !cache.expiresAt || cache.expiresAt < Date.now()) {
localStorage.removeItem(INVITE_STORAGE_KEY);
return "";
}
return cache.code;
} catch (e) {
localStorage.removeItem(INVITE_STORAGE_KEY);
return "";
}
},
captureFromLocation() {
const hashQuery = window.location.hash?.split("?")[1] || "";
const search = window.location.search?.replace(/^\?/, "") || "";
const hashParams = new URLSearchParams(hashQuery);
const searchParams = new URLSearchParams(search);
const code = hashParams.get("inviteCode") || searchParams.get("inviteCode");
if (code) {
this.save(code);
}
},
};
@@ -0,0 +1,13 @@
import { request } from "/@/api/service";
export async function GetMyInvite() {
return await request({ url: "/invite/my", method: "post" });
}
export async function GetInvitees(query: any) {
return await request({ url: "/invite/invitees/page", method: "post", data: query });
}
export async function GetCommissionLogs(query: any) {
return await request({ url: "/invite/commission/page", method: "post", data: query });
}
@@ -0,0 +1,34 @@
import { CreateCrudOptionsProps, CreateCrudOptionsRet, UserPageQuery, UserPageRes } from "@fast-crud/fast-crud";
import * as api from "./api";
export default function (): CreateCrudOptionsRet {
const pageRequest = async (query: UserPageQuery): Promise<UserPageRes> => {
return await api.GetInvitees(query);
};
return {
crudOptions: {
request: { pageRequest },
actionbar: { show: false },
toolbar: { show: false },
rowHandle: { show: false },
columns: {
inviteeUserId: {
title: "被邀请人ID",
type: "number",
column: { width: 140 },
},
inviteCode: {
title: "邀请码",
type: "text",
column: { width: 160 },
},
createTime: {
title: "邀请时间",
type: "datetime",
column: { width: 180 },
},
},
},
};
}
@@ -0,0 +1,59 @@
import { CreateCrudOptionsRet, dict, UserPageQuery, UserPageRes } from "@fast-crud/fast-crud";
import * as api from "./api";
import PriceInput from "/@/views/sys/suite/product/price-input.vue";
export default function (): CreateCrudOptionsRet {
const pageRequest = async (query: UserPageQuery): Promise<UserPageRes> => {
return await api.GetCommissionLogs(query);
};
return {
crudOptions: {
request: { pageRequest },
actionbar: { show: false },
toolbar: { show: false },
rowHandle: { show: false },
columns: {
type: {
title: "类型",
type: "dict-select",
dict: dict({
data: [{ label: "佣金入账", value: "commission", color: "success" }],
}),
column: { width: 130 },
},
amount: {
title: "金额",
type: "number",
column: {
width: 120,
component: { name: PriceInput, vModel: "modelValue", edit: false },
},
},
inviteeUserDisplay: {
title: "被邀请用户",
type: "text",
column: { width: 150 },
},
consumeAmount: {
title: "消费金额",
type: "number",
column: {
width: 120,
component: { name: PriceInput, vModel: "modelValue", edit: false },
},
},
remark: {
title: "备注",
type: "text",
column: { minWidth: 220 },
},
createTime: {
title: "时间",
type: "datetime",
column: { width: 180 },
},
},
},
};
}
@@ -0,0 +1,137 @@
<template>
<fs-page class="page-invite">
<template #header>
<div class="title">邀请返佣</div>
</template>
<div v-if="loaded && enabled" class="invite-body">
<div class="invite-link-row flex-o">
<span class="label">邀请码</span>
<fs-copyable v-model="inviteInfo.inviteCode" />
</div>
<div class="invite-link-row flex-o mt-10">
<span class="label">邀请链接</span>
<fs-copyable v-model="inviteInfo.inviteLink" />
</div>
<a-tabs v-model:active-key="activeTab" class="invite-tabs mt-6">
<a-tab-pane key="invitees" tab="邀请成功">
<fs-crud v-if="activeTab === 'invitees'" ref="inviteesCrudRef" class="invite-crud" v-bind="inviteesCrudBinding" />
</a-tab-pane>
<a-tab-pane key="logs" tab="佣金记录">
<fs-crud v-if="activeTab === 'logs'" ref="logsCrudRef" class="invite-crud" v-bind="logsCrudBinding" />
</a-tab-pane>
</a-tabs>
</div>
<a-empty v-else-if="loaded" description="邀请返佣未开启" />
</fs-page>
</template>
<script lang="ts" setup>
import { computed, nextTick, onActivated, onMounted, reactive, ref } from "vue";
import { useFs } from "@fast-crud/fast-crud";
import * as api from "./api";
import createInviteesCrudOptions from "./crud-invitees";
import createLogsCrudOptions from "./crud-logs";
import { useSettingStore } from "/@/store/settings";
defineOptions({ name: "InviteCommission" });
const settingStore = useSettingStore();
const enabled = computed(() => settingStore.isInviteCommissionEnabled);
const activeTab = ref("invitees");
const inviteInfo = reactive<any>({ inviteCode: "", inviteLink: "" });
const loaded = ref(false);
const { crudBinding: inviteesCrudBinding, crudExpose: inviteesCrudExpose, crudRef: inviteesCrudRef } = useFs({ createCrudOptions: createInviteesCrudOptions });
const { crudBinding: logsCrudBinding, crudExpose: logsCrudExpose, crudRef: logsCrudRef } = useFs({ createCrudOptions: createLogsCrudOptions });
async function loadMyInvite() {
const res: any = await api.GetMyInvite();
inviteInfo.inviteCode = res.inviteCode;
inviteInfo.inviteLink = res.inviteLink;
}
async function refreshActiveList() {
if (activeTab.value === "invitees") {
await inviteesCrudExpose.doRefresh();
} else if (activeTab.value === "logs") {
await logsCrudExpose.doRefresh();
}
}
async function refreshInvitePage(refreshAll = false) {
await settingStore.initOnce();
loaded.value = true;
if (!enabled.value) {
return;
}
await loadMyInvite();
await nextTick();
if (refreshAll) {
await Promise.all([inviteesCrudExpose.doRefresh(), logsCrudExpose.doRefresh()]);
return;
}
await refreshActiveList();
}
onMounted(async () => {
await refreshInvitePage(true);
});
onActivated(async () => {
if (!loaded.value) {
return;
}
await refreshInvitePage();
});
</script>
<style lang="less">
.page-invite {
display: flex;
min-height: 0;
.fs-page-content {
display: flex;
min-height: 0;
}
.invite-body {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
padding: 20px;
}
.invite-tabs {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
}
.ant-tabs-content-holder,
.ant-tabs-content,
.ant-tabs-tabpane {
display: flex;
flex: 1;
min-height: 0;
}
.ant-tabs-tabpane {
flex-direction: column;
}
.invite-crud {
flex: 1;
min-height: 0;
}
.label {
width: 80px;
flex: none;
text-align: right;
margin-right: 8px;
}
}
</style>
@@ -46,6 +46,7 @@ export type TradeCreateReq = {
duration: number; duration: number;
num: number; num: number;
payType: string; payType: string;
useRebateBalance?: boolean;
}; };
export async function TradeCreate(form: TradeCreateReq) { export async function TradeCreate(form: TradeCreateReq) {
@@ -28,10 +28,19 @@
<span class="label">{{ $t("certd.order.price") }}</span> <span class="label">{{ $t("certd.order.price") }}</span>
<price-input :edit="false" :model-value="durationSelected.price"></price-input> <price-input :edit="false" :model-value="durationSelected.price"></price-input>
</div> </div>
<div v-if="durationSelected.price > 0 && wallet.availableAmount > 0" class="flex-o mt-5">
<span class="label">返利抵扣</span>
<a-switch v-model:checked="formRef.useRebateBalance" />
<span class="ml-10">可用 {{ amountToYuan(wallet.availableAmount) }} 预计抵扣 {{ amountToYuan(expectedRebateAmount) }} </span>
</div>
<div v-if="durationSelected.price > 0 && formRef.useRebateBalance" class="flex-o mt-5">
<span class="label">还需支付</span>
<span class="color-red">{{ amountToYuan(expectedThirdPartyAmount) }} </span>
</div>
<div class="flex-o mt-5"> <div class="flex-o mt-5">
<span class="label">{{ $t("certd.order.paymentMethod") }}</span> <span class="label">{{ $t("certd.order.paymentMethod") }}</span>
<div v-if="durationSelected.price === 0">{{ $t("certd.order.free") }}</div> <div v-if="durationSelected.price === 0 || expectedThirdPartyAmount === 0">{{ $t("certd.order.free") }}</div>
<fs-dict-select v-else v-model:value="formRef.payType" :dict="paymentsDictRef" style="width: 200px"> </fs-dict-select> <fs-dict-select v-else v-model:value="formRef.payType" :dict="paymentsDictRef" style="width: 200px"> </fs-dict-select>
</div> </div>
</div> </div>
@@ -39,7 +48,7 @@
</template> </template>
<script setup lang="tsx"> <script setup lang="tsx">
import { ref } from "vue"; import { computed, ref } from "vue";
import { GetPaymentTypes, OrderModalOpenReq, TradeCreate } from "/@/views/certd/suite/api"; import { GetPaymentTypes, OrderModalOpenReq, TradeCreate } from "/@/views/certd/suite/api";
import SuiteValue from "/@/views/sys/suite/product/suite-value.vue"; import SuiteValue from "/@/views/sys/suite/product/suite-value.vue";
import PriceInput from "/@/views/sys/suite/product/price-input.vue"; import PriceInput from "/@/views/sys/suite/product/price-input.vue";
@@ -49,11 +58,14 @@ import DurationValue from "/@/views/sys/suite/product/duration-value.vue";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import qrcode from "qrcode"; import qrcode from "qrcode";
import * as api from "/@/views/certd/suite/api"; import * as api from "/@/views/certd/suite/api";
import { GetMyInvite } from "/@/views/certd/invite/api";
import { util } from "/@/utils";
const openRef = ref(false); const openRef = ref(false);
const product = ref<any>(null); const product = ref<any>(null);
const formRef = ref<any>({}); const formRef = ref<any>({});
const durationSelected = ref<any>(null); const durationSelected = ref<any>(null);
const wallet = ref<any>({ availableAmount: 0 });
async function open(opts: OrderModalOpenReq) { async function open(opts: OrderModalOpenReq) {
openRef.value = true; openRef.value = true;
@@ -63,6 +75,13 @@ async function open(opts: OrderModalOpenReq) {
formRef.value.productId = opts.product.id; formRef.value.productId = opts.product.id;
formRef.value.duration = opts.duration; formRef.value.duration = opts.duration;
formRef.value.num = opts.num ?? 1; formRef.value.num = opts.num ?? 1;
formRef.value.useRebateBalance = false;
try {
const inviteInfo: any = await GetMyInvite();
wallet.value = inviteInfo.wallet || { availableAmount: 0 };
} catch (e) {
wallet.value = { availableAmount: 0 };
}
} }
const paymentsDictRef = dict({ const paymentsDictRef = dict({
async getData() { async getData() {
@@ -77,6 +96,21 @@ const paymentsDictRef = dict({
const router = useRouter(); const router = useRouter();
const expectedRebateAmount = computed(() => {
if (!formRef.value.useRebateBalance) {
return 0;
}
return Math.min(wallet.value.availableAmount || 0, durationSelected.value?.price || 0);
});
const expectedThirdPartyAmount = computed(() => {
return Math.max(0, (durationSelected.value?.price || 0) - expectedRebateAmount.value);
});
function amountToYuan(amount: number) {
return util.amount.toYuan(amount || 0);
}
async function orderCreate() { async function orderCreate() {
if (durationSelected.value.price === 0) { if (durationSelected.value.price === 0) {
//如果是0,直接请求创建订单 //如果是0,直接请求创建订单
@@ -93,7 +127,7 @@ async function orderCreate() {
return; return;
} }
if (!formRef.value.payType) { if (expectedThirdPartyAmount.value > 0 && !formRef.value.payType) {
notification.error({ notification.error({
message: "请选择支付方式", message: "请选择支付方式",
}); });
@@ -104,8 +138,17 @@ async function orderCreate() {
duration: formRef.value.duration, duration: formRef.value.duration,
num: formRef.value.num ?? 1, num: formRef.value.num ?? 1,
payType: formRef.value.payType, payType: formRef.value.payType,
useRebateBalance: formRef.value.useRebateBalance,
}); });
if (paymentReq.paid) {
notification.success({
message: "套餐购买成功",
});
openRef.value = false;
return;
}
async function onPaid() { async function onPaid() {
openRef.value = false; openRef.value = false;
router.push({ router.push({
@@ -151,6 +151,30 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
}, },
}, },
}, },
rebateAmount: {
title: "返利抵扣",
type: "number",
column: {
width: 110,
component: {
name: PriceInput,
vModel: "modelValue",
edit: false,
},
},
},
thirdPartyPayAmount: {
title: "实付金额",
type: "number",
column: {
width: 110,
component: {
name: PriceInput,
vModel: "modelValue",
edit: false,
},
},
},
status: { status: {
title: "状态", title: "状态",
search: { show: true }, search: { show: true },
@@ -177,6 +201,7 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
{ label: "支付宝", value: "alipay" }, { label: "支付宝", value: "alipay" },
{ label: "微信", value: "wxpay" }, { label: "微信", value: "wxpay" },
{ label: "免费", value: "free" }, { label: "免费", value: "free" },
{ label: "返利余额", value: "rebate" },
], ],
}), }),
column: { column: {
@@ -0,0 +1,21 @@
import { request } from "/@/api/service";
export async function GetWalletSummary() {
return await request({ url: "/wallet/summary", method: "post" });
}
export async function GetWithdrawSetting() {
return await request({ url: "/wallet/withdraw/setting/get", method: "post" });
}
export async function SaveWithdrawSetting(data: any) {
return await request({ url: "/wallet/withdraw/setting/save", method: "post", data });
}
export async function ApplyWithdraw(amount: number) {
return await request({ url: "/wallet/withdraw/apply", method: "post", data: { amount } });
}
export async function GetWithdraws(query: any) {
return await request({ url: "/wallet/withdraw/page", method: "post", data: query });
}
@@ -0,0 +1,56 @@
import { CreateCrudOptionsRet, dict, UserPageQuery, UserPageRes } from "@fast-crud/fast-crud";
import * as api from "./api";
import PriceInput from "/@/views/sys/suite/product/price-input.vue";
export default function (): CreateCrudOptionsRet {
const pageRequest = async (query: UserPageQuery): Promise<UserPageRes> => {
return await api.GetWithdraws(query);
};
return {
crudOptions: {
request: { pageRequest },
actionbar: { show: false },
toolbar: { show: false },
rowHandle: { show: false },
columns: {
amount: {
title: "金额",
type: "number",
column: {
width: 120,
component: { name: PriceInput, vModel: "modelValue", edit: false },
},
},
status: {
title: "状态",
type: "dict-select",
dict: dict({
data: [
{ label: "待审核", value: "pending", color: "warning" },
{ label: "已通过", value: "approved", color: "success" },
{ label: "已拒绝", value: "rejected", color: "error" },
],
}),
column: { width: 110 },
},
channel: {
title: "提现渠道",
type: "dict-select",
dict: dict({
data: [
{ label: "支付宝", value: "alipay" },
{ label: "银行卡", value: "bank" },
],
}),
column: { width: 110 },
},
realName: { title: "真实姓名", type: "text", column: { width: 120 } },
account: { title: "收款账号", type: "text", column: { width: 180 } },
bankName: { title: "开户银行", type: "text", column: { width: 160 } },
auditRemark: { title: "审核备注", type: "text", column: { minWidth: 180 } },
createTime: { title: "申请时间", type: "datetime", column: { width: 180 } },
},
},
};
}
@@ -0,0 +1,174 @@
<template>
<fs-page class="page-wallet">
<template #header>
<div class="title">我的钱包</div>
</template>
<div class="wallet-body">
<a-row :gutter="16" class="wallet-summary">
<a-col :span="6">
<a-statistic title="可用余额" :value="amountToYuan(summary.availableAmount)" suffix="元" />
</a-col>
<a-col :span="6">
<a-statistic title="冻结余额" :value="amountToYuan(summary.frozenAmount)" suffix="元" />
</a-col>
<a-col :span="6">
<a-statistic title="累计收入" :value="amountToYuan(summary.totalIncomeAmount)" suffix="元" />
</a-col>
<a-col :span="6">
<a-statistic title="累计提现" :value="amountToYuan(summary.totalWithdrawAmount)" suffix="元" />
</a-col>
</a-row>
<div class="wallet-actions">
<a-space>
<a-button type="primary" @click="openWithdrawSetting">提现设置</a-button>
<a-input-number v-model:value="withdrawAmountYuan" :min="0" addon-before="提现金额" addon-after="" />
<a-button @click="applyWithdraw">申请提现</a-button>
</a-space>
</div>
<fs-crud ref="withdrawCrudRef" class="wallet-crud" v-bind="withdrawCrudBinding" />
</div>
</fs-page>
</template>
<script lang="ts" setup>
import { onActivated, onMounted, reactive, ref } from "vue";
import { compute, dict, useFs } from "@fast-crud/fast-crud";
import { notification } from "ant-design-vue";
import * as api from "./api";
import createWithdrawCrudOptions from "./crud-withdraw";
import { util } from "/@/utils";
import { useFormDialog } from "/@/use/use-dialog";
defineOptions({ name: "MyWallet" });
const summary = reactive<any>({ availableAmount: 0, frozenAmount: 0, totalIncomeAmount: 0, totalWithdrawAmount: 0 });
const withdrawAmountYuan = ref(0);
const loaded = ref(false);
const { openFormDialog } = useFormDialog();
const { crudBinding: withdrawCrudBinding, crudExpose: withdrawCrudExpose, crudRef: withdrawCrudRef } = useFs({ createCrudOptions: createWithdrawCrudOptions });
function amountToYuan(amount: number) {
return util.amount.toYuan(amount || 0);
}
async function loadWalletSummary() {
const res: any = await api.GetWalletSummary();
Object.assign(summary, res || {});
}
async function openWithdrawSetting() {
const setting: any = await api.GetWithdrawSetting();
const initialForm = Object.assign({ channel: "alipay", realName: "", account: "", bankName: "" }, setting || {});
await openFormDialog({
title: "提现设置",
wrapper: {
width: 560,
},
initialForm,
columns: {
channel: {
title: "提现渠道",
type: "dict-radio",
dict: dict({
data: [
{ label: "支付宝", value: "alipay" },
{ label: "银行卡", value: "bank" },
],
}),
form: {
col: { span: 24 },
rules: [{ required: true, message: "请选择提现渠道" }],
},
},
realName: {
title: "真实姓名",
type: "text",
form: {
col: { span: 24 },
rules: [{ required: true, message: "请输入真实姓名" }],
},
},
account: {
title: "收款账号",
type: "text",
form: {
col: { span: 24 },
rules: [{ required: true, message: "请输入收款账号" }],
},
},
bankName: {
title: "开户银行",
type: "text",
form: {
col: { span: 24 },
show: compute(({ form }) => form.channel === "bank"),
rules: [{ required: compute(({ form }) => form.channel === "bank"), message: "请输入开户银行" }],
},
},
},
async onSubmit(form: any) {
await api.SaveWithdrawSetting(form);
notification.success({ message: "保存成功" });
},
});
}
async function applyWithdraw() {
await api.ApplyWithdraw(util.amount.toCent(withdrawAmountYuan.value || 0));
withdrawAmountYuan.value = 0;
await loadWalletSummary();
await withdrawCrudExpose.doRefresh();
notification.success({ message: "提现申请已提交" });
}
async function refreshWalletPage() {
await loadWalletSummary();
await withdrawCrudExpose.doRefresh();
loaded.value = true;
}
onMounted(refreshWalletPage);
onActivated(async () => {
if (!loaded.value) {
return;
}
await refreshWalletPage();
});
</script>
<style lang="less">
.page-wallet {
display: flex;
min-height: 0;
.fs-page-content {
display: flex;
min-height: 0;
}
.wallet-body {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
padding: 20px;
}
.wallet-summary,
.wallet-actions {
flex: none;
}
.wallet-actions {
margin: 16px 0 10px;
}
.wallet-crud {
flex: 1;
min-height: 0;
}
}
</style>
@@ -1,3 +1,4 @@
<!-- eslint-disable vue/multi-word-component-names -->
<template> <template>
<div class="main login-page"> <div class="main login-page">
<a-form v-if="!twoFactor.loginId" ref="formRef" class="user-layout-login" name="custom-validation" :model="formState" v-bind="layout" @finish="handleFinish" @finish-failed="handleFinishFailed"> <a-form v-if="!twoFactor.loginId" ref="formRef" class="user-layout-login" name="custom-validation" :model="formState" v-bind="layout" @finish="handleFinish" @finish-failed="handleFinishFailed">
@@ -108,6 +109,7 @@ import * as oauthApi from "../oauth/api";
import { notification } from "ant-design-vue"; import { notification } from "ant-design-vue";
import { request } from "/src/api/service"; import { request } from "/src/api/service";
import * as UserApi from "/src/store/user/api.user"; import * as UserApi from "/src/store/user/api.user";
import { inviteUtils } from "/@/utils/util.invite";
const { t } = useI18n(); const { t } = useI18n();
const route = useRoute(); const route = useRoute();
@@ -136,6 +138,7 @@ const formState = reactive({
smsCode: "", smsCode: "",
captcha: null, captcha: null,
smsCaptcha: null, smsCaptcha: null,
inviteCode: inviteUtils.get(),
}); });
const rules = { const rules = {
@@ -78,6 +78,14 @@
</a-tab-pane> </a-tab-pane>
</a-tabs> </a-tabs>
<a-form-item v-if="registerType !== 'mobile'" has-feedback name="inviteCode" label="邀请码">
<a-input v-model:value="formState.inviteCode" placeholder="邀请码(选填)" size="large" autocomplete="off">
<template #prefix>
<fs-icon icon="ion:gift-outline"></fs-icon>
</template>
</a-input>
</a-form-item>
<a-form-item v-if="registerType !== 'mobile'"> <a-form-item v-if="registerType !== 'mobile'">
<a-button type="primary" size="large" html-type="submit" class="login-button">注册</a-button> <a-button type="primary" size="large" html-type="submit" class="login-button">注册</a-button>
</a-form-item> </a-form-item>
@@ -97,6 +105,7 @@ import { useSettingStore } from "/@/store/settings";
import { notification } from "ant-design-vue"; import { notification } from "ant-design-vue";
import CaptchaInput from "/@/components/captcha/captcha-input.vue"; import CaptchaInput from "/@/components/captcha/captcha-input.vue";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import { inviteUtils } from "/@/utils/util.invite";
export default defineComponent({ export default defineComponent({
name: "RegisterPage", name: "RegisterPage",
components: { CaptchaInput, EmailCode }, components: { CaptchaInput, EmailCode },
@@ -125,6 +134,7 @@ export default defineComponent({
confirmPassword: "", confirmPassword: "",
captcha: null, captcha: null,
captchaForEmail: null, captchaForEmail: null,
inviteCode: inviteUtils.get(),
}); });
const rules = { const rules = {
@@ -213,6 +223,7 @@ export default defineComponent({
email: formState.email, email: formState.email,
captcha: registerType.value === "email" ? formState.captchaForEmail : formState.captcha, captcha: registerType.value === "email" ? formState.captchaForEmail : formState.captcha,
validateCode: formState.validateCode, validateCode: formState.validateCode,
inviteCode: formState.inviteCode,
}) as any }) as any
); );
} finally { } finally {
@@ -0,0 +1,21 @@
import { request } from "/@/api/service";
export async function GetSettings() {
return await request({ url: "/sys/invite/settings/get", method: "post" });
}
export async function SaveSettings(data: any) {
return await request({ url: "/sys/invite/settings/save", method: "post", data });
}
export async function GetWithdraws(query: any) {
return await request({ url: "/sys/wallet/withdraw/page", method: "post", data: query });
}
export async function ApproveWithdraw(id: number, remark?: string) {
return await request({ url: "/sys/wallet/withdraw/approve", method: "post", data: { id, remark } });
}
export async function RejectWithdraw(id: number, remark: string) {
return await request({ url: "/sys/wallet/withdraw/reject", method: "post", data: { id, remark } });
}
@@ -0,0 +1,135 @@
import { compute, CreateCrudOptionsProps, CreateCrudOptionsRet, dict, UserPageQuery, UserPageRes } from "@fast-crud/fast-crud";
import { Modal, notification } from "ant-design-vue";
import * as api from "./api";
import PriceInput from "/@/views/sys/suite/product/price-input.vue";
import { useFormDialog } from "/@/use/use-dialog";
export default function ({ crudExpose }: CreateCrudOptionsProps): CreateCrudOptionsRet {
const { openFormDialog } = useFormDialog();
const pageRequest = async (query: UserPageQuery): Promise<UserPageRes> => {
return await api.GetWithdraws(query);
};
async function approve(row: any) {
Modal.confirm({
title: "确认提现已线下打款?",
async onOk() {
await api.ApproveWithdraw(row.id);
await crudExpose.doRefresh();
notification.success({ message: "已审核通过" });
},
});
}
async function reject(row: any) {
await openFormDialog({
title: "拒绝提现申请",
wrapper: {
width: 520,
},
initialForm: {
remark: "",
},
columns: {
remark: {
title: "拒绝理由",
type: "textarea",
form: {
col: {
span: 24,
},
component: {
name: "a-textarea",
vModel: "value",
rows: 4,
placeholder: "请填写拒绝理由",
},
rules: [{ required: true, message: "请填写拒绝理由" }],
},
},
},
async onSubmit(form: any) {
const remark = form.remark.trim();
if (!remark) {
notification.error({ message: "请填写拒绝理由" });
throw new Error("请填写拒绝理由");
}
await api.RejectWithdraw(row.id, remark);
await crudExpose.doRefresh();
notification.success({ message: "已拒绝并退回余额" });
},
});
}
return {
crudOptions: {
request: { pageRequest },
actionbar: { show: false },
toolbar: { show: false },
rowHandle: {
width: 150,
fixed: "right",
buttons: {
view: { show: false },
edit: { show: false },
copy: { show: false },
remove: { show: false },
approve: {
text: "通过",
type: "link",
show: compute(({ row }) => row.status === "pending"),
click: ({ row }) => approve(row),
},
reject: {
text: "拒绝",
type: "link",
show: compute(({ row }) => row.status === "pending"),
click: ({ row }) => reject(row),
},
},
},
columns: {
userId: { title: "用户ID", type: "number", search: { show: true }, column: { width: 100 } },
amount: {
title: "金额",
type: "number",
column: {
width: 120,
component: { name: PriceInput, vModel: "modelValue", edit: false },
},
},
status: {
title: "状态",
type: "dict-select",
search: { show: true },
dict: dict({
data: [
{ label: "待审核", value: "pending", color: "warning" },
{ label: "已通过", value: "approved", color: "success" },
{ label: "已拒绝", value: "rejected", color: "error" },
],
}),
column: { width: 110 },
},
channel: {
title: "提现渠道",
type: "dict-select",
search: { show: true },
dict: dict({
data: [
{ label: "支付宝", value: "alipay" },
{ label: "银行卡", value: "bank" },
],
}),
column: { width: 110 },
},
realName: { title: "真实姓名", type: "text", search: { show: true }, column: { width: 120 } },
account: { title: "收款账号", type: "text", column: { width: 180 } },
bankName: { title: "开户银行", type: "text", column: { width: 160 } },
auditRemark: { title: "审核备注", type: "text", column: { minWidth: 180 } },
createTime: { title: "申请时间", type: "datetime", column: { width: 180 } },
},
},
};
}
@@ -0,0 +1,74 @@
<template>
<fs-page class="page-sys-invite-setting">
<template #header>
<div class="title">邀请返佣设置</div>
</template>
<div class="page-body">
<a-form ref="formRef" :model="settings" :label-col="{ style: { width: '140px' } }" class="settings-form">
<a-form-item label="开启返佣" name="enabled">
<a-switch v-model:checked="settings.enabled" />
</a-form-item>
<a-form-item label="返佣比例" name="commissionRate">
<a-input-number v-model:value="settings.commissionRate" :min="0" :max="100" addon-after="%" />
</a-form-item>
<a-form-item label="最低提现金额" name="minWithdrawAmountYuan">
<a-input-number v-model:value="settings.minWithdrawAmountYuan" :min="0" addon-after="" />
</a-form-item>
<a-form-item label="提现渠道" name="withdrawChannels">
<a-checkbox-group v-model:value="settings.withdrawChannels" :options="withdrawChannelOptions" />
</a-form-item>
<a-form-item label=" ">
<a-button type="primary" @click="saveSettings">保存设置</a-button>
</a-form-item>
</a-form>
</div>
</fs-page>
</template>
<script lang="ts" setup>
import { onMounted, reactive } from "vue";
import { notification } from "ant-design-vue";
import * as api from "./api";
import { util } from "/@/utils";
import { useSettingStore } from "/@/store/settings";
defineOptions({ name: "SysInviteCommissionSetting" });
const settings = reactive<any>({ enabled: false, commissionRate: 0, minWithdrawAmountYuan: 0, withdrawChannels: ["alipay", "bank"] });
const withdrawChannelOptions = [
{ label: "支付宝", value: "alipay" },
{ label: "银行卡", value: "bank" },
];
async function loadSettings() {
const data: any = await api.GetSettings();
settings.enabled = !!data?.enabled;
settings.commissionRate = data?.commissionRate || 0;
settings.minWithdrawAmountYuan = util.amount.toYuan(data?.minWithdrawAmount || 0);
settings.withdrawChannels = data?.withdrawChannels?.length ? data.withdrawChannels : ["alipay", "bank"];
}
async function saveSettings() {
await api.SaveSettings({
enabled: settings.enabled,
commissionRate: settings.commissionRate || 0,
minWithdrawAmount: util.amount.toCent(settings.minWithdrawAmountYuan || 0),
withdrawChannels: settings.withdrawChannels || [],
});
await useSettingStore().loadSysSettings();
notification.success({ message: "保存成功" });
}
onMounted(loadSettings);
</script>
<style lang="less">
.page-sys-invite-setting {
.page-body {
padding: 20px;
}
.settings-form {
max-width: 720px;
}
}
</style>
@@ -0,0 +1,25 @@
<template>
<fs-page class="page-sys-invite-withdraw">
<template #header>
<div class="title">提现申请记录</div>
</template>
<fs-crud ref="crudRef" v-bind="crudBinding" />
</fs-page>
</template>
<script lang="ts" setup>
import { onActivated, onMounted } from "vue";
import { useFs } from "@fast-crud/fast-crud";
import createCrudOptions from "./crud-withdraw";
defineOptions({ name: "SysInviteWithdraw" });
const { crudBinding, crudRef, crudExpose } = useFs({ createCrudOptions });
onMounted(() => {
crudExpose.doRefresh();
});
onActivated(() => {
crudExpose.doRefresh();
});
</script>
@@ -174,6 +174,30 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
}, },
}, },
}, },
rebateAmount: {
title: "返利抵扣",
type: "number",
column: {
width: 110,
component: {
name: PriceInput,
vModel: "modelValue",
edit: false,
},
},
},
thirdPartyPayAmount: {
title: "实付金额",
type: "number",
column: {
width: 110,
component: {
name: PriceInput,
vModel: "modelValue",
edit: false,
},
},
},
status: { status: {
title: "状态", title: "状态",
search: { show: true }, search: { show: true },
@@ -200,6 +224,7 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
{ label: "支付宝", value: "alipay" }, { label: "支付宝", value: "alipay" },
{ label: "微信", value: "wxpay" }, { label: "微信", value: "wxpay" },
{ label: "免费", value: "free" }, { label: "免费", value: "free" },
{ label: "返利余额", value: "rebate" },
], ],
}), }),
column: { column: {
@@ -0,0 +1,88 @@
ALTER TABLE cd_trade ADD COLUMN rebate_amount bigint NOT NULL DEFAULT 0;
ALTER TABLE cd_trade ADD COLUMN third_party_pay_amount bigint NOT NULL DEFAULT 0;
CREATE TABLE `cd_invite_code`
(
`id` bigint PRIMARY KEY AUTO_INCREMENT NOT NULL,
`user_id` bigint,
`code` varchar(50),
`disabled` boolean NOT NULL DEFAULT false,
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE UNIQUE INDEX `index_invite_code_user_id` ON `cd_invite_code` (`user_id`);
CREATE UNIQUE INDEX `index_invite_code_code` ON `cd_invite_code` (`code`);
CREATE TABLE `cd_invite_relation`
(
`id` bigint PRIMARY KEY AUTO_INCREMENT NOT NULL,
`inviter_user_id` bigint,
`invitee_user_id` bigint,
`invite_code` varchar(50),
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX `index_invite_relation_inviter` ON `cd_invite_relation` (`inviter_user_id`);
CREATE UNIQUE INDEX `index_invite_relation_invitee` ON `cd_invite_relation` (`invitee_user_id`);
CREATE TABLE `cd_user_wallet`
(
`id` bigint PRIMARY KEY AUTO_INCREMENT NOT NULL,
`user_id` bigint,
`available_amount` bigint NOT NULL DEFAULT 0,
`frozen_amount` bigint NOT NULL DEFAULT 0,
`total_income_amount` bigint NOT NULL DEFAULT 0,
`total_consumed_amount` bigint NOT NULL DEFAULT 0,
`total_withdraw_amount` bigint NOT NULL DEFAULT 0,
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE UNIQUE INDEX `index_user_wallet_user_id` ON `cd_user_wallet` (`user_id`);
CREATE TABLE `cd_invite_commission_log`
(
`id` bigint PRIMARY KEY AUTO_INCREMENT NOT NULL,
`user_id` bigint,
`amount` bigint,
`trade_id` bigint,
`invitee_user_id` bigint,
`consume_amount` bigint NOT NULL DEFAULT 0,
`remark` varchar(2048),
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX `index_invite_log_user_id` ON `cd_invite_commission_log` (`user_id`);
CREATE TABLE `cd_user_wallet_log`
(
`id` bigint PRIMARY KEY AUTO_INCREMENT NOT NULL,
`user_id` bigint,
`type` varchar(50),
`amount` bigint,
`balance_after` bigint,
`trade_id` bigint,
`withdraw_id` bigint,
`remark` varchar(2048),
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX `index_user_wallet_log_user_id` ON `cd_user_wallet_log` (`user_id`);
CREATE TABLE `cd_user_wallet_withdraw`
(
`id` bigint PRIMARY KEY AUTO_INCREMENT NOT NULL,
`user_id` bigint,
`amount` bigint,
`status` varchar(50),
`channel` varchar(50),
`real_name` varchar(100),
`account` varchar(200),
`bank_name` varchar(200),
`audit_user_id` bigint,
`audit_remark` varchar(2048),
`audit_time` bigint,
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX `index_user_wallet_withdraw_user_id` ON `cd_user_wallet_withdraw` (`user_id`);
CREATE INDEX `index_user_wallet_withdraw_status` ON `cd_user_wallet_withdraw` (`status`);
@@ -0,0 +1,88 @@
ALTER TABLE cd_trade ADD COLUMN rebate_amount bigint NOT NULL DEFAULT 0;
ALTER TABLE cd_trade ADD COLUMN third_party_pay_amount bigint NOT NULL DEFAULT 0;
CREATE TABLE "cd_invite_code"
(
"id" bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY NOT NULL,
"user_id" bigint,
"code" varchar(50),
"disabled" boolean NOT NULL DEFAULT (false),
"create_time" timestamp NOT NULL DEFAULT (CURRENT_TIMESTAMP),
"update_time" timestamp NOT NULL DEFAULT (CURRENT_TIMESTAMP)
);
CREATE UNIQUE INDEX "index_invite_code_user_id" ON "cd_invite_code" ("user_id");
CREATE UNIQUE INDEX "index_invite_code_code" ON "cd_invite_code" ("code");
CREATE TABLE "cd_invite_relation"
(
"id" bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY NOT NULL,
"inviter_user_id" bigint,
"invitee_user_id" bigint,
"invite_code" varchar(50),
"create_time" timestamp NOT NULL DEFAULT (CURRENT_TIMESTAMP),
"update_time" timestamp NOT NULL DEFAULT (CURRENT_TIMESTAMP)
);
CREATE INDEX "index_invite_relation_inviter" ON "cd_invite_relation" ("inviter_user_id");
CREATE UNIQUE INDEX "index_invite_relation_invitee" ON "cd_invite_relation" ("invitee_user_id");
CREATE TABLE "cd_user_wallet"
(
"id" bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY NOT NULL,
"user_id" bigint,
"available_amount" bigint NOT NULL DEFAULT 0,
"frozen_amount" bigint NOT NULL DEFAULT 0,
"total_income_amount" bigint NOT NULL DEFAULT 0,
"total_consumed_amount" bigint NOT NULL DEFAULT 0,
"total_withdraw_amount" bigint NOT NULL DEFAULT 0,
"create_time" timestamp NOT NULL DEFAULT (CURRENT_TIMESTAMP),
"update_time" timestamp NOT NULL DEFAULT (CURRENT_TIMESTAMP)
);
CREATE UNIQUE INDEX "index_user_wallet_user_id" ON "cd_user_wallet" ("user_id");
CREATE TABLE "cd_invite_commission_log"
(
"id" bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY NOT NULL,
"user_id" bigint,
"amount" bigint,
"trade_id" bigint,
"invitee_user_id" bigint,
"consume_amount" bigint NOT NULL DEFAULT 0,
"remark" varchar(2048),
"create_time" timestamp NOT NULL DEFAULT (CURRENT_TIMESTAMP),
"update_time" timestamp NOT NULL DEFAULT (CURRENT_TIMESTAMP)
);
CREATE INDEX "index_invite_log_user_id" ON "cd_invite_commission_log" ("user_id");
CREATE TABLE "cd_user_wallet_log"
(
"id" bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY NOT NULL,
"user_id" bigint,
"type" varchar(50),
"amount" bigint,
"balance_after" bigint,
"trade_id" bigint,
"withdraw_id" bigint,
"remark" varchar(2048),
"create_time" timestamp NOT NULL DEFAULT (CURRENT_TIMESTAMP),
"update_time" timestamp NOT NULL DEFAULT (CURRENT_TIMESTAMP)
);
CREATE INDEX "index_user_wallet_log_user_id" ON "cd_user_wallet_log" ("user_id");
CREATE TABLE "cd_user_wallet_withdraw"
(
"id" bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY NOT NULL,
"user_id" bigint,
"amount" bigint,
"status" varchar(50),
"channel" varchar(50),
"real_name" varchar(100),
"account" varchar(200),
"bank_name" varchar(200),
"audit_user_id" bigint,
"audit_remark" varchar(2048),
"audit_time" bigint,
"create_time" timestamp NOT NULL DEFAULT (CURRENT_TIMESTAMP),
"update_time" timestamp NOT NULL DEFAULT (CURRENT_TIMESTAMP)
);
CREATE INDEX "index_user_wallet_withdraw_user_id" ON "cd_user_wallet_withdraw" ("user_id");
CREATE INDEX "index_user_wallet_withdraw_status" ON "cd_user_wallet_withdraw" ("status");
@@ -0,0 +1,88 @@
ALTER TABLE cd_trade ADD COLUMN rebate_amount integer NOT NULL DEFAULT 0;
ALTER TABLE cd_trade ADD COLUMN third_party_pay_amount integer NOT NULL DEFAULT 0;
CREATE TABLE "cd_invite_code"
(
"id" integer PRIMARY KEY AUTOINCREMENT NOT NULL,
"user_id" integer,
"code" varchar(50),
"disabled" boolean NOT NULL DEFAULT (false),
"create_time" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP),
"update_time" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP)
);
CREATE UNIQUE INDEX "index_invite_code_user_id" ON "cd_invite_code" ("user_id");
CREATE UNIQUE INDEX "index_invite_code_code" ON "cd_invite_code" ("code");
CREATE TABLE "cd_invite_relation"
(
"id" integer PRIMARY KEY AUTOINCREMENT NOT NULL,
"inviter_user_id" integer,
"invitee_user_id" integer,
"invite_code" varchar(50),
"create_time" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP),
"update_time" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP)
);
CREATE INDEX "index_invite_relation_inviter" ON "cd_invite_relation" ("inviter_user_id");
CREATE UNIQUE INDEX "index_invite_relation_invitee" ON "cd_invite_relation" ("invitee_user_id");
CREATE TABLE "cd_user_wallet"
(
"id" integer PRIMARY KEY AUTOINCREMENT NOT NULL,
"user_id" integer,
"available_amount" integer NOT NULL DEFAULT 0,
"frozen_amount" integer NOT NULL DEFAULT 0,
"total_income_amount" integer NOT NULL DEFAULT 0,
"total_consumed_amount" integer NOT NULL DEFAULT 0,
"total_withdraw_amount" integer NOT NULL DEFAULT 0,
"create_time" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP),
"update_time" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP)
);
CREATE UNIQUE INDEX "index_user_wallet_user_id" ON "cd_user_wallet" ("user_id");
CREATE TABLE "cd_invite_commission_log"
(
"id" integer PRIMARY KEY AUTOINCREMENT NOT NULL,
"user_id" integer,
"amount" integer,
"trade_id" integer,
"invitee_user_id" integer,
"consume_amount" integer NOT NULL DEFAULT 0,
"remark" varchar(2048),
"create_time" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP),
"update_time" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP)
);
CREATE INDEX "index_invite_log_user_id" ON "cd_invite_commission_log" ("user_id");
CREATE TABLE "cd_user_wallet_log"
(
"id" integer PRIMARY KEY AUTOINCREMENT NOT NULL,
"user_id" integer,
"type" varchar(50),
"amount" integer,
"balance_after" integer,
"trade_id" integer,
"withdraw_id" integer,
"remark" varchar(2048),
"create_time" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP),
"update_time" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP)
);
CREATE INDEX "index_user_wallet_log_user_id" ON "cd_user_wallet_log" ("user_id");
CREATE TABLE "cd_user_wallet_withdraw"
(
"id" integer PRIMARY KEY AUTOINCREMENT NOT NULL,
"user_id" integer,
"amount" integer,
"status" varchar(50),
"channel" varchar(50),
"real_name" varchar(100),
"account" varchar(200),
"bank_name" varchar(200),
"audit_user_id" integer,
"audit_remark" varchar(2048),
"audit_time" integer,
"create_time" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP),
"update_time" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP)
);
CREATE INDEX "index_user_wallet_withdraw_user_id" ON "cd_user_wallet_withdraw" ("user_id");
CREATE INDEX "index_user_wallet_withdraw_status" ON "cd_user_wallet_withdraw" ("status");
@@ -64,6 +64,7 @@ export class LoginController extends BaseController {
mobile: body.mobile, mobile: body.mobile,
smsCode: body.smsCode, smsCode: body.smsCode,
randomStr: body.randomStr, randomStr: body.randomStr,
inviteCode: body.inviteCode,
}); });
this.writeTokenCookie(token); this.writeTokenCookie(token);
@@ -3,6 +3,7 @@ import { BaseController, Constants, SysSettingsService } from '@certd/lib-server
import { RegisterType, UserService } from '../../../modules/sys/authority/service/user-service.js'; import { RegisterType, UserService } from '../../../modules/sys/authority/service/user-service.js';
import { CodeService } from '../../../modules/basic/service/code-service.js'; import { CodeService } from '../../../modules/basic/service/code-service.js';
import { checkComm, checkPlus } from '@certd/plus-core'; import { checkComm, checkPlus } from '@certd/plus-core';
import { InviteService } from '@certd/commercial-core';
export type RegisterReq = { export type RegisterReq = {
type: RegisterType; type: RegisterType;
@@ -14,6 +15,7 @@ export type RegisterReq = {
validateCode: string; validateCode: string;
captcha:any; captcha:any;
inviteCode?: string;
}; };
/** /**
@@ -29,6 +31,9 @@ export class RegisterController extends BaseController {
@Inject() @Inject()
sysSettingsService: SysSettingsService; sysSettingsService: SysSettingsService;
@Inject()
inviteService: InviteService;
@Post('/register', { description: Constants.per.guest }) @Post('/register', { description: Constants.per.guest })
public async register( public async register(
@Body(ALL) @Body(ALL)
@@ -53,10 +58,13 @@ export class RegisterController extends BaseController {
} }
await this.codeService.checkCaptcha(body.captcha,{remoteIp}); await this.codeService.checkCaptcha(body.captcha,{remoteIp});
const newUser = await this.userService.register(body.type, { const registerUser = {
username: body.username, username: body.username,
password: body.password, password: body.password,
} as any); } as any;
const newUser = await this.userService.register(body.type, registerUser, async txManager => {
await this.inviteService.bindInvitee(registerUser.id, body.inviteCode, txManager);
});
return this.ok(newUser); return this.ok(newUser);
} else if (body.type === 'mobile') { } else if (body.type === 'mobile') {
if (sysPublicSettings.mobileRegisterEnabled === false) { if (sysPublicSettings.mobileRegisterEnabled === false) {
@@ -70,12 +78,15 @@ export class RegisterController extends BaseController {
smsCode: body.validateCode, smsCode: body.validateCode,
throwError: true, throwError: true,
}); });
const newUser = await this.userService.register(body.type, { const registerUser = {
username: body.username, username: body.username,
phoneCode: body.phoneCode, phoneCode: body.phoneCode,
mobile: body.mobile, mobile: body.mobile,
password: body.password, password: body.password,
} as any); } as any;
const newUser = await this.userService.register(body.type, registerUser, async txManager => {
await this.inviteService.bindInvitee(registerUser.id, body.inviteCode, txManager);
});
return this.ok(newUser); return this.ok(newUser);
} else if (body.type === 'email') { } else if (body.type === 'email') {
if (sysPublicSettings.emailRegisterEnabled === false) { if (sysPublicSettings.emailRegisterEnabled === false) {
@@ -87,11 +98,14 @@ export class RegisterController extends BaseController {
validateCode: body.validateCode, validateCode: body.validateCode,
throwError: true, throwError: true,
}); });
const newUser = await this.userService.register(body.type, { const registerUser = {
username: body.username, username: body.username,
email: body.email, email: body.email,
password: body.password, password: body.password,
} as any); } as any;
const newUser = await this.userService.register(body.type, registerUser, async txManager => {
await this.inviteService.bindInvitee(registerUser.id, body.inviteCode, txManager);
});
return this.ok(newUser); return this.ok(newUser);
} }
} }
@@ -11,6 +11,7 @@ import {
SysSuiteSetting SysSuiteSetting
} from "@certd/lib-server"; } from "@certd/lib-server";
import { AppKey, getPlusInfo, isComm } from "@certd/plus-core"; import { AppKey, getPlusInfo, isComm } from "@certd/plus-core";
import { SysInviteCommissionSetting } from "@certd/commercial-core";
import { cloneDeep } from "lodash-es"; import { cloneDeep } from "lodash-es";
import { getVersion } from "../../utils/version.js"; import { getVersion } from "../../utils/version.js";
import { http } from "@certd/basic"; import { http } from "@certd/basic";
@@ -57,6 +58,16 @@ export class BasicSettingsController extends BaseController {
}; };
} }
public async getInviteSetting() {
if (!isComm()) {
return { enabled: false };
}
const setting = await this.sysSettingsService.getSetting<SysInviteCommissionSetting>(SysInviteCommissionSetting);
return {
enabled: setting.enabled,
};
}
public async getSiteEnv() { public async getSiteEnv() {
const env: SysSiteEnv = { const env: SysSiteEnv = {
agent: this.agentConfig agent: this.agentConfig
@@ -92,6 +103,7 @@ export class BasicSettingsController extends BaseController {
const plusInfo = await this.plusInfo(); const plusInfo = await this.plusInfo();
const headerMenus = await this.getHeaderMenus(); const headerMenus = await this.getHeaderMenus();
const suiteSetting = await this.getSuiteSetting(); const suiteSetting = await this.getSuiteSetting();
const inviteSetting = await this.getInviteSetting();
const version = await getVersion(); const version = await getVersion();
return this.ok({ return this.ok({
sysPublic, sysPublic,
@@ -101,6 +113,7 @@ export class BasicSettingsController extends BaseController {
plusInfo, plusInfo,
headerMenus, headerMenus,
suiteSetting, suiteSetting,
inviteSetting,
app: { app: {
time: new Date().getTime(), time: new Date().getTime(),
version version
@@ -19,6 +19,7 @@ import { isPlus } from "@certd/plus-core";
import { AddonService } from "@certd/lib-server"; import { AddonService } from "@certd/lib-server";
import { OauthBoundService } from "./oauth-bound-service.js"; import { OauthBoundService } from "./oauth-bound-service.js";
import { PasskeyService } from "./passkey-service.js"; import { PasskeyService } from "./passkey-service.js";
import { InviteService } from "@certd/commercial-core";
/** /**
*/ */
@@ -49,6 +50,9 @@ export class LoginService {
@Inject() @Inject()
passkeyService: PasskeyService; passkeyService: PasskeyService;
@Inject()
inviteService: InviteService;
checkIsBlocked(username: string) { checkIsBlocked(username: string) {
const blockDurationKey = `login_block_duration:${username}`; const blockDurationKey = `login_block_duration:${username}`;
const value = cache.get(blockDurationKey); const value = cache.get(blockDurationKey);
@@ -111,7 +115,7 @@ export class LoginService {
} }
async loginBySmsCode(req: { mobile: string; phoneCode: string; smsCode: string; randomStr: string }) { async loginBySmsCode(req: { mobile: string; phoneCode: string; smsCode: string; randomStr: string; inviteCode?: string }) {
this.checkIsBlocked(req.mobile) this.checkIsBlocked(req.mobile)
@@ -129,11 +133,14 @@ export class LoginService {
let info = await this.userService.findOne({phoneCode, mobile: mobile}); let info = await this.userService.findOne({phoneCode, mobile: mobile});
if (info == null) { if (info == null) {
//用户不存在,注册 //用户不存在,注册
info = await this.userService.register('mobile', { const registerUser = {
phoneCode, phoneCode,
mobile, mobile,
password: '', password: '',
} as any); } as any;
info = await this.userService.register('mobile', registerUser, async txManager => {
await this.inviteService.bindInvitee(registerUser.id, req.inviteCode, txManager);
});
} }
this.clearCacheOnSuccess(mobile); this.clearCacheOnSuccess(mobile);
return this.onLoginSuccess(info); return this.onLoginSuccess(info);
@@ -264,4 +271,3 @@ export class LoginService {
const user = await this.passkeyService.loginByPasskey(credential, challenge, ctx); const user = await this.passkeyService.loginByPasskey(credential, challenge, ctx);
return this.generateToken(user); return this.generateToken(user);
}} }}