Compare commits
No commits in common. "f059aeb08ffe60d02b7181d038e41c9e4a81348f" and "85583b7e06cc58efcd43324780d0c64d5e47c4a5" have entirely different histories.
f059aeb08f
...
85583b7e06
28
Memory.md
28
Memory.md
@ -1,21 +1,5 @@
|
||||
# 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。
|
||||
@ -88,12 +72,6 @@
|
||||
- 重构 `<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)
|
||||
@ -249,12 +227,6 @@
|
||||
- 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`)。
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db } from '@/db';
|
||||
import { assets, assetPricesHistory } from '@/db/schema';
|
||||
import { eq, inArray } from 'drizzle-orm';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { ProxyAgent, setGlobalDispatcher } from 'undici';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
@ -14,23 +14,7 @@ function formatDateStr(date: Date): string {
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
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 }> {
|
||||
async function fetchStockPrice(asset: { symbol: string; exchange: string | null }): Promise<string | null> {
|
||||
const cleanSymbol = asset.symbol.trim().toUpperCase().replace(/[^0-9A-Z]/g, '');
|
||||
let tCode: string;
|
||||
|
||||
@ -60,10 +44,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 { price: latestPrice, rawResponse: match[1] };
|
||||
return latestPrice;
|
||||
}
|
||||
}
|
||||
return { price: null, rawResponse: null };
|
||||
return null;
|
||||
}
|
||||
|
||||
async function fetchCryptoPrice(asset: { symbol: string }): Promise<string | null> {
|
||||
@ -108,7 +92,7 @@ export async function GET(req: Request) {
|
||||
const allAssets = await db
|
||||
.select()
|
||||
.from(assets)
|
||||
.where(inArray(assets.type, ['STOCK', 'CRYPTO']));
|
||||
.where(eq(assets.type, 'STOCK').or(eq(assets.type, 'CRYPTO')));
|
||||
|
||||
if (allAssets.length === 0) {
|
||||
return NextResponse.json({
|
||||
@ -129,12 +113,9 @@ 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') {
|
||||
const result = await fetchStockPrice(asset);
|
||||
price = result.price;
|
||||
rawResponse = result.rawResponse;
|
||||
price = await fetchStockPrice(asset);
|
||||
} else if (asset.type === 'CRYPTO') {
|
||||
price = await fetchCryptoPrice(asset);
|
||||
}
|
||||
@ -146,8 +127,6 @@ export async function GET(req: Request) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const entryDate = asset.type === 'STOCK' && rawResponse ? parseMarketDate(rawResponse) : dateStr;
|
||||
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(assetPricesHistory)
|
||||
@ -155,7 +134,7 @@ export async function GET(req: Request) {
|
||||
eq(assetPricesHistory.assetId, asset.id)
|
||||
)
|
||||
.then((rows) =>
|
||||
rows.filter((row) => row.date === entryDate)
|
||||
rows.filter((row) => row.date === dateStr)
|
||||
);
|
||||
|
||||
if (existing.length > 0) {
|
||||
@ -173,7 +152,7 @@ export async function GET(req: Request) {
|
||||
.values({
|
||||
assetId: asset.id,
|
||||
price,
|
||||
date: entryDate,
|
||||
date: dateStr,
|
||||
});
|
||||
syncedCount++;
|
||||
results.push({ symbol: asset.symbol, price, status: 'inserted' });
|
||||
Loading…
Reference in New Issue
Block a user