albion/server/src/routes/items.ts
Заид Омар Медхат c4683ff5b0 Backend caching + AODP refetch cooldown
- 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>
2026-08-23 05:09:38 +05:00

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;
});
}