- In-memory TTL cache (server/src/cache.ts) with a get/set/clear surface that a Redis backend can replace later for multi-replica setups. - Cache the expensive opportunities ranking (5 min), item detail/search (1 h), and per-item market prices (10 min). All keyed by request; each read endpoint takes a refresh/force bypass. - Invalidate precisely on writes: price upserts, overrides, profile activate/update, economy config, and game-data reparse (full clear). - AODP anti-spam: a price download now skips items priced within a configurable cooldown (default 15 min, Settings page) unless "force" is checked; the job reports how many fresh items it skipped. - No client-side caching (per request); the browser still calls the API, but repeats are served from the server cache without recompute/AODP. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
58 lines
2.1 KiB
TypeScript
58 lines
2.1 KiB
TypeScript
import type { FastifyInstance } from 'fastify';
|
|
import { get, query } from '../db/index.js';
|
|
import { cached } from '../cache.js';
|
|
|
|
// Item/recipe data only changes when game data is re-parsed (which calls
|
|
// cacheClear()), so these can cache for a long time.
|
|
const ITEM_TTL_MS = 60 * 60 * 1000;
|
|
|
|
export async function itemRoutes(app: FastifyInstance): Promise<void> {
|
|
app.get('/api/items/search', async (req) => {
|
|
const { q = '' } = req.query as { q?: string };
|
|
const queryStr = q.trim();
|
|
if (!queryStr) return { results: [] };
|
|
const results = await cached(`item:search:${queryStr.toLowerCase()}`, ITEM_TTL_MS, () =>
|
|
query(
|
|
`SELECT unique_name, name_en, tier, enchantment, shop_category, shop_subcategory
|
|
FROM items
|
|
WHERE name_en ILIKE ? OR unique_name ILIKE ?
|
|
ORDER BY tier, enchantment, unique_name
|
|
LIMIT 50`,
|
|
[`%${queryStr}%`, `%${queryStr}%`],
|
|
),
|
|
);
|
|
return { results };
|
|
});
|
|
|
|
app.get('/api/items/:uniqueName', async (req, reply) => {
|
|
const { uniqueName } = req.params as { uniqueName: string };
|
|
const data = await cached(`item:detail:${uniqueName}`, ITEM_TTL_MS, async () => {
|
|
const item = await get('SELECT * FROM items WHERE unique_name = ?', [uniqueName]);
|
|
if (!item) return null;
|
|
|
|
const recipes = (await query('SELECT * FROM recipes WHERE output_item = ? ORDER BY source, variant', [uniqueName])) as any[];
|
|
for (const r of recipes) {
|
|
r.inputs = await query(
|
|
`SELECT ri.input_item, ri.count, ri.max_return_amount, i.name_en
|
|
FROM recipe_inputs ri LEFT JOIN items i ON i.unique_name = ri.input_item
|
|
WHERE ri.recipe_id = ?`,
|
|
[r.id],
|
|
);
|
|
}
|
|
|
|
const usedIn = await query(
|
|
`SELECT DISTINCT r.output_item, i.name_en
|
|
FROM recipe_inputs ri
|
|
JOIN recipes r ON r.id = ri.recipe_id
|
|
LEFT JOIN items i ON i.unique_name = r.output_item
|
|
WHERE ri.input_item = ?
|
|
LIMIT 100`,
|
|
[uniqueName],
|
|
);
|
|
return { item, recipes, usedIn };
|
|
});
|
|
|
|
if (!data) return reply.code(404).send({ error: 'item not found' });
|
|
return data;
|
|
});
|
|
}
|