feat(tradein): Phase 3.2 — /api/v1/search endpoint + Redis cache #479
No reviewers
Labels
No labels
admin
analytics
auth
automation
bug
business
chore
ci
compliance
data
data-moat
docs
duplicate
dx
enhancement
Fable 5 ревью
feedback/max
generative
GG-форсайт
needs-discussion
needs-human
observability
pause-bots
performance
priority/p0
priority/p1
priority/p2
priority/p3
scope/backend
scope/db
scope/devops
scope/frontend
scope/qa
scrapers
security
site-finder
stage/1
stage/2
status/blocked
status/done
status/needs-analysis
status/needs-fix
status/qa
status/ready
status/review
status/wip
tech-debt
tradein
ux
week ревью 1
wontfix
вторичка
ИРД
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: lekss361/gendesign#479
Loading…
Add table
Reference in a new issue
No description provided.
Delete branch "feat/tradein-search-api"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Motivation
Phase 3.2 of Multi-Source Integration.
POST /api/v1/searchwith cross-source filters + Redis 5-min hot cache, reading fromlistings_search_mvmatview (PR #469).Files
app/schemas/search.pyapp/schemas/search_response.pyapp/services/search_query.pyapp/services/cache.pyapp/api/v1/search.pytests/test_search_api.pyapp/main.pyapp/core/config.pypyproject.tomlTotal: 9 files / +506 / -1.
Filters (~20)
Geo radius (lat/lon/radius_m, max 50km) · rooms · rooms_in[] · area_m2_min/max · price_rub_min/max · price_per_m2_max · floor_min/max · year_built_min/max · house_class[] · floors_total_min/max · has_kadastr · sources[] · multi_source_only · require_avito/cian/yandex · address_query (ILIKE) · description_query (tsv plainto_tsquery 'russian') · sort (whitelisted dict: price/area/date/dist_asc) · pagination (page, page_size max 200).
Coordination
CAST(:x AS type), never:x::type@lru_cache(maxsize=1) get_search_cache()build_count_querystrips ORDER BY / LIMIT / OFFSETVault-flagged DEFAULTs (dropped vs Master Plan)
These columns absent from current
listings_search_mv:district / distance_to_metro_m / last_price_change / photos_count— NULL placeholders in matview (returned in response, never filterable until backfilled)house_type / repair_state / has_balcony / listing_date / days_on_market / has_rosreestr_checkWorker incident notes
Spec had 2 line-length E501 violations in
_SORT_SQLandST_DWithincall — worker split string literals (runtime SQL identical). Pytest skipped — WeasyPrint dep missing on Windows test env (pre-existing harness issue, CI runs Linux).Reviewer
deep-code-reviewer — please verify:
:x::type)get/setnever raise (test redis-down path)data/sql/050_search_optimization.sql(e.g.total_areanotarea_m2,lngnotlon,house_ratingexists)prefix="/api/v1")redis>=5.0.0added to pyproject without breaking existing depsRefs
Deep Code Review — verdict: APPROVE
Files reviewed: 9 (P0: 1 — search_query.py SQL builder; P1: 5 — search.py endpoint, cache.py, schemas/, config.py, main.py; P2: 1 pyproject.toml; P3: 2 — schemas/search_response.py + tests)
LOC: +506 / -1 · Time: ~15 min
Critical focus verification
1. SQL injection —
_SORT_SQLwhitelist PASSparams.sort: SortKey = Literal[...]enforced at Pydantic parse layer → unknown values rejected with HTTP 422 before_SORT_SQL[params.sort]lookup. No KeyError → 500 path possible from user input._SORT_SQLvalues are hardcoded SQL strings (incl.dist_ascST_Distance expr) — no concat with user data.address_query→ bound as:addr_likewith%...%wrapping (parameterized, ILIKE-safe in psycopg v3).description_query→plainto_tsquery('russian', CAST(:descq AS text))— config literal hardcoded, query bound.2. psycopg v3 compliance PASS
:[a-z_]+::shorthand in search_query.py. All casts useCAST(:x AS type).text(sql)+ named binds → SQLAlchemy parameterizes correctly via psycopg v3.3. matview column refs PASS (all 20+ columns cross-verified vs
data/sql/050_search_optimization.sql)total_area(matview aliasesl.area_m2 AS total_area) ✓lng(matview aliasesl.lon AS lng) ✓house_rating(matview aliasesh.rating AS house_rating) ✓source_count/sources[]/has_avito/has_cian/has_yandex✓district / distance_to_metro_m / last_price_change / photos_countreturned but never filtered — matches PR claim.tsv(GIN indexlistings_search_mv_tsv_idx) ✓addressILIKE → backed bylistings_search_mv_address_trgm_idx(gin_trgm_ops) — fast.4. Cache failures swallowed PASS
cache.py:36-39get: try/exceptException→ log.warning + return None.cache.py:48-51set: try/exceptException→ log.warning + silent.test_search_cache_hit(fixture mocks cache, no real Redis required for unit tests).5. Singleton Redis pool PASS
@lru_cache(maxsize=1) get_search_cache()returns singleSearchCache(settings.redis_url)instance per process.redis.asyncio.from_urlinternally manages connection pool. Default max connections = 50 per pool — fine for FastAPI uvicorn workers.6. Geo radius 50km cap PASS —
Field(le=50000)validates server-side, rejects 422.7. main.py router wiring PASS
app.include_router(search.router, prefix="/api/v1", tags=["search"])@router.post("/search")→ final URL/api/v1/search✓ (no double prefix).8.
redis>=5.0.0dep PASS — no existing constraint conflict inpyproject.toml. redis-py 5.x async API used (redis.asyncio.from_url,aclose()) correctly.Minor observations (non-blocking)
get_db()is syncSessionbut handler isasync def: DB I/O runs on event loop, blocks. Matches existing pattern intrade_in.py/other endpoints — acceptable, no scope-creep refactor needed.cache.setcalled even whenrows=[]. TTL=300s bounds blast radius — acceptable.asyncio.gather). SyncSessionmakes parallel non-trivial — keep sequential, latency budget OK at <500ms typical.Cross-file impact
app/main.pyaddssearchto imports +include_routerline — clean addition, no signature changes.app/core/config.pyaddsredis_urldefault — backward-compatible (existing services don't read it).Vault cross-check
fixes/regression risk — fresh code path.Verdict
APPROVE — все 4 critical focus points verified. Merging via squash.