fix(energy): checkpoint validated drill pagination and read-only preview

Co-authored-by: HiFox Agent <agents-noreply@hifox.com>
This commit is contained in:
kfluous
2026-09-05 15:36:09 +08:00
co-authored by HiFox Agent
parent 6c91a6694a
commit 98efde3f75
20 changed files with 564 additions and 71 deletions
+2
View File
@@ -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);
+1
View File
@@ -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);
});
+21
View File
@@ -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');
});
+27
View File
@@ -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;
}
});
+10
View File
@@ -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();
};
+3 -2
View File
@@ -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 },
};
}
+4 -1
View File
@@ -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,