init
This commit is contained in:
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