
财经网站源码的目录结构与核心功能拆解
财经网站看起来像是行情表格、新闻列表和几张走势图,真正的工程难点却藏在数据来源、更新时效、数字精度、权限控制和故障处理里。一个可维护的项目,不只是把页面拼出来,还要能回答:数据从哪里来、什么时候更新、展示的数字代表什么、接口出错时用户会看到什么。
本文以 Node.js、Express 和 PostgreSQL 为例,拆解一个财经信息网站的常见目录、数据模型与接口。代码示例用于构建信息展示类应用的骨架,不包含真实行情供应商接入,也不构成交易系统或投资建议。若要接入生产数据,必须按供应商授权和所在地适用规则处理。
一、先界定网站要做什么
“财经网站”并不是单一产品。开发前应把用途划清楚,不同用途对数据、权限和可靠性的要求差别很大。
- 行情与资讯展示:展示股票、指数、外汇或商品的报价、历史数据、新闻和公告。关键要求是标注来源、时间、市场状态与延迟属性。
- 财经内容管理:编辑发布文章、专题和数据解读。重点是内容审核、作者权限、版本记录和版权管理。
- 投资研究工具:提供筛选、图表、指标或模拟组合。需要解释计算口径、数据缺失处理和模型局限,避免把估算结果说成确定结论。
- 交易或账户服务:涉及资金、账户、下单、身份核验等。它不是普通内容网站的自然延伸,必须单独评估资质、监管要求、账户安全、交易风控和审计能力。本文的示例不适用于真实交易。
还要区分“数据展示”和“数据授权”。能够从公开页面看到某项行情,不代表可以抓取、存储、再分发或商业使用。应从有权提供数据的供应商获取授权,核对允许的市场、字段、用户范围、缓存期限及展示方式。不要把爬虫抓到的数据称作实时行情,也不要在没有依据时宣称数据具有某种准确率或更新频率。
二、推荐的目录结构
下面按职责拆分代码。初期可以把部分目录合并,等功能增长后再拆;关键不是目录越多越好,而是避免页面、数据库和业务规则全部挤进一个文件。
finance-site/
├── package.json
├── .env.example
├── src/
│ ├── app.js # 创建 Express 应用、挂载路由和中间件
│ ├── server.js # 读取配置并启动服务
│ ├── config/
│ │ └── env.js # 配置读取与校验
│ ├── routes/
│ │ ├── quotes.js # 行情查询路由
│ │ └── articles.js # 内容查询路由
│ ├── controllers/
│ │ └── quoteController.js # HTTP 参数解析、状态码和响应
│ ├── services/
│ │ └── quoteService.js # 查询规则、业务编排
│ ├── repositories/
│ │ └── quoteRepository.js # 数据库访问
│ ├── db/
│ │ └── pool.js # PostgreSQL 连接池
│ ├── middleware/
│ │ └── errorHandler.js # 统一错误处理
│ ├── validators/
│ │ └── quoteQuery.js # 输入校验
│ └── jobs/
│ └── refreshQuotes.js # 后台更新任务入口
├── migrations/
│ └── 001_create_quotes.sql
├── public/
│ ├── index.html
│ └── app.js
└── tests/
├── quoteQuery.test.js
└── quotes.test.js
- routes:只负责把 URL 和 HTTP 方法映射到控制器,不应在路由文件里直接写复杂 SQL。
- controllers:处理请求与响应,例如读取查询参数、返回状态码。它不应承担行情指标计算或供应商对接细节。
- services:承载业务规则,例如检查品种是否可查询、组合数据来源和更新时间。
- repositories:封装数据库读写,让业务代码不依赖具体 SQL 细节。
- jobs:负责定时或队列任务。行情采集不能依赖用户打开页面时临时执行,否则访问高峰会放大供应商请求和响应延迟。
- migrations:记录数据库结构变更。不要只在开发者本地手工改表,否则测试和部署环境容易不一致。
三、行情数据模型:先定义口径,再设计字段
报价不是一个孤立数字。至少要知道它属于哪个品种、来自哪个来源、对应哪个时间、是实时还是延迟、是否经过调整。历史行情还可能涉及时区、交易日、复权方式、停牌和缺失值。字段含义不明确,后续图表和统计就会出现“代码能跑、结果却说不清”的问题。
下面是 PostgreSQL 的简化表结构。金额类字段使用 NUMERIC,避免将金融数值存成浮点类型。精度和小数位应根据具体资产类别及供应商定义确定,不能一概假设所有市场都保留两位小数。
CREATE TABLE instruments (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
symbol TEXT NOT NULL,
market TEXT NOT NULL,
name TEXT NOT NULL,
currency CHAR(3),
UNIQUE (market, symbol)
);
CREATE TABLE quotes (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
instrument_id BIGINT NOT NULL REFERENCES instruments(id),
price NUMERIC(24, 10) NOT NULL,
change_value NUMERIC(24, 10),
change_percent NUMERIC(12, 6),
source TEXT NOT NULL,
as_of TIMESTAMPTZ NOT NULL,
received_at TIMESTAMPTZ NOT NULL DEFAULT now(),
is_delayed BOOLEAN NOT NULL DEFAULT TRUE
);
CREATE INDEX quotes_instrument_as_of_idx
ON quotes (instrument_id, as_of DESC);
as_of表示报价对应的时间,received_at表示本系统收到数据的时间。两者分开记录,才能发现供应商延迟、网络积压或任务异常。is_delayed只是业务标记,必须依据数据合同和实际来源填充,不能为了页面好看固定写成 false。
数据库中的 NUMERIC 通常会以字符串形式进入 JavaScript 客户端,保留这种表示比直接转换成 Number 更稳妥。JavaScript 的二进制浮点数不能精确表示许多十进制小数;对外展示前可按产品定义格式化,对需要精确计算的业务则应使用经过审查的十进制定点方案,并明确舍入规则。
四、一个可运行的最小接口
先搭一个只读行情接口,验证目录划分和参数处理。此示例使用内存数组模拟数据,便于直接运行;重启后数据会恢复为初始值,也不具备真实行情含义。生产环境应将仓储层替换为授权数据源与数据库,并补上认证、限流、监控和数据质量检查。
创建 package.json:
{
"name": "finance-site-demo",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"start": "node src/server.js"
},
"dependencies": {
"express": "^4.21.2"
}
}
创建 src/server.js:
import express from "express";
const app = express();
app.disable("x-powered-by");
app.use(express.json({ limit: "32kb" }));
// 仅用于演示接口结构,不是真实行情数据。
const quotes = [
{
symbol: "DEMO",
market: "example",
price: "123.4500",
changePercent: "0.250000",
source: "demo",
asOf: new Date().toISOString(),
delayed: true
},
{
symbol: "SAMPLE",
market: "example",
price: "98.1200",
changePercent: "-0.100000",
source: "demo",
asOf: new Date().toISOString(),
delayed: true
}
];
const allowedSortFields = new Set(["symbol", "market"]);
app.get("/api/quotes", (req, res) => {
const rawLimit = req.query.limit ?? "20";
const rawOffset = req.query.offset ?? "0";
const sort = req.query.sort ?? "symbol";
// 只接受十进制整数文本,避免空字符串、科学计数法等意外输入。
if (!/^\d+$/.test(String(rawLimit)) ||
!/^\d+$/.test(String(rawOffset))) {
return res.status(400).json({ error: "limit 和 offset 必须是非负整数" });
}
const limit = Number(rawLimit);
const offset = Number(rawOffset);
// 这是本示例接口自行设定的上限,不是 Express 或 HTTP 的通用限制。
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100) {
return res.status(400).json({ error: "limit 必须在 1 到 100 之间" });
}
if (!Number.isSafeInteger(offset) || offset > 100000) {
return res.status(400).json({ error: "offset 必须在 0 到 100000 之间" });
}
if (typeof sort !== "string" || !allowedSortFields.has(sort)) {
return res.status(400).json({ error: "不支持的排序字段" });
}
const data = [...quotes]
.sort((a, b) => a[sort].localeCompare(b[sort]))
.slice(offset, offset + limit);
res.json({
data,
pagination: { limit, offset, returned: data.length }
});
});
app.use((req, res) => {
res.status(404).json({ error: "Not found" });
});
app.use((err, req, res, next) => {
console.error(err);
if (res.headersSent) return next(err);
res.status(500).json({ error: "Internal server error" });
});
const port = Number(process.env.PORT ?? 3000);
app.listen(port, () => {
console.log(`Demo server listening on port ${port}`);
});
在项目根目录运行:
npm install
npm start
访问 http://localhost:3000/api/quotes?limit=1&offset=0,会得到 JSON 结果。这里对 limit、offset 设定的是应用自身的输入范围:返回数不超过 100,偏移不超过 100000。它们不是语言、框架或协议规定的通用上限,真实产品应根据数据库容量、接口用途和性能测试调整。偏移分页在大数据集上可能越来越慢,可改用基于时间与唯一键的游标分页。
排序字段通过白名单限定,是因为 SQL 参数化查询通常只能安全绑定值,不能把列名作为普通参数绑定。接入数据库时,不要直接把用户传入的 sort 拼进 SQL;应把允许的字段映射到固定 SQL 片段,并仍然使用参数化查询传递筛选值。
五、数据库访问与分层替换
真实项目可以让路由保持稳定,只替换仓储实现。以 PostgreSQL 驱动 pg 为例,核心思路是查询值使用占位符;动态排序则从固定映射中选择,而不是信任用户输入。
const sortColumns = {
symbol: "i.symbol",
market: "i.market"
};
export async function listQuotes(pool, { limit, offset, sort }) {
const column = sortColumns[sort];
if (!column) throw new Error("Unsupported sort field");
const query = `
SELECT i.symbol, i.market, q.price::text AS price,
q.change_percent::text AS "changePercent",
q.source, q.as_of AS "asOf", q.is_delayed AS delayed
FROM quotes q
JOIN instruments i ON i.id = q.instrument_id
ORDER BY ${column} ASC, q.as_of DESC
LIMIT $1 OFFSET $2
`;
const result = await pool.query(query, [limit, offset]);
return result.rows;
}
示例中的 column 来自静态白名单,因此字符串插值不会把未经校验的用户内容带入 SQL。LIMIT 和 OFFSET 则通过占位符传入。实际使用前还应在服务层处理默认品种、无数据返回、数据库超时和连接失败等情况,并避免把数据库原始错误内容返回给客户端。
六、前端展示:把来源和时间一起展示
行情数字脱离时间与来源,容易让用户误以为是当前报价。页面至少应在适当位置标记来源、报价时间和延迟状态。以下是浏览器端的简化示例,使用 textContent 更新文本,避免把接口返回值当作 HTML 执行。
async function loadQuotes() {
const response = await fetch("/api/quotes?limit=20");
if (!response.ok) {
throw new Error(`Request failed: ${response.status}`);
}
const payload = await response.json();
const list = document.querySelector("#quotes");
list.replaceChildren();
for (const quote of payload.data) {
const item = document.createElement("li");
const label = document.createElement("span");
const meta = document.createElement("small");
label.textContent = `${quote.market} ${quote.symbol}: ${quote.price}`;
meta.textContent =
`来源:${quote.source};报价时间:${quote.asOf}` +
(quote.delayed ? ";延迟数据" : "");
item.append(label, document.createTextNode(" "), meta);
list.append(item);
}
}
loadQuotes().catch((error) => {
console.error(error);
document.querySelector("#status").textContent = "行情暂时无法加载";
});
页面格式化应与字段语义匹配。价格、百分比和成交量不能一律使用相同的小数位;币种、市场时区和负数展示规则也应由产品口径决定。用户输入的文章标题、评论和搜索词同样不能未经转义就插入 innerHTML。使用模板引擎时,应确认默认转义行为及其例外。
七、资讯模块与内容管理
资讯模块通常需要文章、作者、分类、标签、发布时间、状态和来源等信息。最小的文章记录可包含标题、正文、摘要、作者、状态、创建时间和发布时间。公开查询只返回已发布内容;草稿和审核中内容必须由具备权限的管理接口读取。
内容编辑界面还要考虑富文本安全。富文本并不等于可信 HTML:如果允许粘贴 HTML,应在服务端使用成熟的 HTML 清洗策略限制标签、属性和 URL 协议;前端仅靠隐藏按钮或移除脚本标签不足以构成可靠防护。对转载内容还应记录授权、出处和编辑记录,不能用技术上的可访问性替代版权许可。
搜索功能要限制查询长度与返回条数,并对数据库进行适当索引。若使用全文检索,应明确分词、语言和相关性排序方式。不要将用户查询直接拼接进正则表达式或 SQL,也不要默认把搜索结果当成已经核实的事实。
八、后台数据更新与时效管理
行情采集适合由独立任务执行:任务从授权来源获取数据,校验字段和时间,写入原始记录或标准化记录,再由 API 提供查询。建议保留足够的来源信息和任务状态,便于排查供应商中断、重复数据、时间错位和异常跳价。采集失败时不应悄悄用旧值冒充新值。
每条记录应有可解释的时间戳。还需要明确服务器、供应商和市场时区之间的转换规则,数据库内部通常统一保存带时区的时间点,展示时再按用户或市场时区格式化。涉及交易日的逻辑不能仅靠“自然日”判断,节假日、临时休市和不同市场交易时段都需要相应日历数据。
任务调度要考虑重复执行和重试。写入操作最好具备幂等性,例如依据供应商记录标识或“品种加时间加来源”建立唯一约束。重试并不总是安全:对于重复扣款等有副作用的操作尤其如此,而本文的只读展示架构也不应被直接扩展成资金操作系统。
九、关键安全与运营边界
- 输入校验不等于完整防护:参数范围校验能拒绝明显异常输入,但不能替代认证、授权、速率限制、审计、超时和容量管理。
- 防 SQL 注入:查询值使用参数化接口;表名、列名、排序方向等结构部分使用固定白名单。不要用字符串拼接用户输入来生成 SQL。
- 控制接口资源消耗:限制分页大小和正文长度,设置数据库查询超时;根据实际部署增加反向代理或应用层限流。单机示例没有实现限流,不应被描述为能抵御滥用或拒绝服务攻击。
- 妥善管理密钥:数据库口令和供应商密钥放在受控的环境配置或密钥管理服务中,不提交到代码仓库,不发送到浏览器。对开发、测试和生产环境使用不同凭据。
- 处理浏览器安全:按需配置 HTTPS、内容安全策略、跨域策略、Cookie 属性和 CSRF 防护。CORS 不是认证机制;允许某个来源跨域读取响应,也不等于该来源有权操作账户。
- 日志避免泄露:记录请求标识、错误类别和必要的运行指标,不要把密码、访问令牌、完整个人信息或敏感密钥写进日志。
- 识别系统边界:行情接口、资讯后台、用户账户和交易执行最好按不同权限与风险设计。只读页面的安全设计不能直接视为资金系统的安全设计。
十、常见错误及修正方式
- 把示例数字当成实时数据。静态样例只用于开发页面和接口。应清楚标注演示数据,并在生产接口中返回实际来源与时间。
- 用浮点数做所有财务计算。浮点表示可能产生小数误差。金额与报价优先采用数据库定点数或经过验证的十进制方案,定义精度、舍入和单位。
- 只保存价格,不保存时间和来源。这样无法判断数据是否过期,也难以追踪异常。至少区分数据对应时间、系统接收时间和数据来源。
- 把业务规则放在路由里。路由越写越长后,校验、查询和错误处理会重复。将 HTTP 处理、业务编排和数据访问分开,测试也更容易。
- 相信前端校验可以保护服务端。客户端校验是用户体验措施,请求可以被绕过。所有信任边界上的输入,都要由服务端重新校验。
- 用用户参数直接拼接 SQL。尤其是排序字段和过滤表达式。值使用占位符,结构使用白名单,并对排序、分页等参数设置合理范围。
- 无条件使用大偏移分页。数据量增长后,数据库可能需要扫描大量记录。热门列表或长历史查询可考虑游标分页、时间窗口和索引优化。
- 静默返回缓存旧值。缓存可以改善延迟,但必须返回可识别的更新时间或状态。过期数据不能伪装成最新数据。
- 把免责声明当作数据质量方案。免责声明不能弥补错误标注、错误计算、越权使用数据或不符合适用规则的问题。数据授权、口径说明和质量监控应分别落实。
十一、从原型到生产的实施顺序
- 先写数据字典:列明品种标识、币种、市场时区、来源、更新时间、延迟属性和价格精度。
- 用静态样例做页面:先验证列表、详情、加载状态、错误状态和移动端布局,不要过早接入未经确认的数据源。
- 明确数据授权和接口契约:确认可用字段、更新方式、调用频率、缓存和再分发限制,再设计采集任务。
- 建立数据库迁移与仓储层:为主要查询建立索引,设计唯一键、保留策略和备份恢复方案。
- 补齐服务端校验与测试:覆盖缺失参数、非法输入、超界分页、空结果、供应商故障和数据库超时。
- 上线前做运营验证:监控请求延迟、错误率、数据新鲜度和任务成功率;准备告警、回滚和故障说明流程。
结语
财经网站的目录结构,应围绕职责边界组织:路由处理 HTTP,服务层处理业务,仓储层访问数据,后台任务负责采集,前端准确呈现口径与状态。真正决定系统是否可靠的,往往不是页面用了什么框架,而是数据授权是否清楚、时间和精度是否定义一致、异常是否可见,以及安全控制是否落在服务端。
可以先用本文的只读示例搭建原型,再逐步接入经过授权的数据源和持久化数据库。每一步都保留来源、时间、精度和错误状态的解释空间,才能让源码不仅“能跑”,也便于维护和审计。




