ci/woodpecker/push/woodpecker Pipeline was successful
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.
82 lines
3.0 KiB
TypeScript
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,
|
|
]);
|
|
},
|
|
};
|