init
This commit is contained in:
15
api/controller_admin/biz_audit_log.js
Normal file
15
api/controller_admin/biz_audit_log.js
Normal file
@@ -0,0 +1,15 @@
|
||||
const crud = require("../service/biz_admin_crud");
|
||||
const { getRequestBody } = crud;
|
||||
|
||||
module.exports = {
|
||||
"POST /biz_audit_log/page": async (ctx) => {
|
||||
const body = getRequestBody(ctx);
|
||||
const data = await crud.page("biz_audit_log", body);
|
||||
ctx.success({ rows: data.rows, count: data.count });
|
||||
},
|
||||
"POST /biz_audit_log/export": async (ctx) => {
|
||||
const body = getRequestBody(ctx);
|
||||
const res = await crud.exportCsv("biz_audit_log", body);
|
||||
ctx.success(res);
|
||||
},
|
||||
};
|
||||
8
api/controller_admin/biz_dashboard.js
Normal file
8
api/controller_admin/biz_dashboard.js
Normal file
@@ -0,0 +1,8 @@
|
||||
const dashboard = require("../service/biz_dashboard_service");
|
||||
|
||||
module.exports = {
|
||||
"GET /biz_dashboard/summary": async (ctx) => {
|
||||
const data = await dashboard.summary();
|
||||
ctx.success(data);
|
||||
},
|
||||
};
|
||||
24
api/controller_admin/biz_payment.js
Normal file
24
api/controller_admin/biz_payment.js
Normal file
@@ -0,0 +1,24 @@
|
||||
const logic = require("../service/biz_subscription_logic");
|
||||
|
||||
function getRequestBody(ctx) {
|
||||
if (ctx.request && ctx.request.body && Object.keys(ctx.request.body).length > 0) {
|
||||
return ctx.request.body;
|
||||
}
|
||||
if (typeof ctx.getBody === "function") {
|
||||
return ctx.getBody() || {};
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
"POST /biz_payment/confirm-offline": async (ctx) => {
|
||||
const body = getRequestBody(ctx);
|
||||
const row = await logic.confirmOfflinePayment(body);
|
||||
ctx.success(row);
|
||||
},
|
||||
"POST /biz_payment/confirm-link": async (ctx) => {
|
||||
const body = getRequestBody(ctx);
|
||||
const row = await logic.confirmLinkPayment(body);
|
||||
ctx.success(row);
|
||||
},
|
||||
};
|
||||
77
api/controller_admin/biz_plan.js
Normal file
77
api/controller_admin/biz_plan.js
Normal file
@@ -0,0 +1,77 @@
|
||||
const crud = require("../service/biz_admin_crud");
|
||||
const { getRequestBody } = crud;
|
||||
const baseModel = require("../../middleware/baseModel");
|
||||
const audit = require("../service/biz_audit_service");
|
||||
|
||||
module.exports = {
|
||||
"POST /biz_plan/page": async (ctx) => {
|
||||
const body = getRequestBody(ctx);
|
||||
const data = await crud.page("biz_plan", body);
|
||||
ctx.success({ rows: data.rows, count: data.count });
|
||||
},
|
||||
"POST /biz_plan/add": async (ctx) => {
|
||||
const body = getRequestBody(ctx);
|
||||
const row = await crud.add("biz_plan", body);
|
||||
await audit.logAudit({
|
||||
admin_user_id: audit.pickAdminId(ctx),
|
||||
action: "biz_plan.add",
|
||||
resource_type: "biz_plan",
|
||||
resource_id: row.id,
|
||||
detail: { plan_code: row.plan_code },
|
||||
});
|
||||
ctx.success(row);
|
||||
},
|
||||
"POST /biz_plan/edit": async (ctx) => {
|
||||
const body = getRequestBody(ctx);
|
||||
await crud.edit("biz_plan", body);
|
||||
await audit.logAudit({
|
||||
admin_user_id: audit.pickAdminId(ctx),
|
||||
action: "biz_plan.edit",
|
||||
resource_type: "biz_plan",
|
||||
resource_id: body.id,
|
||||
});
|
||||
ctx.success({});
|
||||
},
|
||||
"POST /biz_plan/del": async (ctx) => {
|
||||
const body = getRequestBody(ctx);
|
||||
await crud.del("biz_plan", body);
|
||||
await audit.logAudit({
|
||||
admin_user_id: audit.pickAdminId(ctx),
|
||||
action: "biz_plan.del",
|
||||
resource_type: "biz_plan",
|
||||
resource_id: body.id,
|
||||
});
|
||||
ctx.success({});
|
||||
},
|
||||
"GET /biz_plan/detail": async (ctx) => {
|
||||
const q = ctx.query || {};
|
||||
const row = await crud.detail("biz_plan", { id: q.id || q.ID });
|
||||
ctx.success(row);
|
||||
},
|
||||
"GET /biz_plan/all": async (ctx) => {
|
||||
const rows = await crud.all("biz_plan");
|
||||
ctx.success(rows);
|
||||
},
|
||||
"POST /biz_plan/toggle": async (ctx) => {
|
||||
const body = getRequestBody(ctx);
|
||||
const id = body.id;
|
||||
if (id == null) return ctx.fail("缺少 id");
|
||||
const row = await baseModel.biz_plan.findByPk(id);
|
||||
if (!row) return ctx.fail("套餐不存在");
|
||||
const next = row.status === "active" ? "inactive" : "active";
|
||||
await row.update({ status: next });
|
||||
await audit.logAudit({
|
||||
admin_user_id: audit.pickAdminId(ctx),
|
||||
action: "biz_plan.toggle",
|
||||
resource_type: "biz_plan",
|
||||
resource_id: id,
|
||||
detail: { status: next },
|
||||
});
|
||||
ctx.success({ status: next });
|
||||
},
|
||||
"POST /biz_plan/export": async (ctx) => {
|
||||
const body = getRequestBody(ctx);
|
||||
const res = await crud.exportCsv("biz_plan", body);
|
||||
ctx.success(res);
|
||||
},
|
||||
};
|
||||
56
api/controller_admin/biz_subscription.js
Normal file
56
api/controller_admin/biz_subscription.js
Normal file
@@ -0,0 +1,56 @@
|
||||
const crud = require("../service/biz_admin_crud");
|
||||
const baseModel = require("../../middleware/baseModel");
|
||||
const logic = require("../service/biz_subscription_logic");
|
||||
|
||||
function getRequestBody(ctx) {
|
||||
if (ctx.request && ctx.request.body && Object.keys(ctx.request.body).length > 0) {
|
||||
return ctx.request.body;
|
||||
}
|
||||
if (typeof ctx.getBody === "function") {
|
||||
return ctx.getBody() || {};
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
"POST /biz_subscription/page": async (ctx) => {
|
||||
const body = getRequestBody(ctx);
|
||||
const data = await crud.page("biz_subscription", body);
|
||||
ctx.success({ rows: data.rows, count: data.count });
|
||||
},
|
||||
"GET /biz_subscription/detail": async (ctx) => {
|
||||
const q = ctx.query || {};
|
||||
const row = await crud.detail("biz_subscription", { id: q.id || q.ID });
|
||||
ctx.success(row);
|
||||
},
|
||||
"GET /biz_subscription/by_user": async (ctx) => {
|
||||
const q = ctx.query || {};
|
||||
const user_id = q.user_id || q.userId;
|
||||
if (!user_id) return ctx.fail("缺少 user_id");
|
||||
const rows = await baseModel.biz_subscription.findAll({
|
||||
where: { user_id },
|
||||
order: [["id", "DESC"]],
|
||||
});
|
||||
ctx.success(rows);
|
||||
},
|
||||
"POST /biz_subscription/open": async (ctx) => {
|
||||
const body = getRequestBody(ctx);
|
||||
const row = await logic.openSubscription(body);
|
||||
ctx.success(row);
|
||||
},
|
||||
"POST /biz_subscription/upgrade": async (ctx) => {
|
||||
const body = getRequestBody(ctx);
|
||||
const row = await logic.upgradeSubscription(body);
|
||||
ctx.success(row);
|
||||
},
|
||||
"POST /biz_subscription/renew": async (ctx) => {
|
||||
const body = getRequestBody(ctx);
|
||||
const row = await logic.renewSubscription(body);
|
||||
ctx.success(row);
|
||||
},
|
||||
"POST /biz_subscription/cancel": async (ctx) => {
|
||||
const body = getRequestBody(ctx);
|
||||
const row = await logic.cancelSubscription(body);
|
||||
ctx.success(row);
|
||||
},
|
||||
};
|
||||
34
api/controller_admin/biz_token.js
Normal file
34
api/controller_admin/biz_token.js
Normal file
@@ -0,0 +1,34 @@
|
||||
const crud = require("../service/biz_admin_crud");
|
||||
const { getRequestBody } = crud;
|
||||
const tokenLogic = require("../service/biz_token_logic");
|
||||
const audit = require("../service/biz_audit_service");
|
||||
|
||||
module.exports = {
|
||||
"POST /biz_token/page": async (ctx) => {
|
||||
const body = getRequestBody(ctx);
|
||||
const data = await crud.page("biz_api_token", body);
|
||||
ctx.success({ rows: data.rows, count: data.count });
|
||||
},
|
||||
"POST /biz_token/create": async (ctx) => {
|
||||
const body = getRequestBody(ctx);
|
||||
const result = await tokenLogic.createToken(body);
|
||||
await audit.logAudit({
|
||||
admin_user_id: audit.pickAdminId(ctx),
|
||||
biz_user_id: result.row.user_id,
|
||||
action: "biz_token.create",
|
||||
resource_type: "biz_api_token",
|
||||
resource_id: result.row.id,
|
||||
detail: { token_name: result.row.token_name },
|
||||
});
|
||||
ctx.success({
|
||||
id: result.row.id,
|
||||
user_id: result.row.user_id,
|
||||
plan_id: result.row.plan_id,
|
||||
token_name: result.row.token_name,
|
||||
expire_at: result.row.expire_at,
|
||||
plain_token: result.plain_token,
|
||||
warn: result.warn,
|
||||
});
|
||||
},
|
||||
"POST /biz_token/revoke": async (ctx) => {
|
||||
const body = getRequestBody(ct
|
||||
35
api/controller_admin/biz_usage.js
Normal file
35
api/controller_admin/biz_usage.js
Normal file
@@ -0,0 +1,35 @@
|
||||
const crud = require("../service/biz_admin_crud");
|
||||
const { getRequestBody } = crud;
|
||||
|
||||
module.exports = {
|
||||
"POST /biz_usage/page": async (ctx) => {
|
||||
const body = getRequestBody(ctx);
|
||||
const data = await crud.page("biz_usage_monthly", body);
|
||||
ctx.success({ rows: data.rows, count: data.count });
|
||||
},
|
||||
"POST /biz_usage/add": async (ctx) => {
|
||||
const body = getRequestBody(ctx);
|
||||
const row = await crud.add("biz_usage_monthly", body);
|
||||
ctx.success(row);
|
||||
},
|
||||
"POST /biz_usage/edit": async (ctx) => {
|
||||
const body = getRequestBody(ctx);
|
||||
await crud.edit("biz_usage_monthly", body);
|
||||
ctx.success({});
|
||||
},
|
||||
"POST /biz_usage/del": async (ctx) => {
|
||||
const body = getRequestBody(ctx);
|
||||
await crud.del("biz_usage_monthly", body);
|
||||
ctx.success({});
|
||||
},
|
||||
"GET /biz_usage/detail": async (ctx) => {
|
||||
const q = ctx.query || {};
|
||||
const row = await crud.detail("biz_usage_monthly", { id: q.id || q.ID });
|
||||
ctx.success(row);
|
||||
},
|
||||
"POST /biz_usage/export": async (ctx) => {
|
||||
const body = getRequestBody(ctx);
|
||||
const res = await crud.exportCsv("biz_usage_monthly", body);
|
||||
ctx.success(res);
|
||||
},
|
||||
};
|
||||
109
api/controller_admin/biz_user.js
Normal file
109
api/controller_admin/biz_user.js
Normal file
@@ -0,0 +1,109 @@
|
||||
const crud = require("../service/biz_admin_crud");
|
||||
const { getRequestBody } = crud;
|
||||
const baseModel = require("../../middleware/baseModel");
|
||||
const tokenLogic = require("../service/biz_token_logic");
|
||||
const audit = require("../service/biz_audit_service");
|
||||
|
||||
module.exports = {
|
||||
"POST /biz_user/page": async (ctx) => {
|
||||
const body = getRequestBody(ctx);
|
||||
const data = await crud.page("biz_user", body);
|
||||
ctx.success({ rows: data.rows, count: data.count });
|
||||
},
|
||||
"POST /biz_user/add": async (ctx) => {
|
||||
const body = getRequestBody(ctx);
|
||||
const row = await crud.add("biz_user", body);
|
||||
await audit.logAudit({
|
||||
admin_user_id: audit.pickAdminId(ctx),
|
||||
biz_user_id: row.id,
|
||||
action: "biz_user.add",
|
||||
resource_type: "biz_user",
|
||||
resource_id: row.id,
|
||||
detail: { name: row.name },
|
||||
});
|
||||
ctx.success(row);
|
||||
},
|
||||
"POST /biz_user/edit": async (ctx) => {
|
||||
const body = getRequestBody(ctx);
|
||||
await crud.edit("biz_user", body);
|
||||
await audit.logAudit({
|
||||
admin_user_id: audit.pickAdminId(ctx),
|
||||
biz_user_id: body.id,
|
||||
action: "biz_user.edit",
|
||||
resource_type: "biz_user",
|
||||
resource_id: body.id,
|
||||
});
|
||||
ctx.success({});
|
||||
},
|
||||
"POST /biz_user/del": async (ctx) => {
|
||||
const body = getRequestBody(ctx);
|
||||
await crud.del("biz_user", body);
|
||||
await audit.logAudit({
|
||||
admin_user_id: audit.pickAdminId(ctx),
|
||||
biz_user_id: body.id,
|
||||
action: "biz_user.del",
|
||||
resource_type: "biz_user",
|
||||
resource_id: body.id,
|
||||
});
|
||||
ctx.success({});
|
||||
},
|
||||
"GET /biz_user/detail": async (ctx) => {
|
||||
const q = ctx.query || {};
|
||||
const id = q.id || q.ID;
|
||||
const user = await crud.detail("biz_user", { id });
|
||||
if (!user) {
|
||||
return ctx.fail("用户不存在");
|
||||
}
|
||||
const subscriptions = await baseModel.biz_subscription.findAll({
|
||||
where: { user_id: id },
|
||||
order: [["id", "DESC"]],
|
||||
limit: 10,
|
||||
});
|
||||
const tokenCount = await baseModel.biz_api_token.count({
|
||||
where: { user_id: id },
|
||||
});
|
||||
ctx.success({
|
||||
user,
|
||||
subscriptions,
|
||||
tokenCount,
|
||||
});
|
||||
},
|
||||
"GET /biz_user/all": async (ctx) => {
|
||||
const rows = await crud.all("biz_user");
|
||||
ctx.success(rows);
|
||||
},
|
||||
"POST /biz_user/disable": async (ctx) => {
|
||||
const body = getRequestBody(ctx);
|
||||
const id = body.id;
|
||||
if (id == null) return ctx.fail("缺少 id");
|
||||
await baseModel.biz_user.update({ status: "disabled" }, { where: { id } });
|
||||
await audit.logAudit({
|
||||
admin_user_id: audit.pickAdminId(ctx),
|
||||
biz_user_id: id,
|
||||
action: "biz_user.disable",
|
||||
resource_type: "biz_user",
|
||||
resource_id: id,
|
||||
});
|
||||
ctx.success({});
|
||||
},
|
||||
"POST /biz_user/export": async (ctx) => {
|
||||
const body = getRequestBody(ctx);
|
||||
const res = await crud.exportCsv("biz_user", body);
|
||||
ctx.success(res);
|
||||
},
|
||||
"POST /biz_user/revoke_all_tokens": async (ctx) => {
|
||||
const body = getRequestBody(ctx);
|
||||
const userId = body.user_id != null ? body.user_id : body.id;
|
||||
if (userId == null) return ctx.fail("缺少 user_id");
|
||||
const n = await tokenLogic.revokeAllForUser(userId);
|
||||
await audit.logAudit({
|
||||
admin_user_id: audit.pickAdminId(ctx),
|
||||
biz_user_id: userId,
|
||||
action: "biz_token.revoke_all",
|
||||
resource_type: "biz_user",
|
||||
resource_id: userId,
|
||||
detail: { affected: n },
|
||||
});
|
||||
ctx.success({ revoked: n });
|
||||
},
|
||||
};
|
||||
49
api/controller_admin/sys_file.js
Normal file
49
api/controller_admin/sys_file.js
Normal file
@@ -0,0 +1,49 @@
|
||||
var UUID = require("uuid");
|
||||
var fs = require("fs");
|
||||
var path = require("path");
|
||||
const ossTool = require("../service/ossTool");
|
||||
const funTool = require("../../tool/funTool");
|
||||
|
||||
module.exports = {
|
||||
"POST /sys_file/upload_img": async (ctx, next) => {
|
||||
const files = ctx.request.files; // 获取上传文件
|
||||
let fileArray = [];
|
||||
let rootPath = path.join(__dirname, "../../upload/imgs");
|
||||
for (var key in files) {
|
||||
fileArray.push(files[key]);
|
||||
}
|
||||
|
||||
//创建文件夹
|
||||
await funTool.mkdirsSync(rootPath);
|
||||
|
||||
let resArray = [];
|
||||
fileArray.forEach((file) => {
|
||||
// 创建可读流
|
||||
const reader = fs.createReadStream(file.path);
|
||||
|
||||
let filePath = `/${UUID.v1() + "_" + file.name}`;
|
||||
// 创建可写流
|
||||
const upStream = fs.createWriteStream(path.join(rootPath, filePath));
|
||||
// 可读流通过管道写入可写流
|
||||
|
||||
reader.pipe(upStream);
|
||||
|
||||
resArray.push({ name: file.name, path: path.join("/imgs", filePath) });
|
||||
});
|
||||
|
||||
ctx.success(resArray);
|
||||
},
|
||||
"POST /sys_file/upload_oos_img": async (ctx, next) => {
|
||||
let fileArray = [];
|
||||
const files = ctx.request.files; // 获取上传文件
|
||||
for (var key in files) {
|
||||
fileArray.push(files[key]);
|
||||
}
|
||||
let data = await ossTool.putImg(fileArray[0]);
|
||||
if (data.path) {
|
||||
return ctx.success(data);
|
||||
} else {
|
||||
return ctx.fail();
|
||||
}
|
||||
},
|
||||
};
|
||||
19
api/controller_front/auth_verify.js
Normal file
19
api/controller_front/auth_verify.js
Normal file
@@ -0,0 +1,19 @@
|
||||
const auth = require("../service/biz_auth_verify");
|
||||
|
||||
function getRequestBody(ctx) {
|
||||
if (ctx.request && ctx.request.body && Object.keys(ctx.request.body).length > 0) {
|
||||
return ctx.request.body;
|
||||
}
|
||||
if (typeof ctx.getBody === "function") {
|
||||
return ctx.getBody() || {};
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
"POST /auth/verify": async (ctx) => {
|
||||
const body = getRequestBody(ctx);
|
||||
const result = await auth.verifyRequest(body);
|
||||
ctx.success(result);
|
||||
},
|
||||
};
|
||||
0
api/controller_front/sys_user.js
Normal file
0
api/controller_front/sys_user.js
Normal file
46
api/model/biz_api_token.js
Normal file
46
api/model/biz_api_token.js
Normal file
@@ -0,0 +1,46 @@
|
||||
const Sequelize = require("sequelize");
|
||||
|
||||
module.exports = (db) => {
|
||||
return db.define(
|
||||
"biz_api_token",
|
||||
{
|
||||
id: {
|
||||
type: Sequelize.BIGINT.UNSIGNED,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
},
|
||||
user_id: {
|
||||
type: Sequelize.BIGINT.UNSIGNED,
|
||||
allowNull: false,
|
||||
},
|
||||
plan_id: {
|
||||
type: Sequelize.BIGINT.UNSIGNED,
|
||||
allowNull: true,
|
||||
comment: "冗余:鉴权时少联表",
|
||||
},
|
||||
token_name: {
|
||||
type: Sequelize.STRING(100),
|
||||
allowNull: false,
|
||||
defaultValue: "",
|
||||
},
|
||||
token_hash: {
|
||||
type: Sequelize.STRING(64),
|
||||
allowNull: false,
|
||||
unique: true,
|
||||
},
|
||||
status: {
|
||||
type: Sequelize.ENUM("active", "revoked", "expired"),
|
||||
allowNull: false,
|
||||
defaultValue: "active",
|
||||
},
|
||||
expire_at: { type: Sequelize.DATE, allowNull: false },
|
||||
last_used_at: { type: Sequelize.DATE, allowNull: true },
|
||||
},
|
||||
{
|
||||
tableName: "biz_api_tokens",
|
||||
timestamps: true,
|
||||
underscored: true,
|
||||
comment: "API Token",
|
||||
}
|
||||
);
|
||||
};
|
||||
54
api/model/biz_audit_log.js
Normal file
54
api/model/biz_audit_log.js
Normal file
@@ -0,0 +1,54 @@
|
||||
const Sequelize = require("sequelize");
|
||||
|
||||
module.exports = (db) => {
|
||||
return db.define(
|
||||
"biz_audit_log",
|
||||
{
|
||||
id: {
|
||||
type: Sequelize.BIGINT.UNSIGNED,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
},
|
||||
admin_user_id: {
|
||||
type: Sequelize.BIGINT.UNSIGNED,
|
||||
allowNull: true,
|
||||
},
|
||||
biz_user_id: {
|
||||
type: Sequelize.BIGINT.UNSIGNED,
|
||||
allowNull: true,
|
||||
},
|
||||
action: {
|
||||
type: Sequelize.STRING(64),
|
||||
allowNull: false,
|
||||
},
|
||||
resource_type: {
|
||||
type: Sequelize.STRING(64),
|
||||
allowNull: false,
|
||||
defaultValue: "",
|
||||
},
|
||||
resource_id: {
|
||||
type: Sequelize.BIGINT.UNSIGNED,
|
||||
allowNull: true,
|
||||
},
|
||||
detail: {
|
||||
type: Sequelize.JSON,
|
||||
allowNull: true,
|
||||
},
|
||||
created_at: {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
tableName: "biz_audit_log",
|
||||
timestamps: false,
|
||||
underscored: true,
|
||||
comment: "审计日志",
|
||||
hooks: {
|
||||
beforeCreate(row) {
|
||||
if (!row.created_at) row.created_at = new Date();
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
56
api/model/biz_plan.js
Normal file
56
api/model/biz_plan.js
Normal file
@@ -0,0 +1,56 @@
|
||||
const Sequelize = require("sequelize");
|
||||
|
||||
module.exports = (db) => {
|
||||
return db.define(
|
||||
"biz_plan",
|
||||
{
|
||||
id: {
|
||||
type: Sequelize.BIGINT.UNSIGNED,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
},
|
||||
plan_code: {
|
||||
type: Sequelize.STRING(64),
|
||||
allowNull: false,
|
||||
unique: true,
|
||||
},
|
||||
plan_name: {
|
||||
type: Sequelize.STRING(128),
|
||||
allowNull: false,
|
||||
defaultValue: "",
|
||||
},
|
||||
monthly_price: {
|
||||
type: Sequelize.DECIMAL(12, 2),
|
||||
allowNull: false,
|
||||
defaultValue: 0,
|
||||
},
|
||||
auth_fee: {
|
||||
type: Sequelize.DECIMAL(12, 2),
|
||||
allowNull: false,
|
||||
defaultValue: 0,
|
||||
},
|
||||
account_limit: { type: Sequelize.INTEGER, allowNull: false, defaultValue: 0 },
|
||||
active_user_limit: { type: Sequelize.INTEGER, allowNull: false, defaultValue: 0 },
|
||||
msg_quota: { type: Sequelize.INTEGER, allowNull: false, defaultValue: 0 },
|
||||
mass_quota: { type: Sequelize.INTEGER, allowNull: false, defaultValue: 0 },
|
||||
friend_quota: { type: Sequelize.INTEGER, allowNull: false, defaultValue: 0 },
|
||||
sns_quota: { type: Sequelize.INTEGER, allowNull: false, defaultValue: 0 },
|
||||
enabled_features: {
|
||||
type: Sequelize.JSON,
|
||||
allowNull: true,
|
||||
comment: "JSON 功能点白名单",
|
||||
},
|
||||
status: {
|
||||
type: Sequelize.ENUM("active", "inactive"),
|
||||
allowNull: false,
|
||||
defaultValue: "active",
|
||||
},
|
||||
},
|
||||
{
|
||||
tableName: "biz_plans",
|
||||
timestamps: true,
|
||||
underscored: true,
|
||||
comment: "套餐",
|
||||
}
|
||||
);
|
||||
};
|
||||
48
api/model/biz_subscription.js
Normal file
48
api/model/biz_subscription.js
Normal file
@@ -0,0 +1,48 @@
|
||||
const Sequelize = require("sequelize");
|
||||
|
||||
module.exports = (db) => {
|
||||
return db.define(
|
||||
"biz_subscription",
|
||||
{
|
||||
id: {
|
||||
type: Sequelize.BIGINT.UNSIGNED,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
},
|
||||
user_id: {
|
||||
type: Sequelize.BIGINT.UNSIGNED,
|
||||
allowNull: false,
|
||||
},
|
||||
plan_id: {
|
||||
type: Sequelize.BIGINT.UNSIGNED,
|
||||
allowNull: false,
|
||||
},
|
||||
status: {
|
||||
type: Sequelize.ENUM("pending", "active", "expired", "cancelled"),
|
||||
allowNull: false,
|
||||
defaultValue: "pending",
|
||||
},
|
||||
start_time: { type: Sequelize.DATE, allowNull: false },
|
||||
end_time: { type: Sequelize.DATE, allowNull: false },
|
||||
renew_mode: {
|
||||
type: Sequelize.ENUM("manual", "auto"),
|
||||
allowNull: false,
|
||||
defaultValue: "manual",
|
||||
},
|
||||
payment_channel: {
|
||||
type: Sequelize.ENUM("offline", "pay_link"),
|
||||
allowNull: true,
|
||||
},
|
||||
payment_ref: {
|
||||
type: Sequelize.STRING(200),
|
||||
allowNull: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
tableName: "biz_subscriptions",
|
||||
timestamps: true,
|
||||
underscored: true,
|
||||
comment: "订阅",
|
||||
}
|
||||
);
|
||||
};
|
||||
38
api/model/biz_usage_monthly.js
Normal file
38
api/model/biz_usage_monthly.js
Normal file
@@ -0,0 +1,38 @@
|
||||
const Sequelize = require("sequelize");
|
||||
|
||||
module.exports = (db) => {
|
||||
return db.define(
|
||||
"biz_usage_monthly",
|
||||
{
|
||||
id: {
|
||||
type: Sequelize.BIGINT.UNSIGNED,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
},
|
||||
user_id: {
|
||||
type: Sequelize.BIGINT.UNSIGNED,
|
||||
allowNull: false,
|
||||
},
|
||||
plan_id: {
|
||||
type: Sequelize.BIGINT.UNSIGNED,
|
||||
allowNull: false,
|
||||
},
|
||||
stat_month: {
|
||||
type: Sequelize.STRING(7),
|
||||
allowNull: false,
|
||||
comment: "YYYY-MM",
|
||||
},
|
||||
msg_count: { type: Sequelize.INTEGER, allowNull: false, defaultValue: 0 },
|
||||
mass_count: { type: Sequelize.INTEGER, allowNull: false, defaultValue: 0 },
|
||||
friend_count: { type: Sequelize.INTEGER, allowNull: false, defaultValue: 0 },
|
||||
sns_count: { type: Sequelize.INTEGER, allowNull: false, defaultValue: 0 },
|
||||
active_user_count: { type: Sequelize.INTEGER, allowNull: false, defaultValue: 0 },
|
||||
},
|
||||
{
|
||||
tableName: "biz_usage_monthly",
|
||||
timestamps: true,
|
||||
underscored: true,
|
||||
comment: "月用量",
|
||||
}
|
||||
);
|
||||
};
|
||||
45
api/model/biz_user.js
Normal file
45
api/model/biz_user.js
Normal file
@@ -0,0 +1,45 @@
|
||||
const Sequelize = require("sequelize");
|
||||
|
||||
module.exports = (db) => {
|
||||
return db.define(
|
||||
"biz_user",
|
||||
{
|
||||
id: {
|
||||
type: Sequelize.BIGINT.UNSIGNED,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
},
|
||||
name: {
|
||||
type: Sequelize.STRING(100),
|
||||
allowNull: false,
|
||||
defaultValue: "",
|
||||
comment: "姓名/称呼",
|
||||
},
|
||||
mobile: {
|
||||
type: Sequelize.STRING(20),
|
||||
allowNull: true,
|
||||
comment: "手机号",
|
||||
},
|
||||
email: {
|
||||
type: Sequelize.STRING(120),
|
||||
allowNull: true,
|
||||
},
|
||||
company_name: {
|
||||
type: Sequelize.STRING(200),
|
||||
allowNull: true,
|
||||
comment: "公司名",
|
||||
},
|
||||
status: {
|
||||
type: Sequelize.ENUM("active", "disabled"),
|
||||
allowNull: false,
|
||||
defaultValue: "active",
|
||||
},
|
||||
},
|
||||
{
|
||||
tableName: "biz_users",
|
||||
timestamps: true,
|
||||
underscored: true,
|
||||
comment: "业务用户",
|
||||
}
|
||||
);
|
||||
};
|
||||
26
api/model/sys_control_type.js
Normal file
26
api/model/sys_control_type.js
Normal file
@@ -0,0 +1,26 @@
|
||||
|
||||
const Sequelize = require("sequelize");
|
||||
module.exports = (db) => {
|
||||
return db.define("sys_control_type", {
|
||||
name: {
|
||||
type: Sequelize.STRING(100),
|
||||
allowNull: false,
|
||||
defaultValue: "",
|
||||
comment: "控件名称",
|
||||
},
|
||||
module_key: {
|
||||
type: Sequelize.STRING(100),
|
||||
allowNull: false,
|
||||
defaultValue: "",
|
||||
comment: "组件key",
|
||||
},
|
||||
data_lenght: {
|
||||
type: Sequelize.INTEGER(11),
|
||||
allowNull: false,
|
||||
defaultValue: "50",
|
||||
comment: "数据长度",
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
37
api/model/sys_log.js
Normal file
37
api/model/sys_log.js
Normal file
@@ -0,0 +1,37 @@
|
||||
|
||||
const Sequelize = require("sequelize");
|
||||
// db日志管理
|
||||
module.exports = (db) => {
|
||||
return db.define("sys_log", {
|
||||
table_name: {
|
||||
type: Sequelize.STRING(100),
|
||||
allowNull: false,
|
||||
defaultValue: "",
|
||||
comment: "表名",
|
||||
},
|
||||
operate: {
|
||||
type: Sequelize.STRING(100),
|
||||
allowNull: false,
|
||||
defaultValue: "",
|
||||
comment: "操作",
|
||||
},
|
||||
content: {
|
||||
type: Sequelize.JSON,
|
||||
allowNull: false,
|
||||
defaultValue: "",
|
||||
comment: "内容",
|
||||
set(value) {
|
||||
this.setDataValue("content", { value });
|
||||
},
|
||||
get() {
|
||||
let jsonValue = this.getDataValue("content");
|
||||
if (jsonValue && jsonValue.value !== undefined) {
|
||||
return jsonValue.value;
|
||||
} else {
|
||||
return jsonValue;
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
96
api/model/sys_menu.js
Normal file
96
api/model/sys_menu.js
Normal file
@@ -0,0 +1,96 @@
|
||||
|
||||
const Sequelize = require("sequelize");
|
||||
// 菜单表
|
||||
module.exports = (db) => {
|
||||
return db.define("sys_menu", {
|
||||
// 菜单名称
|
||||
name: {
|
||||
type: Sequelize.STRING(100),
|
||||
allowNull: false,
|
||||
defaultValue: "",
|
||||
comment: "菜单名称",
|
||||
},
|
||||
// 父id
|
||||
parent_id: {
|
||||
type: Sequelize.INTEGER(11).UNSIGNED,
|
||||
allowNull: true,
|
||||
defaultValue: 0,
|
||||
comment: "父id",
|
||||
},
|
||||
// 图标
|
||||
icon: {
|
||||
type: Sequelize.STRING(100),
|
||||
allowNull: false,
|
||||
defaultValue: "",
|
||||
comment: "图标",
|
||||
},
|
||||
path: {
|
||||
type: Sequelize.STRING(255),
|
||||
allowNull: false,
|
||||
defaultValue: "",
|
||||
comment: "路径",
|
||||
},
|
||||
|
||||
// 菜单类型 "菜单", "页面", "外链", "功能"
|
||||
type: {
|
||||
type: Sequelize.STRING(255),
|
||||
allowNull: false,
|
||||
defaultValue: "页面",
|
||||
comment: "菜单类型",
|
||||
},
|
||||
//模型id
|
||||
model_id: {
|
||||
type: Sequelize.INTEGER(11).UNSIGNED,
|
||||
allowNull: true,
|
||||
defaultValue: 0,
|
||||
comment: "模型id",
|
||||
},
|
||||
|
||||
//表单id
|
||||
form_id: {
|
||||
type: Sequelize.INTEGER(11).UNSIGNED,
|
||||
allowNull: true,
|
||||
defaultValue: 0,
|
||||
comment: "表单id",
|
||||
},
|
||||
|
||||
// 组件地址
|
||||
component: {
|
||||
type: Sequelize.STRING(100),
|
||||
allowNull: false,
|
||||
defaultValue: "",
|
||||
comment: "组件地址",
|
||||
},
|
||||
|
||||
// api地址
|
||||
api_path: {
|
||||
type: Sequelize.STRING(100),
|
||||
allowNull: false,
|
||||
defaultValue: "",
|
||||
comment: "api地址",
|
||||
},
|
||||
// 是否显示在菜单中
|
||||
is_show_menu: {
|
||||
type: Sequelize.INTEGER(1),
|
||||
allowNull: false,
|
||||
defaultValue: true,
|
||||
comment: "是否显示在菜单中",
|
||||
},
|
||||
is_show: {
|
||||
type: Sequelize.INTEGER(1),
|
||||
allowNull: false,
|
||||
defaultValue: true,
|
||||
comment: "是否展示",
|
||||
},
|
||||
|
||||
// 菜单类型
|
||||
sort: {
|
||||
type: Sequelize.INTEGER(11),
|
||||
allowNull: false,
|
||||
defaultValue: "0",
|
||||
comment: "菜单类型",
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
37
api/model/sys_parameter.js
Normal file
37
api/model/sys_parameter.js
Normal file
@@ -0,0 +1,37 @@
|
||||
|
||||
const Sequelize = require("sequelize");
|
||||
// 字典表
|
||||
module.exports = (db) => {
|
||||
return db.define("sys_parameter", {
|
||||
key: {
|
||||
type: Sequelize.STRING(100),
|
||||
allowNull: false,
|
||||
defaultValue: "",
|
||||
comment: "字典key",
|
||||
},
|
||||
|
||||
value: {
|
||||
type: Sequelize.STRING(100),
|
||||
allowNull: false,
|
||||
defaultValue: "",
|
||||
comment: "值",
|
||||
},
|
||||
|
||||
remark: {
|
||||
type: Sequelize.STRING(500),
|
||||
allowNull: false,
|
||||
defaultValue: "",
|
||||
comment: "备注",
|
||||
},
|
||||
|
||||
// 是否允许修改 0 允许,1 不允许
|
||||
is_modified: {
|
||||
type: Sequelize.INTEGER(2),
|
||||
allowNull: false,
|
||||
defaultValue: 0,
|
||||
comment: "是否允许修改",
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
41
api/model/sys_role.js
Normal file
41
api/model/sys_role.js
Normal file
@@ -0,0 +1,41 @@
|
||||
|
||||
const Sequelize = require("sequelize");
|
||||
//角色表
|
||||
module.exports = (db) => {
|
||||
return db.define("sys_role", {
|
||||
name: {
|
||||
type: Sequelize.STRING(100),
|
||||
allowNull: false,
|
||||
defaultValue: "",
|
||||
comment: "角色名称",
|
||||
},
|
||||
// 0 普通角色 1 系统角色
|
||||
type: {
|
||||
type: Sequelize.INTEGER(1),
|
||||
allowNull: false,
|
||||
defaultValue: "0",
|
||||
comment: "角色类型",
|
||||
},
|
||||
menus: {
|
||||
type: Sequelize.JSON,
|
||||
allowNull: false,
|
||||
defaultValue: "",
|
||||
comment: "权限菜单",
|
||||
set(value) {
|
||||
this.setDataValue('menus', { value });
|
||||
},
|
||||
get() {
|
||||
let jsonValue = this.getDataValue("menus")
|
||||
if (jsonValue && jsonValue.value !== undefined) {
|
||||
|
||||
return jsonValue.value;
|
||||
}
|
||||
else {
|
||||
return jsonValue
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
26
api/model/sys_user.js
Normal file
26
api/model/sys_user.js
Normal file
@@ -0,0 +1,26 @@
|
||||
|
||||
const Sequelize = require("sequelize");
|
||||
// 系统用户表
|
||||
module.exports = (db) => {
|
||||
return db.define("sys_user", {
|
||||
name: {
|
||||
type: Sequelize.STRING(100),
|
||||
allowNull: false,
|
||||
defaultValue: "",
|
||||
comment: "名称",
|
||||
},
|
||||
password: {
|
||||
type: Sequelize.STRING(100),
|
||||
allowNull: false,
|
||||
defaultValue: "",
|
||||
comment: "密码",
|
||||
},
|
||||
roleId: {
|
||||
type: Sequelize.INTEGER,
|
||||
allowNull: false,
|
||||
comment: "角色id",
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
189
api/service/biz_admin_crud.js
Normal file
189
api/service/biz_admin_crud.js
Normal file
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* 订阅模块通用 CRUD(与 admin 约定 POST /{model}/page|add|edit|del ,GET /{model}/detail|all)
|
||||
*/
|
||||
const baseModel = require("../../middleware/baseModel");
|
||||
const { op } = baseModel;
|
||||
|
||||
function getRequestBody(ctx) {
|
||||
if (ctx.request && ctx.request.body && Object.keys(ctx.request.body).length > 0) {
|
||||
return ctx.request.body;
|
||||
}
|
||||
if (typeof ctx.getBody === "function") {
|
||||
return ctx.getBody() || {};
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function getModel(modelName) {
|
||||
const m = baseModel[modelName];
|
||||
if (!m) {
|
||||
throw new Error(`模型不存在: ${modelName}`);
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
function normalizeForWrite(model, data, { forCreate } = {}) {
|
||||
const attrs = model.rawAttributes;
|
||||
const out = {};
|
||||
for (const k of Object.keys(data || {})) {
|
||||
if (!attrs[k]) continue;
|
||||
let v = data[k];
|
||||
if (v === "") {
|
||||
if (k === "id" && forCreate) continue;
|
||||
if (k.endsWith("_id") || k === "id") {
|
||||
v = null;
|
||||
} else if (attrs[k].allowNull) {
|
||||
v = null;
|
||||
}
|
||||
}
|
||||
if (k === "enabled_features" && typeof v === "string" && v.trim() !== "") {
|
||||
try {
|
||||
v = JSON.parse(v);
|
||||
} catch (e) {
|
||||
/* 保持原字符串,由 Sequelize 或 DB 报错 */
|
||||
}
|
||||
}
|
||||
out[k] = v;
|
||||
}
|
||||
if (forCreate && out.id !== undefined && (out.id === "" || out.id === null)) {
|
||||
delete out.id;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function buildSearchWhere(model, seachOption) {
|
||||
const key = seachOption && seachOption.key;
|
||||
const raw = seachOption && seachOption.value;
|
||||
if (!key || raw === undefined || raw === null) return {};
|
||||
const str = String(raw).trim();
|
||||
if (str === "") return {};
|
||||
|
||||
const attr = model.rawAttributes[key];
|
||||
if (!attr) {
|
||||
return { [key]: { [op.like]: `%${str}%` } };
|
||||
}
|
||||
|
||||
const typeKey = attr.type && attr.type.key;
|
||||
|
||||
if (typeKey === "BOOLEAN") {
|
||||
if (str === "true" || str === "1" || str === "是") return { [key]: true };
|
||||
if (str === "false" || str === "0" || str === "否") return { [key]: false };
|
||||
return {};
|
||||
}
|
||||
|
||||
if (typeKey === "ENUM") {
|
||||
return { [key]: str };
|
||||
}
|
||||
|
||||
if (
|
||||
typeKey === "INTEGER" ||
|
||||
typeKey === "BIGINT" ||
|
||||
typeKey === "FLOAT" ||
|
||||
typeKey === "DOUBLE" ||
|
||||
typeKey === "DECIMAL"
|
||||
) {
|
||||
const n = Number(str);
|
||||
if (!Number.isNaN(n)) return { [key]: n };
|
||||
return {};
|
||||
}
|
||||
|
||||
if (typeKey === "DATE" || typeKey === "DATEONLY") {
|
||||
return { [key]: str };
|
||||
}
|
||||
|
||||
return { [key]: { [op.like]: `%${str}%` } };
|
||||
}
|
||||
|
||||
async function page(modelName, body) {
|
||||
const model = getModel(modelName);
|
||||
const param = body.param || body;
|
||||
const pageOption = param.pageOption || {};
|
||||
const seachOption = param.seachOption || {};
|
||||
|
||||
const pageNum = parseInt(pageOption.page, 10) || 1;
|
||||
const pageSize = parseInt(pageOption.pageSize, 10) || 20;
|
||||
const offset = (pageNum - 1) * pageSize;
|
||||
|
||||
const where = buildSearchWhere(model, seachOption);
|
||||
|
||||
const { count, rows } = await model.findAndCountAll({
|
||||
where,
|
||||
offset,
|
||||
limit: pageSize,
|
||||
order: [["id", "DESC"]],
|
||||
});
|
||||
|
||||
return { rows, count };
|
||||
}
|
||||
|
||||
async function add(modelName, body) {
|
||||
const model = getModel(modelName);
|
||||
const payload = normalizeForWrite(model, body, { forCreate: true });
|
||||
const row = await model.create(payload);
|
||||
return row;
|
||||
}
|
||||
|
||||
async function edit(modelName, body) {
|
||||
const model = getModel(modelName);
|
||||
const id = body.id;
|
||||
if (id === undefined || id === null || id === "") {
|
||||
throw new Error("缺少 id");
|
||||
}
|
||||
const payload = normalizeForWrite(model, body, { forCreate: false });
|
||||
delete payload.id;
|
||||
await model.update(payload, { where: { id } });
|
||||
return {};
|
||||
}
|
||||
|
||||
async function del(modelName, body) {
|
||||
const model = getModel(modelName);
|
||||
const id = body.id !== undefined ? body.id : body;
|
||||
if (id === undefined || id === null || id === "") {
|
||||
throw new Error("缺少 id");
|
||||
}
|
||||
await model.destroy({ where: { id } });
|
||||
return {};
|
||||
}
|
||||
|
||||
async function detail(modelName, query) {
|
||||
const model = getModel(modelName);
|
||||
const id = query && (query.id || query.ID);
|
||||
if (id === undefined || id === null || id === "") {
|
||||
throw new Error("缺少 id");
|
||||
}
|
||||
const row = await model.findByPk(id);
|
||||
return row;
|
||||
}
|
||||
|
||||
async function all(modelName) {
|
||||
const model = getModel(modelName);
|
||||
const rows = await model.findAll({
|
||||
limit: 2000,
|
||||
order: [["id", "DESC"]],
|
||||
});
|
||||
return rows;
|
||||
}
|
||||
|
||||
async function exportCsv(modelName, body) {
|
||||
const model = getModel(modelName);
|
||||
const param = body.param || body;
|
||||
const where = buildSearchWhere(model, param.seachOption || {});
|
||||
const rows = await model.findAll({
|
||||
where,
|
||||
limit: 10000,
|
||||
order: [["id", "DESC"]],
|
||||
});
|
||||
return { rows };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
page,
|
||||
add,
|
||||
edit,
|
||||
del,
|
||||
detail,
|
||||
all,
|
||||
exportCsv,
|
||||
getRequestBody,
|
||||
buildSearchWhere,
|
||||
};
|
||||
37
api/service/biz_audit_service.js
Normal file
37
api/service/biz_audit_service.js
Normal file
@@ -0,0 +1,37 @@
|
||||
const baseModel = require("../../middleware/baseModel");
|
||||
const logs = require("../../tool/logs_proxy");
|
||||
|
||||
/**
|
||||
* 记录审计(失败不影响主流程)
|
||||
* @param {object} p
|
||||
* @param {number} [p.admin_user_id]
|
||||
* @param {number} [p.biz_user_id]
|
||||
* @param {string} p.action
|
||||
* @param {string} [p.resource_type]
|
||||
* @param {number} [p.resource_id]
|
||||
* @param {object} [p.detail]
|
||||
*/
|
||||
async function logAudit(p) {
|
||||
try {
|
||||
await baseModel.biz_audit_log.create({
|
||||
admin_user_id: p.admin_user_id || null,
|
||||
biz_user_id: p.biz_user_id || null,
|
||||
action: p.action,
|
||||
resource_type: p.resource_type || "",
|
||||
resource_id: p.resource_id != null ? p.resource_id : null,
|
||||
detail: p.detail || null,
|
||||
created_at: new Date(),
|
||||
});
|
||||
} catch (e) {
|
||||
logs.error("[biz_audit] 写入失败", e);
|
||||
}
|
||||
}
|
||||
|
||||
function pickAdminId(ctx) {
|
||||
if (!ctx) return null;
|
||||
const u = ctx.user || ctx.state?.user || ctx.session?.user;
|
||||
if (u && (u.id != null || u.userId != null)) return u.id != null ? u.id : u.userId;
|
||||
return null;
|
||||
}
|
||||
|
||||
module.exports = { logAudit, pickAdminId };
|
||||
107
api/service/biz_auth_verify.js
Normal file
107
api/service/biz_auth_verify.js
Normal file
@@ -0,0 +1,107 @@
|
||||
const baseModel = require("../../middleware/baseModel");
|
||||
const tokenLogic = require("./biz_token_logic");
|
||||
const usageSvc = require("./biz_usage_service");
|
||||
|
||||
function featureAllowed(plan, feature) {
|
||||
if (!feature) return true;
|
||||
const feats = plan.enabled_features;
|
||||
if (feats == null) return true;
|
||||
if (Array.isArray(feats)) return feats.includes(feature);
|
||||
if (typeof feats === "object") {
|
||||
return feats[feature] === true || feats[feature] === 1 || feats[feature] === "1";
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function normalizeUsageDelta(raw) {
|
||||
if (!raw || typeof raw !== "object") return {};
|
||||
return {
|
||||
msg: usageSvc.num(raw.msg ?? raw.msg_count),
|
||||
mass: usageSvc.num(raw.mass ?? raw.mass_count),
|
||||
friend: usageSvc.num(raw.friend ?? raw.friend_count),
|
||||
sns: usageSvc.num(raw.sns ?? raw.sns_count),
|
||||
active_user: usageSvc.num(raw.active_user ?? raw.active_user_count),
|
||||
};
|
||||
}
|
||||
|
||||
function hasPositiveDelta(delta) {
|
||||
return Object.values(delta).some((v) => usageSvc.num(v) > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 对外鉴权:Token + 用户 + 有效订阅 + 功能点 + 可选用量上报与额度
|
||||
* body: { token, feature?, usage_delta?: { msg?, mass?, ... } }
|
||||
*/
|
||||
async function verifyRequest(body) {
|
||||
const { token, feature } = body || {};
|
||||
if (!token) {
|
||||
return { ok: false, error_code: "TOKEN_INVALID", message: "缺少 token" };
|
||||
}
|
||||
|
||||
const hash = tokenLogic.hashPlainToken(token);
|
||||
const row = await baseModel.biz_api_token.findOne({ where: { token_hash: hash } });
|
||||
if (!row) {
|
||||
return { ok: false, error_code: "TOKEN_INVALID", message: "Token 不存在" };
|
||||
}
|
||||
if (row.status === "revoked") {
|
||||
return { ok: false, error_code: "TOKEN_REVOKED", message: "Token 已吊销" };
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
if (new Date(row.expire_at) < now) {
|
||||
return { ok: false, error_code: "TOKEN_EXPIRED", message: "Token 已过期" };
|
||||
}
|
||||
|
||||
const user = await baseModel.biz_user.findByPk(row.user_id);
|
||||
if (!user || user.status !== "active") {
|
||||
return { ok: false, error_code: "SUBSCRIPTION_INACTIVE", message: "用户不可用" };
|
||||
}
|
||||
|
||||
const sub = await tokenLogic.findActiveSubscriptionForUser(row.user_id);
|
||||
if (!sub) {
|
||||
return { ok: false, error_code: "SUBSCRIPTION_INACTIVE", message: "无有效订阅" };
|
||||
}
|
||||
|
||||
const plan = await baseModel.biz_plan.findByPk(sub.plan_id);
|
||||
if (!plan || plan.status !== "active") {
|
||||
return { ok: false, error_code: "SUBSCRIPTION_INACTIVE", message: "套餐不可用" };
|
||||
}
|
||||
|
||||
if (feature && !featureAllowed(plan, feature)) {
|
||||
return { ok: false, error_code: "FEATURE_NOT_ALLOWED", message: "功能未在套餐内" };
|
||||
}
|
||||
|
||||
const statMonth = usageSvc.currentStatMonth();
|
||||
let usageRow = await usageSvc.getOrCreateUsage(row.user_id, sub.plan_id, statMonth);
|
||||
|
||||
const delta = normalizeUsageDelta(body.usage_delta || body.usage_report);
|
||||
if (hasPositiveDelta(delta)) {
|
||||
const q = usageSvc.checkQuotaAfterDelta(plan, usageRow, delta);
|
||||
if (!q.ok) {
|
||||
return { ok: false, error_code: q.error_code || "QUOTA_EXCEEDED", message: q.message || "额度不足" };
|
||||
}
|
||||
usageRow = await usageSvc.applyDelta(row.user_id, sub.plan_id, statMonth, delta);
|
||||
}
|
||||
|
||||
await row.update({ last_used_at: now });
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
context: {
|
||||
user_id: row.user_id,
|
||||
plan_id: sub.plan_id,
|
||||
subscription_id: sub.id,
|
||||
token_id: row.id,
|
||||
stat_month: statMonth,
|
||||
usage_snapshot: {
|
||||
msg_count: usageSvc.num(usageRow.msg_count),
|
||||
mass_count: usageSvc.num(usageRow.mass_count),
|
||||
friend_count: usageSvc.num(usageRow.friend_count),
|
||||
sns_count: usageSvc.num(usageRow.sns_count),
|
||||
active_user_count: usageSvc.num(usageRow.active_user_count),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { verifyRequest };
|
||||
47
api/service/biz_dashboard_service.js
Normal file
47
api/service/biz_dashboard_service.js
Normal file
@@ -0,0 +1,47 @@
|
||||
const baseModel = require("../../middleware/baseModel");
|
||||
const { op } = baseModel;
|
||||
|
||||
async function summary() {
|
||||
const now = new Date();
|
||||
const in7 = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000);
|
||||
|
||||
const [
|
||||
userTotal,
|
||||
userActive,
|
||||
planActive,
|
||||
subPending,
|
||||
subActive,
|
||||
subExpired,
|
||||
tokenActive,
|
||||
renewSoon,
|
||||
] = await Promise.all([
|
||||
baseModel.biz_user.count(),
|
||||
baseModel.biz_user.count({ where: { status: "active" } }),
|
||||
baseModel.biz_plan.count({ where: { status: "active" } }),
|
||||
baseModel.biz_subscription.count({ where: { status: "pending" } }),
|
||||
baseModel.biz_subscription.count({ where: { status: "active" } }),
|
||||
baseModel.biz_subscription.count({ where: { status: "expired" } }),
|
||||
baseModel.biz_api_token.count({ where: { status: "active" } }),
|
||||
baseModel.biz_subscription.count({
|
||||
where: {
|
||||
status: "active",
|
||||
end_time: { [op.between]: [now, in7] },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
users: { total: userTotal, active: userActive },
|
||||
plans: { active: planActive },
|
||||
subscriptions: {
|
||||
pending: subPending,
|
||||
active: subActive,
|
||||
expired: subExpired,
|
||||
renew_within_7d: renewSoon,
|
||||
},
|
||||
tokens: { active: tokenActive },
|
||||
server_time: now.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { summary };
|
||||
130
api/service/biz_subscription_logic.js
Normal file
130
api/service/biz_subscription_logic.js
Normal file
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* 订阅开通 / 升级 / 续费 / 取消 / 到期扫描 / 支付确认
|
||||
*/
|
||||
const baseModel = require("../../middleware/baseModel");
|
||||
const { op } = baseModel;
|
||||
|
||||
async function assertUserActive(userId) {
|
||||
const u = await baseModel.biz_user.findByPk(userId);
|
||||
if (!u) throw new Error("用户不存在");
|
||||
if (u.status !== "active") throw new Error("用户已禁用");
|
||||
return u;
|
||||
}
|
||||
|
||||
async function assertPlanActive(planId) {
|
||||
const p = await baseModel.biz_plan.findByPk(planId);
|
||||
if (!p) throw new Error("套餐不存在");
|
||||
if (p.status !== "active") throw new Error("套餐未上线");
|
||||
return p;
|
||||
}
|
||||
|
||||
async function openSubscription(body) {
|
||||
const {
|
||||
user_id,
|
||||
plan_id,
|
||||
start_time,
|
||||
end_time,
|
||||
status = "pending",
|
||||
renew_mode = "manual",
|
||||
payment_channel,
|
||||
payment_ref,
|
||||
} = body;
|
||||
await assertUserActive(user_id);
|
||||
await assertPlanActive(plan_id);
|
||||
const row = await baseModel.biz_subscription.create({
|
||||
user_id,
|
||||
plan_id,
|
||||
status,
|
||||
start_time,
|
||||
end_time,
|
||||
renew_mode,
|
||||
payment_channel: payment_channel || null,
|
||||
payment_ref: payment_ref || null,
|
||||
});
|
||||
return row;
|
||||
}
|
||||
|
||||
async function upgradeSubscription(body) {
|
||||
const { subscription_id, new_plan_id, start_time, end_time } = body;
|
||||
const sub = await baseModel.biz_subscription.findByPk(subscription_id);
|
||||
if (!sub) throw new Error("订阅不存在");
|
||||
await assertPlanActive(new_plan_id);
|
||||
await sub.update({
|
||||
plan_id: new_plan_id,
|
||||
start_time: start_time || sub.start_time,
|
||||
end_time: end_time || sub.end_time,
|
||||
});
|
||||
return sub;
|
||||
}
|
||||
|
||||
async function renewSubscription(body) {
|
||||
const { subscription_id, end_time } = body;
|
||||
const sub = await baseModel.biz_subscription.findByPk(subscription_id);
|
||||
if (!sub) throw new Error("订阅不存在");
|
||||
await sub.update({
|
||||
end_time,
|
||||
status: "active",
|
||||
});
|
||||
return sub;
|
||||
}
|
||||
|
||||
async function cancelSubscription(body) {
|
||||
const { subscription_id } = body;
|
||||
const sub = await baseModel.biz_subscription.findByPk(subscription_id);
|
||||
if (!sub) throw new Error("订阅不存在");
|
||||
await sub.update({ status: "cancelled" });
|
||||
return sub;
|
||||
}
|
||||
|
||||
/** 每天扫描:将已过期且仍为 active 的订阅置为 expired */
|
||||
async function expireDueSubscriptions() {
|
||||
const now = new Date();
|
||||
const [n] = await baseModel.biz_subscription.update(
|
||||
{ status: "expired" },
|
||||
{
|
||||
where: {
|
||||
status: "active",
|
||||
end_time: { [op.lt]: now },
|
||||
},
|
||||
}
|
||||
);
|
||||
return n;
|
||||
}
|
||||
|
||||
/** 线下确认:pending -> active,写入 payment_ref */
|
||||
async function confirmOfflinePayment(body) {
|
||||
const { subscription_id, payment_ref } = body;
|
||||
if (!subscription_id) throw new Error("缺少 subscription_id");
|
||||
const sub = await baseModel.biz_subscription.findByPk(subscription_id);
|
||||
if (!sub) throw new Error("订阅不存在");
|
||||
await sub.update({
|
||||
status: "active",
|
||||
payment_channel: "offline",
|
||||
payment_ref: payment_ref || sub.payment_ref,
|
||||
});
|
||||
return sub;
|
||||
}
|
||||
|
||||
/** 链接支付确认(MVP:与线下类似,仅标记渠道) */
|
||||
async function confirmLinkPayment(body) {
|
||||
const { subscription_id, payment_ref } = body;
|
||||
if (!subscription_id) throw new Error("缺少 subscription_id");
|
||||
const sub = await baseModel.biz_subscription.findByPk(subscription_id);
|
||||
if (!sub) throw new Error("订阅不存在");
|
||||
await sub.update({
|
||||
status: "active",
|
||||
payment_channel: "pay_link",
|
||||
payment_ref: payment_ref || sub.payment_ref,
|
||||
});
|
||||
return sub;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
openSubscription,
|
||||
upgradeSubscription,
|
||||
renewSubscription,
|
||||
cancelSubscription,
|
||||
expireDueSubscriptions,
|
||||
confirmOfflinePayment,
|
||||
confirmLinkPayment,
|
||||
};
|
||||
90
api/service/biz_token_logic.js
Normal file
90
api/service/biz_token_logic.js
Normal file
@@ -0,0 +1,90 @@
|
||||
const crypto = require("crypto");
|
||||
const baseModel = require("../../middleware/baseModel");
|
||||
const { op } = baseModel;
|
||||
|
||||
const MAX_TOKENS_PER_USER = 5;
|
||||
|
||||
function hashPlainToken(plain) {
|
||||
return crypto.createHash("sha256").update(plain, "utf8").digest("hex");
|
||||
}
|
||||
|
||||
function generatePlainToken() {
|
||||
return `waw_${crypto.randomBytes(24).toString("hex")}`;
|
||||
}
|
||||
|
||||
/** 当前时间在 [start,end] 内且 status=active 的订阅 */
|
||||
async function findActiveSubscriptionForUser(userId) {
|
||||
const now = new Date();
|
||||
return baseModel.biz_subscription.findOne({
|
||||
where: {
|
||||
user_id: userId,
|
||||
status: "active",
|
||||
start_time: { [op.lte]: now },
|
||||
end_time: { [op.gte]: now },
|
||||
},
|
||||
order: [["id", "DESC"]],
|
||||
});
|
||||
}
|
||||
|
||||
async function createToken(body) {
|
||||
const { user_id, token_name, expire_at } = body;
|
||||
if (!user_id || !expire_at) throw new Error("缺少 user_id 或 expire_at");
|
||||
const u = await baseModel.biz_user.findByPk(user_id);
|
||||
if (!u) throw new Error("用户不存在");
|
||||
if (u.status !== "active") throw new Error("用户已禁用");
|
||||
|
||||
const activeCount = await baseModel.biz_api_token.count({
|
||||
where: { user_id, status: "active" },
|
||||
});
|
||||
if (activeCount >= MAX_TOKENS_PER_USER) {
|
||||
throw new Error(`单用户最多 ${MAX_TOKENS_PER_USER} 个有效 Token`);
|
||||
}
|
||||
|
||||
const sub = await findActiveSubscriptionForUser(user_id);
|
||||
const plan_id = sub ? sub.plan_id : null;
|
||||
|
||||
const plain = generatePlainToken();
|
||||
const token_hash = hashPlainToken(plain);
|
||||
|
||||
const row = await baseModel.biz_api_token.create({
|
||||
user_id,
|
||||
plan_id,
|
||||
token_name: token_name || "default",
|
||||
token_hash,
|
||||
status: "active",
|
||||
expire_at,
|
||||
});
|
||||
|
||||
return {
|
||||
row,
|
||||
plain_token: plain,
|
||||
warn: sub ? null : "当前无生效中的订阅,鉴权将失败",
|
||||
};
|
||||
}
|
||||
|
||||
async function revokeToken(body) {
|
||||
const id = body.id;
|
||||
if (id == null) throw new Error("缺少 id");
|
||||
const row = await baseModel.biz_api_token.findByPk(id);
|
||||
if (!row) throw new Error("Token 不存在");
|
||||
await row.update({ status: "revoked" });
|
||||
return row;
|
||||
}
|
||||
|
||||
async function revokeAllForUser(userId) {
|
||||
if (userId == null) throw new Error("缺少 user_id");
|
||||
const [n] = await baseModel.biz_api_token.update(
|
||||
{ status: "revoked" },
|
||||
{ where: { user_id: userId, status: "active" } }
|
||||
);
|
||||
return n;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
hashPlainToken,
|
||||
createToken,
|
||||
revokeToken,
|
||||
revokeAllForUser,
|
||||
findActiveSubscriptionForUser,
|
||||
MAX_TOKENS_PER_USER,
|
||||
};
|
||||
110
api/service/biz_usage_service.js
Normal file
110
api/service/biz_usage_service.js
Normal file
@@ -0,0 +1,110 @@
|
||||
const baseModel = require("../../middleware/baseModel");
|
||||
const { op } = baseModel;
|
||||
|
||||
function currentStatMonth(d = new Date()) {
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||||
return `${y}-${m}`;
|
||||
}
|
||||
|
||||
function num(v) {
|
||||
if (v === null || v === undefined || v === "") return 0;
|
||||
const n = Number(v);
|
||||
return Number.isNaN(n) ? 0 : n;
|
||||
}
|
||||
|
||||
/** 0 表示无限制(不校验) */
|
||||
function quotaExceeded(used, limit) {
|
||||
const li = num(limit);
|
||||
if (li <= 0) return false;
|
||||
return num(used) >= li;
|
||||
}
|
||||
|
||||
/**
|
||||
* 取或创建当月用量行
|
||||
*/
|
||||
async function getOrCreateUsage(userId, planId, statMonth) {
|
||||
const [row] = await baseModel.biz_usage_monthly.findOrCreate({
|
||||
where: { user_id: userId, stat_month: statMonth },
|
||||
defaults: {
|
||||
user_id: userId,
|
||||
plan_id: planId,
|
||||
stat_month: statMonth,
|
||||
msg_count: 0,
|
||||
mass_count: 0,
|
||||
friend_count: 0,
|
||||
sns_count: 0,
|
||||
active_user_count: 0,
|
||||
},
|
||||
});
|
||||
if (num(row.plan_id) !== num(planId)) {
|
||||
await row.update({ plan_id: planId });
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
async function applyDelta(userId, planId, statMonth, delta) {
|
||||
const row = await getOrCreateUsage(userId, planId, statMonth);
|
||||
const next = {
|
||||
msg_count: num(row.msg_count) + num(delta.msg),
|
||||
mass_count: num(row.mass_count) + num(delta.mass),
|
||||
friend_count: num(row.friend_count) + num(delta.friend),
|
||||
sns_count: num(row.sns_count) + num(delta.sns),
|
||||
active_user_count: num(row.active_user_count) + num(delta.active_user),
|
||||
};
|
||||
await row.update(next);
|
||||
return row.reload();
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验「增量」后是否超限(与套餐额度对比)
|
||||
* feature: msg | mass | friend | sns | active_user
|
||||
*/
|
||||
function checkQuotaAfterDelta(plan, usageRow, delta) {
|
||||
const checks = [
|
||||
["msg", "msg_count", "msg_quota"],
|
||||
["mass", "mass_count", "mass_quota"],
|
||||
["friend", "friend_count", "friend_quota"],
|
||||
["sns", "sns_count", "sns_quota"],
|
||||
["active_user", "active_user_count", "active_user_limit"],
|
||||
];
|
||||
for (const [key, uCol, pCol] of checks) {
|
||||
const add = num(delta[key]);
|
||||
if (add <= 0) continue;
|
||||
const used = num(usageRow[uCol]) + add;
|
||||
if (quotaExceeded(used, plan[pCol])) {
|
||||
return { ok: false, error_code: "QUOTA_EXCEEDED", message: `额度不足: ${key}` };
|
||||
}
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* 为当前月所有有效订阅补用量行(月结/初始化)
|
||||
*/
|
||||
async function ensureUsageRowsForCurrentMonth() {
|
||||
const statMonth = currentStatMonth();
|
||||
const now = new Date();
|
||||
const subs = await baseModel.biz_subscription.findAll({
|
||||
where: {
|
||||
status: "active",
|
||||
start_time: { [op.lte]: now },
|
||||
end_time: { [op.gte]: now },
|
||||
},
|
||||
});
|
||||
let n = 0;
|
||||
for (const s of subs) {
|
||||
await getOrCreateUsage(s.user_id, s.plan_id, statMonth);
|
||||
n += 1;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
currentStatMonth,
|
||||
getOrCreateUsage,
|
||||
applyDelta,
|
||||
checkQuotaAfterDelta,
|
||||
ensureUsageRowsForCurrentMonth,
|
||||
num,
|
||||
};
|
||||
333
api/service/ossTool.js
Normal file
333
api/service/ossTool.js
Normal file
@@ -0,0 +1,333 @@
|
||||
const OSS = require('ali-oss')
|
||||
const fs = require('fs')
|
||||
const config = require('../../config/config')['aliyun']
|
||||
const uuid = require('node-uuid')
|
||||
const logs = require('../../tool/logs_proxy')
|
||||
|
||||
/**
|
||||
* OSS 文件上传工具类
|
||||
* 统一管理文件上传、存储路径、文件类型等
|
||||
*/
|
||||
class OSSTool {
|
||||
constructor() {
|
||||
this.client = new OSS({
|
||||
region: 'oss-cn-shanghai',
|
||||
accessKeyId: config.accessKeyId,
|
||||
accessKeySecret: config.accessKeySecret,
|
||||
bucket:config.bucket
|
||||
})
|
||||
|
||||
|
||||
// 基础存储路径前缀
|
||||
this.basePrefix = 'app/uploads'
|
||||
|
||||
// 文件类型映射
|
||||
this.fileTypeMap = {
|
||||
// 图片类型
|
||||
'image/jpeg': 'jpg',
|
||||
'image/jpg': 'jpg',
|
||||
'image/png': 'png',
|
||||
'image/gif': 'gif',
|
||||
'image/webp': 'webp',
|
||||
'image/svg+xml': 'svg',
|
||||
|
||||
// 视频类型
|
||||
'video/mp4': 'mp4',
|
||||
'video/avi': 'avi',
|
||||
'video/mov': 'mov',
|
||||
'video/wmv': 'wmv',
|
||||
'video/flv': 'flv',
|
||||
'video/webm': 'webm',
|
||||
'video/mkv': 'mkv',
|
||||
|
||||
// 音频类型
|
||||
'audio/mp3': 'mp3',
|
||||
'audio/wav': 'wav',
|
||||
'audio/aac': 'aac',
|
||||
'audio/ogg': 'ogg',
|
||||
'audio/flac': 'flac',
|
||||
|
||||
// 文档类型
|
||||
'application/pdf': 'pdf',
|
||||
'application/msword': 'doc',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'docx',
|
||||
'application/vnd.ms-excel': 'xls',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'xlsx',
|
||||
'application/vnd.ms-powerpoint': 'ppt',
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'pptx',
|
||||
'text/plain': 'txt',
|
||||
'text/html': 'html',
|
||||
'text/css': 'css',
|
||||
'application/javascript': 'js',
|
||||
'application/json': 'json'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件后缀名
|
||||
* @param {Object} file - 文件对象(兼容 formidable 格式)
|
||||
* @returns {string} 文件后缀名
|
||||
*/
|
||||
getFileSuffix(file) {
|
||||
// 优先使用 MIME 类型判断(兼容 type 和 mimetype)
|
||||
const mimeType = file.mimetype || file.type
|
||||
if (mimeType && this.fileTypeMap[mimeType]) {
|
||||
return this.fileTypeMap[mimeType]
|
||||
}
|
||||
|
||||
// 备用方案:从文件名获取(兼容 originalFilename 和 name)
|
||||
const fileName = file.originalFilename || file.name
|
||||
if (fileName) {
|
||||
const lastIndex = fileName.lastIndexOf('.')
|
||||
if (lastIndex > -1) {
|
||||
return fileName.substring(lastIndex + 1).toLowerCase()
|
||||
}
|
||||
}
|
||||
|
||||
return 'bin'
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件存储路径
|
||||
* @param {Object} file - 文件对象(兼容 formidable 格式)
|
||||
* @param {string} category - 存储分类
|
||||
* @returns {string} 完整的存储路径
|
||||
*/
|
||||
getStoragePath(file, category = 'files') {
|
||||
const suffix = this.getFileSuffix(file)
|
||||
const uid = uuid.v4()
|
||||
|
||||
// 根据文件类型确定子路径(兼容 mimetype 和 type)
|
||||
let subPath = category
|
||||
const mimeType = file.mimetype || file.type
|
||||
|
||||
if (mimeType) {
|
||||
if (mimeType.startsWith('image/')) {
|
||||
subPath = 'images'
|
||||
} else if (mimeType.startsWith('video/')) {
|
||||
subPath = 'videos'
|
||||
} else if (mimeType.startsWith('audio/')) {
|
||||
subPath = 'audios'
|
||||
} else if (mimeType.startsWith('application/') || mimeType.startsWith('text/')) {
|
||||
subPath = 'documents'
|
||||
}
|
||||
}
|
||||
|
||||
// 完整路径:front/ball/{subPath}/{uid}.{suffix}
|
||||
return `${this.basePrefix}/${subPath}/${uid}.${suffix}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 核心文件上传方法
|
||||
* @param {Object} file - 文件对象(兼容 formidable 格式)
|
||||
* @param {string} category - 存储分类
|
||||
* @returns {Object} 上传结果
|
||||
*/
|
||||
async uploadFile(file, category = 'files') {
|
||||
try {
|
||||
// 兼容不同的文件对象格式(filepath 或 path)
|
||||
const filePath = file.filepath || file.path
|
||||
|
||||
// 验证文件
|
||||
if (!file || !filePath) {
|
||||
return { success: false, error: '无效的文件对象' }
|
||||
}
|
||||
|
||||
const stream = fs.createReadStream(filePath)
|
||||
const storagePath = this.getStoragePath(file, category)
|
||||
const suffix = this.getFileSuffix(file)
|
||||
|
||||
// 设置 content-type(兼容 mimetype 和 type)
|
||||
const contentType = file.mimetype || file.type || 'application/octet-stream'
|
||||
|
||||
// 上传到 OSS
|
||||
const result = await this.client.put(storagePath, stream, {
|
||||
headers: {
|
||||
'content-disposition': 'inline',
|
||||
"content-type": contentType
|
||||
}
|
||||
})
|
||||
|
||||
if (result.res.status === 200) {
|
||||
const ossPath = config.ossUrl + '/' + result.name
|
||||
|
||||
// 上传成功后删除临时文件
|
||||
try {
|
||||
if (fs.existsSync(filePath)) {
|
||||
fs.unlinkSync(filePath)
|
||||
}
|
||||
} catch (unlinkError) {
|
||||
logs.error('删除临时文件失败:', unlinkError)
|
||||
}
|
||||
|
||||
// 使用 ossPath(https)作为 path,确保返回 https 格式
|
||||
const path = ossPath
|
||||
|
||||
return {
|
||||
success: true,
|
||||
name: result.name,
|
||||
path: path,
|
||||
ossPath,
|
||||
fileType: file.mimetype || file.type,
|
||||
fileSize: file.size,
|
||||
originalName: file.originalFilename || file.name,
|
||||
suffix: suffix,
|
||||
storagePath: storagePath
|
||||
}
|
||||
} else {
|
||||
return { success: false, error: 'OSS 上传失败' }
|
||||
}
|
||||
} catch (error) {
|
||||
logs.error('文件上传错误:', error)
|
||||
|
||||
// 上传失败也要清理临时文件
|
||||
try {
|
||||
const filePath = file.filepath || file.path
|
||||
if (filePath && fs.existsSync(filePath)) {
|
||||
fs.unlinkSync(filePath)
|
||||
}
|
||||
} catch (unlinkError) {
|
||||
logs.error('删除临时文件失败:', unlinkError)
|
||||
}
|
||||
|
||||
return { success: false, error: error.message }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传流数据
|
||||
* @param {Stream} stream - 文件流
|
||||
* @param {string} contentType - 内容类型
|
||||
* @param {string} suffix - 文件后缀
|
||||
* @returns {Object} 上传结果
|
||||
*/
|
||||
async uploadStream(stream, contentType, suffix) {
|
||||
try {
|
||||
const uid = uuid.v4()
|
||||
const storagePath = `${this.basePrefix}/files/${uid}.${suffix}`
|
||||
|
||||
const result = await this.client.put(storagePath, stream, {
|
||||
headers: {
|
||||
'content-disposition': 'inline',
|
||||
"content-type": contentType
|
||||
}
|
||||
})
|
||||
|
||||
if (result.res.status === 200) {
|
||||
const ossPath = config.ossUrl + '/' + result.name
|
||||
// 使用 ossPath(https)作为 path,确保返回 https 格式
|
||||
const path = ossPath
|
||||
return {
|
||||
success: true,
|
||||
name: result.name,
|
||||
path: path,
|
||||
ossPath,
|
||||
storagePath: storagePath
|
||||
}
|
||||
} else {
|
||||
return { success: false, error: 'OSS 上传失败' }
|
||||
}
|
||||
} catch (error) {
|
||||
logs.error('流上传错误:', error)
|
||||
return { success: false, error: error.message }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除文件
|
||||
* @param {string} filePath - 文件路径
|
||||
* @returns {Object} 删除结果
|
||||
*/
|
||||
async deleteFile(filePath) {
|
||||
try {
|
||||
if (!filePath) {
|
||||
return { success: false, error: '文件路径不能为空' }
|
||||
}
|
||||
|
||||
// 从完整 URL 中提取相对路径
|
||||
const relativePath = filePath.replace(config.ossUrl + '/', '')
|
||||
const result = await this.client.delete(relativePath)
|
||||
|
||||
if (result.res.status === 204) {
|
||||
return { success: true, message: '文件删除成功' }
|
||||
} else {
|
||||
return { success: false, error: '文件删除失败' }
|
||||
}
|
||||
} catch (error) {
|
||||
logs.error('文件删除错误:', error)
|
||||
return { success: false, error: error.message }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件信息
|
||||
* @param {string} filePath - 文件路径
|
||||
* @returns {Object} 文件信息
|
||||
*/
|
||||
async getFileInfo(filePath) {
|
||||
try {
|
||||
if (!filePath) {
|
||||
return { success: false, error: '文件路径不能为空' }
|
||||
}
|
||||
|
||||
const relativePath = filePath.replace(config.ossUrl + '/', '')
|
||||
const result = await this.client.head(relativePath)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
size: result.res.headers['content-length'],
|
||||
type: result.res.headers['content-type'],
|
||||
lastModified: result.res.headers['last-modified'],
|
||||
etag: result.res.headers['etag']
|
||||
}
|
||||
} catch (error) {
|
||||
logs.error('获取文件信息错误:', error)
|
||||
return { success: false, error: error.message }
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 便捷方法 ====================
|
||||
|
||||
/**
|
||||
* 上传图片文件(保持向后兼容)
|
||||
* @param {Object} file - 图片文件
|
||||
* @returns {Object} 上传结果
|
||||
*/
|
||||
async putImg(file) {
|
||||
return await this.uploadFile(file, 'images')
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 上传视频文件
|
||||
* @param {Object} file - 视频文件
|
||||
* @returns {Object} 上传结果
|
||||
*/
|
||||
async uploadVideo(file) {
|
||||
return await this.uploadFile(file, 'videos')
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传音频文件
|
||||
* @param {Object} file - 音频文件
|
||||
* @returns {Object} 上传结果
|
||||
*/
|
||||
async uploadAudio(file) {
|
||||
return await this.uploadFile(file, 'audios')
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传文档文件
|
||||
* @param {Object} file - 文档文件
|
||||
* @returns {Object} 上传结果
|
||||
*/
|
||||
async uploadDocument(file) {
|
||||
return await this.uploadFile(file, 'documents')
|
||||
}
|
||||
}
|
||||
|
||||
// 创建单例实例
|
||||
const ossTool = new OSSTool()
|
||||
|
||||
// 导出实例(保持向后兼容)
|
||||
module.exports = ossTool
|
||||
Reference in New Issue
Block a user