This commit is contained in:
张成
2026-03-24 17:03:54 +08:00
parent 268520a0f2
commit 5b654824b4
25 changed files with 799 additions and 111 deletions

View File

@@ -0,0 +1,59 @@
-- =============================================================================
-- sys_menu 订阅模块菜单插入脚本(字段与 api/model/sys_menu.js 一致)
-- 执行前请备份。若已存在同名「订阅管理」父菜单,请先删除子菜单再删父级,或改下面名称。
-- 若数据库表另有 created_at / updated_at 等列,请在 INSERT 中补全或给默认值。
-- =============================================================================
-- -----------------------------------------------------------------------------
-- 方案一(推荐):一级目录「订阅管理」+ 子菜单parent_id 指向父记录 id
-- -----------------------------------------------------------------------------
INSERT INTO sys_menu
(name, parent_id, icon, path, type, model_id, form_id, component, api_path, is_show_menu, is_show, sort)
VALUES
(
'订阅管理',
0,
'ios-apps',
'/subscription',
'菜单',
0,
0,
'',
'',
1,
1,
900
);
SET @sub_parent_id = LAST_INSERT_ID();
INSERT INTO sys_menu
(name, parent_id, icon, path, type, model_id, form_id, component, api_path, is_show_menu, is_show, sort)
VALUES
('运营看板', @sub_parent_id, 'ios-speedometer', '/subscription/dashboard', '页面', 0, 0, 'subscription/dashboard', '', 1, 1, 10),
('业务用户', @sub_parent_id, 'ios-people', '/subscription/user', '页面', 0, 0, 'subscription/user', '', 1, 1, 20),
('套餐管理', @sub_parent_id, 'ios-pricetags', '/subscription/plan', '页面', 0, 0, 'subscription/plan', '', 1, 1, 30),
('订阅列表', @sub_parent_id, 'ios-list', '/subscription/subscription', '页面', 0, 0, 'subscription/subscription', '', 1, 1, 40),
('API Token', @sub_parent_id, 'ios-key', '/subscription/token', '页面', 0, 0, 'subscription/token', '', 1, 1, 50),
('支付确认', @sub_parent_id, 'ios-cash', '/subscription/payment', '页面', 0, 0, 'subscription/payment', '', 1, 1, 60),
('月用量', @sub_parent_id, 'ios-analytics', '/subscription/usage', '页面', 0, 0, 'subscription/usage', '', 1, 1, 70),
('审计日志', @sub_parent_id, 'ios-paper', '/subscription/audit', '页面', 0, 0, 'subscription/audit', '', 1, 1, 80);
-- -----------------------------------------------------------------------------
-- 方案二(可选):全部挂在根节点 parent_id=0无父级目录与 component-map 仍一致)
-- 若已执行方案一,请勿再执行下面语句,避免重复菜单。
-- -----------------------------------------------------------------------------
/*
INSERT INTO sys_menu
(name, parent_id, icon, path, type, model_id, form_id, component, api_path, is_show_menu, is_show, sort)
VALUES
('运营看板', 0, 'ios-speedometer', '/subscription/dashboard', '页面', 0, 0, 'subscription/dashboard', '', 1, 1, 910),
('业务用户', 0, 'ios-people', '/subscription/user', '页面', 0, 0, 'subscription/user', '', 1, 1, 920),
('套餐管理', 0, 'ios-pricetags', '/subscription/plan', '页面', 0, 0, 'subscription/plan', '', 1, 1, 930),
('订阅列表', 0, 'ios-list', '/subscription/subscription', '页面', 0, 0, 'subscription/subscription', '', 1, 1, 940),
('API Token', 0, 'ios-key', '/subscription/token', '页面', 0, 0, 'subscription/token', '', 1, 1, 950),
('支付确认', 0, 'ios-cash', '/subscription/payment', '页面', 0, 0, 'subscription/payment', '', 1, 1, 960),
('月用量', 0, 'ios-analytics', '/subscription/usage', '页面', 0, 0, 'subscription/usage', '', 1, 1, 970),
('审计日志', 0, 'ios-paper', '/subscription/audit', '页面', 0, 0, 'subscription/audit', '', 1, 1, 980);
*/

View File

@@ -0,0 +1,6 @@
订阅模块数据库脚本建议执行顺序:
1. 001_biz_schema.sql — 业务表(用户/套餐/订阅/Token/月用量)
2. 003_biz_audit.sql — 审计表 biz_audit_log
3. 002_biz_menu_seed.sql — 管理端菜单(按实际 sys_menu 表结构调整列后执行)
说明:若 002 与现有 sys_menu 字段不一致,请在库中对照 sys_menu 结构增删列后再插入。

View File

@@ -119,11 +119,11 @@
| 页面 | 路由 component key示例 | 功能 | | 页面 | 路由 component key示例 | 功能 |
|------|------------------------------|------| |------|------------------------------|------|
| 用户列表/编辑 | `biz/user` | 表格 + 搜索 + 抽屉表单 | | 用户列表/编辑 | `subscription/user` | 表格 + 搜索 + 抽屉表单 |
| 套餐列表/编辑 | `biz/plan` | JSON 功能点编辑器(简易 textarea 或 key-value | | 套餐列表/编辑 | `subscription/plan` | JSON 功能点编辑器(简易 textarea 或 key-value |
| 订阅操作 | `biz/subscription` | 开通/升级/续费/取消向导或表单 | | 订阅操作 | `subscription/subscription` | 开通/升级/续费/取消向导或表单 |
| Token | `biz/token` | 列表、创建(展示一次明文)、吊销 | | Token | `subscription/token` | 列表、创建(展示一次明文)、吊销 |
| 支付确认 | `biz/payment` | 线下确认、链接确认表单 | | 支付确认 | `subscription/payment` | 线下确认、链接确认表单 |
菜单数据若来自后端 `sys_menu`,需在库中插入对应菜单项,`component``component-map.js` **key 一致** 菜单数据若来自后端 `sys_menu`,需在库中插入对应菜单项,`component``component-map.js` **key 一致**

View File

@@ -0,0 +1,11 @@
class AuditServer {
async page(row) {
return window.framework.http.post("/biz_audit_log/page", row);
}
async exportRows(row) {
return window.framework.http.post("/biz_audit_log/export", row);
}
}
export default new AuditServer();

View File

@@ -0,0 +1,7 @@
class DashboardServer {
async summary() {
return window.framework.http.get("/biz_dashboard/summary", {});
}
}
export default new DashboardServer();

View File

@@ -1,4 +1,4 @@
class BizPaymentServer { class PaymentServer {
async confirmOffline(row) { async confirmOffline(row) {
return window.framework.http.post("/biz_payment/confirm-offline", row); return window.framework.http.post("/biz_payment/confirm-offline", row);
} }
@@ -8,4 +8,4 @@ class BizPaymentServer {
} }
} }
export default new BizPaymentServer(); export default new PaymentServer();

View File

@@ -1,4 +1,4 @@
class BizPlanServer { class PlanServer {
async page(row) { async page(row) {
return window.framework.http.post("/biz_plan/page", row); return window.framework.http.post("/biz_plan/page", row);
} }
@@ -22,6 +22,10 @@ class BizPlanServer {
async all() { async all() {
return window.framework.http.get("/biz_plan/all", {}); return window.framework.http.get("/biz_plan/all", {});
} }
async exportRows(row) {
return window.framework.http.post("/biz_plan/export", row);
}
} }
export default new BizPlanServer(); export default new PlanServer();

View File

@@ -1,4 +1,4 @@
class BizSubscriptionServer { class SubscriptionsServer {
async page(row) { async page(row) {
return window.framework.http.post("/biz_subscription/page", row); return window.framework.http.post("/biz_subscription/page", row);
} }
@@ -22,6 +22,10 @@ class BizSubscriptionServer {
async cancel(row) { async cancel(row) {
return window.framework.http.post("/biz_subscription/cancel", row); return window.framework.http.post("/biz_subscription/cancel", row);
} }
async exportRows(row) {
return window.framework.http.post("/biz_subscription/export", row);
}
} }
export default new BizSubscriptionServer(); export default new SubscriptionsServer();

View File

@@ -1,4 +1,4 @@
class BizTokenServer { class TokenServer {
async page(row) { async page(row) {
return window.framework.http.post("/biz_token/page", row); return window.framework.http.post("/biz_token/page", row);
} }
@@ -10,6 +10,10 @@ class BizTokenServer {
async revoke(row) { async revoke(row) {
return window.framework.http.post("/biz_token/revoke", row); return window.framework.http.post("/biz_token/revoke", row);
} }
async exportRows(row) {
return window.framework.http.post("/biz_token/export", row);
}
} }
export default new BizTokenServer(); export default new TokenServer();

View File

@@ -0,0 +1,23 @@
class UsageServer {
async page(row) {
return window.framework.http.post("/biz_usage/page", row);
}
async add(row) {
return window.framework.http.post("/biz_usage/add", row);
}
async edit(row) {
return window.framework.http.post("/biz_usage/edit", row);
}
async del(row) {
return window.framework.http.post("/biz_usage/del", row);
}
async exportRows(row) {
return window.framework.http.post("/biz_usage/export", row);
}
}
export default new UsageServer();

View File

@@ -1,4 +1,4 @@
class BizUserServer { class UserServer {
async page(row) { async page(row) {
return window.framework.http.post("/biz_user/page", row); return window.framework.http.post("/biz_user/page", row);
} }
@@ -22,6 +22,14 @@ class BizUserServer {
async disable(row) { async disable(row) {
return window.framework.http.post("/biz_user/disable", row); return window.framework.http.post("/biz_user/disable", row);
} }
async exportRows(row) {
return window.framework.http.post("/biz_user/export", row);
}
async revokeAllTokens(row) {
return window.framework.http.post("/biz_user/revoke_all_tokens", row);
}
} }
export default new BizUserServer(); export default new UserServer();

View File

@@ -1,18 +1,24 @@
// 组件映射表:后端菜单返回的 component 路径需与此处 key 一致(不含 .vue // 组件映射表:后端菜单返回的 component 路径需与此处 key 一致(不含 .vue
import TestPage from '../views/test/test.vue' import TestPage from '../views/test/test.vue'
import BizUsers from '../views/biz/biz_users.vue' import SubscriptionDashboard from '../views/subscription/dashboard.vue'
import BizPlans from '../views/biz/biz_plans.vue' import SubscriptionUsers from '../views/subscription/users.vue'
import BizSubscriptions from '../views/biz/biz_subscriptions.vue' import SubscriptionPlans from '../views/subscription/plans.vue'
import BizTokens from '../views/biz/biz_tokens.vue' import SubscriptionRecords from '../views/subscription/subscriptions.vue'
import BizPayment from '../views/biz/biz_payment.vue' import SubscriptionTokens from '../views/subscription/tokens.vue'
import SubscriptionPayment from '../views/subscription/payment.vue'
import SubscriptionUsage from '../views/subscription/usage.vue'
import SubscriptionAuditLog from '../views/subscription/audit_log.vue'
const componentMap = { const componentMap = {
'test/test': TestPage, 'test/test': TestPage,
'biz/user': BizUsers, 'subscription/dashboard': SubscriptionDashboard,
'biz/plan': BizPlans, 'subscription/user': SubscriptionUsers,
'biz/subscription': BizSubscriptions, 'subscription/plan': SubscriptionPlans,
'biz/token': BizTokens, 'subscription/subscription': SubscriptionRecords,
'biz/payment': BizPayment, 'subscription/token': SubscriptionTokens,
'subscription/payment': SubscriptionPayment,
'subscription/usage': SubscriptionUsage,
'subscription/audit': SubscriptionAuditLog,
} }
export default componentMap; export default componentMap;

View File

@@ -0,0 +1,24 @@
/** 将对象数组导出为 CSV 并触发浏览器下载(兼容 POST /export 返回的 rows */
export function downloadCsvFromRows(rows, filename = "export.csv") {
if (!rows || !rows.length) {
return false;
}
const keys = Object.keys(rows[0]);
const esc = (v) => {
const s = v === null || v === undefined ? "" : String(v);
if (/[",\n\r]/.test(s)) return `"${s.replace(/"/g, '""')}"`;
return s;
};
const lines = [keys.join(",")];
for (const r of rows) {
lines.push(keys.map((k) => esc(r[k])).join(","));
}
const blob = new Blob(["\ufeff" + lines.join("\n")], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
return true;
}

View File

@@ -0,0 +1,131 @@
<template>
<div class="sub-page">
<div class="sub-toolbar">
<h2 class="sub-title">审计日志</h2>
<Button type="primary" @click="load(1)">刷新</Button>
<Button class="ml8" @click="doExport">导出 CSV</Button>
</div>
<div class="sub-search">
<Form inline>
<FormItem label="动作">
<Select v-model="param.seachOption.key" style="width: 140px">
<Option value="action">action</Option>
<Option value="resource_type">resource_type</Option>
</Select>
<Input v-model="param.seachOption.value" class="ml8" style="width: 220px" placeholder="模糊/精确" />
</FormItem>
<Button type="primary" @click="load(1)">查询</Button>
</Form>
</div>
<Table :columns="columns" :data="rows" border stripe />
<div class="sub-page-bar">
<Page
:total="total"
:current="param.pageOption.page"
:page-size="param.pageOption.pageSize"
show-total
@on-change="onPage"
@on-page-size-change="onSize"
/>
</div>
</div>
</template>
<script>
import auditServer from '@/api/subscription/audit_server.js'
import { downloadCsvFromRows } from '@/utils/csvExport.js'
export default {
name: 'SubscriptionAuditLog',
data() {
return {
rows: [],
total: 0,
param: {
seachOption: { key: 'action', value: '' },
pageOption: { page: 1, pageSize: 20, total: 0 },
},
}
},
computed: {
columns() {
return [
{ title: 'ID', key: 'id', width: 80 },
{ title: '后台用户', key: 'admin_user_id', width: 100 },
{ title: '业务用户', key: 'biz_user_id', width: 100 },
{ title: '动作', key: 'action', minWidth: 160 },
{ title: '资源类型', key: 'resource_type', width: 120 },
{ title: '资源ID', key: 'resource_id', width: 90 },
{
title: '详情',
key: 'detail',
minWidth: 200,
render: (h, p) => {
const d = p.row.detail
const s = d == null ? '' : typeof d === 'string' ? d : JSON.stringify(d)
return h('span', { attrs: { title: s } }, s.length > 80 ? s.slice(0, 80) + '…' : s)
},
},
{ title: '时间', key: 'created_at', minWidth: 160 },
]
},
},
mounted() {
this.load(1)
},
methods: {
async load(page) {
if (page) this.param.pageOption.page = page
const res = await auditServer.page({ param: this.param })
if (res && res.code === 0) {
this.rows = res.data.rows || []
this.total = res.data.count || 0
} else {
this.$Message.error((res && res.message) || '加载失败')
}
},
onPage(p) {
this.param.pageOption.page = p
this.load()
},
onSize(s) {
this.param.pageOption.pageSize = s
this.load(1)
},
async doExport() {
const res = await auditServer.exportRows({ param: this.param })
if (res && res.code === 0 && res.data && res.data.rows) {
const rows = res.data.rows.map((r) => ({
...r,
detail: r.detail == null ? '' : typeof r.detail === 'object' ? JSON.stringify(r.detail) : r.detail,
}))
downloadCsvFromRows(rows, 'audit_log.csv')
this.$Message.success('已导出')
} else {
this.$Message.error((res && res.message) || '导出失败')
}
},
},
}
</script>
<style scoped>
.sub-page {
padding: 16px;
}
.sub-title {
display: inline-block;
margin: 0 16px 0 0;
font-size: 18px;
}
.ml8 {
margin-left: 8px;
}
.sub-search {
margin-bottom: 12px;
}
.sub-page-bar {
margin-top: 12px;
text-align: right;
}
</style>

View File

@@ -0,0 +1,95 @@
<template>
<div class="sub-page">
<div class="sub-toolbar">
<h2 class="sub-title">订阅运营看板</h2>
<Button type="primary" @click="load">刷新</Button>
</div>
<Row :gutter="16" v-if="stats">
<Col span="6">
<Card dis-hover><p class="lbl">业务用户总数</p><p class="num">{{ stats.users.total }}</p></Card>
</Col>
<Col span="6">
<Card dis-hover><p class="lbl">正常用户</p><p class="num">{{ stats.users.active }}</p></Card>
</Col>
<Col span="6">
<Card dis-hover><p class="lbl">上线套餐数</p><p class="num">{{ stats.plans.active }}</p></Card>
</Col>
<Col span="6">
<Card dis-hover><p class="lbl">有效 Token</p><p class="num">{{ stats.tokens.active }}</p></Card>
</Col>
<Col span="6">
<Card dis-hover><p class="lbl">待支付订阅</p><p class="num">{{ stats.subscriptions.pending }}</p></Card>
</Col>
<Col span="6">
<Card dis-hover><p class="lbl">生效中订阅</p><p class="num">{{ stats.subscriptions.active }}</p></Card>
</Col>
<Col span="6">
<Card dis-hover><p class="lbl">已过期订阅</p><p class="num">{{ stats.subscriptions.expired }}</p></Card>
</Col>
<Col span="6">
<Card dis-hover
><p class="lbl">7 天内到期活跃</p><p class="num warn">{{ stats.subscriptions.renew_within_7d }}</p></Card
>
</Col>
</Row>
<p v-else class="muted">加载中</p>
<p class="muted small">数据时间{{ stats && stats.server_time }}</p>
</div>
</template>
<script>
import dashboardServer from '@/api/subscription/dashboard_server.js'
export default {
name: 'SubscriptionDashboard',
data() {
return {
stats: null,
}
},
mounted() {
this.load()
},
methods: {
async load() {
const res = await dashboardServer.summary()
if (res && res.code === 0) {
this.stats = res.data
} else {
this.$Message.error((res && res.message) || '加载失败')
}
},
},
}
</script>
<style scoped>
.sub-page {
padding: 16px;
}
.sub-title {
display: inline-block;
margin: 0 16px 0 0;
font-size: 18px;
}
.lbl {
margin: 0;
color: #888;
font-size: 13px;
}
.num {
margin: 8px 0 0;
font-size: 26px;
font-weight: 600;
}
.num.warn {
color: #ed4014;
}
.muted {
color: #888;
}
.small {
margin-top: 16px;
font-size: 12px;
}
</style>

View File

@@ -1,11 +1,11 @@
<template> <template>
<div class="biz-page"> <div class="sub-page">
<h2 class="biz-title">支付确认轻量</h2> <h2 class="sub-title">支付确认轻量</h2>
<p class="biz-desc"> <code>pending</code> 订阅置为 <code>active</code>并写入支付单号</p> <p class="sub-desc"> <code>pending</code> 订阅置为 <code>active</code>并写入支付单号</p>
<Card dis-hover title="线下确认" style="max-width: 520px; margin-bottom: 16px"> <Card dis-hover title="线下确认" style="max-width: 520px; margin-bottom: 16px">
<Form :label-width="110"> <Form :label-width="110">
<FormItem label="订阅ID"> <FormItem label="订阅ID">
<Input v-model="offline.subscription_id" type="number" placeholder="biz_subscriptions.id" /> <Input v-model="offline.subscription_id" type="number" placeholder="订阅记录 id" />
</FormItem> </FormItem>
<FormItem label="支付单号"> <FormItem label="支付单号">
<Input v-model="offline.payment_ref" placeholder="流水号/凭证号" /> <Input v-model="offline.payment_ref" placeholder="流水号/凭证号" />
@@ -15,6 +15,10 @@
</FormItem> </FormItem>
</Form> </Form>
</Card> </Card>
<Alert type="info" show-icon style="max-width: 720px; margin-bottom: 16px">
对外鉴权接口免登录见白名单<code>POST /api/auth/verify</code>body 示例
<code>{"token":"明文","feature":"msg","usage_delta":{"msg":1}}</code>
</Alert>
<Card dis-hover title="链接支付确认" style="max-width: 520px"> <Card dis-hover title="链接支付确认" style="max-width: 520px">
<Form :label-width="110"> <Form :label-width="110">
<FormItem label="订阅ID"> <FormItem label="订阅ID">
@@ -32,10 +36,10 @@
</template> </template>
<script> <script>
import bizPaymentServer from '@/api/biz/biz_payment_server.js' import paymentServer from '@/api/subscription/payment_server.js'
export default { export default {
name: 'BizPayment', name: 'SubscriptionPayment',
data() { data() {
return { return {
offline: { subscription_id: '', payment_ref: '' }, offline: { subscription_id: '', payment_ref: '' },
@@ -48,7 +52,7 @@ export default {
async doOffline() { async doOffline() {
this.loading1 = true this.loading1 = true
try { try {
const res = await bizPaymentServer.confirmOffline({ const res = await paymentServer.confirmOffline({
subscription_id: Number(this.offline.subscription_id), subscription_id: Number(this.offline.subscription_id),
payment_ref: this.offline.payment_ref, payment_ref: this.offline.payment_ref,
}) })
@@ -64,7 +68,7 @@ export default {
async doLink() { async doLink() {
this.loading2 = true this.loading2 = true
try { try {
const res = await bizPaymentServer.confirmLink({ const res = await paymentServer.confirmLink({
subscription_id: Number(this.link.subscription_id), subscription_id: Number(this.link.subscription_id),
payment_ref: this.link.payment_ref, payment_ref: this.link.payment_ref,
}) })
@@ -82,14 +86,14 @@ export default {
</script> </script>
<style scoped> <style scoped>
.biz-page { .sub-page {
padding: 16px; padding: 16px;
} }
.biz-title { .sub-title {
margin: 0 0 8px; margin: 0 0 8px;
font-size: 18px; font-size: 18px;
} }
.biz-desc { .sub-desc {
color: #666; color: #666;
margin-bottom: 16px; margin-bottom: 16px;
} }

View File

@@ -1,11 +1,12 @@
<template> <template>
<div class="biz-page"> <div class="sub-page">
<div class="biz-toolbar"> <div class="sub-toolbar">
<h2 class="biz-title">套餐</h2> <h2 class="sub-title">套餐</h2>
<Button type="primary" @click="openEdit(null)">新增套餐</Button> <Button type="primary" @click="openEdit(null)">新增套餐</Button>
<Button class="ml8" @click="load(1)">刷新</Button> <Button class="ml8" @click="load(1)">刷新</Button>
<Button class="ml8" @click="doExport">导出 CSV</Button>
</div> </div>
<div class="biz-search"> <div class="sub-search">
<Form inline :label-width="70"> <Form inline :label-width="70">
<FormItem label="条件"> <FormItem label="条件">
<Select v-model="param.seachOption.key" style="width: 140px"> <Select v-model="param.seachOption.key" style="width: 140px">
@@ -19,7 +20,7 @@
</Form> </Form>
</div> </div>
<Table :columns="columns" :data="rows" border stripe /> <Table :columns="columns" :data="rows" border stripe />
<div class="biz-page-bar"> <div class="sub-page-bar">
<Page <Page
:total="total" :total="total"
:current="param.pageOption.page" :current="param.pageOption.page"
@@ -77,10 +78,11 @@
</template> </template>
<script> <script>
import bizPlanServer from '@/api/biz/biz_plan_server.js' import planServer from '@/api/subscription/plan_server.js'
import { downloadCsvFromRows } from '@/utils/csvExport.js'
export default { export default {
name: 'BizPlans', name: 'SubscriptionPlans',
data() { data() {
return { return {
rows: [], rows: [],
@@ -128,7 +130,7 @@ export default {
methods: { methods: {
async load(page) { async load(page) {
if (page) this.param.pageOption.page = page if (page) this.param.pageOption.page = page
const res = await bizPlanServer.page({ param: this.param }) const res = await planServer.page({ param: this.param })
if (res && res.code === 0) { if (res && res.code === 0) {
this.rows = res.data.rows || [] this.rows = res.data.rows || []
this.total = res.data.count || 0 this.total = res.data.count || 0
@@ -191,7 +193,7 @@ export default {
} }
const payload = { ...this.form, enabled_features } const payload = { ...this.form, enabled_features }
try { try {
const res = this.form.id ? await bizPlanServer.edit(payload) : await bizPlanServer.add(payload) const res = this.form.id ? await planServer.edit(payload) : await planServer.add(payload)
if (res && res.code === 0) { if (res && res.code === 0) {
this.$Message.success('保存成功') this.$Message.success('保存成功')
this.modal = false this.modal = false
@@ -205,7 +207,7 @@ export default {
}) })
}, },
async toggle(row) { async toggle(row) {
const res = await bizPlanServer.toggle({ id: row.id }) const res = await planServer.toggle({ id: row.id })
if (res && res.code === 0) { if (res && res.code === 0) {
this.$Message.success('状态已更新为 ' + (res.data && res.data.status)) this.$Message.success('状态已更新为 ' + (res.data && res.data.status))
this.load() this.load()
@@ -218,7 +220,7 @@ export default {
title: '删除套餐', title: '删除套餐',
content: '确认删除?若已被订阅引用可能失败。', content: '确认删除?若已被订阅引用可能失败。',
onOk: async () => { onOk: async () => {
const res = await bizPlanServer.del({ id: row.id }) const res = await planServer.del({ id: row.id })
if (res && res.code === 0) { if (res && res.code === 0) {
this.$Message.success('已删除') this.$Message.success('已删除')
this.load(1) this.load(1)
@@ -228,18 +230,27 @@ export default {
}, },
}) })
}, },
async doExport() {
const res = await planServer.exportRows({ param: this.param })
if (res && res.code === 0 && res.data && res.data.rows) {
downloadCsvFromRows(res.data.rows, 'plans.csv')
this.$Message.success('已导出')
} else {
this.$Message.error((res && res.message) || '导出失败')
}
},
}, },
} }
</script> </script>
<style scoped> <style scoped>
.biz-page { .sub-page {
padding: 16px; padding: 16px;
} }
.biz-toolbar { .sub-toolbar {
margin-bottom: 12px; margin-bottom: 12px;
} }
.biz-title { .sub-title {
display: inline-block; display: inline-block;
margin: 0 16px 0 0; margin: 0 16px 0 0;
font-size: 18px; font-size: 18px;
@@ -248,10 +259,10 @@ export default {
.ml8 { .ml8 {
margin-left: 8px; margin-left: 8px;
} }
.biz-search { .sub-search {
margin-bottom: 12px; margin-bottom: 12px;
} }
.biz-page-bar { .sub-page-bar {
margin-top: 12px; margin-top: 12px;
text-align: right; text-align: right;
} }

View File

@@ -1,11 +1,12 @@
<template> <template>
<div class="biz-page"> <div class="sub-page">
<div class="biz-toolbar"> <div class="sub-toolbar">
<h2 class="biz-title">订阅</h2> <h2 class="sub-title">订阅</h2>
<Button type="primary" @click="openOpen">开通订阅</Button> <Button type="primary" @click="openOpen">开通订阅</Button>
<Button class="ml8" @click="load(1)">刷新</Button> <Button class="ml8" @click="load(1)">刷新</Button>
<Button class="ml8" @click="doExport">导出 CSV</Button>
</div> </div>
<div class="biz-search"> <div class="sub-search">
<Form inline> <Form inline>
<FormItem label="用户ID"> <FormItem label="用户ID">
<Input v-model="param.seachOption.value" style="width: 140px" placeholder="筛选 user_id" /> <Input v-model="param.seachOption.value" style="width: 140px" placeholder="筛选 user_id" />
@@ -20,7 +21,7 @@
</Form> </Form>
</div> </div>
<Table :columns="columns" :data="rows" border stripe /> <Table :columns="columns" :data="rows" border stripe />
<div class="biz-page-bar"> <div class="sub-page-bar">
<Page <Page
:total="total" :total="total"
:current="param.pageOption.page" :current="param.pageOption.page"
@@ -76,10 +77,11 @@
</template> </template>
<script> <script>
import bizSubscriptionServer from '@/api/biz/biz_subscription_server.js' import subscriptionsServer from '@/api/subscription/subscriptions_server.js'
import { downloadCsvFromRows } from '@/utils/csvExport.js'
export default { export default {
name: 'BizSubscriptions', name: 'SubscriptionRecords',
data() { data() {
return { return {
rows: [], rows: [],
@@ -127,7 +129,7 @@ export default {
methods: { methods: {
async load(page) { async load(page) {
if (page) this.param.pageOption.page = page if (page) this.param.pageOption.page = page
const res = await bizSubscriptionServer.page({ param: this.param }) const res = await subscriptionsServer.page({ param: this.param })
if (res && res.code === 0) { if (res && res.code === 0) {
this.rows = res.data.rows || [] this.rows = res.data.rows || []
this.total = res.data.count || 0 this.total = res.data.count || 0
@@ -176,7 +178,7 @@ export default {
payment_channel: this.openForm.payment_channel || null, payment_channel: this.openForm.payment_channel || null,
payment_ref: this.openForm.payment_ref || null, payment_ref: this.openForm.payment_ref || null,
} }
const res = await bizSubscriptionServer.open(body) const res = await subscriptionsServer.open(body)
if (res && res.code === 0) { if (res && res.code === 0) {
this.$Message.success('已创建订阅') this.$Message.success('已创建订阅')
this.openModal = false this.openModal = false
@@ -197,7 +199,7 @@ export default {
if (!this.currentRow) return if (!this.currentRow) return
this.saving = true this.saving = true
try { try {
const res = await bizSubscriptionServer.renew({ const res = await subscriptionsServer.renew({
subscription_id: this.currentRow.id, subscription_id: this.currentRow.id,
end_time: this.renewForm.end_time, end_time: this.renewForm.end_time,
}) })
@@ -221,7 +223,7 @@ export default {
if (!this.currentRow) return if (!this.currentRow) return
this.saving = true this.saving = true
try { try {
const res = await bizSubscriptionServer.upgrade({ const res = await subscriptionsServer.upgrade({
subscription_id: this.currentRow.id, subscription_id: this.currentRow.id,
new_plan_id: Number(this.upgradeForm.new_plan_id), new_plan_id: Number(this.upgradeForm.new_plan_id),
start_time: this.upgradeForm.start_time || undefined, start_time: this.upgradeForm.start_time || undefined,
@@ -243,7 +245,7 @@ export default {
title: '取消订阅', title: '取消订阅',
content: '确认取消?', content: '确认取消?',
onOk: async () => { onOk: async () => {
const res = await bizSubscriptionServer.cancel({ subscription_id: row.id }) const res = await subscriptionsServer.cancel({ subscription_id: row.id })
if (res && res.code === 0) { if (res && res.code === 0) {
this.$Message.success('已取消') this.$Message.success('已取消')
this.load(1) this.load(1)
@@ -253,18 +255,27 @@ export default {
}, },
}) })
}, },
async doExport() {
const res = await subscriptionsServer.exportRows({ param: this.param })
if (res && res.code === 0 && res.data && res.data.rows) {
downloadCsvFromRows(res.data.rows, 'subscriptions.csv')
this.$Message.success('已导出')
} else {
this.$Message.error((res && res.message) || '导出失败')
}
},
}, },
} }
</script> </script>
<style scoped> <style scoped>
.biz-page { .sub-page {
padding: 16px; padding: 16px;
} }
.biz-toolbar { .sub-toolbar {
margin-bottom: 12px; margin-bottom: 12px;
} }
.biz-title { .sub-title {
display: inline-block; display: inline-block;
margin: 0 16px 0 0; margin: 0 16px 0 0;
font-size: 18px; font-size: 18px;
@@ -273,7 +284,7 @@ export default {
.ml8 { .ml8 {
margin-left: 8px; margin-left: 8px;
} }
.biz-page-bar { .sub-page-bar {
margin-top: 12px; margin-top: 12px;
text-align: right; text-align: right;
} }

View File

@@ -1,11 +1,12 @@
<template> <template>
<div class="biz-page"> <div class="sub-page">
<div class="biz-toolbar"> <div class="sub-toolbar">
<h2 class="biz-title">API Token</h2> <h2 class="sub-title">API Token</h2>
<Button type="primary" @click="openCreate">创建 Token</Button> <Button type="primary" @click="openCreate">创建 Token</Button>
<Button class="ml8" @click="load(1)">刷新</Button> <Button class="ml8" @click="load(1)">刷新</Button>
<Button class="ml8" @click="doExport">导出 CSV</Button>
</div> </div>
<div class="biz-search"> <div class="sub-search">
<Form inline> <Form inline>
<FormItem label="用户ID"> <FormItem label="用户ID">
<Input v-model="param.seachOption.value" style="width: 140px" /> <Input v-model="param.seachOption.value" style="width: 140px" />
@@ -20,7 +21,7 @@
</Form> </Form>
</div> </div>
<Table :columns="columns" :data="rows" border stripe /> <Table :columns="columns" :data="rows" border stripe />
<div class="biz-page-bar"> <div class="sub-page-bar">
<Page <Page
:total="total" :total="total"
:current="param.pageOption.page" :current="param.pageOption.page"
@@ -50,10 +51,11 @@
</template> </template>
<script> <script>
import bizTokenServer from '@/api/biz/biz_token_server.js' import tokenServer from '@/api/subscription/token_server.js'
import { downloadCsvFromRows } from '@/utils/csvExport.js'
export default { export default {
name: 'BizTokens', name: 'SubscriptionTokens',
data() { data() {
return { return {
rows: [], rows: [],
@@ -104,7 +106,7 @@ export default {
methods: { methods: {
async load(page) { async load(page) {
if (page) this.param.pageOption.page = page if (page) this.param.pageOption.page = page
const res = await bizTokenServer.page({ param: this.param }) const res = await tokenServer.page({ param: this.param })
if (res && res.code === 0) { if (res && res.code === 0) {
this.rows = res.data.rows || [] this.rows = res.data.rows || []
this.total = res.data.count || 0 this.total = res.data.count || 0
@@ -133,7 +135,7 @@ export default {
async submitCreate() { async submitCreate() {
this.saving = true this.saving = true
try { try {
const res = await bizTokenServer.create({ const res = await tokenServer.create({
user_id: Number(this.createForm.user_id), user_id: Number(this.createForm.user_id),
token_name: this.createForm.token_name || 'default', token_name: this.createForm.token_name || 'default',
expire_at: this.createForm.expire_at, expire_at: this.createForm.expire_at,
@@ -156,7 +158,7 @@ export default {
title: '吊销 Token', title: '吊销 Token',
content: '确认吊销?', content: '确认吊销?',
onOk: async () => { onOk: async () => {
const res = await bizTokenServer.revoke({ id: row.id }) const res = await tokenServer.revoke({ id: row.id })
if (res && res.code === 0) { if (res && res.code === 0) {
this.$Message.success('已吊销') this.$Message.success('已吊销')
this.load(1) this.load(1)
@@ -166,18 +168,27 @@ export default {
}, },
}) })
}, },
async doExport() {
const res = await tokenServer.exportRows({ param: this.param })
if (res && res.code === 0 && res.data && res.data.rows) {
downloadCsvFromRows(res.data.rows, 'api_tokens.csv')
this.$Message.success('已导出')
} else {
this.$Message.error((res && res.message) || '导出失败')
}
},
}, },
} }
</script> </script>
<style scoped> <style scoped>
.biz-page { .sub-page {
padding: 16px; padding: 16px;
} }
.biz-toolbar { .sub-toolbar {
margin-bottom: 12px; margin-bottom: 12px;
} }
.biz-title { .sub-title {
display: inline-block; display: inline-block;
margin: 0 16px 0 0; margin: 0 16px 0 0;
font-size: 18px; font-size: 18px;
@@ -186,7 +197,7 @@ export default {
.ml8 { .ml8 {
margin-left: 8px; margin-left: 8px;
} }
.biz-page-bar { .sub-page-bar {
margin-top: 12px; margin-top: 12px;
text-align: right; text-align: right;
} }

View File

@@ -0,0 +1,208 @@
<template>
<div class="sub-page">
<div class="sub-toolbar">
<h2 class="sub-title">月用量</h2>
<Button type="primary" @click="openEdit(null)">新增</Button>
<Button class="ml8" @click="load(1)">刷新</Button>
<Button class="ml8" @click="doExport">导出 CSV</Button>
</div>
<div class="sub-search">
<Form inline>
<FormItem label="条件">
<Select v-model="param.seachOption.key" style="width: 140px">
<Option value="user_id">用户ID</Option>
<Option value="stat_month">月份</Option>
<Option value="plan_id">套餐ID</Option>
</Select>
<Input v-model="param.seachOption.value" class="ml8" style="width: 200px" />
</FormItem>
<Button type="primary" @click="load(1)">查询</Button>
</Form>
</div>
<Table :columns="columns" :data="rows" border stripe />
<div class="sub-page-bar">
<Page
:total="total"
:current="param.pageOption.page"
:page-size="param.pageOption.pageSize"
show-total
@on-change="onPage"
@on-page-size-change="onSize"
/>
</div>
<Modal v-model="modal" :title="form.id ? '编辑用量' : '新增用量'" width="640" :loading="saving" @on-ok="save">
<Form ref="formRef" :model="form" :rules="rules" :label-width="120">
<FormItem label="用户ID" prop="user_id"><Input v-model="form.user_id" type="number" /></FormItem>
<FormItem label="套餐ID" prop="plan_id"><Input v-model="form.plan_id" type="number" /></FormItem>
<FormItem label="月份 YYYY-MM" prop="stat_month"><Input v-model="form.stat_month" placeholder="2025-03" /></FormItem>
<FormItem label="msg_count"><Input v-model="form.msg_count" type="number" /></FormItem>
<FormItem label="mass_count"><Input v-model="form.mass_count" type="number" /></FormItem>
<FormItem label="friend_count"><Input v-model="form.friend_count" type="number" /></FormItem>
<FormItem label="sns_count"><Input v-model="form.sns_count" type="number" /></FormItem>
<FormItem label="active_user_count"><Input v-model="form.active_user_count" type="number" /></FormItem>
</Form>
</Modal>
</div>
</template>
<script>
import usageServer from '@/api/subscription/usage_server.js'
import { downloadCsvFromRows } from '@/utils/csvExport.js'
export default {
name: 'SubscriptionUsage',
data() {
return {
rows: [],
total: 0,
param: {
seachOption: { key: 'stat_month', value: '' },
pageOption: { page: 1, pageSize: 20, total: 0 },
},
modal: false,
saving: false,
form: {},
rules: {
user_id: [{ required: true, message: '必填', trigger: 'blur' }],
plan_id: [{ required: true, message: '必填', trigger: 'blur' }],
stat_month: [{ required: true, message: '必填', trigger: 'blur' }],
},
}
},
computed: {
columns() {
return [
{ title: 'ID', key: 'id', width: 70 },
{ title: '用户', key: 'user_id', width: 90 },
{ title: '套餐', key: 'plan_id', width: 90 },
{ title: '月份', key: 'stat_month', width: 100 },
{ title: 'msg', key: 'msg_count', width: 80 },
{ title: 'mass', key: 'mass_count', width: 80 },
{ title: 'friend', key: 'friend_count', width: 80 },
{ title: 'sns', key: 'sns_count', width: 80 },
{ title: 'active_user', key: 'active_user_count', width: 110 },
{
title: '操作',
key: 'a',
width: 140,
render: (h, p) =>
h('div', [
h('Button', { props: { type: 'info', size: 'small' }, on: { click: () => this.openEdit(p.row) } }, '编辑'),
h('Button', { props: { type: 'error', size: 'small' }, class: { ml8: true }, on: { click: () => this.doDel(p.row) } }, '删除'),
]),
},
]
},
},
mounted() {
this.load(1)
},
methods: {
async load(page) {
if (page) this.param.pageOption.page = page
const res = await usageServer.page({ param: this.param })
if (res && res.code === 0) {
this.rows = res.data.rows || []
this.total = res.data.count || 0
} else {
this.$Message.error((res && res.message) || '加载失败')
}
},
onPage(p) {
this.param.pageOption.page = p
this.load()
},
onSize(s) {
this.param.pageOption.pageSize = s
this.load(1)
},
openEdit(row) {
if (row) {
this.form = { ...row }
} else {
this.form = {
user_id: '',
plan_id: '',
stat_month: '',
msg_count: 0,
mass_count: 0,
friend_count: 0,
sns_count: 0,
active_user_count: 0,
}
}
this.modal = true
},
save() {
this.saving = true
this.$refs.formRef.validate(async (ok) => {
if (!ok) {
this.saving = false
return
}
const payload = { ...this.form }
payload.user_id = Number(payload.user_id)
payload.plan_id = Number(payload.plan_id)
try {
const res = this.form.id ? await usageServer.edit(payload) : await usageServer.add(payload)
if (res && res.code === 0) {
this.$Message.success('保存成功')
this.modal = false
this.load(1)
} else {
this.$Message.error((res && res.message) || '失败')
}
} finally {
this.saving = false
}
})
},
doDel(row) {
this.$Modal.confirm({
title: '删除',
content: '确认删除该用量记录?',
onOk: async () => {
const res = await usageServer.del({ id: row.id })
if (res && res.code === 0) {
this.$Message.success('已删除')
this.load(1)
} else {
this.$Message.error((res && res.message) || '失败')
}
},
})
},
async doExport() {
const res = await usageServer.exportRows({ param: this.param })
if (res && res.code === 0 && res.data && res.data.rows) {
downloadCsvFromRows(res.data.rows, 'usage_monthly.csv')
this.$Message.success('已导出')
} else {
this.$Message.error((res && res.message) || '导出失败')
}
},
},
}
</script>
<style scoped>
.sub-page {
padding: 16px;
}
.sub-title {
display: inline-block;
margin: 0 16px 0 0;
font-size: 18px;
}
.ml8 {
margin-left: 8px;
}
.sub-search {
margin-bottom: 12px;
}
.sub-page-bar {
margin-top: 12px;
text-align: right;
}
</style>

View File

@@ -1,11 +1,12 @@
<template> <template>
<div class="biz-page"> <div class="sub-page">
<div class="biz-toolbar"> <div class="sub-toolbar">
<h2 class="biz-title">业务用户</h2> <h2 class="sub-title">业务用户</h2>
<Button type="primary" @click="openEdit(null)">新增</Button> <Button type="primary" @click="openEdit(null)">新增</Button>
<Button class="ml8" @click="load(1)">刷新</Button> <Button class="ml8" @click="load(1)">刷新</Button>
<Button class="ml8" @click="doExport">导出 CSV</Button>
</div> </div>
<div class="biz-search"> <div class="sub-search">
<Form inline :label-width="70"> <Form inline :label-width="70">
<FormItem label="条件"> <FormItem label="条件">
<Select v-model="param.seachOption.key" style="width: 140px"> <Select v-model="param.seachOption.key" style="width: 140px">
@@ -21,7 +22,7 @@
</Form> </Form>
</div> </div>
<Table :columns="columns" :data="rows" border stripe /> <Table :columns="columns" :data="rows" border stripe />
<div class="biz-page-bar"> <div class="sub-page-bar">
<Page <Page
:total="total" :total="total"
:current="param.pageOption.page" :current="param.pageOption.page"
@@ -63,10 +64,11 @@
</template> </template>
<script> <script>
import bizUserServer from '@/api/biz/biz_user_server.js' import userServer from '@/api/subscription/user_server.js'
import { downloadCsvFromRows } from '@/utils/csvExport.js'
export default { export default {
name: 'BizUsers', name: 'SubscriptionUsers',
data() { data() {
return { return {
rows: [], rows: [],
@@ -104,7 +106,7 @@ export default {
{ {
title: '操作', title: '操作',
key: 'a', key: 'a',
width: 220, width: 340,
render: (h, p) => { render: (h, p) => {
return h('div', [ return h('div', [
h( h(
@@ -133,6 +135,15 @@ export default {
}, },
'禁用' '禁用'
), ),
h(
'Button',
{
props: { type: 'default', size: 'small' },
class: { ml8: true },
on: { click: () => this.revokeAllTokens(p.row) },
},
'吊销全部Token'
),
h( h(
'Button', 'Button',
{ {
@@ -154,7 +165,7 @@ export default {
methods: { methods: {
async load(page) { async load(page) {
if (page) this.param.pageOption.page = page if (page) this.param.pageOption.page = page
const res = await bizUserServer.page({ param: this.param }) const res = await userServer.page({ param: this.param })
if (res && res.code === 0) { if (res && res.code === 0) {
this.rows = res.data.rows || [] this.rows = res.data.rows || []
this.total = res.data.count || 0 this.total = res.data.count || 0
@@ -187,8 +198,8 @@ export default {
} }
try { try {
const res = this.form.id const res = this.form.id
? await bizUserServer.edit(this.form) ? await userServer.edit(this.form)
: await bizUserServer.add(this.form) : await userServer.add(this.form)
if (res && res.code === 0) { if (res && res.code === 0) {
this.$Message.success('保存成功') this.$Message.success('保存成功')
this.modal = false this.modal = false
@@ -202,7 +213,7 @@ export default {
}) })
}, },
async showDetail(row) { async showDetail(row) {
const res = await bizUserServer.detail(row.id) const res = await userServer.detail(row.id)
if (res && res.code === 0) { if (res && res.code === 0) {
this.detail = res.data this.detail = res.data
this.detailVisible = true this.detailVisible = true
@@ -215,7 +226,7 @@ export default {
title: '禁用用户', title: '禁用用户',
content: '确认禁用该用户?', content: '确认禁用该用户?',
onOk: async () => { onOk: async () => {
const res = await bizUserServer.disable({ id: row.id }) const res = await userServer.disable({ id: row.id })
if (res && res.code === 0) { if (res && res.code === 0) {
this.$Message.success('已禁用') this.$Message.success('已禁用')
this.load(1) this.load(1)
@@ -230,7 +241,7 @@ export default {
title: '删除用户', title: '删除用户',
content: '确认删除?若存在订阅/Token 可能受外键限制。', content: '确认删除?若存在订阅/Token 可能受外键限制。',
onOk: async () => { onOk: async () => {
const res = await bizUserServer.del({ id: row.id }) const res = await userServer.del({ id: row.id })
if (res && res.code === 0) { if (res && res.code === 0) {
this.$Message.success('已删除') this.$Message.success('已删除')
this.load(1) this.load(1)
@@ -240,18 +251,43 @@ export default {
}, },
}) })
}, },
async doExport() {
const res = await userServer.exportRows({ param: this.param })
if (res && res.code === 0 && res.data && res.data.rows) {
downloadCsvFromRows(res.data.rows, 'users.csv')
this.$Message.success('已导出')
} else {
this.$Message.error((res && res.message) || '导出失败')
}
},
revokeAllTokens(row) {
this.$Modal.confirm({
title: '吊销全部 Token',
content: '将吊销该用户下所有有效 Token是否继续',
onOk: async () => {
const res = await userServer.revokeAllTokens({ user_id: row.id })
if (res && res.code === 0) {
const n = (res.data && res.data.revoked) != null ? res.data.revoked : 0
this.$Message.success('已吊销 ' + n + ' 条')
this.load(1)
} else {
this.$Message.error((res && res.message) || '操作失败')
}
},
})
},
}, },
} }
</script> </script>
<style scoped> <style scoped>
.biz-page { .sub-page {
padding: 16px; padding: 16px;
} }
.biz-toolbar { .sub-toolbar {
margin-bottom: 12px; margin-bottom: 12px;
} }
.biz-title { .sub-title {
display: inline-block; display: inline-block;
margin: 0 16px 0 0; margin: 0 16px 0 0;
font-size: 18px; font-size: 18px;
@@ -260,10 +296,10 @@ export default {
.ml8 { .ml8 {
margin-left: 8px; margin-left: 8px;
} }
.biz-search { .sub-search {
margin-bottom: 12px; margin-bottom: 12px;
} }
.biz-page-bar { .sub-page-bar {
margin-top: 12px; margin-top: 12px;
text-align: right; text-align: right;
} }

View File

@@ -13,11 +13,11 @@ function num(v) {
return Number.isNaN(n) ? 0 : n; return Number.isNaN(n) ? 0 : n;
} }
/** 0 表示无限制(不校验 */ /** 0 表示无限制;已用量超过 limit 则超限(允许用到等于 limit */
function quotaExceeded(used, limit) { function quotaExceeded(used, limit) {
const li = num(limit); const li = num(limit);
if (li <= 0) return false; if (li <= 0) return false;
return num(used) >= li; return num(used) > li;
} }
/** /**

View File

@@ -9,10 +9,10 @@ module.exports = {
// 数据库配置(测试环境) // 数据库配置(测试环境)
// ============================================ // ============================================
"db": { "db": {
"username": "wms_node", "username": "wechat_admin",
"password": "SLrhewk5eePr5Z7Y", "password": "YJEjLXnxJ3WraSiy",
"database": "wms_node", "database": "wechat_admin",
"host": "192.144.167.231", "host": "101.132.75.138",
"port": 3306, "port": 3306,
"dialect": "mysql" "dialect": "mysql"
}, },

View File

@@ -9,10 +9,10 @@ module.exports = {
// 数据库配置(测试环境) // 数据库配置(测试环境)
// ============================================ // ============================================
"db": { "db": {
"username": "wms_node", "username": "wechat_admin",
"password": "SLrhewk5eePr5Z7Y", "password": "YJEjLXnxJ3WraSiy",
"database": "wms_node", "database": "wechat_admin",
"host": "192.144.167.231", "host": "101.132.75.138",
"port": 3306, "port": 3306,
"dialect": "mysql" "dialect": "mysql"
}, },

View File

@@ -36,6 +36,8 @@ class Schedule {
async init() { async init() {
const bizSubscriptionLogic = require("../api/service/biz_subscription_logic"); const bizSubscriptionLogic = require("../api/service/biz_subscription_logic");
const usageSvc = require("../api/service/biz_usage_service");
const baseModel = require("../middleware/baseModel");
node_schedule.scheduleJob("10 0 * * *", async () => { node_schedule.scheduleJob("10 0 * * *", async () => {
await this.execute_with_lock("biz_subscription_expire", async () => { await this.execute_with_lock("biz_subscription_expire", async () => {
@@ -43,6 +45,29 @@ class Schedule {
logs.log(`[定时任务] 订阅到期扫描完成,更新行数: ${n}`); logs.log(`[定时任务] 订阅到期扫描完成,更新行数: ${n}`);
}); });
}); });
node_schedule.scheduleJob("0 9 * * *", async () => {
await this.execute_with_lock("biz_subscription_renew_remind", async () => {
const subs = await baseModel.biz_subscription.findAll({ where: { status: "active" } });
const now = Date.now();
for (const s of subs) {
const end = new Date(s.end_time).getTime();
const days = Math.ceil((end - now) / 86400000);
if (days >= 0 && [7, 3, 1].includes(days)) {
logs.log(
`[续费提醒] subscription_id=${s.id} user_id=${s.user_id}${days} 天后到期`
);
}
}
});
});
node_schedule.scheduleJob("30 0 1 * *", async () => {
await this.execute_with_lock("biz_usage_monthly_init", async () => {
const n = await usageSvc.ensureUsageRowsForCurrentMonth();
logs.log(`[定时任务] 月用量行初始化/补齐,处理订阅数: ${n}`);
});
});
} }
} }