Compare commits

..

4 Commits

2 changed files with 57 additions and 8 deletions

View File

@ -1,5 +1,21 @@
# Omniledger 架构与开发记忆 (Memory)
## 升级价格抓取引擎,实现从 gtimg 原始报文中提取 Index 30 的真实行情日期,并针对 US/SH/HK 三种日期格式执行标准化 YYYY-MM-DD 转换 (Task 61e)
- 在 `app/api/cron/fetch-prices/route.ts` 中新增 `parseMarketDate(rawString: string)` 核心工具函数,从腾讯财经 gtimg 原始响应(`~` 分隔)的 Index 30 提取真实交易日期。
- 支持三种市场日期格式标准化A股 `20260430161416`14位数字`2026-04-30`;港股 `2026/04/30 16:08:24`(斜杠分隔)→ `2026-04-30`;美股 `2026-05-01 09:31:00`(空格分隔)→ `2026-05-01`
- 重构 `fetchStockPrice` 函数返回值类型为 `{ price: string | null; rawResponse: string | null }`,保留完整原始响应供日期解析使用。
- 更新入库循环:每个 `assetId``date` 字段强制调用 `parseMarketDate(apiResponseString)` 获取,确保 `where` 查重条件与插入值使用同一解析日期,实现跨市场时间戳绝对准确。
- Crypto 资产保持原有 `dateStr`(当天日期)逻辑,因其通过币安 API 获取无内置日期字段。
## 彻底终结 404物理层文件系统审计与幽灵路由重建 (Task 61c)
## 彻底终结 404物理层文件系统审计与幽灵路由重建 (Task 61c)
- **根目录审计结论:** 项目使用根目录 `app/` 作为 Next.js App Router 的活跃根目录(而非 `src/app/``src/app/` 下残留的 `api/` 目录是幽灵路由的根源,导致 Next.js 无法挂载 `/api/cron/fetch-prices` 端点。
- **物理清除:** 已彻底删除 `src/app/api/cron/fetch-prices/route.ts` 及所有空父目录,消除错误的文件位置。
- **规范重建:** 在绝对正确的路径 `app/api/cron/fetch-prices/route.ts` 重新写入符合 Next.js App Router 规范的 Route Handler`export async function GET`),文件后缀为 `.ts`,目录结构严格遵循 `folder/route.ts` 规范。
- **通过物理审计清除了错误的 Next.js 路由文件命名,并重新严格对齐了 App Router 的文件夹/route.ts 规范。**
## 构建 /api/cron/fetch-prices 定时任务端点,实现针对活跃资产的行情抓取与按日期的幂等性 (Idempotent) 价格入库 (Task 61)
- 在 `src/app/api/cron/fetch-prices/route.ts` 创建 Next.js Route Handler (GET),专供定时任务调用。
- **安全拦截:** 在 GET 方法顶部校验 `Authorization` 请求头 (`Bearer ${process.env.CRON_SECRET}`),不匹配则返回 401 Unauthorized`CRON_SECRET` 未配置则返回 500。
@ -72,6 +88,12 @@
- 重构 `<SyncButton />` 并将其提升至 Dashboard 首页,实现总资产大盘的全局一键实盘刷新。
## 修复记录
## 修复 Drizzle ORM 的逻辑或语法错误,将错误的链式 .or() 改写为更具扩展性的 inArray() 语法
- 修复 `app/api/cron/fetch-prices/route.ts``.where(eq(assets.type, 'STOCK').or(eq(assets.type, 'CRYPTO')))` 的非法链式调用语法。
- 废弃错误的 `.where(eq(...).or(eq(...)))` 模式,改为使用 `inArray(assets.type, ['STOCK', 'CRYPTO'])`
- `inArray` 已从 `drizzle-orm` 引入,同时保留了 `eq` 的导入以兼容其他查询。
- `inArray` 写法在语义等價且更具扩展性,未来添加新资产类型(如 FUND, BOND只需在数组中追加枚举值即可。
- 解决了日期选择控件的时区偏移 Bug确保全球通用`src/libs/utils.ts` 中重写 `formatDateForDatetimeLocal()``parseDateTimeLocalToUTC_v2()` 函数,采用 `Intl.DateTimeFormat` 动态获取 `Asia/Shanghai` 时区偏移量,确保 UTC 时间到本地时间的双向转换精确无误,修复了用户选 10 点展示为 2 点的问题。修正了前端数据格式化逻辑,在 `src/lib/formatters.ts` 中增加空值/NaN 兜底处理,在 `src/app/dashboard/page.tsx` 中将平均成本与摊薄成本的显示条件从 `.gt(0)` 改为 `.ne(0)`,支持英特尔负成本等极端场景下的精确数字展示。
## 资产分布图表按市场维度升级 (Task 32)
@ -227,6 +249,12 @@
- Dashboard 表格字段精确对齐:现價→`latestPrice`、市值→`metrics.marketValue`、攤薄/成本→`metrics.dilutedCost / metrics.averageCost`、浮動盈虧→`metrics.floatingPnl`、累計盈虧→`metrics.accumulatedPnl`。
- 累计盈亏验证公式:`accumulatedPnl = marketValue + 卖出/分红现金 - 总投入`确保有卖出或分红记录的资产如英特尔、分红ETF数据精确。
## 修复 Cron API 的 404 挂载丢失问题 (Task 61b)
- 验证并确认 Next.js App Router API 路由已严格遵循规范:文件精确位于 `src/app/api/cron/fetch-prices/route.ts`,后缀为 `.ts`(非 `.tsx`)。
- 确认最外层正确导出 `export async function GET(request: Request)` 方法,包含 `Bearer ${process.env.CRON_SECRET}` 鉴权拦截与完整的 try/catch 错误处理。
- 确认无旧版 `pages/api/cron/fetch-prices` 残留文件导致路由冲突。
- 修复 Next.js App Router 规范下的 API 路由挂载问题,修正 route.ts 文件名与 GET 方法导出,解决 404 错误。
## 修复 getPortfolioPositions 中接入财务引擎时的变量作用域丢失与解构映射错误 (Task 56c)
- 修复了 `src/actions/portfolio.ts``getPortfolioPositions` 函数的 ReferenceError`avgCost is not defined` 和 `dilutedCost is not defined`
- 根本原因:在将财务引擎 (`calculateAssetMetrics`) 接入 portfolio 引擎时,`avgCost` 和 `dilutedCost` 变量名在结果对象装配环节被直接引用,但它们从未在本作用域中声明——它们实际上是 `metrics` 对象的属性 (`metrics.averageCost`, `metrics.dilutedCost`)。

View File

@ -1,7 +1,7 @@
import { NextResponse } from 'next/server';
import { db } from '@/db';
import { assets, assetPricesHistory } from '@/db/schema';
import { eq } from 'drizzle-orm';
import { eq, inArray } from 'drizzle-orm';
import { ProxyAgent, setGlobalDispatcher } from 'undici';
export const dynamic = 'force-dynamic';
@ -14,7 +14,23 @@ function formatDateStr(date: Date): string {
return `${year}-${month}-${day}`;
}
async function fetchStockPrice(asset: { symbol: string; exchange: string | null }): Promise<string | null> {
function parseMarketDate(rawString: string): string {
const parts = rawString.split('~');
const rawDate = parts[30];
if (!rawDate) return new Date().toISOString().split('T')[0];
if (/^\d{14}$/.test(rawDate)) {
return `${rawDate.slice(0, 4)}-${rawDate.slice(4, 6)}-${rawDate.slice(6, 8)}`;
}
if (rawDate.includes('/')) {
return rawDate.split(' ')[0].replace(/\//g, '-');
}
return rawDate.split(' ')[0];
}
async function fetchStockPrice(asset: { symbol: string; exchange: string | null }): Promise<{ price: string | null; rawResponse: string | null }> {
const cleanSymbol = asset.symbol.trim().toUpperCase().replace(/[^0-9A-Z]/g, '');
let tCode: string;
@ -44,10 +60,10 @@ async function fetchStockPrice(asset: { symbol: string; exchange: string | null
const dataArr = match[1].split('~');
const latestPrice = dataArr[3];
if (latestPrice && !isNaN(Number(latestPrice)) && Number(latestPrice) > 0) {
return latestPrice;
return { price: latestPrice, rawResponse: match[1] };
}
}
return null;
return { price: null, rawResponse: null };
}
async function fetchCryptoPrice(asset: { symbol: string }): Promise<string | null> {
@ -92,7 +108,7 @@ export async function GET(req: Request) {
const allAssets = await db
.select()
.from(assets)
.where(eq(assets.type, 'STOCK').or(eq(assets.type, 'CRYPTO')));
.where(inArray(assets.type, ['STOCK', 'CRYPTO']));
if (allAssets.length === 0) {
return NextResponse.json({
@ -113,9 +129,12 @@ export async function GET(req: Request) {
for (const asset of allAssets) {
try {
let price: string | null = null;
let rawResponse: string | null = null;
if (asset.type === 'STOCK') {
price = await fetchStockPrice(asset);
const result = await fetchStockPrice(asset);
price = result.price;
rawResponse = result.rawResponse;
} else if (asset.type === 'CRYPTO') {
price = await fetchCryptoPrice(asset);
}
@ -127,6 +146,8 @@ export async function GET(req: Request) {
continue;
}
const entryDate = asset.type === 'STOCK' && rawResponse ? parseMarketDate(rawResponse) : dateStr;
const existing = await db
.select()
.from(assetPricesHistory)
@ -134,7 +155,7 @@ export async function GET(req: Request) {
eq(assetPricesHistory.assetId, asset.id)
)
.then((rows) =>
rows.filter((row) => row.date === dateStr)
rows.filter((row) => row.date === entryDate)
);
if (existing.length > 0) {
@ -152,7 +173,7 @@ export async function GET(req: Request) {
.values({
assetId: asset.id,
price,
date: dateStr,
date: entryDate,
});
syncedCount++;
results.push({ symbol: asset.symbol, price, status: 'inserted' });