diff --git a/docs/development-state.md b/docs/development-state.md index 7029744..329da9d 100644 --- a/docs/development-state.md +++ b/docs/development-state.md @@ -14,6 +14,11 @@ - 实际主分析与复核均已启用 gpt-6.1-sol,现有加密密钥保留;AI 审核策略版本 2 已启用自动发布。核对实际审计:两条已有候选在 22:11 已经人工发布,一个计划在 22:17 已确认,均早于本轮 AI 启用;App 当前可见两条候选、一个计划、原有模拟账户现金 1000.00。不能把既有人工发布称作 AI 结果;暂无待审核候选,AI 任务列表为空,后续新候选自动处理。没有创建或改写用户资金、计划、成交。 - 部署后的配置校验发现旧数据库 timeout_seconds 上限仍为 30,已新增十六版迁移匹配调用端 120 秒上限,并使用实际数据库隔离 schema 测试 120 秒配置持久化;Go race 与 go vet 通过,正在通过 PR / CI 部署修复。 +## 股票名称搜索修复(2026-10-09) + +- 实际 HTTPS 查询复现:名称“诺普信”无结果,但代码 002215 和名称“诺 普 信”均返回该深交所股票。数据源在名称中填充了空格,原 SQL LIKE 连续匹配漏掉无空格查询,证券并未漏同步。 +- 查询统一忽略名称与输入中的空格、制表符和全角空格,保持原始源名称;转义 LIKE 通配符为字面匹配,避免输入 % 或 _ 返回全部股票。管理端无搜索结果文案与无同步数据区分。真实 PostgreSQL 隔离 schema 的 race 回归覆盖名称/代码/空格和通配符,go vet、管理端类型检查/build/embed 通过;待 PR / CI 上线后实查。 + ## App Expo Go 真机入口(2026-10-09) - 真机空列表排查:公开 HTTPS 下测试用户账户列表为一个已有模拟账户;候选、计划、提醒接口均成功返回空数组。管理员候选列表两条(中洲控股、胜利股份)仍为 PENDING_REVIEW,策略 ACTIVE,尚未人工审核发布;因此 App 机会列表为空,用户也未创建计划。排查只读业务数据,没有替用户发布候选或保留资金。 diff --git a/internal/integration/screening_test.go b/internal/integration/screening_test.go index 685c65d..e999856 100644 --- a/internal/integration/screening_test.go +++ b/internal/integration/screening_test.go @@ -47,6 +47,22 @@ func testScreeningLifecycle(t *testing.T, ctx context.Context, pool *pgxpool.Poo if err := repository.NewMarketRepository(pool).Save(ctx, provider, []model.Instrument{instrument}, time.Now().UTC(), ""); err != nil { t.Fatal(err) } + searchRepo := repository.NewMarketRepository(pool) + if err := searchRepo.Save(ctx, provider, []model.Instrument{{Symbol: "002215", Exchange: "SZSE", Name: "诺 普 信", PriceCents: 1000, ObservedAt: observed}}, time.Now().UTC(), ""); err != nil { + t.Fatal(err) + } + for _, query := range []string{"诺普信", "诺 普 信", " 诺\t普 信 ", "002215", "002 215"} { + stocks, err := searchRepo.List(ctx, query) + if err != nil || len(stocks) != 1 || stocks[0].Symbol != "002215" || stocks[0].Name != "诺 普 信" { + t.Fatalf("padded security search %q: %v, %v", query, stocks, err) + } + } + for _, query := range []string{"not-a-security", "%", "_"} { + stocks, err := searchRepo.List(ctx, query) + if err != nil || len(stocks) != 0 { + t.Fatalf("literal security search %q matched: %v, %v", query, stocks, err) + } + } repo := repository.NewScreeningRepository(pool) targets, err := repo.Targets(ctx, provider) if err != nil || len(targets) != 1 { diff --git a/internal/repository/market.go b/internal/repository/market.go index 8a79536..ca1225b 100644 --- a/internal/repository/market.go +++ b/internal/repository/market.go @@ -7,6 +7,7 @@ import ( "git.sundynix.cn/Blizzard/Veyra/internal/model" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgxpool" + "strings" "time" ) @@ -14,7 +15,10 @@ type MarketRepository struct{ pool *pgxpool.Pool } func NewMarketRepository(pool *pgxpool.Pool) *MarketRepository { return &MarketRepository{pool} } func (r *MarketRepository) List(ctx context.Context, query string) ([]model.Instrument, error) { - rows, e := r.pool.Query(ctx, `SELECT id,symbol,exchange,name,price_cents,observed_at,received_at,source_id,source_version FROM veyra_instrument WHERE symbol LIKE $1 OR name LIKE $1 ORDER BY (price_cents>0) DESC,symbol LIMIT 100`, "%"+query+"%") + // Vendors pad some short names with whitespace (for example "诺 普 信"). + // Normalize only for matching; preserve the supplied name in stored data. + pattern := "%" + strings.NewReplacer(`\`, `\\`, "%", `\%`, "_", `\_`).Replace(query) + "%" + rows, e := r.pool.Query(ctx, `SELECT id,symbol,exchange,name,price_cents,observed_at,received_at,source_id,source_version FROM veyra_instrument WHERE symbol LIKE regexp_replace($1,'[[:space:] ]+','','g') OR regexp_replace(name,'[[:space:] ]+','','g') LIKE regexp_replace($1,'[[:space:] ]+','','g') ORDER BY (price_cents>0) DESC,symbol LIMIT 100`, pattern) if e != nil { return nil, mapError(e) } diff --git a/web/admin/src/components/MarketPanel.tsx b/web/admin/src/components/MarketPanel.tsx index 91671ee..02334ec 100644 --- a/web/admin/src/components/MarketPanel.tsx +++ b/web/admin/src/components/MarketPanel.tsx @@ -11,5 +11,5 @@ export function MarketPanel({onSessionExpired,initialSymbol}:{onSessionExpired:( function fail(e:unknown){if(e instanceof ApiError&&e.status===401)onSessionExpired();else setError(e instanceof Error?e.message:"行情读取失败")} async function load(){setBusy(true);setError("");try{const [stocks,runs]=await Promise.all([request(`/api/admin/v1/instruments?q=${encodeURIComponent(query)}`),request("/api/admin/v1/market/syncs")]);setList(stocks);setTasks(runs)}catch(e){fail(e)}finally{setBusy(false)}} useEffect(()=>{void load()},[]) - return 沪深股票池 · 行情与日线

启用公开行情适配器后,后端启动即同步沪深股票,此后每五分钟更新。此接口用于开发研究,价格按供应商时间展示,休市或同步失败时保留上次快照。不会把抓取时间当成实时行情时间。

{e.preventDefault();void load()}}>setQuery(e.target.value)}/>
{error&&

{error}

}
{["股票","价格","行情时间"].map(v=>)}{list.map(v=>)}
{v}

{v.exchange}:{v.symbol}

{v.price==="0.00"?"无报价":v.price+" 元"}{new Date(v.observed_at).toLocaleString("zh-CN")}
{list.length===0&&

暂无同步数据,请检查系统设置中的行情源与同步记录。

}
{selected&&

{selected.name}

{selected.exchange}:{selected.symbol}

¥{selected.price}
}
最近同步记录
{tasks.length===0&&

还没有同步记录。

}{tasks.map(t=>
{new Date(t.finished_at).toLocaleString("zh-CN")} · {t.status==="COMPLETED"?`同步 ${t.instrument_count} 个证券`:t.message}
)}
+ return 沪深股票池 · 行情与日线

启用公开行情适配器后,后端启动即同步沪深股票,此后每五分钟更新。此接口用于开发研究,价格按供应商时间展示,休市或同步失败时保留上次快照。不会把抓取时间当成实时行情时间。

{e.preventDefault();void load()}}>setQuery(e.target.value)}/>
{error&&

{error}

}
{["股票","价格","行情时间"].map(v=>)}{list.map(v=>)}
{v}

{v.exchange}:{v.symbol}

{v.price==="0.00"?"无报价":v.price+" 元"}{new Date(v.observed_at).toLocaleString("zh-CN")}
{list.length===0&&

{query.trim()?"未找到匹配股票,请尝试股票代码或其他名称。":"暂无同步数据,请检查系统设置中的行情源与同步记录。"}

}
{selected&&

{selected.name}

{selected.exchange}:{selected.symbol}

¥{selected.price}
}
最近同步记录
{tasks.length===0&&

还没有同步记录。

}{tasks.map(t=>
{new Date(t.finished_at).toLocaleString("zh-CN")} · {t.status==="COMPLETED"?`同步 ${t.instrument_count} 个证券`:t.message}
)}
}