Files
ln-bi/src/server/routes/asset-assistant/gaps.ts
T
shishengliang f1b3f8d3da
ci/woodpecker/push/woodpecker Pipeline was successful
feat(assets): add dimension analysis and authorized user previews
Refactor asset drilldowns with combined dimension filters, parent summaries and progressive loading. Add the hidden /who read-only permission preview with scoped authorization, identity mapping and audit logging. Refine inventory counts and AI问数 presentation.
2026-09-28 12:29:51 +08:00

82 lines
3.0 KiB
TypeScript

import { createHash } from "node:crypto";
import type { RowDataPacket } from "mysql2";
import pool from "../../db.js";
import type {
DemandGap,
GapReason,
GapStatus,
} from "../../../shared/assets/assistant.js";
// Store the unmet request and concise analysis context for product feedback, not full sessions or result rows.
let ready: Promise<void> | undefined;
const purge = () =>
pool.query(
"DELETE FROM bi_asset_ai_gaps WHERE last_seen < DATE_SUB(UTC_TIMESTAMP(), INTERVAL 90 DAY)",
);
const SCHEMA = `CREATE TABLE IF NOT EXISTS bi_asset_ai_gaps (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
user_id VARCHAR(64) NOT NULL, fingerprint CHAR(64) NOT NULL,
demand VARCHAR(600) NOT NULL, reason VARCHAR(24) NOT NULL, status VARCHAR(24) NOT NULL DEFAULT 'open',
occurrences INT NOT NULL DEFAULT 1, first_seen DATETIME NOT NULL, last_seen DATETIME NOT NULL,
UNIQUE KEY user_demand (user_id, fingerprint), KEY last_seen (last_seen)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`;
async function ensure() {
if (!ready)
ready = (async () => {
// Precreated production tables need no DDL permission.
try {
await pool.query("SELECT id FROM bi_asset_ai_gaps LIMIT 0");
} catch (error) {
if ((error as { code?: string }).code !== "ER_NO_SUCH_TABLE")
throw error;
await pool.query(SCHEMA);
}
await purge();
setInterval(
() => {
void purge().catch(() =>
console.warn("asset_ai_gap_retention_failed"),
);
},
60 * 60 * 1000,
).unref();
})().catch((error) => {
ready = undefined;
throw error;
});
await ready;
}
export const gapStore = {
async record(userId: string, demand: string, reason: GapReason) {
await ensure();
const fingerprint = createHash("sha256")
.update(reason + "|" + demand)
.digest("hex");
await pool.query(
`INSERT INTO bi_asset_ai_gaps (user_id,fingerprint,demand,reason,first_seen,last_seen)
VALUES (?,?,?,?,UTC_TIMESTAMP(),UTC_TIMESTAMP()) ON DUPLICATE KEY UPDATE
occurrences=occurrences+1, last_seen=UTC_TIMESTAMP(), status=IF(status='resolved','open',status)`,
[userId, fingerprint, demand, reason],
);
},
async list(userId: string | null, options: { readOnly?: boolean } = {}): Promise<DemandGap[]> {
if (!options.readOnly) {
await ensure();
await purge();
}
const [rows] = await pool.query<RowDataPacket[]>(
`SELECT id,demand,reason,status,occurrences,
DATE_FORMAT(first_seen,'%Y-%m-%dT%H:%i:%sZ') firstSeen, DATE_FORMAT(last_seen,'%Y-%m-%dT%H:%i:%sZ') lastSeen
FROM bi_asset_ai_gaps WHERE ${userId ? "user_id = ? AND " : ""}last_seen >= DATE_SUB(UTC_TIMESTAMP(), INTERVAL 90 DAY) ORDER BY last_seen DESC LIMIT 200`,
userId ? [userId] : [],
);
return rows as DemandGap[];
},
async update(id: number, status: GapStatus) {
await ensure();
await pool.query("UPDATE bi_asset_ai_gaps SET status=? WHERE id=?", [
status,
id,
]);
},
};