diff --git a/app/static/portal/common/mock-data.js b/app/static/portal/common/mock-data.js index 41df12f..0c20788 100644 --- a/app/static/portal/common/mock-data.js +++ b/app/static/portal/common/mock-data.js @@ -15,6 +15,8 @@ const PRODUCT_BASE = { single_investor_max_holding_ratio: '10.0000', management_fee_rate: '0.5000', custodian_fee_rate: '0.1000', + //: 产品级说明,默认无。只有需要额外披露的条目才填,例如「同指数参考产品」。 + product_note: null, risk_disclosure_required: 1, second_confirmation_required: 0, recording_required: 0, @@ -24,7 +26,15 @@ const PRODUCT_BASE = { }; export const MOCK_PRODUCTS = Object.freeze([ - { ...PRODUCT_BASE, id: 1, product_code: '510300', product_name: '南方沪深300ETF', current_nav: '4.218000', current_nav_at: '2026-09-11T15:00:00', product_category: '宽基ETF', risk_level: 'R3' }, + { + ...PRODUCT_BASE, id: 1, product_code: '510300', product_name: '南方沪深300ETF', + current_nav: '4.579400', current_nav_at: '2026-09-11T15:00:00', + product_category: '宽基ETF', risk_level: 'R3', + management_fee_rate: '0.1500', custodian_fee_rate: '0.0500', + // 这只的代码 510300 在真实市场上对应的是**另一家管理人**的沪深300ETF, + // 所以必须显式披露:否则客户会以为它是本公司发行的产品。 + product_note: '本产品为同指数参考产品,非本公司发行的基金,仅用于功能演示;净值与费率取自公开数据源。', + }, { ...PRODUCT_BASE, id: 2, product_code: '510500', product_name: '南方中证500ETF', current_nav: '6.742000', current_nav_at: '2026-09-11T15:00:00', product_category: '宽基ETF', risk_level: 'R4' }, { ...PRODUCT_BASE, id: 3, product_code: '159915', product_name: '南方创业板ETF', current_nav: '2.184000', current_nav_at: '2026-09-11T15:00:00', product_category: '成长ETF', risk_level: 'R4', exchange_code: 'SZSE' }, { ...PRODUCT_BASE, id: 4, product_code: '512100', product_name: '南方中证1000ETF', current_nav: '2.367000', current_nav_at: '2026-09-11T15:00:00', product_category: '宽基ETF', risk_level: 'R4' }, diff --git a/app/static/portal/guest/product-detail/index.html b/app/static/portal/guest/product-detail/index.html index 1ace937..40e6a68 100644 --- a/app/static/portal/guest/product-detail/index.html +++ b/app/static/portal/guest/product-detail/index.html @@ -5,18 +5,19 @@ 基金详情 · 南方财富 - +
返回基金列表

基金详情

+

历史净值

近十二期
----

风险与交易说明

风险等级
最小金额
交易方式场内市价模拟成交

基金净值会随市场变化。历史数据不代表未来表现,交易前应结合自身风险承受能力判断。

- + diff --git a/app/static/portal/guest/product-detail/product-detail.css b/app/static/portal/guest/product-detail/product-detail.css index f0b969e..89b6dcc 100644 --- a/app/static/portal/guest/product-detail/product-detail.css +++ b/app/static/portal/guest/product-detail/product-detail.css @@ -1,5 +1,6 @@ .detail-back { display: inline-block; margin-bottom: var(--space-3); color: var(--brand-dark); font-size: var(--fs-small); } .source-notice { margin-bottom: var(--space-4); padding: var(--space-3) var(--space-4); color: var(--ink-soft); background: var(--accent-soft); border-left: 3px solid var(--accent); font-size: var(--fs-small); line-height: 1.6; } +.product-note { margin-bottom: var(--space-4); padding: var(--space-3) var(--space-4); color: var(--brand-dark); background: var(--surface); border: 1px solid var(--line); border-left: 3px solid var(--brand); border-radius: var(--radius-sm); font-size: var(--fs-small); line-height: 1.6; } .detail-grid { display: grid; grid-template-columns: minmax(0, 2fr) minmax(280px, 1fr); gap: var(--space-4); } .detail-chart__quote { display: flex; align-items: baseline; gap: var(--space-3); } .detail-chart__quote strong { font-size: var(--fs-metric); } diff --git a/app/static/portal/guest/product-detail/product-detail.js b/app/static/portal/guest/product-detail/product-detail.js index b585b8c..f6890b2 100644 --- a/app/static/portal/guest/product-detail/product-detail.js +++ b/app/static/portal/guest/product-detail/product-detail.js @@ -13,6 +13,12 @@ document.querySelector('[data-product-name]').textContent = product.product_name document.querySelector('[data-product-meta]').textContent = `${product.product_code} · ${product.exchange_code} · ${product.product_category}`; // 详情页含历史净值曲线,全部来自 common/mock-data.js(公开产品 HTTP 接口尚未实现)。 document.querySelector('[data-source-notice]').textContent = MOCK_SOURCE_NOTICE; +// 产品级披露:只有带 product_note 的条目才显示(如"同指数参考产品")。 +const noteNode = document.querySelector('[data-product-note]'); +if (product.product_note) { + noteNode.textContent = product.product_note; + noteNode.hidden = false; +} document.querySelector('[data-current-nav]').textContent = product.current_nav; const changeNode = document.querySelector('[data-change]'); changeNode.textContent = formatPercent(change); diff --git a/docs/42-场内基金知识条目草稿.md b/docs/42-场内基金知识条目草稿.md index eec21e2..5cd45cf 100644 --- a/docs/42-场内基金知识条目草稿.md +++ b/docs/42-场内基金知识条目草稿.md @@ -25,9 +25,9 @@ 问:场内基金怎么买?最小买多少? 答:本平台的场内基金按"**手**"撮合,**1 手 = 100 份**,委托数量必须是 100 份的整数倍。 -最小买入金额 = 成交价 × 100 份,因此**随市价变动**,没有固定门槛。 -按当前净值估算,价格 1 元的基金买 1 手约需 100 元,价格 4.5 元的基金买 1 手约需 450 元。 -实际所需金额与可买数量请以交易页面的实时报价为准。 +最小交易金额以**产品详情页披露的字段为准**(本平台产品库中登记为 **100.00 元**); +实际所需金额按成交价 × 份数计算,**随市价变动**。 +实际可买数量与所需金额请以交易页面的实时报价为准。 ### 1.2 场内基金的费用有哪些 @@ -68,8 +68,8 @@ 场外基金按当日净值申赎,通常 T+1 确认。本平台的模拟交易针对**场内**基金, 下单即按市价全额成交;场外基金的业务流程与本模块相互独立。 -> ⚠️ **待你确认**:上面 1.1 里"按净值估算约需金额"的算法(净值 × 100 份)是否需要写进知识? -> 也可以只保留"1 手 = 100 份、金额随市价变动"这种不含算例的表述。 +> **口径已定**(项目方 2026-09-13):「最小交易金额」一律**以产品字段为准**, +> 不用「1 手 × 净值」推算金额 —— 后者会与字段值不一致,客户会看到两套数字。 --- @@ -129,30 +129,29 @@ 该产品的管理费率为 **0.15%/年**、托管费率为 **0.05%/年**(从基金资产中计提,不单独向您收取)。 实际成交金额、可买数量与费用请以交易页面的实时报价为准。 -> 🔴 **这只不是南方基金的产品**。数据源返回的基金全称是 -> 「**华泰柏瑞**沪深300交易型开放式指数证券投资基金」,而库里 `fund_manager` 写的是 -> **「南方基金」**。其余 19 只的全称都带"南方"前缀,只有它例外。 +> ✅ **已按项目方决定处理**(2026-09-13):保留 `fund_manager = "南方基金"` 作为演示口径 +> —— **不**改成"华泰柏瑞",因为改了它就会退出 `MarketQuoteSyncService` 的同步范围, +> 投顾与知识条目也不该再把它算作自家产品;改为**在产品页显式披露**: +> `common/mock-data.js` 给这只加了 `product_note`,详情页会显示 +> 「本产品为同指数参考产品,非本公司发行的基金,仅用于功能演示」。 > -> 这解释了为什么**唯独它**的数据处处特殊: +> 同时**净值与费率已回填为真实值**: > -> | 字段 | 库里 | 真实(东方财富) | +> ``` +> python tools/backfill_product_snapshot.py --codes 510300 --apply --overwrite +> ``` +> +> | 字段 | 原值 | 现值(真实) | > |---|---|---| -> | `fund_manager` | 南方基金 | **华泰柏瑞** | -> | `current_nav` | 4.500000(快照时间**当天**) | 4.5794 | +> | `current_nav` | 4.500000 | **4.5794** | +> | `current_nav_at` | 2026-09-13 08:19:37 | **2026-09-11 15:00:00** | > | `management_fee_rate` | 0.5000 | **0.15** | > | `custodian_fee_rate` | 0.1000 | **0.05** | > -> 而且 `0.50/0.10` 恰好是另外几只 ETF(`159329`/`159382`/`159511`/`588890`)的真实费率, -> 看起来是**照抄过来的值**。 -> -> 影响面比"知识库答不准"更大:`MarketQuoteSyncService` 按 `fund_manager = '南方基金'` -> 筛选同步对象,所以这只**会被当成南方产品一起同步、一起显示**。 -> **请你决定**:是把它的 `fund_manager` 改回"华泰柏瑞"(那它就会退出同步范围、 -> 投顾与知识条目也不该再把它算作自家产品),还是保留现状作为演示数据 —— -> 若保留,建议在演示口径里说清楚"这只是同指数产品、不是本公司产品"。 -> -> ⚠️ 另外两点仍需你定:**(a)**净值该用真实值 4.5794 还是库里的演示值 4.500000; -> **(b)**"1 手约 458 元"与"最小交易金额 100.00 元"仍不相等,请确认以哪个为准。 +> 上面词条里的数字已按真实值书写。**保留一条披露备查**:这只的真实全称是 +> 「华泰柏瑞沪深300交易型开放式指数证券投资基金」,其余 19 只全称都以"南方"开头; +> 且 `0.50/0.10` 恰好是另外四只 ETF(`159329`/`159382`/`159511`/`588890`)的真实费率, +> 原先那组值像是照抄来的。 ### 3.2 其他产品(简版模板) @@ -196,13 +195,30 @@ - 要决定的:知识条目用真实值还是演示值。**若交易链路按库里的 4.5 成交, 而知识说 4.5794,两处口径会打架** —— 这比"知识库有没有这条"更要紧 -**`511810 货币ETF南方`:库里 `0.266100` vs 真实 `0.2332`** +**`511810 货币ETF南方`:不是脏数据,是数据已更新 —— 而且我一开始比错了接口** -- 这只的口径**本身可疑**:货币 ETF 通常以 **100 元/份**为面值(如 511990 华宝添益), - 而 `0.2661` 和 `0.2332` 都不是这个量级 -- 我第一次用 `QUOTE_API`(场内实时价)取它拿到的是 **100.012**,与 `NAV_API` 的 `0.2332` - **差三个数量级** —— 两个接口对这只的含义不同,**需要人工核实该用哪个口径** -- 建议:核实之前,知识条目**不要写这只的具体净值** +查证结果(2026-09-13): + +| 日期 | `DWJZ`(单位净值) | `LJJZ`(累计净值) | +|---|---|---| +| 2026-09-13 | 0.2332 | 0.8740 | +| 2026-09-11 | **0.2661** | 0.8740 | + +- 库里存的 `0.266100` **正是 09-11 的真实 `DWJZ`**,快照时间 `2026-09-11 15:00:00` 也对得上 + ⇒ **它不是脏数据,只是旧值** +- 我先前说的"差三个数量级"是**我自己用错了接口**:`QUOTE_API` 返的是**场内交易价格** + (货币 ETF 约 100 元/份,所以 100.012),而 `current_nav` 的口径是 **`DWJZ` 单位净值**。 + 两者不是一回事,不能混比 +- **口径由既有数据确定**:其余 19 只的 `current_nav` 与 `fetch_history` 的 `DWJZ` 逐只一致 + +⇒ 这只若要刷新,按 `DWJZ` 口径刷成 **0.2332** 即可: + +``` +python tools/backfill_product_snapshot.py --codes 511810 --apply --overwrite +``` + +**本次未执行**,等你确认 —— 因为 0.2661 本身也是真实值(只是旧一天), +刷新属于"要不要把快照推进到最新",是运维节奏问题,不是修错。 > 复现:`python tools/fetch_live_quotes.py`(默认表格对比,`--json` 输出结构化结果, > `--no-fees` 可跳过费率抓取)。该脚本只报告差异、**不写库** —— `fin_product.current_nav` diff --git a/tools/backfill_product_fees.py b/tools/backfill_product_fees.py deleted file mode 100644 index dc1ba2f..0000000 --- a/tools/backfill_product_fees.py +++ /dev/null @@ -1,144 +0,0 @@ -"""把真实费率回填到 `fin_product`(默认 dry-run,只有 `--apply` 才写库)。 - -## 为什么需要它 - -`fin_product.management_fee_rate` / `custodian_fee_rate` 在本机 **20 只里 19 只为空**, -客户问"管理费多少"只能转人工。费率可以从东方财富 f10 基金概况页取到 -(`EastmoneyFundAdapter.fetch_fees`),本脚本负责把它落到库里。 - -## 安全设计 - -1. **默认 dry-run**:只打印将执行的 UPDATE,不写库。要真正写入必须显式给 `--apply`。 -2. **默认只补空值**:`NULL` 才写,**已有值一律不动** —— 因为已有值可能是人为设定 - (本机 `510300` 就是:库里 0.5000/0.1000,真实 0.15/0.05)。要覆盖必须再给 `--overwrite`。 -3. **`--overwrite` 也只改这一只这一类**:它不会顺手把别的东西一起改掉。 -4. 写入走单个事务,任一失败整体回滚。 - -## 用法 - - python tools/backfill_product_fees.py # 看将要改什么 - python tools/backfill_product_fees.py --apply # 只补空值 - python tools/backfill_product_fees.py --apply --overwrite # 连已有值一起改成真实值 -""" - -from __future__ import annotations - -import argparse -import asyncio -import sys -from datetime import UTC, datetime -from pathlib import Path - -from sqlalchemy import text - -PROJECT_ROOT = Path(__file__).resolve().parents[1] -if str(PROJECT_ROOT) not in sys.path: - sys.path.insert(0, str(PROJECT_ROOT)) - -from app.infrastructure.db import SessionFactory # noqa: E402 -from app.infrastructure.fund_market_adapter import EastmoneyFundAdapter # noqa: E402 - -if hasattr(sys.stdout, "reconfigure"): - sys.stdout.reconfigure(errors="replace") # type: ignore[union-attr] - -#: 与 `MarketQuoteSyncService.sync` 同一批产品。 -PRODUCT_QUERY = """ - SELECT id, product_code, product_name, management_fee_rate, custodian_fee_rate - FROM fin_product - WHERE fund_manager = '南方基金' - AND exchange_code IN ('SSE', 'SZSE') - AND status = '上市' - ORDER BY product_code -""" - -#: 库字段 ↔ 数据源字段。 -FEE_FIELDS = ( - ("management_fee_rate", "management_fee_rate"), - ("custodian_fee_rate", "custodian_fee_rate"), -) - - -def _same(left: object, right: object) -> bool: - if left is None or right is None: - return False - try: - return abs(float(str(left)) - float(str(right))) < 1e-9 - except (TypeError, ValueError): - return str(left) == str(right) - - -async def main() -> int: - parser = argparse.ArgumentParser(description="把真实费率回填到 fin_product") - parser.add_argument("--apply", action="store_true", help="真正写库(默认只看不改)") - parser.add_argument( - "--overwrite", action="store_true", - help="连已有值也改(默认只补 NULL,不动人为设定过的值)", - ) - args = parser.parse_args() - - async with SessionFactory() as session: - rows = (await session.execute(text(PRODUCT_QUERY))).mappings().all() - - adapter = EastmoneyFundAdapter() - fees = await adapter.fetch_fees([str(row["product_code"]) for row in rows]) - print(f"产品 {len(rows)} 只;取到真实费率 {len(fees)} 只\n") - - updates: list[tuple[int, str, str, object]] = [] - conflicts: list[str] = [] - for row in rows: - code = str(row["product_code"]) - live = fees.get(code) - if not live: - continue - for column, key in FEE_FIELDS: - live_value = live.get(key) - if live_value is None: - continue - stored = row[column] - if stored is not None and not _same(stored, live_value): - conflicts.append( - f"{code} {row['product_name']} 的 {column}:库里 {stored} → 真实 {live_value}" - ) - if not args.overwrite: - continue - if stored is not None and not args.overwrite: - continue - if _same(stored, live_value): - continue - updates.append((int(row["id"]), code, column, live_value)) - - if conflicts: - print("与库里已有值冲突(默认不动,需要 --overwrite 才覆盖):") - for line in conflicts: - print(f" * {line}") - print() - - if not updates: - print("没有需要回填的字段。") - return 0 - - print(f"将执行 {len(updates)} 条 UPDATE:") - for _, code, column, value in updates: - print(f" · {code} {column} = {value}") - - if not args.apply: - print("\n[dry-run] 未写库。加 --apply 执行。") - return 0 - - now = datetime.now(UTC).replace(tzinfo=None) - async with SessionFactory() as session, session.begin(): - for product_id, _, column, value in updates: - # 列名来自本模块常量,不是外部输入;值走参数绑定。 - await session.execute( - text( - f"UPDATE fin_product SET {column} = :value, updated_at = :now " - "WHERE id = :product_id" - ), - {"value": value, "now": now, "product_id": product_id}, - ) - print(f"\n已写入 {len(updates)} 个字段。") - return 0 - - -if __name__ == "__main__": - sys.exit(asyncio.run(main())) diff --git a/tools/backfill_product_snapshot.py b/tools/backfill_product_snapshot.py new file mode 100644 index 0000000..537add8 --- /dev/null +++ b/tools/backfill_product_snapshot.py @@ -0,0 +1,192 @@ +"""把真实行情快照(净值 + 费率)回填到 `fin_product`(默认 dry-run,只有 `--apply` 才写库)。 + +## 为什么需要它 + +`fin_product` 里两个费率字段在本机 **20 只里 19 只为空**,客户问"管理费多少"只能转人工; +`current_nav` 则有几只被人为改成演示值。这些都能从东方财富取到 +(`EastmoneyFundAdapter.fetch_history` / `fetch_fees`),本脚本负责落到库里。 + +## 口径 + +- **净值取 `DWJZ`(单位净值)**,不是场内交易价。这条口径由既有数据确定: + 库里 19 只的 `current_nav` 与 `fetch_history` 的 `DWJZ` 逐只一致。 + 货币 ETF 是唯一容易搞混的 —— 它 `QUOTE_API` 的 `f2` 约 100 元(场内价), + 而 `DWJZ` 是 0.2332 这种量级,**两者不是一回事,不要混用**。 +- 费率取 f10 基金概况页(管理费/托管费),单位是「%/年」。 + +## 安全设计 + +1. **默认 dry-run**:只打印将执行的 UPDATE,不写库。真写要显式 `--apply`。 +2. **默认只补空值**:`NULL` 才写,**已有值一律不动** —— 已有值可能是人为设定。 + 净值字段**从来不为空**,所以刷新净值必须显式给 `--overwrite`。 +3. **`--codes` 限定范围**:只回填指定产品,避免"顺手"把全部快照刷成最新 + (全量刷新是 `ProductHistorySyncService` / `MarketQuoteSyncService` 的职责)。 +4. 写入走单个事务,任一失败整体回滚。 + +## 用法 + + python tools/backfill_product_snapshot.py # 看将改什么 + python tools/backfill_product_snapshot.py --apply # 只补空值(费率) + python tools/backfill_product_snapshot.py --apply --overwrite --codes 510300 + # 连净值和已有费率一起改 +""" + +from __future__ import annotations + +import argparse +import asyncio +import sys +from datetime import UTC, date, datetime, time +from pathlib import Path + +from sqlalchemy import text + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from app.infrastructure.db import SessionFactory # noqa: E402 +from app.infrastructure.fund_market_adapter import EastmoneyFundAdapter # noqa: E402 + +if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(errors="replace") # type: ignore[union-attr] + +#: 与 `MarketQuoteSyncService.sync` 同一批产品。 +PRODUCT_QUERY = """ + SELECT id, product_code, product_name, current_nav, current_nav_at, + management_fee_rate, custodian_fee_rate + FROM fin_product + WHERE fund_manager = '南方基金' + AND exchange_code IN ('SSE', 'SZSE') + AND status = '上市' + ORDER BY product_code +""" + +#: 库里已有值 → 数据源字段。**净值排在最前**,便于阅读。 +FEE_FIELDS = ( + ("management_fee_rate", "management_fee_rate"), + ("custodian_fee_rate", "custodian_fee_rate"), +) + +#: 净值快照写回时的时间部分:与库里既有记录的 `15:00:00` 保持一致(收盘时刻)。 +NAV_AT_TIME = time(15, 0, 0) + + +def _same(left: object, right: object) -> bool: + """比较两个数值文本,容忍 `0.5` 与 `0.5000`、`4.5` 与 `4.500000` 这种表示差异。""" + if left is None or right is None: + return False + try: + return abs(float(str(left)) - float(str(right))) < 1e-9 + except (TypeError, ValueError): + return str(left) == str(right) + + +async def main() -> int: + parser = argparse.ArgumentParser(description="把真实净值与费率回填到 fin_product") + parser.add_argument("--apply", action="store_true", help="真正写库(默认只看不改)") + parser.add_argument( + "--overwrite", action="store_true", + help="连已有值也改(默认只补 NULL,不动人为设定过的值)", + ) + parser.add_argument( + "--codes", default="", + help="只处理这些产品代码(逗号分隔);留空表示全部", + ) + args = parser.parse_args() + wanted = {code.strip() for code in args.codes.split(",") if code.strip()} + + async with SessionFactory() as session: + rows = (await session.execute(text(PRODUCT_QUERY))).mappings().all() + if wanted: + rows = [row for row in rows if str(row["product_code"]) in wanted] + if not rows: + print("没有匹配的产品。") + return 1 + + adapter = EastmoneyFundAdapter() + codes = [str(row["product_code"]) for row in rows] + histories = await asyncio.gather( + *(adapter.fetch_history(code, date.today()) for code in codes), + return_exceptions=True, + ) + fees = await adapter.fetch_fees(codes) + + print(f"产品 {len(rows)} 只;取到净值 {sum(1 for h in histories if isinstance(h, dict) and not h.get('degraded'))} 只" + f"、费率 {len(fees)} 只\n") + + updates: list[tuple[int, str, str, object]] = [] + conflicts: list[str] = [] + for row, history in zip(rows, histories, strict=True): + code = str(row["product_code"]) + live_nav: object = None + nav_at: datetime | None = None + if isinstance(history, dict) and not history.get("degraded"): + live_nav = history.get("nav") + nav_date = history.get("nav_date") + if isinstance(nav_date, date): + nav_at = datetime.combine(nav_date, NAV_AT_TIME) + if live_nav is not None: + stored_nav = row["current_nav"] + if _same(stored_nav, live_nav): + pass + elif stored_nav is not None and not args.overwrite: + conflicts.append( + f"{code} {row['product_name']} 的 current_nav:" + f"库里 {stored_nav} → 真实 {live_nav}" + ) + else: + updates.append((int(row["id"]), code, "current_nav", live_nav)) + if nav_at is not None: + updates.append((int(row["id"]), code, "current_nav_at", nav_at)) + + live_fee = fees.get(code) or {} + for column, key in FEE_FIELDS: + live_value = live_fee.get(key) + if live_value is None: + continue + stored = row[column] + if _same(stored, live_value): + continue + if stored is not None and not args.overwrite: + conflicts.append( + f"{code} {row['product_name']} 的 {column}:库里 {stored} → 真实 {live_value}" + ) + continue + updates.append((int(row["id"]), code, column, live_value)) + + if conflicts: + print("与库里已有值冲突(默认不动,需要 --overwrite 才覆盖):") + for line in conflicts: + print(f" * {line}") + print() + + if not updates: + print("没有需要回填的字段。") + return 0 + + print(f"将执行 {len(updates)} 条 UPDATE:") + for _, code, column, value in updates: + print(f" · {code} {column} = {value}") + + if not args.apply: + print("\n[dry-run] 未写库。加 --apply 执行。") + return 0 + + now = datetime.now(UTC).replace(tzinfo=None) + async with SessionFactory() as session, session.begin(): + for product_id, _, column, value in updates: + # 列名来自本模块常量,不是外部输入;值一律走参数绑定。 + await session.execute( + text( + f"UPDATE fin_product SET {column} = :value, updated_at = :now " + "WHERE id = :product_id" + ), + {"value": value, "now": now, "product_id": product_id}, + ) + print(f"\n已写入 {len(updates)} 个字段。") + return 0 + + +if __name__ == "__main__": + sys.exit(asyncio.run(main()))