fix(energy): checkpoint validated drill pagination and read-only preview
Co-authored-by: HiFox Agent <agents-noreply@hifox.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { serveStatic } from '@hono/node-server/serve-static';
|
||||
import { Hono } from 'hono';
|
||||
import { readOnlyMiddleware } from './read-only-middleware.js';
|
||||
import { cors } from 'hono/cors';
|
||||
import authRouter from './auth/login.js';
|
||||
import { authMiddleware } from './auth/middleware.js';
|
||||
@@ -20,6 +21,7 @@ export function createApp(): Hono {
|
||||
const app = new Hono();
|
||||
|
||||
app.use('/api/*', cors());
|
||||
app.use('/api/*', readOnlyMiddleware);
|
||||
|
||||
// 登录接口公开,其余 API 统一经过认证与数据权限检查。
|
||||
app.route('/api/auth', authRouter);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { startMileageBackgroundJobs } from './routes/mileage/index.js';
|
||||
|
||||
/** 启动只应在服务进程中运行的数据库准备和定时任务。 */
|
||||
export function startBackgroundServices(): void {
|
||||
if (process.env.DB_READ_ONLY === '1') return;
|
||||
ensureSchedulingTables().catch((error) => {
|
||||
console.error('scheduling bootstrap error:', error);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
// Local credentials stay outside version control. Exported environment wins.
|
||||
dotenv.config({ path: '.env.local' });
|
||||
dotenv.config();
|
||||
Object.assign(process.env, {
|
||||
DB_READ_ONLY: '1',
|
||||
HYDROGEN_DB_READ_ONLY: '1',
|
||||
MILEAGE_REPORT_AUTO_ARCHIVE: '0',
|
||||
DEV_BYPASS_AUTH: '1',
|
||||
});
|
||||
|
||||
// Import after configuring the environment: pools/auth read it at module load.
|
||||
const [{ serve }, { createApp }] = await Promise.all([
|
||||
import('@hono/node-server'),
|
||||
import('./app.js'),
|
||||
]);
|
||||
// Intentionally do not call bootstrap: no schema initialization or schedulers.
|
||||
serve({ fetch: createApp().fetch, hostname: '127.0.0.1', port: 3001 }, () => {
|
||||
console.log('Local read-only BI API: http://127.0.0.1:3001');
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { Hono } from 'hono';
|
||||
import { readOnlyMiddleware } from './read-only-middleware.js';
|
||||
|
||||
test('read-only preview blocks write handlers but permits reads; normal mode is unchanged', async () => {
|
||||
const previous = process.env.DB_READ_ONLY;
|
||||
try {
|
||||
let calls = 0;
|
||||
const app = new Hono();
|
||||
app.use('*', readOnlyMiddleware);
|
||||
app.all('*', (c) => { calls++; return c.text('ok'); });
|
||||
process.env.DB_READ_ONLY = '1';
|
||||
for (const method of ['POST', 'PUT', 'PATCH', 'DELETE']) {
|
||||
assert.equal((await app.request('/api/example', { method })).status, 403);
|
||||
}
|
||||
assert.equal(calls, 0);
|
||||
for (const method of ['GET', 'HEAD', 'OPTIONS']) {
|
||||
assert.equal((await app.request('/api/example', { method })).status, 200);
|
||||
}
|
||||
delete process.env.DB_READ_ONLY;
|
||||
assert.equal((await app.request('/api/example', { method: 'POST' })).status, 200);
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env.DB_READ_ONLY;
|
||||
else process.env.DB_READ_ONLY = previous;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { MiddlewareHandler } from 'hono';
|
||||
|
||||
/** Guard preview write endpoints before auth/route handlers can perform work. */
|
||||
export const readOnlyMiddleware: MiddlewareHandler = async (context, next) => {
|
||||
if (process.env.DB_READ_ONLY === '1'
|
||||
&& !['GET', 'HEAD', 'OPTIONS'].includes(context.req.method)) {
|
||||
return context.json({ error: '当前为只读预览环境,不允许写入操作' }, 403);
|
||||
}
|
||||
return next();
|
||||
};
|
||||
@@ -765,7 +765,8 @@ async function drill(
|
||||
: groupBy === "date"
|
||||
? `DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m-%d')`
|
||||
: "COALESCE(NULLIF(b.license_plate, ''), '无车牌')";
|
||||
const groupOrder = groupBy === "date" ? "id DESC" : "kg DESC";
|
||||
// Stable tie-breakers prevent equal-volume groups drifting between pages.
|
||||
const groupOrder = groupBy === "date" ? "id DESC" : "kg DESC, id ASC, name ASC";
|
||||
const groupHaving =
|
||||
groupBy === "station" ? "HAVING SUM(COALESCE(b.amount_kg, 0)) > 0" : "";
|
||||
const [summaryRows, groupRows, recordRows] = await Promise.all([
|
||||
@@ -864,7 +865,7 @@ async function drill(
|
||||
cost: number(row.cost),
|
||||
revenue: number(row.revenue),
|
||||
})),
|
||||
page: { page, pageSize, hasMore: recordRows[0].length === pageSize },
|
||||
page: { page, pageSize, hasMore: (groupBy === "record" ? recordRows[0] : groupRows[0]).length === pageSize },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -246,13 +246,16 @@ test("氢能 BI v2 利润下钻只保留客户承担订单,并与对客总价
|
||||
} as unknown as HydrogenBiV2Dependencies);
|
||||
|
||||
const response = await v2App.request(
|
||||
"/h2/v2/drill?year=2026&vehicleScope=all&verifyScope=all&groupBy=station&amountScope=customer",
|
||||
"/h2/v2/drill?year=2026&vehicleScope=all&verifyScope=all&groupBy=station&amountScope=customer&pageSize=1&page=2",
|
||||
);
|
||||
assert.equal(response.status, 200);
|
||||
const payload = await response.json();
|
||||
assert.equal(payload.amountScope, "customer");
|
||||
assert.equal(payload.page.hasMore, true);
|
||||
assert.deepEqual((calls[1].params as unknown[]).slice(-2), [1, 1]);
|
||||
assert.equal(payload.summary.revenue - payload.summary.cost, 60);
|
||||
assert.equal(calls.length, 2);
|
||||
assert.match(calls[1].sql, /ORDER BY kg DESC, id ASC, name ASC LIMIT \? OFFSET \?/);
|
||||
for (const call of calls) {
|
||||
assert.match(
|
||||
call.sql,
|
||||
|
||||
Reference in New Issue
Block a user