Compare commits

...

44 Commits

Author SHA1 Message Date
rosemariejebbjtxbfp d18fc2470b chore(web): add WeChat domain verification file
CI / Go (api) (pull_request) Successful in 54s
CI / Python (ingestion) (pull_request) Successful in 26s
CI / Migrations (postgres) (pull_request) Successful in 27s
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-06-24 07:10:07 +00:00
lixu f29696607a Merge pull request 'feat(api): Redis read cache for product details and search (stage 0)' (#29) from devin/1782283345-redis-read-cache into main
CI / Go (api) (push) Successful in 50s
CI / Python (ingestion) (push) Successful in 37s
CI / Migrations (postgres) (push) Successful in 27s
2026-06-24 14:54:59 +08:00
rosemariejebbjtxbfp a75318b811 feat(api): Redis read cache for product details and search
CI / Go (api) (pull_request) Successful in 54s
CI / Python (ingestion) (pull_request) Successful in 19s
CI / Migrations (postgres) (pull_request) Successful in 27s
Stage-0 caching from docs/scalability.md. The Go API now caches hot
product-detail and search-result reads in Redis with a fail-open,
epoch-versioned scheme; Python ingestion bumps the epoch after a write
run to invalidate the cache globally in O(1).

- api/internal/cache: fail-open Cache (GetJSON/SetJSON) namespaced by an
  epoch counter (og:cache:epoch); disabled when Redis is unconfigured.
- store: ProductByID/ProductByGTIN (24h TTL) and SearchProducts (1h TTL)
  read-through the cache via WithCache.
- ingestion: bump_cache_epoch() called after update_off/seed_off/
  import_bypos/dedup commits when rows changed; best-effort, never fails
  a run. Adds redis dependency.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-06-24 06:52:56 +00:00
lixu 20b9eb0fc9 Merge pull request 'ci: 修复 main 上 Python CI 历史失败(在 python3.12+node 容器内运行)' (#28) from devin/1782280936-fix-python-ci into main
CI / Go (api) (push) Successful in 54s
CI / Python (ingestion) (push) Successful in 23s
CI / Migrations (postgres) (push) Successful in 27s
2026-06-24 14:32:51 +08:00
rosemariejebbjtxbfp 17bc0ed680 ci: remove GitHub-hosted actions, manual checkout + Go from CN mirror
CI / Go (api) (pull_request) Successful in 51s
CI / Python (ingestion) (pull_request) Successful in 21s
CI / Migrations (postgres) (pull_request) Successful in 26s
Self-hosted Gitea runner has flaky/blocked access to github.com, causing
actions/checkout and actions/setup-go to time out intermittently across all
jobs. Replace them with:
- manual git checkout against $GITHUB_SERVER_URL (the Gitea host, reachable
  from job containers)
- Go installed from mirrors.aliyun.com

Python job stays on the python3.12-nodejs20 container (fixes the original
PEP 660 editable-install failure). No more github.com network dependency.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-06-24 06:30:03 +00:00
rosemariejebbjtxbfp 5020fdcc19 ci: re-trigger with public gitea actions url
CI / Go (api) (pull_request) Failing after 11s
CI / Python (ingestion) (pull_request) Failing after 31s
CI / Migrations (postgres) (pull_request) Failing after 11s
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-06-24 06:24:52 +00:00
rosemariejebbjtxbfp 885f6c2b01 ci: re-trigger with self-hosted actions
CI / Go (api) (pull_request) Failing after 12s
CI / Python (ingestion) (pull_request) Failing after 1m31s
CI / Migrations (postgres) (pull_request) Failing after 32s
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-06-24 06:21:01 +00:00
rosemariejebbjtxbfp 2aff6287d2 ci: re-trigger after configuring action proxy
CI / Go (api) (pull_request) Failing after 13s
CI / Python (ingestion) (pull_request) Failing after 31s
CI / Migrations (postgres) (pull_request) Failing after 1m32s
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-06-24 06:10:22 +00:00
rosemariejebbjtxbfp 3364a50c68 ci: run Python job in python3.12+node container
CI / Go (api) (pull_request) Failing after 1m32s
CI / Python (ingestion) (pull_request) Failing after 31s
CI / Migrations (postgres) (pull_request) Failing after 19m16s
setup-python@v5 can't fetch CPython 3.12 on the self-hosted Gitea runner
(it queries the Gitea API for actions/python-versions, which 404s), so the
job silently fell back to the image's system Python 3.10 + pip 22.0.2. That
old pip lacks PEP 660 editable support, so 'pip install -e' failed with
'build backend is missing the build_editable hook', and 3.10 is below the
project's requires-python>=3.11 (code uses datetime.UTC).

Run the job inside nikolaik/python-nodejs:python3.12-nodejs20 which bundles
Python 3.12, Node 20 (for actions/checkout) and modern pip, removing the
GitHub download dependency entirely.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-06-24 06:02:28 +00:00
lixu 936828abae Merge pull request 'perf(search): 浏览路径部分复合索引 (0012)' (#27) from devin/1782279211-search-indexes into main
CI / Go (api) (push) Successful in 49s
CI / Python (ingestion) (push) Failing after 15s
CI / Migrations (postgres) (push) Successful in 31s
2026-06-24 13:35:50 +08:00
rosemariejebbjtxbfp ab7a964934 perf(search): 为浏览路径补部分复合索引 (0012)
CI / Go (api) (pull_request) Successful in 58s
CI / Python (ingestion) (pull_request) Failing after 21s
CI / Migrations (postgres) (pull_request) Successful in 32s
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-06-24 05:33:31 +00:00
lixu 649fcc711c Merge pull request 'docs: 可扩展性设计与路线图 (scalability roadmap)' (#26) from devin/1782278890-scalability-doc into main
CI / Go (api) (push) Successful in 52s
CI / Python (ingestion) (push) Failing after 16s
CI / Migrations (postgres) (push) Successful in 26s
2026-06-24 13:31:21 +08:00
rosemariejebbjtxbfp 3b62f61288 docs: 新增可扩展性设计与路线图 (scalability roadmap)
CI / Go (api) (pull_request) Successful in 59s
CI / Python (ingestion) (pull_request) Failing after 20s
CI / Migrations (postgres) (pull_request) Successful in 34s
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-06-24 05:29:08 +00:00
lixu ba36e3ff4c Merge pull request '新增联系我们页面' (#25) from devin/1782277231-contact-us into main
CI / Go (api) (push) Failing after 32s
CI / Python (ingestion) (push) Failing after 31s
CI / Migrations (postgres) (push) Successful in 35s
2026-06-24 13:02:07 +08:00
rosemariejebbjtxbfp d837dd38bf feat(public-frontend): 新增联系我们页面
CI / Go (api) (pull_request) Successful in 56s
CI / Python (ingestion) (pull_request) Failing after 1m31s
CI / Migrations (postgres) (pull_request) Failing after 32s
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-06-24 05:00:31 +00:00
lixu 53ce705572 Merge pull request '移除首页 API 调用说明入口' (#23) from devin/1782276245-home-remove-api-cta into main
CI / Go (api) (push) Failing after 16m4s
CI / Python (ingestion) (push) Failing after 17s
CI / Migrations (postgres) (push) Successful in 29s
2026-06-24 12:46:03 +08:00
rosemariejebbjtxbfp 836ee73d73 feat(public-frontend): 移除首页 API 调用说明入口
CI / Go (api) (pull_request) Failing after 1m31s
CI / Python (ingestion) (pull_request) Failing after 16s
CI / Migrations (postgres) (pull_request) Successful in 25s
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-06-24 04:44:05 +00:00
lixu dbbad274b6 Merge pull request 'feat(public-frontend): 公开前端 UI 视觉升级' (#22) from devin/1782270985-public-ui-redesign into main
CI / Go (api) (push) Successful in 55s
CI / Python (ingestion) (push) Failing after 14s
CI / Migrations (postgres) (push) Successful in 23s
2026-06-24 12:36:17 +08:00
rosemariejebbjtxbfp d58f46bc80 feat(public-frontend): 公开前端 UI 视觉升级
CI / Go (api) (pull_request) Failing after 32s
CI / Python (ingestion) (pull_request) Failing after 31s
CI / Migrations (postgres) (pull_request) Failing after 32s
引入统一品牌主题(色阶/字体/阴影/动效),重做首页 hero、卡片、导航与页脚,统一各页面的卡片/输入框/按钮样式。

- tailwind: 新增 brand 色阶、Inter 字体、card/glow 阴影与 fade-up 动效
- index.html: 引入 Inter 字体与 theme-color/description meta
- index.css: 双径向渐变背景 + @layer 组件类(.card/.input/.btn-primary 等)
- App/Home/ProductView/Contribute/ApiDocs/Account: 套用新设计系统

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-06-24 03:16:41 +00:00
lixu 8f9a03a929 Merge pull request 'feat(ingestion): import bypos-collector JSONL into goods' (#20) from devin/1782268418-bypos-importer into main
CI / Python (ingestion) (push) Successful in 11s
CI / Migrations (postgres) (push) Successful in 24s
CI / Go (api) (push) Successful in 49s
2026-06-24 10:35:27 +08:00
novaalphastrikeomegaz663 f04da0a135 feat(ingestion): import bypos-collector JSONL into goods
CI / Python (ingestion) (pull_request) Successful in 16s
CI / Migrations (postgres) (pull_request) Successful in 24s
CI / Go (api) (pull_request) Successful in 51s
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-06-24 02:33:45 +00:00
lixu a2ef7319e9 Merge pull request 'chore(tools): add bypos-collector (条码批量采集器源码备份)' (#19) from devin/1782267629-add-bypos-collector into main
CI / Python (ingestion) (push) Successful in 11s
CI / Migrations (postgres) (push) Successful in 22s
CI / Go (api) (push) Successful in 48s
2026-06-24 10:27:15 +08:00
lixu ddda61252b Merge pull request '后台商品档案:点击表头排序' (#18) from devin/1782028836-product-sort into main
CI / Go (api) (push) Successful in 9s
CI / Python (ingestion) (push) Successful in 10s
CI / Migrations (postgres) (push) Successful in 15s
2026-06-21 16:01:56 +08:00
sulaimaannaasif6866 241fd38a56 feat(admin): sortable product list column headers
CI / Go (api) (pull_request) Successful in 11s
CI / Python (ingestion) (pull_request) Successful in 9s
CI / Migrations (postgres) (pull_request) Successful in 14s
Click a column header (名称/品牌/条码/品类/状态/质量分) to sort asc, click
again for desc, and a third time to clear back to the default
most-recently-updated order. Sort key/direction are whitelisted server-side.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-06-21 08:00:36 +00:00
lixu 3178f8a85a Merge pull request 'API 配额分级:免费累计 1000 次 + 公开注册自助领取更高配额密钥' (#17) from devin/1782026795-api-quota-registration into main
CI / Go (api) (push) Successful in 10s
CI / Python (ingestion) (push) Successful in 9s
CI / Migrations (postgres) (push) Successful in 17s
2026-06-21 15:28:59 +08:00
sulaimaannaasif6866 7434e5195e feat(api): tiered cumulative quota + self-service registration
CI / Go (api) (pull_request) Successful in 15s
CI / Python (ingestion) (pull_request) Successful in 10s
CI / Migrations (postgres) (pull_request) Successful in 16s
Anonymous callers get a free cumulative quota (1000 calls per IP); once
exhausted they get 403 quota_exhausted and must register. Public users can
self-register (email+password) to obtain a higher-quota API key, view usage,
and regenerate the key. Quota counters live in Redis; the public API stays
read-only except for the registration writes.

- migration 0011: app_user table + api_key.quota_total + 'registered' tier
- ratelimit: IncrTotal/TotalUsed/CopyTotal lifetime counters
- middleware: enforce cumulative quota + X-Quota-* headers
- store: RegisterUser/Authenticate/RegenerateKey (bcrypt)
- handlers: POST /api/v1/register, /account, /account/regenerate
- admin: quota_total column + registered tier
- public: 'API 密钥' account page + API docs quota section

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-06-21 07:26:41 +00:00
lixu 7f66aad779 Merge pull request 'feat: 可扩展档案模式框架 + 内置 3C(电子) 模式' (#16) from devin/1782022411-archive-kind-3c into main
CI / Go (api) (push) Successful in 12s
CI / Python (ingestion) (push) Successful in 10s
CI / Migrations (postgres) (push) Successful in 14s
2026-06-21 14:15:21 +08:00
sulaimaannaasif6866 916bdd9c7c feat: 可扩展档案模式框架 + 内置 3C(电子) 模式
CI / Go (api) (pull_request) Successful in 13s
CI / Python (ingestion) (pull_request) Successful in 9s
CI / Migrations (postgres) (pull_request) Successful in 14s
- 新增 category.archive_kind 与 kind_field 字段模板表(迁移 0010)
- 种子 electronics 字段模板 + 3C 品类树(手机/笔记本/平板等)
- 后端按档案模式动态计算完整度/合格:食品沿用 food_detail,
  其它模式走 product.attributes + kind_field
- 新增 GET /api/kind-fields?kind= 接口
- 后台编辑页按品类模式动态渲染规格参数表单
- 公开详情页/接口输出带标签的规格表

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-06-21 06:13:42 +00:00
lixu 98eb01f4ea Merge pull request 'feat(admin): 商品编辑页 上一个/下一个 导航' (#15) from devin/1782021020-detail-prevnext into main
CI / Go (api) (push) Successful in 9s
CI / Python (ingestion) (push) Successful in 11s
CI / Migrations (postgres) (push) Successful in 14s
2026-06-21 13:51:19 +08:00
sulaimaannaasif6866 51304d782a feat(admin): 商品编辑页增加上一个/下一个导航
CI / Go (api) (pull_request) Successful in 9s
CI / Python (ingestion) (pull_request) Successful in 10s
CI / Migrations (postgres) (pull_request) Successful in 14s
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-06-21 05:50:20 +00:00
lixu 6e81a7eb36 Merge pull request 'feat(admin): 商品列表分页增强(每页数量+跳页)' (#14) from devin/1782012403-list-pagination into main
CI / Go (api) (push) Successful in 8s
CI / Python (ingestion) (push) Successful in 9s
CI / Migrations (postgres) (push) Successful in 14s
2026-06-21 11:27:41 +08:00
sulaimaannaasif6866 8bee570cb2 feat(admin): 商品列表底部增加每页数量与跳页控制
CI / Go (api) (pull_request) Successful in 9s
CI / Python (ingestion) (pull_request) Successful in 9s
CI / Migrations (postgres) (pull_request) Successful in 14s
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-06-21 03:26:43 +00:00
lixu ed06d269c6 Merge pull request 'style(public): 页脚内容居中' (#13) from devin/1782012124-footer-center into main
CI / Go (api) (push) Successful in 9s
CI / Python (ingestion) (push) Successful in 9s
CI / Migrations (postgres) (push) Successful in 18s
2026-06-21 11:23:02 +08:00
sulaimaannaasif6866 0214253b48 style(public): 页脚内容居中
CI / Go (api) (pull_request) Successful in 8s
CI / Python (ingestion) (pull_request) Successful in 9s
CI / Migrations (postgres) (pull_request) Successful in 14s
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-06-21 03:22:04 +00:00
lixu afced01ba6 Merge pull request 'style(public): 更新页脚免责文案' (#12) from devin/1782011674-footer-text into main
CI / Go (api) (push) Successful in 9s
CI / Python (ingestion) (push) Successful in 10s
CI / Migrations (postgres) (push) Successful in 14s
2026-06-21 11:15:29 +08:00
sulaimaannaasif6866 8812e5f5e3 style(public): 更新页脚免责文案
CI / Go (api) (pull_request) Successful in 9s
CI / Python (ingestion) (pull_request) Successful in 10s
CI / Migrations (postgres) (pull_request) Successful in 13s
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-06-21 03:14:34 +00:00
lixu 01055ded02 Merge pull request 'style(admin): 后台各页容器统一居中' (#11) from devin/1782011126-admin-center into main
CI / Go (api) (push) Successful in 8s
CI / Python (ingestion) (push) Successful in 9s
CI / Migrations (postgres) (push) Successful in 15s
2026-06-21 11:06:31 +08:00
sulaimaannaasif6866 01593dcbdc style(admin): 后台各页容器统一居中 (mx-auto)
CI / Go (api) (pull_request) Successful in 8s
CI / Python (ingestion) (pull_request) Successful in 13s
CI / Migrations (postgres) (pull_request) Successful in 14s
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-06-21 03:05:26 +00:00
lixu afdd26cb59 Merge pull request 'feat(public): 首页移除品牌/产地筛选,合格档案数移至页脚' (#10) from devin/1782010793-home-tweaks into main
CI / Go (api) (push) Successful in 8s
CI / Python (ingestion) (push) Successful in 10s
CI / Migrations (postgres) (push) Successful in 14s
2026-06-21 11:01:03 +08:00
sulaimaannaasif6866 4b4758a60f feat(public): 首页移除品牌/产地筛选,合格档案数移至页脚
CI / Go (api) (pull_request) Successful in 14s
CI / Python (ingestion) (pull_request) Successful in 10s
CI / Migrations (postgres) (pull_request) Successful in 14s
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-06-21 02:59:53 +00:00
lixu f9360c15e3 Merge pull request 'feat(admin): 后台完善 — 分类管理 + 品牌管理 + 新建商品' (#9) from devin/1782002626-admin-category-management into main
CI / Go (api) (push) Successful in 10s
CI / Python (ingestion) (push) Successful in 9s
CI / Migrations (postgres) (push) Successful in 15s
feat(admin): 后台完善 — 分类/品牌管理、新建商品、数据概览、操作日志、批量操作 + 首页 (#9)
2026-06-21 10:00:38 +08:00
sulaimaannaasif6866 f382c27200 feat: 数据概览/操作日志/批量操作 + 首页合格档案数
CI / Go (api) (pull_request) Successful in 13s
CI / Python (ingestion) (pull_request) Successful in 9s
CI / Migrations (postgres) (pull_request) Successful in 14s
后台新增「数据概览」(商品/合格/按状态/品牌/分类/待审核) 与「操作日志」(全局审计分页);商品列表支持多选批量改状态/分类。公开首页标题改为「天工」并展示合格档案数;新增公开接口 /api/v1/stats 与后台 /api/stats、/api/audit、/api/products/bulk。合格口径=quality_score≥0.6 且在用。

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-06-21 01:45:28 +00:00
sulaimaannaasif6866 a36700076e feat(admin): 品牌管理 + 新建商品
CI / Go (api) (pull_request) Successful in 37s
CI / Python (ingestion) (pull_request) Successful in 10s
CI / Migrations (postgres) (pull_request) Successful in 16s
品牌管理:列出品牌及引用商品数,支持改名、合并重复品牌(把源品牌的商品并入目标后删除源)、删除未被引用的品牌。新建商品:商品列表新增「新建商品」入口,填写名称/条码/品牌/品类后创建并进入详情页继续补全。

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-06-21 01:31:07 +00:00
sulaimaannaasif6866 4a693c8fe9 feat(admin): 分类管理(分类树增删改移 + 后台页面)
CI / Go (api) (pull_request) Successful in 1m15s
CI / Python (ingestion) (pull_request) Successful in 10s
CI / Migrations (postgres) (pull_request) Successful in 16s
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-06-21 00:43:49 +00:00
71 changed files with 6181 additions and 311 deletions
+45 -13
View File
@@ -5,6 +5,10 @@ on:
branches: [main]
pull_request:
env:
GO_VERSION: "1.23.12"
GOPROXY: "https://goproxy.cn,direct"
jobs:
go:
name: Go (api)
@@ -25,11 +29,22 @@ jobs:
env:
OPENGOODS_DATABASE_URL: postgres://opengoods:opengoods@postgres:5432/opengoods?sslmode=disable
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: "1.23"
cache-dependency-path: api/go.sum
- name: Checkout
working-directory: ${{ github.workspace }}
run: |
git config --global --add safe.directory '*'
git init -q .
git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
git -c protocol.version=2 fetch -q --no-tags --depth 1 origin "${GITHUB_REF}"
git checkout -q --force FETCH_HEAD
- name: Setup Go
working-directory: ${{ github.workspace }}
run: |
curl -fsSL -o /tmp/go.tgz "https://mirrors.aliyun.com/golang/go${GO_VERSION}.linux-amd64.tar.gz"
rm -rf /usr/local/go
tar -C /usr/local -xzf /tmp/go.tgz
echo "/usr/local/go/bin" >> "$GITHUB_PATH"
echo "$HOME/go/bin" >> "$GITHUB_PATH"
- name: Apply migrations
working-directory: .
run: |
@@ -44,14 +59,20 @@ jobs:
python:
name: Python (ingestion)
runs-on: ubuntu-latest
container:
image: nikolaik/python-nodejs:python3.12-nodejs20
defaults:
run:
working-directory: ingestion
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Checkout
working-directory: ${{ github.workspace }}
run: |
git config --global --add safe.directory '*'
git init -q .
git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
git -c protocol.version=2 fetch -q --no-tags --depth 1 origin "${GITHUB_REF}"
git checkout -q --force FETCH_HEAD
- name: Install
run: pip install -e ".[dev]"
- name: Ruff lint
@@ -77,10 +98,21 @@ jobs:
env:
DBURL: postgres://opengoods:opengoods@postgres:5432/opengoods?sslmode=disable
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: "1.23"
- name: Checkout
working-directory: ${{ github.workspace }}
run: |
git config --global --add safe.directory '*'
git init -q .
git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
git -c protocol.version=2 fetch -q --no-tags --depth 1 origin "${GITHUB_REF}"
git checkout -q --force FETCH_HEAD
- name: Setup Go
run: |
curl -fsSL -o /tmp/go.tgz "https://mirrors.aliyun.com/golang/go${GO_VERSION}.linux-amd64.tar.gz"
rm -rf /usr/local/go
tar -C /usr/local -xzf /tmp/go.tgz
echo "/usr/local/go/bin" >> "$GITHUB_PATH"
echo "$HOME/go/bin" >> "$GITHUB_PATH"
- name: Install golang-migrate
run: go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@v4.18.1
- name: Migrate up
+78 -5
View File
@@ -5,9 +5,20 @@ import ProductList from "./components/ProductList";
import ProductDetail from "./components/ProductDetail";
import SubmissionsPage from "./components/SubmissionsPage";
import ApiKeysPage from "./components/ApiKeysPage";
import { Inbox, KeyRound, LogOut, Package } from "lucide-react";
import CategoriesPage from "./components/CategoriesPage";
import BrandsPage from "./components/BrandsPage";
import StatsPage from "./components/StatsPage";
import AuditLogPage from "./components/AuditLogPage";
import { BarChart3, FolderTree, Inbox, KeyRound, LogOut, Package, ScrollText, Tag } from "lucide-react";
type Tab = "products" | "submissions" | "keys";
type Tab =
| "overview"
| "products"
| "submissions"
| "categories"
| "brands"
| "audit"
| "keys";
type View = { name: "list" } | { name: "detail"; id: string };
export default function App() {
@@ -17,6 +28,7 @@ export default function App() {
const [tab, setTab] = useState<Tab>("products");
const [pending, setPending] = useState<number | null>(null);
const [view, setView] = useState<View>({ name: "list" });
const [navIds, setNavIds] = useState<string[]>([]);
useEffect(() => {
if (!authed) return;
@@ -72,6 +84,16 @@ export default function App() {
·
</div>
<nav className="flex items-center gap-1 text-sm">
<button
onClick={() => setTab("overview")}
className={`px-3 py-1.5 rounded-md flex items-center gap-1.5 ${
tab === "overview"
? "bg-emerald-50 text-emerald-700"
: "text-gray-600 hover:bg-gray-100"
}`}
>
<BarChart3 className="h-4 w-4" />
</button>
<button
onClick={() => {
setTab("products");
@@ -100,6 +122,39 @@ export default function App() {
</span>
)}
</button>
<button
onClick={() => {
setTab("categories");
setView({ name: "list" });
}}
className={`px-3 py-1.5 rounded-md flex items-center gap-1.5 ${
tab === "categories"
? "bg-emerald-50 text-emerald-700"
: "text-gray-600 hover:bg-gray-100"
}`}
>
<FolderTree className="h-4 w-4" />
</button>
<button
onClick={() => setTab("brands")}
className={`px-3 py-1.5 rounded-md flex items-center gap-1.5 ${
tab === "brands"
? "bg-emerald-50 text-emerald-700"
: "text-gray-600 hover:bg-gray-100"
}`}
>
<Tag className="h-4 w-4" />
</button>
<button
onClick={() => setTab("audit")}
className={`px-3 py-1.5 rounded-md flex items-center gap-1.5 ${
tab === "audit"
? "bg-emerald-50 text-emerald-700"
: "text-gray-600 hover:bg-gray-100"
}`}
>
<ScrollText className="h-4 w-4" />
</button>
<button
onClick={() => setTab("keys")}
className={`px-3 py-1.5 rounded-md flex items-center gap-1.5 ${
@@ -123,14 +178,32 @@ export default function App() {
</div>
</header>
<main className="flex-1 overflow-auto p-6">
{tab === "keys" ? (
{tab === "overview" ? (
<StatsPage />
) : tab === "audit" ? (
<AuditLogPage />
) : tab === "keys" ? (
<ApiKeysPage />
) : tab === "categories" ? (
<CategoriesPage />
) : tab === "brands" ? (
<BrandsPage />
) : tab === "submissions" ? (
<SubmissionsPage onPending={setPending} />
) : view.name === "list" ? (
<ProductList onOpen={(id) => setView({ name: "detail", id })} />
<ProductList
onOpen={(id, ids) => {
setNavIds(ids);
setView({ name: "detail", id });
}}
/>
) : (
<ProductDetail id={view.id} onBack={() => setView({ name: "list" })} />
<ProductDetail
id={view.id}
ids={navIds}
onNavigate={(id) => setView({ name: "detail", id })}
onBack={() => setView({ name: "list" })}
/>
)}
</main>
</div>
+68 -2
View File
@@ -51,16 +51,30 @@ export const api = {
body: JSON.stringify({ username, password }),
}),
me: () => request<{ username: string }>("/me"),
listProducts: (q: string, page: number, size: number) =>
listProducts: (
q: string,
page: number,
size: number,
sort?: string,
order?: string,
) =>
request<{
items: import("./types").ProductRow[];
page: number;
size: number;
total: number;
completeness_fields: string[];
}>(`/products?q=${encodeURIComponent(q)}&page=${page}&size=${size}`),
}>(
`/products?q=${encodeURIComponent(q)}&page=${page}&size=${size}` +
(sort ? `&sort=${sort}&order=${order || "asc"}` : ""),
),
getProduct: (id: string) =>
request<import("./types").ProductDetail>(`/products/${id}`),
createProduct: (body: unknown) =>
request<import("./types").ProductDetail>("/products", {
method: "POST",
body: JSON.stringify(body),
}),
updateProduct: (id: string, body: unknown) =>
request<import("./types").ProductDetail>(`/products/${id}`, {
method: "PUT",
@@ -102,8 +116,41 @@ export const api = {
),
listBrands: () =>
request<{ items: import("./types").Brand[] }>("/brands"),
createBrand: (name: string) =>
request<import("./types").Brand>("/brands", {
method: "POST",
body: JSON.stringify({ name }),
}),
updateBrand: (id: string, name: string) =>
request<import("./types").Brand>(`/brands/${id}`, {
method: "PUT",
body: JSON.stringify({ name }),
}),
mergeBrands: (id: string, targetId: string) =>
request<import("./types").Brand>(`/brands/${id}/merge`, {
method: "POST",
body: JSON.stringify({ target_id: targetId }),
}),
deleteBrand: (id: string) =>
request<{ status: string }>(`/brands/${id}`, { method: "DELETE" }),
listKindFields: (kind: string) =>
request<{ items: import("./types").KindField[]; kind: string }>(
`/kind-fields?kind=${encodeURIComponent(kind)}`,
),
listCategories: () =>
request<{ items: import("./types").Category[] }>("/categories"),
createCategory: (body: import("./types").CategoryInput) =>
request<import("./types").Category>("/categories", {
method: "POST",
body: JSON.stringify(body),
}),
updateCategory: (id: string, body: import("./types").CategoryInput) =>
request<import("./types").Category>(`/categories/${id}`, {
method: "PUT",
body: JSON.stringify(body),
}),
deleteCategory: (id: string) =>
request<{ status: string }>(`/categories/${id}`, { method: "DELETE" }),
listSubmissions: (status: string, page: number, size: number) =>
request<{
items: import("./types").SubmissionRow[];
@@ -130,6 +177,7 @@ export const api = {
owner_email?: string;
tier?: string;
rate_limit_per_min?: number;
quota_total?: number;
}) =>
request<{ key: string; item: import("./types").ApiKey; warning: string }>(
"/keys",
@@ -137,4 +185,22 @@ export const api = {
),
revokeApiKey: (id: string) =>
request<{ status: string }>(`/keys/${id}`, { method: "DELETE" }),
stats: () => request<import("./types").AdminStats>("/stats"),
auditLog: (page: number, size: number) =>
request<{
items: import("./types").AuditLogRow[];
page: number;
size: number;
total: number;
}>(`/audit?page=${page}&size=${size}`),
bulkProducts: (body: {
ids: string[];
action: "status" | "category";
status?: string;
category_id?: string | null;
}) =>
request<{ status: string; affected: number }>("/products/bulk", {
method: "POST",
body: JSON.stringify(body),
}),
};
+32 -6
View File
@@ -4,9 +4,10 @@ import type { ApiKey } from "../types";
import { Copy, KeyRound, Plus, Trash2 } from "lucide-react";
const TIERS = [
{ key: "free", label: "免费 (free)", rate: 120 },
{ key: "partner", label: "合作方 (partner)", rate: 600 },
{ key: "internal", label: "内部 (internal)", rate: 6000 },
{ key: "free", label: "免费 (free)", rate: 120, quota: 1000 },
{ key: "registered", label: "注册用户 (registered)", rate: 300, quota: 100000 },
{ key: "partner", label: "合作方 (partner)", rate: 600, quota: 0 },
{ key: "internal", label: "内部 (internal)", rate: 6000, quota: 0 },
];
function tierLabel(tier: string): string {
@@ -44,7 +45,7 @@ export default function ApiKeysPage() {
}
return (
<div className="max-w-4xl">
<div className="mx-auto max-w-4xl">
<div className="flex items-center justify-between mb-4">
<div>
<h2 className="text-lg font-semibold text-gray-800 flex items-center gap-2">
@@ -111,6 +112,7 @@ export default function ApiKeysPage() {
<th className="px-4 py-2 font-medium"></th>
<th className="px-4 py-2 font-medium">/</th>
<th className="px-4 py-2 font-medium">(/)</th>
<th className="px-4 py-2 font-medium"></th>
<th className="px-4 py-2 font-medium"></th>
<th className="px-4 py-2 font-medium"></th>
</tr>
@@ -118,7 +120,7 @@ export default function ApiKeysPage() {
<tbody className="divide-y">
{rows.length === 0 ? (
<tr>
<td colSpan={7} className="px-4 py-8 text-center text-gray-400">
<td colSpan={8} className="px-4 py-8 text-center text-gray-400">
</td>
</tr>
@@ -139,6 +141,15 @@ export default function ApiKeysPage() {
<td className="px-4 py-2 text-gray-600">
{k.usage.today} / {k.usage.total}
</td>
<td className="px-4 py-2 text-gray-600">
{k.quota_total > 0 ? (
<span className={k.usage.total >= k.quota_total ? "text-red-600" : ""}>
{k.usage.total.toLocaleString()} / {k.quota_total.toLocaleString()}
</span>
) : (
<span className="text-gray-400"></span>
)}
</td>
<td className="px-4 py-2">
{k.revoked_at ? (
<span className="text-xs rounded px-2 py-0.5 bg-red-50 text-red-700">
@@ -182,13 +193,17 @@ function CreateKeyForm({
const [ownerEmail, setOwnerEmail] = useState("");
const [tier, setTier] = useState("free");
const [rate, setRate] = useState(120);
const [quota, setQuota] = useState(1000);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
function pickTier(t: string) {
setTier(t);
const def = TIERS.find((x) => x.key === t);
if (def) setRate(def.rate);
if (def) {
setRate(def.rate);
setQuota(def.quota);
}
}
async function submit() {
@@ -204,6 +219,7 @@ function CreateKeyForm({
owner_email: ownerEmail.trim() || undefined,
tier,
rate_limit_per_min: rate,
quota_total: quota,
});
onCreated(res.key);
} catch (e) {
@@ -260,6 +276,16 @@ function CreateKeyForm({
onChange={(e) => setRate(Math.max(1, parseInt(e.target.value || "1", 10)))}
/>
</label>
<label className="block">
<span className="text-xs text-gray-500">0=</span>
<input
type="number"
min={0}
className="w-full border rounded-md px-3 py-2 text-sm mt-1"
value={quota}
onChange={(e) => setQuota(Math.max(0, parseInt(e.target.value || "0", 10)))}
/>
</label>
</div>
<div className="mt-4 flex gap-2">
<button
@@ -0,0 +1,115 @@
import { useEffect, useState } from "react";
import { api, ApiError } from "../api";
import type { AuditLogRow } from "../types";
import { ScrollText } from "lucide-react";
const ACTION_LABEL: Record<string, string> = {
create: "新建",
update: "修改",
delete: "删除",
add_image: "添加图片",
delete_image: "删除图片",
bulk_status: "批量改状态",
bulk_category: "批量改分类",
};
const ENTITY_LABEL: Record<string, string> = {
product: "商品",
category: "分类",
brand: "品牌",
};
export default function AuditLogPage() {
const [rows, setRows] = useState<AuditLogRow[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const size = 30;
const [error, setError] = useState("");
useEffect(() => {
setError("");
api
.auditLog(page, size)
.then((r) => {
setRows(r.items);
setTotal(r.total);
})
.catch((e) => setError(e instanceof ApiError ? e.message : "加载失败"));
}, [page]);
const pages = Math.max(1, Math.ceil(total / size));
return (
<div className="mx-auto max-w-5xl">
<h2 className="mb-4 flex items-center gap-2 text-lg font-semibold text-gray-800">
<ScrollText className="h-5 w-5 text-emerald-600" />
<span className="text-sm font-normal text-gray-400"> {total} </span>
</h2>
{error && (
<div className="mb-3 rounded bg-red-50 px-4 py-2 text-sm text-red-700">{error}</div>
)}
<div className="overflow-hidden rounded-lg border bg-white">
<table className="w-full text-sm">
<thead className="bg-gray-50 text-left text-xs uppercase text-gray-500">
<tr>
<th className="px-4 py-2 font-medium"></th>
<th className="px-4 py-2 font-medium"></th>
<th className="px-4 py-2 font-medium"></th>
<th className="px-4 py-2 font-medium"></th>
<th className="px-4 py-2 font-medium"></th>
</tr>
</thead>
<tbody className="divide-y">
{rows.length === 0 ? (
<tr>
<td colSpan={5} className="px-4 py-8 text-center text-gray-400">
</td>
</tr>
) : (
rows.map((e) => (
<tr key={e.id} className="hover:bg-gray-50">
<td className="whitespace-nowrap px-4 py-2 text-gray-500">
{new Date(e.created_at).toLocaleString()}
</td>
<td className="px-4 py-2 text-gray-700">{e.actor}</td>
<td className="px-4 py-2 text-gray-700">
{ACTION_LABEL[e.action] || e.action}
</td>
<td className="px-4 py-2 text-gray-600">
{ENTITY_LABEL[e.entity] || e.entity}
</td>
<td className="px-4 py-2 text-xs text-gray-400">
{e.fields.length ? e.fields.join(", ") : "—"}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
<div className="mt-4 flex items-center justify-end gap-2 text-sm text-gray-600">
<button
disabled={page <= 1}
onClick={() => setPage((p) => p - 1)}
className="rounded border border-gray-300 px-3 py-1 disabled:opacity-50"
>
</button>
<span>
{page} / {pages}
</span>
<button
disabled={page >= pages}
onClick={() => setPage((p) => p + 1)}
className="rounded border border-gray-300 px-3 py-1 disabled:opacity-50"
>
</button>
</div>
</div>
);
}
@@ -0,0 +1,305 @@
import { useEffect, useMemo, useState } from "react";
import { api, ApiError } from "../api";
import type { Brand } from "../types";
import { Tag, Pencil, Plus, Trash2, GitMerge, Search } from "lucide-react";
type EditState = { id: string; name: string };
type MergeState = { source: Brand; targetId: string };
export default function BrandsPage() {
const [rows, setRows] = useState<Brand[]>([]);
const [error, setError] = useState("");
const [query, setQuery] = useState("");
const [creating, setCreating] = useState(false);
const [newName, setNewName] = useState("");
const [edit, setEdit] = useState<EditState | null>(null);
const [merge, setMerge] = useState<MergeState | null>(null);
const [busy, setBusy] = useState(false);
async function load() {
setError("");
try {
const res = await api.listBrands();
setRows(res.items);
} catch (e) {
setError(e instanceof ApiError ? e.message : "加载失败");
}
}
useEffect(() => {
load();
}, []);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return rows;
return rows.filter((b) => b.name.toLowerCase().includes(q));
}, [rows, query]);
function fail(e: unknown, fallback: string) {
setError(e instanceof ApiError ? e.message : fallback);
}
async function create() {
if (!newName.trim()) return;
setBusy(true);
setError("");
try {
await api.createBrand(newName.trim());
setNewName("");
setCreating(false);
await load();
} catch (e) {
fail(e, "新建失败");
} finally {
setBusy(false);
}
}
async function saveEdit() {
if (!edit || !edit.name.trim()) return;
setBusy(true);
setError("");
try {
await api.updateBrand(edit.id, edit.name.trim());
setEdit(null);
await load();
} catch (e) {
fail(e, "保存失败");
} finally {
setBusy(false);
}
}
async function remove(b: Brand) {
if (!confirm(`确认删除品牌「${b.name}」?`)) return;
setError("");
try {
await api.deleteBrand(b.id);
await load();
} catch (e) {
fail(e, "删除失败");
}
}
async function doMerge() {
if (!merge || !merge.targetId) return;
const target = rows.find((b) => b.id === merge.targetId);
if (
!confirm(
`将「${merge.source.name}」的 ${merge.source.product_count} 个商品并入「${target?.name}」,并删除「${merge.source.name}」?`,
)
)
return;
setBusy(true);
setError("");
try {
await api.mergeBrands(merge.source.id, merge.targetId);
setMerge(null);
await load();
} catch (e) {
fail(e, "合并失败");
} finally {
setBusy(false);
}
}
return (
<div className="mx-auto max-w-4xl">
<div className="flex items-center justify-between mb-4">
<div>
<h2 className="text-lg font-semibold text-gray-800 flex items-center gap-2">
<Tag className="h-5 w-5 text-emerald-600" />
</h2>
<p className="text-sm text-gray-500 mt-1">
</p>
</div>
<button
onClick={() => {
setCreating(true);
setNewName("");
}}
className="px-4 py-2 rounded-lg bg-emerald-600 text-white text-sm font-medium hover:bg-emerald-700 flex items-center gap-1.5"
>
<Plus className="h-4 w-4" />
</button>
</div>
{error && (
<div className="mb-3 bg-red-50 text-red-700 text-sm rounded px-4 py-2">{error}</div>
)}
<div className="mb-3 relative w-72">
<Search className="absolute left-2 top-2.5 h-4 w-4 text-gray-400" />
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="筛选品牌名"
className="w-full rounded border border-gray-300 py-2 pl-8 pr-3 text-sm focus:border-emerald-500 focus:outline-none"
/>
</div>
{creating && (
<div className="mb-4 bg-white border rounded-lg p-5">
<h3 className="font-medium text-gray-700 mb-3"></h3>
<div className="flex items-end gap-2">
<label className="block flex-1">
<span className="text-xs text-gray-500"> *</span>
<input
autoFocus
className="w-full border rounded-md px-3 py-2 text-sm mt-1"
value={newName}
onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && create()}
placeholder="例如:可口可乐"
/>
</label>
<button
onClick={create}
disabled={busy}
className="px-4 py-2 rounded bg-emerald-600 text-white text-sm hover:bg-emerald-700 disabled:opacity-60"
>
</button>
<button
onClick={() => setCreating(false)}
className="px-4 py-2 rounded border text-sm text-gray-600"
>
</button>
</div>
</div>
)}
{merge && (
<div className="mb-4 bg-white border rounded-lg p-5">
<h3 className="font-medium text-gray-700 mb-3 flex items-center gap-1.5">
<GitMerge className="h-4 w-4 text-emerald-600" />
</h3>
<p className="text-sm text-gray-500 mb-3">
{merge.source.name}{merge.source.product_count}
</p>
<div className="flex items-end gap-2">
<label className="block flex-1">
<span className="text-xs text-gray-500"></span>
<select
className="w-full border rounded-md px-3 py-2 text-sm mt-1 bg-white"
value={merge.targetId}
onChange={(e) => setMerge({ ...merge, targetId: e.target.value })}
>
<option value=""></option>
{rows
.filter((b) => b.id !== merge.source.id)
.map((b) => (
<option key={b.id} value={b.id}>
{b.name}{b.product_count}
</option>
))}
</select>
</label>
<button
onClick={doMerge}
disabled={busy || !merge.targetId}
className="px-4 py-2 rounded bg-emerald-600 text-white text-sm hover:bg-emerald-700 disabled:opacity-60"
>
</button>
<button
onClick={() => setMerge(null)}
className="px-4 py-2 rounded border text-sm text-gray-600"
>
</button>
</div>
</div>
)}
<div className="bg-white border rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-gray-50 text-gray-500 text-left">
<tr>
<th className="px-4 py-2 font-medium"></th>
<th className="px-4 py-2 font-medium"></th>
<th className="px-4 py-2 font-medium"></th>
</tr>
</thead>
<tbody className="divide-y">
{filtered.length === 0 ? (
<tr>
<td colSpan={3} className="px-4 py-8 text-center text-gray-400">
{rows.length === 0 ? "暂无品牌" : "无匹配品牌"}
</td>
</tr>
) : (
filtered.map((b) => (
<tr key={b.id} className="hover:bg-gray-50">
<td className="px-4 py-2 text-gray-800">
{edit && edit.id === b.id ? (
<input
autoFocus
className="border rounded px-2 py-1 text-sm w-64"
value={edit.name}
onChange={(e) => setEdit({ ...edit, name: e.target.value })}
onKeyDown={(e) => {
if (e.key === "Enter") saveEdit();
if (e.key === "Escape") setEdit(null);
}}
/>
) : (
<span className="font-medium">{b.name}</span>
)}
</td>
<td className="px-4 py-2 text-gray-600">{b.product_count}</td>
<td className="px-4 py-2 text-right whitespace-nowrap">
{edit && edit.id === b.id ? (
<>
<button
onClick={saveEdit}
disabled={busy}
className="text-emerald-600 hover:text-emerald-700 text-xs mr-3"
>
</button>
<button
onClick={() => setEdit(null)}
className="text-gray-400 hover:text-gray-600 text-xs"
>
</button>
</>
) : (
<>
<button
onClick={() => setEdit({ id: b.id, name: b.name })}
className="text-gray-400 hover:text-emerald-600 mr-3"
title="重命名"
>
<Pencil className="h-4 w-4" />
</button>
<button
onClick={() => setMerge({ source: b, targetId: "" })}
className="text-gray-400 hover:text-emerald-600 mr-3"
title="合并到其它品牌"
>
<GitMerge className="h-4 w-4" />
</button>
<button
onClick={() => remove(b)}
className="text-gray-400 hover:text-red-600"
title="删除"
>
<Trash2 className="h-4 w-4" />
</button>
</>
)}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
);
}
@@ -0,0 +1,311 @@
import { useEffect, useMemo, useState } from "react";
import { api, ApiError } from "../api";
import type { Category, CategoryInput } from "../types";
import { FolderTree, Pencil, Plus, Trash2 } from "lucide-react";
type FormState = {
mode: "create" | "edit";
id?: string;
name_zh: string;
name_en: string;
slug: string;
parent_id: string; // "" = top level
gpc_brick_code: string;
};
function emptyForm(parentId = ""): FormState {
return {
mode: "create",
name_zh: "",
name_en: "",
slug: "",
parent_id: parentId,
gpc_brick_code: "",
};
}
export default function CategoriesPage() {
const [rows, setRows] = useState<Category[]>([]);
const [error, setError] = useState("");
const [form, setForm] = useState<FormState | null>(null);
async function load() {
setError("");
try {
const res = await api.listCategories();
setRows(res.items);
} catch (e) {
setError(e instanceof ApiError ? e.message : "加载失败");
}
}
useEffect(() => {
load();
}, []);
async function remove(c: Category) {
if (!confirm(`确认删除分类「${c.name_zh}」(${c.path})`)) return;
setError("");
try {
await api.deleteCategory(c.id);
await load();
} catch (e) {
setError(e instanceof ApiError ? e.message : "删除失败");
}
}
return (
<div className="mx-auto max-w-5xl">
<div className="flex items-center justify-between mb-4">
<div>
<h2 className="text-lg font-semibold text-gray-800 flex items-center gap-2">
<FolderTree className="h-5 w-5 text-emerald-600" />
</h2>
<p className="text-sm text-gray-500 mt-1">
(slug)
</p>
</div>
<button
onClick={() => setForm(emptyForm())}
className="px-4 py-2 rounded-lg bg-emerald-600 text-white text-sm font-medium hover:bg-emerald-700 flex items-center gap-1.5"
>
<Plus className="h-4 w-4" />
</button>
</div>
{error && (
<div className="mb-3 bg-red-50 text-red-700 text-sm rounded px-4 py-2">{error}</div>
)}
{form && (
<CategoryForm
form={form}
categories={rows}
onClose={() => setForm(null)}
onSaved={() => {
setForm(null);
load();
}}
onError={setError}
/>
)}
<div className="bg-white border rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-gray-50 text-gray-500 text-left">
<tr>
<th className="px-4 py-2 font-medium"></th>
<th className="px-4 py-2 font-medium"></th>
<th className="px-4 py-2 font-medium">GPC</th>
<th className="px-4 py-2 font-medium"></th>
<th className="px-4 py-2 font-medium"></th>
</tr>
</thead>
<tbody className="divide-y">
{rows.length === 0 ? (
<tr>
<td colSpan={5} className="px-4 py-8 text-center text-gray-400">
</td>
</tr>
) : (
rows.map((c) => (
<tr key={c.id} className="hover:bg-gray-50">
<td className="px-4 py-2 text-gray-800">
<span style={{ paddingLeft: `${c.level * 18}px` }} className="inline-flex items-center gap-2">
{c.level > 0 && <span className="text-gray-300"></span>}
<span className="font-medium">{c.name_zh}</span>
{c.name_en && <span className="text-xs text-gray-400">{c.name_en}</span>}
</span>
</td>
<td className="px-4 py-2 text-gray-500">
<code className="text-xs">{c.path}</code>
</td>
<td className="px-4 py-2 text-gray-500 text-xs">{c.gpc_brick_code || "—"}</td>
<td className="px-4 py-2 text-gray-600">{c.product_count}</td>
<td className="px-4 py-2 text-right whitespace-nowrap">
<button
onClick={() => setForm(emptyForm(c.id))}
className="text-gray-400 hover:text-emerald-600 mr-3"
title="新增子分类"
>
<Plus className="h-4 w-4" />
</button>
<button
onClick={() =>
setForm({
mode: "edit",
id: c.id,
name_zh: c.name_zh,
name_en: c.name_en ?? "",
slug: c.path,
parent_id: c.parent_id ?? "",
gpc_brick_code: c.gpc_brick_code ?? "",
})
}
className="text-gray-400 hover:text-emerald-600 mr-3"
title="编辑"
>
<Pencil className="h-4 w-4" />
</button>
<button
onClick={() => remove(c)}
className="text-gray-400 hover:text-red-600"
title="删除"
>
<Trash2 className="h-4 w-4" />
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
);
}
function CategoryForm({
form,
categories,
onClose,
onSaved,
onError,
}: {
form: FormState;
categories: Category[];
onClose: () => void;
onSaved: () => void;
onError: (msg: string) => void;
}) {
const [state, setState] = useState<FormState>(form);
const [busy, setBusy] = useState(false);
// When editing, the node itself and its descendants are not valid parents.
const parentOptions = useMemo(() => {
if (state.mode === "create") return categories;
const self = categories.find((c) => c.id === state.id);
if (!self) return categories;
return categories.filter(
(c) => c.id !== self.id && !c.path.startsWith(self.path + "."),
);
}, [categories, state.mode, state.id]);
function set<K extends keyof FormState>(key: K, value: FormState[K]) {
setState((s) => ({ ...s, [key]: value }));
}
async function submit() {
if (!state.name_zh.trim()) {
onError("分类名称不能为空");
return;
}
setBusy(true);
onError("");
const body: CategoryInput = {
name_zh: state.name_zh.trim(),
name_en: state.name_en.trim() || null,
parent_id: state.parent_id || null,
gpc_brick_code: state.gpc_brick_code.trim() || null,
};
if (state.mode === "create") body.slug = state.slug.trim() || null;
try {
if (state.mode === "create") {
await api.createCategory(body);
} else if (state.id) {
await api.updateCategory(state.id, body);
}
onSaved();
} catch (e) {
onError(e instanceof ApiError ? e.message : "保存失败");
} finally {
setBusy(false);
}
}
return (
<div className="mb-4 bg-white border rounded-lg p-5">
<h3 className="font-medium text-gray-700 mb-3">
{state.mode === "create" ? "新建分类" : "编辑分类"}
</h3>
<div className="grid grid-cols-2 gap-4">
<label className="block">
<span className="text-xs text-gray-500"> *</span>
<input
className="w-full border rounded-md px-3 py-2 text-sm mt-1"
value={state.name_zh}
onChange={(e) => set("name_zh", e.target.value)}
placeholder="例如:饮料"
/>
</label>
<label className="block">
<span className="text-xs text-gray-500"></span>
<input
className="w-full border rounded-md px-3 py-2 text-sm mt-1"
value={state.name_en}
onChange={(e) => set("name_en", e.target.value)}
placeholder="Beverages"
/>
</label>
<label className="block">
<span className="text-xs text-gray-500"></span>
<select
className="w-full border rounded-md px-3 py-2 text-sm mt-1 bg-white"
value={state.parent_id}
onChange={(e) => set("parent_id", e.target.value)}
>
<option value=""></option>
{parentOptions.map((c) => (
<option key={c.id} value={c.id}>
{"\u00A0".repeat(c.level * 2)}
{c.name_zh} ({c.path})
</option>
))}
</select>
</label>
{state.mode === "create" ? (
<label className="block">
<span className="text-xs text-gray-500"> slug</span>
<input
className="w-full border rounded-md px-3 py-2 text-sm mt-1"
value={state.slug}
onChange={(e) => set("slug", e.target.value)}
placeholder="beverages"
/>
</label>
) : (
<label className="block">
<span className="text-xs text-gray-500"></span>
<input
className="w-full border rounded-md px-3 py-2 text-sm mt-1 bg-gray-50 text-gray-400"
value={state.slug}
disabled
/>
</label>
)}
<label className="block">
<span className="text-xs text-gray-500">GPC Brick </span>
<input
className="w-full border rounded-md px-3 py-2 text-sm mt-1"
value={state.gpc_brick_code}
onChange={(e) => set("gpc_brick_code", e.target.value)}
placeholder="10000224"
/>
</label>
</div>
<div className="mt-4 flex gap-2">
<button
onClick={submit}
disabled={busy}
className="px-4 py-2 rounded bg-emerald-600 text-white text-sm hover:bg-emerald-700 disabled:opacity-60"
>
</button>
<button onClick={onClose} className="px-4 py-2 rounded border text-sm text-gray-600">
</button>
</div>
</div>
);
}
+173 -7
View File
@@ -5,10 +5,13 @@ import {
Brand,
Category,
FIELD_LABELS,
KindField,
ProductDetail as Detail,
} from "../types";
import {
ArrowLeft,
ChevronLeft,
ChevronRight,
Plus,
Save,
Trash2,
@@ -88,10 +91,18 @@ const inputCls =
export default function ProductDetail({
id,
onBack,
ids = [],
onNavigate,
}: {
id: string;
onBack: () => void;
ids?: string[];
onNavigate?: (id: string) => void;
}) {
const navIndex = ids.indexOf(id);
const prevId = navIndex > 0 ? ids[navIndex - 1] : null;
const nextId =
navIndex >= 0 && navIndex < ids.length - 1 ? ids[navIndex + 1] : null;
const [d, setD] = useState<Detail | null>(null);
const [brands, setBrands] = useState<Brand[]>([]);
const [categories, setCategories] = useState<Category[]>([]);
@@ -117,6 +128,8 @@ export default function ProductDetail({
const [basis, setBasis] = useState("");
const [serving, setServing] = useState("");
const [nutriScore, setNutriScore] = useState("");
const [kindFields, setKindFields] = useState<KindField[]>([]);
const [attrs, setAttrs] = useState<Record<string, string>>({});
function hydrate(detail: Detail) {
setD(detail);
@@ -139,6 +152,13 @@ export default function ProductDetail({
setBasis(detail.nutrition_basis || "");
setServing(detail.serving_size || "");
setNutriScore(detail.nutri_score || "");
const am: Record<string, string> = {};
if (detail.attributes) {
for (const [k, v] of Object.entries(detail.attributes)) {
am[k] = v == null ? "" : Array.isArray(v) ? v.join(", ") : String(v);
}
}
setAttrs(am);
}
function reload() {
@@ -161,6 +181,41 @@ export default function ProductDetail({
const missing = useMemo(() => d?.missing ?? [], [d]);
const selectedKind = useMemo(() => {
const c = categories.find((x) => x.id === categoryId);
return c?.archive_kind || d?.archive_kind || "generic";
}, [categories, categoryId, d]);
useEffect(() => {
if (selectedKind && selectedKind !== "food") {
api
.listKindFields(selectedKind)
.then((r) => setKindFields(r.items))
.catch(() => setKindFields([]));
} else {
setKindFields([]);
}
}, [selectedKind]);
const specGroups = useMemo(() => {
const groups: { label: string; fields: KindField[] }[] = [];
for (const f of kindFields) {
let g = groups.find((x) => x.label === f.group_label);
if (!g) {
g = { label: f.group_label, fields: [] };
groups.push(g);
}
g.fields.push(f);
}
return groups;
}, [kindFields]);
const attrLabels = useMemo(() => {
const m: Record<string, string> = {};
for (const f of kindFields) m[f.field_key] = f.label_zh;
return m;
}, [kindFields]);
function parseList(s: string): string[] {
return s
.split(",")
@@ -177,6 +232,22 @@ export default function ProductDetail({
const n = parseFloat(v);
if (!Number.isNaN(n)) nm[k] = n;
}
let attributes: Record<string, unknown> | undefined;
if (selectedKind !== "food") {
attributes = {};
for (const f of kindFields) {
const raw = (attrs[f.field_key] ?? "").trim();
if (raw === "") continue;
if (f.field_type === "number") {
const n = parseFloat(raw);
if (!Number.isNaN(n)) attributes[f.field_key] = n;
} else if (f.field_type === "list") {
attributes[f.field_key] = parseList(raw);
} else {
attributes[f.field_key] = raw;
}
}
}
const body = {
gtin: gtin.trim() || null,
name: name.trim(),
@@ -194,6 +265,7 @@ export default function ProductDetail({
nutrition_basis: basis || null,
serving_size: serving.trim() || null,
nutri_score: nutriScore || null,
...(attributes !== undefined ? { attributes } : {}),
};
try {
const updated = await api.updateProduct(id, body);
@@ -226,12 +298,35 @@ export default function ProductDetail({
return (
<div className="mx-auto max-w-5xl space-y-5">
<div className="flex items-center justify-between">
<button
onClick={onBack}
className="flex items-center gap-1 text-sm text-gray-600 hover:text-gray-900"
>
<ArrowLeft className="h-4 w-4" />
</button>
<div className="flex items-center gap-2">
<button
onClick={onBack}
className="flex items-center gap-1 text-sm text-gray-600 hover:text-gray-900"
>
<ArrowLeft className="h-4 w-4" />
</button>
{ids.length > 1 && navIndex >= 0 && (
<div className="ml-2 flex items-center gap-1 text-sm">
<button
onClick={() => prevId && onNavigate?.(prevId)}
disabled={!prevId}
className="flex items-center gap-1 rounded border border-gray-300 px-2 py-1 text-gray-600 hover:bg-gray-50 disabled:opacity-40"
>
<ChevronLeft className="h-4 w-4" />
</button>
<span className="text-xs text-gray-400">
{navIndex + 1} / {ids.length}
</span>
<button
onClick={() => nextId && onNavigate?.(nextId)}
disabled={!nextId}
className="flex items-center gap-1 rounded border border-gray-300 px-2 py-1 text-gray-600 hover:bg-gray-50 disabled:opacity-40"
>
<ChevronRight className="h-4 w-4" />
</button>
</div>
)}
</div>
<div className="flex items-center gap-3">
{msg && <span className="text-sm text-emerald-600">{msg}</span>}
{error && <span className="text-sm text-red-600">{error}</span>}
@@ -251,7 +346,8 @@ export default function ProductDetail({
{missing.length > 0 && (
<div className="flex items-center gap-2 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-700">
<AlertCircle className="h-4 w-4" />
{missing.map((f) => FIELD_LABELS[f] || f).join("、")}
{missing.map((f) => FIELD_LABELS[f] || attrLabels[f] || f).join("、")}
</div>
)}
@@ -338,6 +434,7 @@ export default function ProductDetail({
</div>
</Card>
{selectedKind === "food" && (
<Card title="配料与营养">
<div className="mb-4 grid grid-cols-2 gap-4">
<Field label="配料表">
@@ -410,6 +507,75 @@ export default function ProductDetail({
))}
</div>
</Card>
)}
{selectedKind !== "food" && kindFields.length > 0 && (
<Card title="规格参数">
{specGroups.map((grp) => (
<div key={grp.label} className="mb-4 last:mb-0">
{grp.label && (
<h4 className="mb-2 text-xs font-medium text-gray-500">
{grp.label}
</h4>
)}
<div className="grid grid-cols-3 gap-3">
{grp.fields.map((f) => (
<Field
key={f.field_key}
label={f.unit ? `${f.label_zh} (${f.unit})` : f.label_zh}
>
{f.field_type === "select" ? (
<select
className={inputCls}
value={attrs[f.field_key] ?? ""}
onChange={(e) =>
setAttrs((prev) => ({
...prev,
[f.field_key]: e.target.value,
}))
}
>
<option value=""></option>
{f.options.map((o) => (
<option key={o} value={o}>
{o}
</option>
))}
</select>
) : f.field_type === "textarea" ? (
<textarea
className={inputCls}
rows={3}
value={attrs[f.field_key] ?? ""}
onChange={(e) =>
setAttrs((prev) => ({
...prev,
[f.field_key]: e.target.value,
}))
}
/>
) : (
<input
className={inputCls}
type={f.field_type === "number" ? "number" : "text"}
step={f.field_type === "number" ? "any" : undefined}
placeholder={f.placeholder ?? undefined}
value={attrs[f.field_key] ?? ""}
onChange={(e) =>
setAttrs((prev) => ({
...prev,
[f.field_key]: e.target.value,
}))
}
/>
)}
</Field>
))}
</div>
</div>
))}
</Card>
)}
<BarcodesCard product={d} onChange={reload} onError={setError} />
<ImagesCard
+415 -19
View File
@@ -1,7 +1,15 @@
import { useEffect, useState } from "react";
import { api } from "../api";
import { FIELD_LABELS, ProductRow } from "../types";
import { Search, AlertCircle } from "lucide-react";
import { api, ApiError } from "../api";
import { Brand, Category, FIELD_LABELS, ProductRow } from "../types";
import { Search, AlertCircle, Plus, ChevronUp, ChevronDown, ChevronsUpDown } from "lucide-react";
type SortKey =
| "name"
| "brand"
| "gtin"
| "category_path"
| "status"
| "quality_score";
const STATUS_LABEL: Record<string, string> = {
active: "在用",
@@ -24,32 +32,154 @@ function QualityBadge({ score }: { score: number }) {
);
}
function SortableTh({
label,
sortKey,
sort,
order,
onSort,
}: {
label: string;
sortKey: SortKey;
sort: SortKey | "";
order: "asc" | "desc";
onSort: (key: SortKey) => void;
}) {
const active = sort === sortKey;
return (
<th className="px-4 py-3">
<button
type="button"
onClick={() => onSort(sortKey)}
className={`flex items-center gap-1 uppercase hover:text-gray-700 ${
active ? "text-emerald-600" : ""
}`}
>
{label}
{!active ? (
<ChevronsUpDown className="h-3.5 w-3.5 text-gray-300" />
) : order === "asc" ? (
<ChevronUp className="h-3.5 w-3.5" />
) : (
<ChevronDown className="h-3.5 w-3.5" />
)}
</button>
</th>
);
}
export default function ProductList({
onOpen,
}: {
onOpen: (id: string) => void;
onOpen: (id: string, ids: string[]) => void;
}) {
const [q, setQ] = useState("");
const [input, setInput] = useState("");
const [page, setPage] = useState(1);
const [size] = useState(20);
const [size, setSize] = useState(20);
const [sort, setSort] = useState<SortKey | "">("");
const [order, setOrder] = useState<"asc" | "desc">("asc");
const [jump, setJump] = useState("");
const [rows, setRows] = useState<ProductRow[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [creating, setCreating] = useState(false);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [categories, setCategories] = useState<Category[]>([]);
const [bulkStatus, setBulkStatus] = useState("");
const [bulkCategory, setBulkCategory] = useState("");
const [bulkBusy, setBulkBusy] = useState(false);
useEffect(() => {
function reload() {
setLoading(true);
setError("");
api
.listProducts(q, page, size)
.listProducts(q, page, size, sort || undefined, order)
.then((r) => {
setRows(r.items);
setTotal(r.total);
})
.catch((e) => setError(e.message))
.finally(() => setLoading(false));
}, [q, page, size]);
}
useEffect(() => {
setSelected(new Set());
reload();
}, [q, page, size, sort, order]);
function toggleSort(key: SortKey) {
setPage(1);
if (sort !== key) {
setSort(key);
setOrder("asc");
} else if (order === "asc") {
setOrder("desc");
} else {
setSort("");
setOrder("asc");
}
}
useEffect(() => {
api.listCategories().then((r) => setCategories(r.items)).catch(() => {});
}, []);
function toggle(id: string) {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
function toggleAll() {
setSelected((prev) =>
prev.size === rows.length ? new Set() : new Set(rows.map((r) => r.id)),
);
}
async function applyBulkStatus() {
if (!bulkStatus || selected.size === 0) return;
setBulkBusy(true);
setError("");
try {
await api.bulkProducts({
ids: [...selected],
action: "status",
status: bulkStatus,
});
setSelected(new Set());
setBulkStatus("");
reload();
} catch (e) {
setError(e instanceof ApiError ? e.message : "批量操作失败");
} finally {
setBulkBusy(false);
}
}
async function applyBulkCategory() {
if (selected.size === 0) return;
setBulkBusy(true);
setError("");
try {
await api.bulkProducts({
ids: [...selected],
action: "category",
category_id: bulkCategory || null,
});
setSelected(new Set());
setBulkCategory("");
reload();
} catch (e) {
setError(e instanceof ApiError ? e.message : "批量操作失败");
} finally {
setBulkBusy(false);
}
}
const pages = Math.max(1, Math.ceil(total / size));
@@ -79,38 +209,120 @@ export default function ProductList({
<button className="rounded bg-emerald-600 px-3 py-2 text-sm text-white hover:bg-emerald-700">
</button>
<button
type="button"
onClick={() => setCreating(true)}
className="flex items-center gap-1 rounded bg-gray-700 px-3 py-2 text-sm text-white hover:bg-gray-800"
>
<Plus className="h-4 w-4" />
</button>
</form>
</div>
{creating && (
<CreateProductModal
onClose={() => setCreating(false)}
onCreated={(id) => {
setCreating(false);
onOpen(id, [id]);
}}
/>
)}
{error && (
<div className="mb-3 rounded bg-red-50 px-3 py-2 text-sm text-red-600">
{error}
</div>
)}
{selected.size > 0 && (
<div className="mb-3 flex flex-wrap items-center gap-3 rounded-lg border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm">
<span className="font-medium text-emerald-800">
{selected.size}
</span>
<div className="flex items-center gap-1.5">
<span className="text-gray-500"></span>
<select
value={bulkStatus}
onChange={(e) => setBulkStatus(e.target.value)}
className="rounded border border-gray-300 bg-white px-2 py-1"
>
<option value=""></option>
<option value="active"></option>
<option value="deprecated"></option>
<option value="merged"></option>
</select>
<button
onClick={applyBulkStatus}
disabled={bulkBusy || !bulkStatus}
className="rounded bg-emerald-600 px-3 py-1 text-white hover:bg-emerald-700 disabled:opacity-50"
>
</button>
</div>
<div className="flex items-center gap-1.5">
<span className="text-gray-500"></span>
<select
value={bulkCategory}
onChange={(e) => setBulkCategory(e.target.value)}
className="rounded border border-gray-300 bg-white px-2 py-1"
>
<option value=""></option>
{categories.map((c) => (
<option key={c.id} value={c.id}>
{"\u00A0".repeat(c.level * 2)}
{c.name_zh}
</option>
))}
</select>
<button
onClick={applyBulkCategory}
disabled={bulkBusy}
className="rounded bg-emerald-600 px-3 py-1 text-white hover:bg-emerald-700 disabled:opacity-50"
>
</button>
</div>
<button
onClick={() => setSelected(new Set())}
className="text-gray-500 hover:text-gray-700"
>
</button>
</div>
)}
<div className="overflow-hidden rounded-lg border border-gray-200 bg-white">
<table className="w-full text-sm">
<thead className="bg-gray-50 text-left text-xs uppercase text-gray-500">
<tr>
<th className="px-4 py-3"></th>
<th className="px-4 py-3"></th>
<th className="px-4 py-3"></th>
<th className="px-4 py-3"></th>
<th className="px-4 py-3"></th>
<th className="px-4 py-3"></th>
<th className="w-10 px-4 py-3">
<input
type="checkbox"
checked={rows.length > 0 && selected.size === rows.length}
onChange={toggleAll}
aria-label="全选"
/>
</th>
<SortableTh label="名称" sortKey="name" sort={sort} order={order} onSort={toggleSort} />
<SortableTh label="品牌" sortKey="brand" sort={sort} order={order} onSort={toggleSort} />
<SortableTh label="条码" sortKey="gtin" sort={sort} order={order} onSort={toggleSort} />
<SortableTh label="品类" sortKey="category_path" sort={sort} order={order} onSort={toggleSort} />
<SortableTh label="状态" sortKey="status" sort={sort} order={order} onSort={toggleSort} />
<SortableTh label="质量分" sortKey="quality_score" sort={sort} order={order} onSort={toggleSort} />
<th className="px-4 py-3"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{loading ? (
<tr>
<td colSpan={7} className="px-4 py-8 text-center text-gray-400">
<td colSpan={8} className="px-4 py-8 text-center text-gray-400">
</td>
</tr>
) : rows.length === 0 ? (
<tr>
<td colSpan={7} className="px-4 py-8 text-center text-gray-400">
<td colSpan={8} className="px-4 py-8 text-center text-gray-400">
</td>
</tr>
@@ -118,9 +330,22 @@ export default function ProductList({
rows.map((r) => (
<tr
key={r.id}
onClick={() => onOpen(r.id)}
className="cursor-pointer hover:bg-emerald-50/50"
onClick={() => onOpen(r.id, rows.map((x) => x.id))}
className={`cursor-pointer hover:bg-emerald-50/50 ${
selected.has(r.id) ? "bg-emerald-50/60" : ""
}`}
>
<td
className="px-4 py-3"
onClick={(e) => e.stopPropagation()}
>
<input
type="checkbox"
checked={selected.has(r.id)}
onChange={() => toggle(r.id)}
aria-label="选择"
/>
</td>
<td className="px-4 py-3 font-medium text-gray-800">{r.name}</td>
<td className="px-4 py-3 text-gray-600">{r.brand || "—"}</td>
<td className="px-4 py-3 font-mono text-xs text-gray-500">
@@ -154,7 +379,25 @@ export default function ProductList({
</table>
</div>
<div className="mt-4 flex items-center justify-end gap-2 text-sm text-gray-600">
<div className="mt-4 flex flex-wrap items-center justify-end gap-2 text-sm text-gray-600">
<div className="mr-auto flex items-center gap-1">
<span></span>
<select
value={size}
onChange={(e) => {
setSize(Number(e.target.value));
setPage(1);
}}
className="rounded border border-gray-300 px-2 py-1"
>
{[20, 50, 100].map((n) => (
<option key={n} value={n}>
{n}
</option>
))}
</select>
<span> · {total} </span>
</div>
<button
disabled={page <= 1}
onClick={() => setPage((p) => p - 1)}
@@ -172,6 +415,159 @@ export default function ProductList({
>
</button>
<form
onSubmit={(e) => {
e.preventDefault();
const n = Number(jump);
if (Number.isFinite(n) && n >= 1) {
setPage(Math.min(Math.max(1, Math.trunc(n)), pages));
setJump("");
}
}}
className="flex items-center gap-1"
>
<span></span>
<input
value={jump}
onChange={(e) => setJump(e.target.value.replace(/[^0-9]/g, ""))}
placeholder={String(page)}
className="w-14 rounded border border-gray-300 px-2 py-1 text-center"
aria-label="跳转页码"
/>
<button
type="submit"
className="rounded border border-gray-300 px-3 py-1 hover:bg-gray-50"
>
</button>
</form>
</div>
</div>
);
}
function CreateProductModal({
onClose,
onCreated,
}: {
onClose: () => void;
onCreated: (id: string) => void;
}) {
const [name, setName] = useState("");
const [gtin, setGtin] = useState("");
const [brand, setBrand] = useState("");
const [categoryId, setCategoryId] = useState("");
const [brands, setBrands] = useState<Brand[]>([]);
const [categories, setCategories] = useState<Category[]>([]);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
api.listBrands().then((r) => setBrands(r.items)).catch(() => {});
api.listCategories().then((r) => setCategories(r.items)).catch(() => {});
}, []);
async function submit() {
if (!name.trim()) {
setError("名称不能为空");
return;
}
setBusy(true);
setError("");
try {
const created = await api.createProduct({
name: name.trim(),
gtin: gtin.trim() || null,
brand_name: brand.trim() || null,
category_id: categoryId || null,
status: "active",
});
onCreated(created.id);
} catch (e) {
setError(e instanceof ApiError ? e.message : "新建失败");
} finally {
setBusy(false);
}
}
return (
<div className="fixed inset-0 z-20 flex items-center justify-center bg-black/30">
<div className="w-full max-w-md rounded-lg bg-white p-6 shadow-lg">
<h3 className="mb-4 text-base font-semibold text-gray-800"></h3>
{error && (
<div className="mb-3 rounded bg-red-50 px-3 py-2 text-sm text-red-600">
{error}
</div>
)}
<div className="space-y-3">
<label className="block">
<span className="text-xs text-gray-500"> *</span>
<input
autoFocus
className="mt-1 w-full rounded border border-gray-300 px-3 py-2 text-sm"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="商品名称"
/>
</label>
<label className="block">
<span className="text-xs text-gray-500"> (GTIN)</span>
<input
className="mt-1 w-full rounded border border-gray-300 px-3 py-2 text-sm"
value={gtin}
onChange={(e) => setGtin(e.target.value)}
placeholder="8/12/13/14 位"
/>
</label>
<label className="block">
<span className="text-xs text-gray-500"></span>
<input
list="create-brand-list"
className="mt-1 w-full rounded border border-gray-300 px-3 py-2 text-sm"
value={brand}
onChange={(e) => setBrand(e.target.value)}
/>
<datalist id="create-brand-list">
{brands.map((b) => (
<option key={b.id} value={b.name} />
))}
</datalist>
</label>
<label className="block">
<span className="text-xs text-gray-500"></span>
<select
className="mt-1 w-full rounded border border-gray-300 bg-white px-3 py-2 text-sm"
value={categoryId}
onChange={(e) => setCategoryId(e.target.value)}
>
<option value=""></option>
{categories.map((c) => (
<option key={c.id} value={c.id}>
{"\u00A0".repeat(c.level * 2)}
{c.name_zh} ({c.path})
</option>
))}
</select>
</label>
</div>
<p className="mt-3 text-xs text-gray-400">
</p>
<div className="mt-4 flex justify-end gap-2">
<button
onClick={onClose}
className="rounded border px-4 py-2 text-sm text-gray-600"
>
</button>
<button
onClick={submit}
disabled={busy}
className="rounded bg-emerald-600 px-4 py-2 text-sm text-white hover:bg-emerald-700 disabled:opacity-60"
>
{busy ? "创建中…" : "创建并编辑"}
</button>
</div>
</div>
</div>
);
+125
View File
@@ -0,0 +1,125 @@
import { useEffect, useState } from "react";
import { api, ApiError } from "../api";
import type { AdminStats } from "../types";
import { BarChart3, Package, CheckCircle2, Tag, FolderTree, Inbox } from "lucide-react";
const STATUS_LABEL: Record<string, string> = {
active: "在用",
merged: "已合并",
deprecated: "已停用",
};
function Card({
icon,
label,
value,
hint,
}: {
icon: React.ReactNode;
label: string;
value: string | number;
hint?: string;
}) {
return (
<div className="rounded-lg border bg-white p-5">
<div className="flex items-center gap-2 text-sm text-gray-500">
{icon}
{label}
</div>
<div className="mt-2 text-2xl font-semibold text-gray-800">{value}</div>
{hint && <div className="mt-1 text-xs text-gray-400">{hint}</div>}
</div>
);
}
export default function StatsPage() {
const [stats, setStats] = useState<AdminStats | null>(null);
const [error, setError] = useState("");
useEffect(() => {
api
.stats()
.then(setStats)
.catch((e) => setError(e instanceof ApiError ? e.message : "加载失败"));
}, []);
return (
<div className="mx-auto max-w-5xl">
<h2 className="mb-4 flex items-center gap-2 text-lg font-semibold text-gray-800">
<BarChart3 className="h-5 w-5 text-emerald-600" />
</h2>
{error && (
<div className="mb-3 rounded bg-red-50 px-4 py-2 text-sm text-red-700">{error}</div>
)}
{!stats ? (
<div className="text-sm text-gray-400"></div>
) : (
<>
<div className="grid grid-cols-2 gap-4 md:grid-cols-3">
<Card
icon={<Package className="h-4 w-4" />}
label="商品总数"
value={stats.products.toLocaleString()}
/>
<Card
icon={<CheckCircle2 className="h-4 w-4 text-emerald-600" />}
label="合格档案"
value={stats.qualified.toLocaleString()}
hint={`质量分 ≥ ${stats.min_score} 且在用`}
/>
<Card
icon={<BarChart3 className="h-4 w-4" />}
label="平均质量分"
value={Math.round(stats.avg_quality * 100)}
hint="满分 100"
/>
<Card
icon={<Tag className="h-4 w-4" />}
label="品牌数"
value={stats.brands.toLocaleString()}
/>
<Card
icon={<FolderTree className="h-4 w-4" />}
label="分类数"
value={stats.categories.toLocaleString()}
/>
<Card
icon={<Inbox className="h-4 w-4" />}
label="待审核投稿"
value={stats.pending_submissions.toLocaleString()}
/>
</div>
<div className="mt-6 rounded-lg border bg-white p-5">
<h3 className="mb-3 text-sm font-medium text-gray-700"></h3>
<div className="space-y-2">
{Object.keys(stats.by_status).length === 0 ? (
<div className="text-sm text-gray-400"></div>
) : (
Object.entries(stats.by_status).map(([st, n]) => {
const pct = stats.products > 0 ? (n / stats.products) * 100 : 0;
return (
<div key={st} className="flex items-center gap-3 text-sm">
<span className="w-16 text-gray-600">
{STATUS_LABEL[st] || st}
</span>
<div className="h-3 flex-1 overflow-hidden rounded bg-gray-100">
<div
className="h-full bg-emerald-500"
style={{ width: `${pct}%` }}
/>
</div>
<span className="w-12 text-right text-gray-500">{n}</span>
</div>
);
})
)}
</div>
</div>
</>
)}
</div>
);
}
@@ -56,7 +56,7 @@ export default function SubmissionsPage({ onPending }: { onPending?: (n: number)
}
return (
<div>
<div className="mx-auto max-w-5xl">
<div className="flex items-center gap-2 mb-4">
{STATUS_TABS.map((t) => (
<button
@@ -193,7 +193,7 @@ function SubmissionView({ id, onBack }: { id: string; onBack: () => void }) {
const nutri = Object.entries(p.nutriments || {});
return (
<div className="max-w-3xl">
<div className="mx-auto max-w-3xl">
<button onClick={onBack} className="text-sm text-gray-500 flex items-center gap-1 mb-4">
<ArrowLeft className="h-4 w-4" /> 稿
</button>
+50
View File
@@ -44,6 +44,8 @@ export interface ProductDetail {
brand: string | null;
category_id: string | null;
category_path: string | null;
archive_kind: string;
attributes: Record<string, unknown>;
net_content_value: number | null;
net_content_unit: string | null;
country_of_origin: string | null;
@@ -66,6 +68,7 @@ export interface ProductDetail {
export interface Brand {
id: string;
name: string;
product_count: number;
}
export interface Category {
@@ -74,6 +77,31 @@ export interface Category {
name_en: string | null;
path: string;
level: number;
parent_id: string | null;
gpc_brick_code: string | null;
archive_kind: string;
product_count: number;
}
export interface KindField {
kind: string;
field_key: string;
group_label: string;
label_zh: string;
field_type: string;
unit: string | null;
options: string[];
placeholder: string | null;
sort_order: number;
qualified: boolean;
}
export interface CategoryInput {
name_zh: string;
name_en?: string | null;
slug?: string | null;
parent_id?: string | null;
gpc_brick_code?: string | null;
}
export interface AuditEntry {
@@ -84,6 +112,27 @@ export interface AuditEntry {
created_at: string;
}
export interface AuditLogRow {
id: string;
actor: string;
action: string;
entity: string;
entity_id: string | null;
fields: string[];
created_at: string;
}
export interface AdminStats {
products: number;
qualified: number;
min_score: number;
by_status: Record<string, number>;
brands: number;
categories: number;
pending_submissions: number;
avg_quality: number;
}
export interface SubmissionRow {
id: string;
gtin: string | null;
@@ -150,6 +199,7 @@ export interface ApiKey {
owner_email: string | null;
tier: string;
rate_limit_per_min: number;
quota_total: number;
revoked_at: string | null;
created_by: string | null;
created_at: string;
+9 -2
View File
@@ -9,6 +9,7 @@ import (
"github.com/jackc/pgx/v5/pgxpool"
"github.com/baicai2026-baicai/goods/api/internal/cache"
"github.com/baicai2026-baicai/goods/api/internal/config"
"github.com/baicai2026-baicai/goods/api/internal/handler"
"github.com/baicai2026-baicai/goods/api/internal/publicweb"
@@ -36,8 +37,14 @@ func main() {
if !limiter.Enabled() {
log.Print("warning: Redis not configured; public API rate limiting disabled")
}
h := handler.New(store.New(pool), publicweb.Dist()).
WithRateLimit(limiter, cfg.AnonRateLimitPerMin)
readCache := cache.New(cfg.RedisURL)
if !readCache.Enabled() {
log.Print("warning: Redis not configured; public API read cache disabled")
}
h := handler.New(store.New(pool).WithCache(readCache), publicweb.Dist()).
WithRateLimit(limiter, cfg.AnonRateLimitPerMin).
WithQuotas(cfg.AnonTotalQuota, cfg.RegisteredRateLimitPerMin, cfg.RegisteredQuotaTotal)
srv := &http.Server{
Addr: cfg.Addr,
+1 -1
View File
@@ -46,7 +46,7 @@ func (h *Handler) CreateAPIKey(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "bad_request", "名称不能为空")
return
}
if in.Tier != "" && in.Tier != "free" && in.Tier != "partner" && in.Tier != "internal" {
if in.Tier != "" && in.Tier != "free" && in.Tier != "registered" && in.Tier != "partner" && in.Tier != "internal" {
writeError(w, http.StatusBadRequest, "bad_request", "tier 取值无效")
return
}
+280 -1
View File
@@ -64,7 +64,11 @@ func (h *Handler) Router() http.Handler {
r.Group(func(r chi.Router) {
r.Use(h.authn.Middleware)
r.Get("/api/me", h.Me)
r.Get("/api/stats", h.Stats)
r.Get("/api/audit", h.ListAllAudit)
r.Get("/api/products", h.ListProducts)
r.Post("/api/products", h.CreateProduct)
r.Post("/api/products/bulk", h.BulkProducts)
r.Get("/api/products/{id}", h.GetProduct)
r.Put("/api/products/{id}", h.UpdateProduct)
r.Get("/api/products/{id}/audit", h.ListAudit)
@@ -76,7 +80,15 @@ func (h *Handler) Router() http.Handler {
r.Delete("/api/products/{id}/barcodes/{barcodeID}", h.DeleteBarcode)
r.Post("/api/products/{id}/barcodes/{barcodeID}/primary", h.SetPrimaryBarcode)
r.Get("/api/brands", h.ListBrands)
r.Post("/api/brands", h.CreateBrand)
r.Put("/api/brands/{id}", h.UpdateBrand)
r.Post("/api/brands/{id}/merge", h.MergeBrands)
r.Delete("/api/brands/{id}", h.DeleteBrand)
r.Get("/api/kind-fields", h.ListKindFields)
r.Get("/api/categories", h.ListCategories)
r.Post("/api/categories", h.CreateCategory)
r.Put("/api/categories/{id}", h.UpdateCategory)
r.Delete("/api/categories/{id}", h.DeleteCategory)
r.Get("/api/submissions", h.ListSubmissions)
r.Get("/api/submissions/{id}", h.GetSubmission)
@@ -152,8 +164,10 @@ func (h *Handler) Me(w http.ResponseWriter, r *http.Request) {
// ListProducts returns a paginated product list.
func (h *Handler) ListProducts(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query().Get("q")
sort := r.URL.Query().Get("sort")
order := r.URL.Query().Get("order")
page, size := pageParams(r)
items, total, err := h.store.ListProducts(r.Context(), q, size, (page-1)*size)
items, total, err := h.store.ListProducts(r.Context(), q, sort, order, size, (page-1)*size)
if h.handleErr(w, err) {
return
}
@@ -172,6 +186,99 @@ func (h *Handler) GetProduct(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, d)
}
// CreateProduct adds a new product with core fields; the rest is filled in via
// the detail editor.
func (h *Handler) CreateProduct(w http.ResponseWriter, r *http.Request) {
var in adminstore.ProductInput
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "invalid body")
return
}
if strings.TrimSpace(in.Name) == "" {
writeError(w, http.StatusBadRequest, "bad_request", "名称不能为空")
return
}
d, err := h.store.CreateProduct(r.Context(), auth.UserFrom(r.Context()), in)
if errors.Is(err, adminstore.ErrDuplicateGTIN) {
writeError(w, http.StatusConflict, "duplicate_gtin", "该条码(GTIN)已被其它商品使用")
return
}
if h.handleErr(w, err) {
return
}
writeJSON(w, http.StatusCreated, d)
}
// Stats returns the dashboard overview counters.
func (h *Handler) Stats(w http.ResponseWriter, r *http.Request) {
st, err := h.store.Stats(r.Context())
if h.handleErr(w, err) {
return
}
writeJSON(w, http.StatusOK, st)
}
// ListAllAudit returns a page of the global operations audit log.
func (h *Handler) ListAllAudit(w http.ResponseWriter, r *http.Request) {
page, size := pageParams(r)
items, total, err := h.store.ListAllAudit(r.Context(), size, (page-1)*size)
if h.handleErr(w, err) {
return
}
writeJSON(w, http.StatusOK, map[string]any{
"items": items, "page": page, "size": size, "total": total,
})
}
type bulkInput struct {
IDs []string `json:"ids"`
Action string `json:"action"`
Status string `json:"status"`
CategoryID *string `json:"category_id"`
}
// BulkProducts applies a status or category change to many products at once.
func (h *Handler) BulkProducts(w http.ResponseWriter, r *http.Request) {
var in bulkInput
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "invalid body")
return
}
if len(in.IDs) == 0 {
writeError(w, http.StatusBadRequest, "bad_request", "未选择任何商品")
return
}
actor := auth.UserFrom(r.Context())
var (
affected int
err error
)
switch in.Action {
case "status":
affected, err = h.store.BulkSetStatus(r.Context(), actor, in.IDs, in.Status)
case "category":
affected, err = h.store.BulkSetCategory(r.Context(), actor, in.IDs, in.CategoryID)
default:
writeError(w, http.StatusBadRequest, "bad_request", "未知的批量操作")
return
}
switch {
case errors.Is(err, adminstore.ErrInvalidStatus):
writeError(w, http.StatusBadRequest, "invalid_status", "无效的状态值")
return
case errors.Is(err, adminstore.ErrInvalidParent):
writeError(w, http.StatusBadRequest, "invalid_parent", "目标分类无效")
return
case errors.Is(err, adminstore.ErrNoTargets):
writeError(w, http.StatusBadRequest, "bad_request", "未选择任何商品")
return
}
if h.handleErr(w, err) {
return
}
writeJSON(w, http.StatusOK, map[string]any{"status": "ok", "affected": affected})
}
// UpdateProduct applies an edit.
func (h *Handler) UpdateProduct(w http.ResponseWriter, r *http.Request) {
var in adminstore.ProductInput
@@ -319,6 +426,20 @@ func (h *Handler) ListBrands(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"items": items})
}
// ListKindFields returns the editable spec field template for an archive kind.
func (h *Handler) ListKindFields(w http.ResponseWriter, r *http.Request) {
kind := strings.TrimSpace(r.URL.Query().Get("kind"))
if kind == "" {
writeError(w, http.StatusBadRequest, "bad_request", "缺少 kind 参数")
return
}
items, err := h.store.ListKindFields(r.Context(), kind)
if h.handleErr(w, err) {
return
}
writeJSON(w, http.StatusOK, map[string]any{"items": items, "kind": kind})
}
// ListCategories returns category options.
func (h *Handler) ListCategories(w http.ResponseWriter, r *http.Request) {
items, err := h.store.ListCategories(r.Context())
@@ -328,6 +449,164 @@ func (h *Handler) ListCategories(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"items": items})
}
// CreateCategory adds a category node.
func (h *Handler) CreateCategory(w http.ResponseWriter, r *http.Request) {
var in adminstore.CategoryInput
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "invalid body")
return
}
if strings.TrimSpace(in.NameZH) == "" {
writeError(w, http.StatusBadRequest, "bad_request", "分类名称不能为空")
return
}
c, err := h.store.CreateCategory(r.Context(), auth.UserFrom(r.Context()), in)
if h.handleCategoryErr(w, err) {
return
}
writeJSON(w, http.StatusCreated, c)
}
// UpdateCategory renames and/or moves a category node.
func (h *Handler) UpdateCategory(w http.ResponseWriter, r *http.Request) {
var in adminstore.CategoryInput
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "invalid body")
return
}
if strings.TrimSpace(in.NameZH) == "" {
writeError(w, http.StatusBadRequest, "bad_request", "分类名称不能为空")
return
}
c, err := h.store.UpdateCategory(r.Context(), chi.URLParam(r, "id"), auth.UserFrom(r.Context()), in)
if h.handleCategoryErr(w, err) {
return
}
writeJSON(w, http.StatusOK, c)
}
// DeleteCategory removes a leaf category that no product uses.
func (h *Handler) DeleteCategory(w http.ResponseWriter, r *http.Request) {
err := h.store.DeleteCategory(r.Context(), chi.URLParam(r, "id"), auth.UserFrom(r.Context()))
if h.handleCategoryErr(w, err) {
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
}
// handleCategoryErr maps category-specific errors to client statuses, falling
// back to handleErr otherwise.
func (h *Handler) handleCategoryErr(w http.ResponseWriter, err error) bool {
if err == nil {
return false
}
switch {
case errors.Is(err, adminstore.ErrDuplicatePath):
writeError(w, http.StatusConflict, "duplicate_path", "该分类路径已存在,请换一个英文标识(slug)")
return true
case errors.Is(err, adminstore.ErrCategoryHasChildren):
writeError(w, http.StatusConflict, "has_children", "该分类存在子分类,请先删除或移动其子分类")
return true
case errors.Is(err, adminstore.ErrCategoryInUse):
writeError(w, http.StatusConflict, "in_use", "仍有商品归属于该分类,请先改归其它分类")
return true
case errors.Is(err, adminstore.ErrInvalidParent):
writeError(w, http.StatusBadRequest, "invalid_parent", "上级分类无效(不存在或不能移动到自身/子级下)")
return true
}
return h.handleErr(w, err)
}
type brandInput struct {
Name string `json:"name"`
}
type brandMergeInput struct {
TargetID string `json:"target_id"`
}
// CreateBrand adds a brand.
func (h *Handler) CreateBrand(w http.ResponseWriter, r *http.Request) {
var in brandInput
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "invalid body")
return
}
if strings.TrimSpace(in.Name) == "" {
writeError(w, http.StatusBadRequest, "bad_request", "品牌名称不能为空")
return
}
b, err := h.store.CreateBrand(r.Context(), auth.UserFrom(r.Context()), in.Name)
if h.handleBrandErr(w, err) {
return
}
writeJSON(w, http.StatusCreated, b)
}
// UpdateBrand renames a brand.
func (h *Handler) UpdateBrand(w http.ResponseWriter, r *http.Request) {
var in brandInput
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "invalid body")
return
}
if strings.TrimSpace(in.Name) == "" {
writeError(w, http.StatusBadRequest, "bad_request", "品牌名称不能为空")
return
}
b, err := h.store.UpdateBrand(r.Context(), chi.URLParam(r, "id"), auth.UserFrom(r.Context()), in.Name)
if h.handleBrandErr(w, err) {
return
}
writeJSON(w, http.StatusOK, b)
}
// MergeBrands folds one brand's products into another, then deletes the source.
func (h *Handler) MergeBrands(w http.ResponseWriter, r *http.Request) {
var in brandMergeInput
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "invalid body")
return
}
if strings.TrimSpace(in.TargetID) == "" {
writeError(w, http.StatusBadRequest, "bad_request", "请选择合并目标品牌")
return
}
b, err := h.store.MergeBrands(r.Context(), chi.URLParam(r, "id"), in.TargetID, auth.UserFrom(r.Context()))
if h.handleBrandErr(w, err) {
return
}
writeJSON(w, http.StatusOK, b)
}
// DeleteBrand removes a brand no product references.
func (h *Handler) DeleteBrand(w http.ResponseWriter, r *http.Request) {
err := h.store.DeleteBrand(r.Context(), chi.URLParam(r, "id"), auth.UserFrom(r.Context()))
if h.handleBrandErr(w, err) {
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
}
// handleBrandErr maps brand-specific errors to client statuses.
func (h *Handler) handleBrandErr(w http.ResponseWriter, err error) bool {
if err == nil {
return false
}
switch {
case errors.Is(err, adminstore.ErrDuplicateBrand):
writeError(w, http.StatusConflict, "duplicate_brand", "该品牌名称已存在")
return true
case errors.Is(err, adminstore.ErrBrandInUse):
writeError(w, http.StatusConflict, "in_use", "仍有商品使用该品牌,请先改用其它品牌或合并")
return true
case errors.Is(err, adminstore.ErrInvalidMerge):
writeError(w, http.StatusBadRequest, "invalid_merge", "合并目标无效(不存在或与源品牌相同)")
return true
}
return h.handleErr(w, err)
}
// ---------- submissions ----------
// CreateSubmission accepts an anonymous public contribution into the queue.
+68 -8
View File
@@ -9,6 +9,7 @@ import (
"encoding/json"
"errors"
"strconv"
"strings"
"time"
"github.com/jackc/pgx/v5"
@@ -50,8 +51,34 @@ type ProductRow struct {
UpdatedAt string `json:"updated_at"`
}
// productSortColumns whitelists the sortable list columns, mapping the API sort
// key to a SQL expression. NULLs sort last regardless of direction.
var productSortColumns = map[string]string{
"name": "p.name",
"brand": "b.name",
"gtin": "p.gtin",
"category_path": "c.path",
"status": "p.status",
"quality_score": "p.quality_score",
"updated_at": "p.updated_at",
}
// productOrderBy returns a safe ORDER BY clause for the given sort key/direction,
// falling back to the default (most recently updated first) for unknown keys.
func productOrderBy(sort, order string) string {
col, ok := productSortColumns[sort]
if !ok {
return "p.updated_at DESC"
}
dir := "ASC"
if strings.EqualFold(order, "desc") {
dir = "DESC"
}
return col + " " + dir + " NULLS LAST, p.updated_at DESC"
}
// ListProducts returns a paginated, optionally name/gtin-filtered list.
func (s *Store) ListProducts(ctx context.Context, q string, limit, offset int) ([]ProductRow, int, error) {
func (s *Store) ListProducts(ctx context.Context, q, sort, order string, limit, offset int) ([]ProductRow, int, error) {
args := []any{}
where := "WHERE 1=1"
if q != "" {
@@ -64,10 +91,15 @@ func (s *Store) ListProducts(ctx context.Context, q string, limit, offset int) (
return nil, 0, err
}
qualified, err := s.kindQualifiedKeys(ctx, s.pool)
if err != nil {
return nil, 0, err
}
args = append(args, limit, offset)
sql := `
SELECT p.id, p.gtin, p.name, b.name, c.path::text, p.status, p.quality_score,
p.updated_at,
p.updated_at, COALESCE(c.archive_kind, 'generic'), p.attributes,
(p.brand_id IS NOT NULL) AS has_brand,
(p.category_id IS NOT NULL) AS has_cat,
(p.net_content_canonical IS NOT NULL) AS has_net,
@@ -79,7 +111,8 @@ FROM product p
LEFT JOIN brand b ON b.id = p.brand_id
LEFT JOIN category c ON c.id = p.category_id
LEFT JOIN food_detail f ON f.product_id = p.id ` + where +
" ORDER BY p.updated_at DESC LIMIT $" + strconv.Itoa(len(args)-1) + " OFFSET $" + strconv.Itoa(len(args))
" ORDER BY " + productOrderBy(sort, order) +
" LIMIT $" + strconv.Itoa(len(args)-1) + " OFFSET $" + strconv.Itoa(len(args))
rows, err := s.pool.Query(ctx, sql, args...)
if err != nil {
@@ -91,13 +124,21 @@ LEFT JOIN food_detail f ON f.product_id = p.id ` + where +
for rows.Next() {
var r ProductRow
var hasBrand, hasCat, hasNet, hasCountry, hasNutri, hasIng, hasImg bool
var kind string
var attributes []byte
var updated time.Time
if err := rows.Scan(&r.ID, &r.GTIN, &r.Name, &r.Brand, &r.CategoryPath, &r.Status,
&r.QualityScore, &updated, &hasBrand, &hasCat, &hasNet, &hasCountry,
&r.QualityScore, &updated, &kind, &attributes,
&hasBrand, &hasCat, &hasNet, &hasCountry,
&hasNutri, &hasIng, &hasImg); err != nil {
return nil, 0, err
}
r.UpdatedAt = updated.Format(time.RFC3339)
attrs := map[string]any{}
if len(attributes) > 0 {
_ = json.Unmarshal(attributes, &attrs)
}
qkeys := qualified[kind]
present := map[string]bool{
"name": r.Name != "",
"gtin": r.GTIN != nil && *r.GTIN != "",
@@ -109,8 +150,11 @@ LEFT JOIN food_detail f ON f.product_id = p.id ` + where +
"ingredients": hasIng,
"image": hasImg,
}
for _, k := range qkeys {
present[k] = attrPresent(attrs, k)
}
r.Missing = []string{}
for _, f := range CompletenessFields {
for _, f := range completenessKeys(kind, qkeys) {
if !present[f] {
r.Missing = append(r.Missing, f)
}
@@ -150,6 +194,8 @@ type ProductDetail struct {
Brand *string `json:"brand"`
CategoryID *string `json:"category_id"`
CategoryPath *string `json:"category_path"`
ArchiveKind string `json:"archive_kind"`
Attributes map[string]any `json:"attributes"`
NetContentValue *float64 `json:"net_content_value"`
NetContentUnit *string `json:"net_content_unit"`
CountryOfOrigin *string `json:"country_of_origin"`
@@ -173,9 +219,11 @@ type ProductDetail struct {
func (s *Store) GetProduct(ctx context.Context, id string) (*ProductDetail, error) {
var d ProductDetail
var nutriments []byte
var attributes []byte
var updated time.Time
err := s.pool.QueryRow(ctx, `
SELECT p.id, p.gtin, p.name, p.brand_id, b.name, p.category_id, c.path::text,
COALESCE(c.archive_kind, 'generic'), p.attributes,
p.net_content_value, p.net_content_unit, p.country_of_origin, p.status,
p.quality_score, p.updated_at,
f.ingredients_text, f.allergens, f.additives, f.nutriments,
@@ -186,6 +234,7 @@ LEFT JOIN category c ON c.id = p.category_id
LEFT JOIN food_detail f ON f.product_id = p.id
WHERE p.id = $1`, id).Scan(
&d.ID, &d.GTIN, &d.Name, &d.BrandID, &d.Brand, &d.CategoryID, &d.CategoryPath,
&d.ArchiveKind, &attributes,
&d.NetContentValue, &d.NetContentUnit, &d.CountryOfOrigin, &d.Status,
&d.QualityScore, &updated,
&d.IngredientsText, &d.Allergens, &d.Additives, &nutriments,
@@ -198,6 +247,10 @@ WHERE p.id = $1`, id).Scan(
return nil, err
}
d.UpdatedAt = updated.Format(time.RFC3339)
d.Attributes = map[string]any{}
if len(attributes) > 0 {
_ = json.Unmarshal(attributes, &d.Attributes)
}
if len(nutriments) > 0 {
_ = json.Unmarshal(nutriments, &d.Nutriments)
}
@@ -226,11 +279,15 @@ WHERE p.id = $1`, id).Scan(
}
d.MSRP = msrps
d.Missing = missingFromDetail(&d)
qualified, err := s.kindQualifiedKeys(ctx, s.pool)
if err != nil {
return nil, err
}
d.Missing = missingFromDetail(&d, qualified[d.ArchiveKind])
return &d, nil
}
func missingFromDetail(d *ProductDetail) []string {
func missingFromDetail(d *ProductDetail, qualifiedAttrKeys []string) []string {
present := map[string]bool{
"name": d.Name != "",
"gtin": d.GTIN != nil && *d.GTIN != "",
@@ -242,8 +299,11 @@ func missingFromDetail(d *ProductDetail) []string {
"ingredients": d.IngredientsText != nil && *d.IngredientsText != "",
"image": len(d.Images) > 0,
}
for _, k := range qualifiedAttrKeys {
present[k] = attrPresent(d.Attributes, k)
}
missing := []string{}
for _, f := range CompletenessFields {
for _, f := range completenessKeys(d.ArchiveKind, qualifiedAttrKeys) {
if !present[f] {
missing = append(missing, f)
}
+13 -7
View File
@@ -19,6 +19,7 @@ type APIKeyRow struct {
OwnerEmail *string `json:"owner_email"`
Tier string `json:"tier"`
RateLimitPerMin int `json:"rate_limit_per_min"`
QuotaTotal int64 `json:"quota_total"`
RevokedAt *string `json:"revoked_at"`
CreatedBy *string `json:"created_by"`
CreatedAt string `json:"created_at"`
@@ -30,6 +31,7 @@ type APIKeyInput struct {
OwnerEmail string `json:"owner_email"`
Tier string `json:"tier"`
RateLimitPerMin int `json:"rate_limit_per_min"`
QuotaTotal int64 `json:"quota_total"`
}
// CreateAPIKey issues a new key, returning the one-time plaintext alongside the
@@ -43,6 +45,10 @@ func (s *Store) CreateAPIKey(ctx context.Context, in APIKeyInput, createdBy stri
if rate <= 0 {
rate = 120
}
quota := in.QuotaTotal
if quota < 0 {
quota = 0
}
var owner *string
if e := strings.TrimSpace(in.OwnerEmail); e != "" {
owner = &e
@@ -56,12 +62,12 @@ func (s *Store) CreateAPIKey(ctx context.Context, in APIKeyInput, createdBy stri
var revoked, created *time.Time
var createdByOut *string
err = s.pool.QueryRow(ctx, `
INSERT INTO api_key (name, key_prefix, key_hash, owner_email, tier, rate_limit_per_min, created_by)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id, name, key_prefix, owner_email, tier, rate_limit_per_min, revoked_at, created_by, created_at`,
strings.TrimSpace(in.Name), prefix, hash, owner, tier, rate, createdBy,
INSERT INTO api_key (name, key_prefix, key_hash, owner_email, tier, rate_limit_per_min, quota_total, created_by)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id, name, key_prefix, owner_email, tier, rate_limit_per_min, quota_total, revoked_at, created_by, created_at`,
strings.TrimSpace(in.Name), prefix, hash, owner, tier, rate, quota, createdBy,
).Scan(&row.ID, &row.Name, &row.KeyPrefix, &row.OwnerEmail, &row.Tier,
&row.RateLimitPerMin, &revoked, &createdByOut, &created)
&row.RateLimitPerMin, &row.QuotaTotal, &revoked, &createdByOut, &created)
if err != nil {
return "", row, err
}
@@ -75,7 +81,7 @@ RETURNING id, name, key_prefix, owner_email, tier, rate_limit_per_min, revoked_a
// ListAPIKeys returns all keys (active first, newest first).
func (s *Store) ListAPIKeys(ctx context.Context) ([]APIKeyRow, error) {
rows, err := s.pool.Query(ctx, `
SELECT id, name, key_prefix, owner_email, tier, rate_limit_per_min, revoked_at, created_by, created_at
SELECT id, name, key_prefix, owner_email, tier, rate_limit_per_min, quota_total, revoked_at, created_by, created_at
FROM api_key
ORDER BY (revoked_at IS NULL) DESC, created_at DESC`)
if err != nil {
@@ -87,7 +93,7 @@ ORDER BY (revoked_at IS NULL) DESC, created_at DESC`)
var r APIKeyRow
var revoked, created *time.Time
if err := rows.Scan(&r.ID, &r.Name, &r.KeyPrefix, &r.OwnerEmail, &r.Tier,
&r.RateLimitPerMin, &revoked, &r.CreatedBy, &created); err != nil {
&r.RateLimitPerMin, &r.QuotaTotal, &revoked, &r.CreatedBy, &created); err != nil {
return nil, err
}
if revoked != nil {
+154
View File
@@ -0,0 +1,154 @@
package adminstore
import (
"context"
"errors"
"fmt"
"strings"
"github.com/jackc/pgx/v5"
)
// Brand-management errors, mapped to client statuses by the handler.
var (
// ErrDuplicateBrand is returned when a brand name already exists.
ErrDuplicateBrand = errors.New("duplicate brand name")
// ErrBrandInUse blocks deleting a brand still referenced by products.
ErrBrandInUse = errors.New("brand in use")
// ErrInvalidMerge is returned when a merge target is missing or equal to
// the source.
ErrInvalidMerge = errors.New("invalid merge target")
)
// CreateBrand inserts a new brand. Names are unique by normalized form.
func (s *Store) CreateBrand(ctx context.Context, actor, name string) (*Brand, error) {
name = strings.TrimSpace(name)
if name == "" {
return nil, errors.New("name required")
}
var b Brand
err := s.pool.QueryRow(ctx,
`INSERT INTO brand (name, normalized_name) VALUES ($1, $2) RETURNING id, name, 0`,
name, normBrand(name)).Scan(&b.ID, &b.Name, &b.ProductCount)
if isUniqueViolation(err) {
return nil, ErrDuplicateBrand
}
if err != nil {
return nil, err
}
_ = s.writeAudit(ctx, actor, "create", "brand", &b.ID, []string{"name"}, nil, b)
return &b, nil
}
// UpdateBrand renames a brand, keeping the normalized name in sync.
func (s *Store) UpdateBrand(ctx context.Context, id, actor, name string) (*Brand, error) {
name = strings.TrimSpace(name)
if name == "" {
return nil, errors.New("name required")
}
ct, err := s.pool.Exec(ctx,
"UPDATE brand SET name = $1, normalized_name = $2 WHERE id = $3",
name, normBrand(name), id)
if isUniqueViolation(err) {
return nil, ErrDuplicateBrand
}
if err != nil {
return nil, err
}
if ct.RowsAffected() == 0 {
return nil, ErrNotFound
}
out, err := s.getBrand(ctx, id)
if err != nil {
return nil, err
}
_ = s.writeAudit(ctx, actor, "update", "brand", &id, []string{"name"}, nil, out)
return out, nil
}
// MergeBrands reassigns every product of src to dst, then deletes src. Useful
// for collapsing duplicate brands (e.g. "可口可乐" and "Coca-Cola").
func (s *Store) MergeBrands(ctx context.Context, srcID, dstID, actor string) (*Brand, error) {
if srcID == dstID {
return nil, ErrInvalidMerge
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
var dstName string
err = tx.QueryRow(ctx, "SELECT name FROM brand WHERE id = $1", dstID).Scan(&dstName)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrInvalidMerge
}
if err != nil {
return nil, err
}
var srcName string
err = tx.QueryRow(ctx, "SELECT name FROM brand WHERE id = $1", srcID).Scan(&srcName)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
if _, err := tx.Exec(ctx, "UPDATE product SET brand_id = $1 WHERE brand_id = $2", dstID, srcID); err != nil {
return nil, err
}
if _, err := tx.Exec(ctx, "DELETE FROM brand WHERE id = $1", srcID); err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
out, err := s.getBrand(ctx, dstID)
if err != nil {
return nil, err
}
_ = s.writeAudit(ctx, actor, "merge", "brand", &srcID, []string{"name"},
map[string]string{"name": srcName},
map[string]string{"merged_into": dstName, "merged_into_id": dstID})
return out, nil
}
// DeleteBrand removes a brand not referenced by any product.
func (s *Store) DeleteBrand(ctx context.Context, id, actor string) error {
before, err := s.getBrand(ctx, id)
if err != nil {
return err
}
if before.ProductCount > 0 {
return fmt.Errorf("%w: %d products", ErrBrandInUse, before.ProductCount)
}
ct, err := s.pool.Exec(ctx, "DELETE FROM brand WHERE id = $1", id)
if err != nil {
if isForeignKeyViolation(err) {
return ErrBrandInUse
}
return err
}
if ct.RowsAffected() == 0 {
return ErrNotFound
}
_ = s.writeAudit(ctx, actor, "delete", "brand", &id, []string{"name"}, before, nil)
return nil
}
func (s *Store) getBrand(ctx context.Context, id string) (*Brand, error) {
var b Brand
err := s.pool.QueryRow(ctx, `
SELECT b.id, b.name,
(SELECT count(*) FROM product p WHERE p.brand_id = b.id)
FROM brand b WHERE b.id = $1`, id).Scan(&b.ID, &b.Name, &b.ProductCount)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
return &b, nil
}
+78
View File
@@ -0,0 +1,78 @@
package adminstore
import (
"context"
"errors"
"testing"
)
func TestBrandLifecycle(t *testing.T) {
s := newTestStore(t)
ctx := context.Background()
a, err := s.CreateBrand(ctx, "tester", "品牌A "+randomHex(4))
if err != nil {
t.Fatalf("create A: %v", err)
}
t.Cleanup(func() { _, _ = s.pool.Exec(ctx, "DELETE FROM brand WHERE id = $1", a.ID) })
b, err := s.CreateBrand(ctx, "tester", "品牌B "+randomHex(4))
if err != nil {
t.Fatalf("create B: %v", err)
}
t.Cleanup(func() { _, _ = s.pool.Exec(ctx, "DELETE FROM brand WHERE id = $1", b.ID) })
// Duplicate (normalized) name must be rejected.
if _, err := s.CreateBrand(ctx, "tester", " "+a.Name+" "); !errors.Is(err, ErrDuplicateBrand) {
t.Fatalf("expected ErrDuplicateBrand, got %v", err)
}
// Rename.
renamed, err := s.UpdateBrand(ctx, a.ID, "tester", "品牌A改名")
if err != nil {
t.Fatalf("rename: %v", err)
}
if renamed.Name != "品牌A改名" {
t.Fatalf("rename not applied: %q", renamed.Name)
}
// Attach a product to brand A so deletion is blocked and merge moves it.
var prodID string
err = s.pool.QueryRow(ctx,
"INSERT INTO product (name, brand_id, status) VALUES ($1,$2,'active') RETURNING id",
"测试商品 "+randomHex(4), a.ID).Scan(&prodID)
if err != nil {
t.Fatalf("insert product: %v", err)
}
t.Cleanup(func() { _, _ = s.pool.Exec(ctx, "DELETE FROM product WHERE id = $1", prodID) })
// Deleting an in-use brand must fail.
if err := s.DeleteBrand(ctx, a.ID, "tester"); !errors.Is(err, ErrBrandInUse) {
t.Fatalf("expected ErrBrandInUse, got %v", err)
}
// Merge A into B: product reassigned, A deleted.
merged, err := s.MergeBrands(ctx, a.ID, b.ID, "tester")
if err != nil {
t.Fatalf("merge: %v", err)
}
if merged.ID != b.ID || merged.ProductCount < 1 {
t.Fatalf("merge result wrong: %+v", merged)
}
if _, err := s.getBrand(ctx, a.ID); !errors.Is(err, ErrNotFound) {
t.Fatalf("source brand should be gone, got %v", err)
}
// Self-merge is invalid.
if _, err := s.MergeBrands(ctx, b.ID, b.ID, "tester"); !errors.Is(err, ErrInvalidMerge) {
t.Fatalf("expected ErrInvalidMerge, got %v", err)
}
// After moving the product away from B, B can be deleted.
if _, err := s.pool.Exec(ctx, "DELETE FROM product WHERE id = $1", prodID); err != nil {
t.Fatalf("cleanup product: %v", err)
}
if err := s.DeleteBrand(ctx, b.ID, "tester"); err != nil {
t.Fatalf("delete unused brand: %v", err)
}
}
+80
View File
@@ -0,0 +1,80 @@
package adminstore
import (
"context"
"errors"
)
// Bulk-operation errors.
var (
// ErrNoTargets is returned when a bulk request selects no products.
ErrNoTargets = errors.New("no products selected")
// ErrInvalidStatus is returned for an unknown product status value.
ErrInvalidStatus = errors.New("invalid status")
)
var validStatus = map[string]bool{"active": true, "merged": true, "deprecated": true}
// BulkSetStatus updates the status of every selected product in one statement.
func (s *Store) BulkSetStatus(ctx context.Context, actor string, ids []string, status string) (int, error) {
if len(ids) == 0 {
return 0, ErrNoTargets
}
if !validStatus[status] {
return 0, ErrInvalidStatus
}
ct, err := s.pool.Exec(ctx,
"UPDATE product SET status = $1 WHERE id = ANY($2)", status, ids)
if err != nil {
return 0, err
}
n := int(ct.RowsAffected())
_ = s.writeAudit(ctx, actor, "bulk_status", "product", nil,
[]string{"status"}, map[string]any{"ids": ids}, map[string]any{"status": status})
return n, nil
}
// BulkSetCategory reassigns the category of every selected product, syncing the
// GPC brick code and recomputing quality for each one.
func (s *Store) BulkSetCategory(ctx context.Context, actor string, ids []string, categoryID *string) (int, error) {
if len(ids) == 0 {
return 0, ErrNoTargets
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return 0, err
}
defer tx.Rollback(ctx)
var gpc *string
if categoryID != nil && *categoryID != "" {
if err := tx.QueryRow(ctx, "SELECT gpc_brick_code FROM category WHERE id = $1", *categoryID).Scan(&gpc); err != nil {
return 0, ErrInvalidParent
}
} else {
categoryID = nil
}
ct, err := tx.Exec(ctx,
"UPDATE product SET category_id = $1, gpc_brick_code = $2 WHERE id = ANY($3)",
categoryID, gpc, ids)
if err != nil {
return 0, err
}
for _, id := range ids {
if _, err := s.recomputeQualityTx(ctx, tx, id); err != nil {
return 0, err
}
}
if err := tx.Commit(ctx); err != nil {
return 0, err
}
n := int(ct.RowsAffected())
cat := ""
if categoryID != nil {
cat = *categoryID
}
_ = s.writeAudit(ctx, actor, "bulk_category", "product", nil,
[]string{"category"}, map[string]any{"ids": ids}, map[string]any{"category_id": cat})
return n, nil
}
+298
View File
@@ -0,0 +1,298 @@
package adminstore
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"regexp"
"strings"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
)
// randomHex returns n random lowercase hex characters for fallback ltree slugs.
func randomHex(n int) string {
b := make([]byte, (n+1)/2)
if _, err := rand.Read(b); err != nil {
return "x"
}
return hex.EncodeToString(b)[:n]
}
// Category-management errors, mapped to client statuses by the handler.
var (
// ErrDuplicatePath is returned when a category path already exists.
ErrDuplicatePath = errors.New("duplicate category path")
// ErrCategoryHasChildren blocks deleting a node that still has children.
ErrCategoryHasChildren = errors.New("category has children")
// ErrCategoryInUse blocks deleting a node still referenced by products.
ErrCategoryInUse = errors.New("category in use")
// ErrInvalidParent is returned for a missing parent or an illegal move
// (onto itself or one of its own descendants).
ErrInvalidParent = errors.New("invalid parent category")
)
// CategoryInput is the payload accepted when creating or editing a category.
// Slug is the ltree label (ASCII); when empty it is derived from NameEN, then
// from a random suffix, since ltree labels cannot contain CJK or spaces.
type CategoryInput struct {
NameZH string `json:"name_zh"`
NameEN *string `json:"name_en"`
Slug *string `json:"slug"`
ParentID *string `json:"parent_id"`
GPCBrickCode *string `json:"gpc_brick_code"`
}
var slugInvalid = regexp.MustCompile(`[^a-z0-9_]+`)
// slugify converts a string into a valid ltree label ([a-z0-9_]).
func slugify(s string) string {
s = strings.ToLower(strings.TrimSpace(s))
s = slugInvalid.ReplaceAllString(s, "_")
s = strings.Trim(s, "_")
for strings.Contains(s, "__") {
s = strings.ReplaceAll(s, "__", "_")
}
return s
}
// resolveSlug picks an ltree label from the explicit slug, then NameEN, then a
// random fallback so a Chinese-only category still gets a valid path label.
func resolveSlug(in CategoryInput) string {
if in.Slug != nil {
if s := slugify(*in.Slug); s != "" {
return s
}
}
if in.NameEN != nil {
if s := slugify(*in.NameEN); s != "" {
return s
}
}
return "cat_" + randomHex(6)
}
func trimPtr(p *string) *string {
if p == nil {
return nil
}
t := strings.TrimSpace(*p)
if t == "" {
return nil
}
return &t
}
// CreateCategory inserts a new category node. With no parent it becomes a root
// (level 0); otherwise its path is parentPath.slug and level is parentLevel+1.
func (s *Store) CreateCategory(ctx context.Context, actor string, in CategoryInput) (*Category, error) {
name := strings.TrimSpace(in.NameZH)
if name == "" {
return nil, errors.New("name_zh required")
}
parentPath := ""
parentLevel := -1
kind := DefaultKind
var parentID *string
if pid := trimPtr(in.ParentID); pid != nil {
var path string
var level int
err := s.pool.QueryRow(ctx, "SELECT path::text, level, archive_kind FROM category WHERE id = $1", *pid).Scan(&path, &level, &kind)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrInvalidParent
}
if err != nil {
return nil, err
}
parentPath, parentLevel, parentID = path, level, pid
}
slug := resolveSlug(in)
path := slug
if parentPath != "" {
path = parentPath + "." + slug
}
level := parentLevel + 1
var c Category
err := s.pool.QueryRow(ctx, `
INSERT INTO category (name_zh, name_en, parent_id, path, gpc_brick_code, level, archive_kind)
VALUES ($1, $2, $3, $4::ltree, $5, $6, $7)
RETURNING id, name_zh, name_en, path::text, level, parent_id::text, gpc_brick_code, archive_kind, 0`,
name, trimPtr(in.NameEN), parentID, path, trimPtr(in.GPCBrickCode), level, kind).
Scan(&c.ID, &c.NameZH, &c.NameEN, &c.Path, &c.Level, &c.ParentID, &c.GPCBrickCode, &c.ArchiveKind, &c.ProductCount)
if isUniqueViolation(err) {
return nil, ErrDuplicatePath
}
if err != nil {
return nil, err
}
_ = s.writeAudit(ctx, actor, "create", "category", &c.ID, []string{"name_zh", "path"}, nil, c)
return &c, nil
}
// UpdateCategory renames a node and/or moves it under a new parent. Moving
// rewrites the path of the node and every descendant via ltree, keeping level
// in sync. Moving a node onto itself or a descendant is rejected.
func (s *Store) UpdateCategory(ctx context.Context, id, actor string, in CategoryInput) (*Category, error) {
name := strings.TrimSpace(in.NameZH)
if name == "" {
return nil, errors.New("name_zh required")
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
var oldPath string
var oldLevel int
var oldParent *string
err = tx.QueryRow(ctx, "SELECT path::text, level, parent_id::text FROM category WHERE id = $1", id).
Scan(&oldPath, &oldLevel, &oldParent)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
if _, err := tx.Exec(ctx,
"UPDATE category SET name_zh = $1, name_en = $2, gpc_brick_code = $3 WHERE id = $4",
name, trimPtr(in.NameEN), trimPtr(in.GPCBrickCode), id); err != nil {
return nil, err
}
newParent := trimPtr(in.ParentID)
if !strEq(newParent, oldParent) {
if err := s.moveCategoryTx(ctx, tx, id, oldPath, newParent); err != nil {
return nil, err
}
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
out, err := s.getCategory(ctx, id)
if err != nil {
return nil, err
}
_ = s.writeAudit(ctx, actor, "update", "category", &id, []string{"name_zh", "name_en", "gpc_brick_code", "parent_id"}, nil, out)
return out, nil
}
// moveCategoryTx re-parents a subtree. The node's slug (last path label) is
// preserved; only its ancestor prefix and level change.
func (s *Store) moveCategoryTx(ctx context.Context, tx pgx.Tx, id, oldPath string, newParent *string) error {
slug := oldPath
if i := strings.LastIndex(oldPath, "."); i >= 0 {
slug = oldPath[i+1:]
}
newBase := slug
if newParent != nil {
var parentPath string
err := tx.QueryRow(ctx, "SELECT path::text FROM category WHERE id = $1", *newParent).Scan(&parentPath)
if errors.Is(err, pgx.ErrNoRows) {
return ErrInvalidParent
}
if err != nil {
return err
}
// Disallow moving a node under itself or one of its descendants.
if parentPath == oldPath || strings.HasPrefix(parentPath, oldPath+".") {
return ErrInvalidParent
}
newBase = parentPath + "." + slug
}
// Rewrite the node and all descendants in one statement; level tracks depth.
_, err := tx.Exec(ctx, `
UPDATE category
SET path = ($1::ltree || subpath(path, nlevel($2::ltree) - 1)),
level = nlevel($1::ltree) + (nlevel(path) - nlevel($2::ltree)) - 1
WHERE path = $2::ltree OR path <@ $2::ltree`, newBase, oldPath)
if isUniqueViolation(err) {
return ErrDuplicatePath
}
if err != nil {
return err
}
_, err = tx.Exec(ctx, "UPDATE category SET parent_id = $1 WHERE id = $2", newParent, id)
return err
}
// DeleteCategory removes a leaf node not referenced by any product. Nodes with
// children or in-use nodes are rejected with a specific error.
func (s *Store) DeleteCategory(ctx context.Context, id, actor string) error {
before, err := s.getCategory(ctx, id)
if err != nil {
return err
}
var children int
if err := s.pool.QueryRow(ctx, "SELECT count(*) FROM category WHERE parent_id = $1", id).Scan(&children); err != nil {
return err
}
if children > 0 {
return ErrCategoryHasChildren
}
var products int
if err := s.pool.QueryRow(ctx, "SELECT count(*) FROM product WHERE category_id = $1", id).Scan(&products); err != nil {
return err
}
if products > 0 {
return fmt.Errorf("%w: %d products", ErrCategoryInUse, products)
}
ct, err := s.pool.Exec(ctx, "DELETE FROM category WHERE id = $1", id)
if err != nil {
// A concurrent product assignment can still trip the FK.
if isForeignKeyViolation(err) {
return ErrCategoryInUse
}
return err
}
if ct.RowsAffected() == 0 {
return ErrNotFound
}
_ = s.writeAudit(ctx, actor, "delete", "category", &id, []string{"path"}, before, nil)
return nil
}
func (s *Store) getCategory(ctx context.Context, id string) (*Category, error) {
var c Category
err := s.pool.QueryRow(ctx, `
SELECT c.id, c.name_zh, c.name_en, c.path::text, c.level, c.parent_id::text,
c.gpc_brick_code, c.archive_kind,
(SELECT count(*) FROM product p WHERE p.category_id = c.id)
FROM category c WHERE c.id = $1`, id).
Scan(&c.ID, &c.NameZH, &c.NameEN, &c.Path, &c.Level, &c.ParentID, &c.GPCBrickCode, &c.ArchiveKind, &c.ProductCount)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
return &c, nil
}
func isUniqueViolation(err error) bool {
var pgErr *pgconn.PgError
return errors.As(err, &pgErr) && pgErr.Code == "23505"
}
func isForeignKeyViolation(err error) bool {
var pgErr *pgconn.PgError
return errors.As(err, &pgErr) && pgErr.Code == "23503"
}
+130
View File
@@ -0,0 +1,130 @@
package adminstore
import (
"context"
"errors"
"os"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
// newTestStore connects to the test database, skipping when it is unreachable
// or migrations have not been applied.
func newTestStore(t *testing.T) *Store {
t.Helper()
dsn := os.Getenv("OPENGOODS_DATABASE_URL")
if dsn == "" {
dsn = "postgres://opengoods:opengoods@localhost:5432/opengoods?sslmode=disable"
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Skipf("no database: %v", err)
}
if err := pool.Ping(ctx); err != nil {
pool.Close()
t.Skipf("database not reachable: %v", err)
}
var hasTable bool
if err := pool.QueryRow(ctx, "SELECT to_regclass('public.category') IS NOT NULL").Scan(&hasTable); err != nil || !hasTable {
pool.Close()
t.Skip("migrations not applied (category missing)")
}
t.Cleanup(pool.Close)
return New(pool)
}
func ptr(s string) *string { return &s }
func TestCategoryLifecycle(t *testing.T) {
s := newTestStore(t)
ctx := context.Background()
root, err := s.CreateCategory(ctx, "tester", CategoryInput{
NameZH: "测试根", Slug: ptr("test_root_" + randomHex(6)),
})
if err != nil {
t.Fatalf("create root: %v", err)
}
t.Cleanup(func() { _, _ = s.pool.Exec(ctx, "DELETE FROM category WHERE path <@ $1::ltree", root.Path) })
if root.Level != 0 || root.ParentID != nil {
t.Fatalf("root level/parent wrong: level=%d parent=%v", root.Level, root.ParentID)
}
child, err := s.CreateCategory(ctx, "tester", CategoryInput{
NameZH: "测试子", NameEN: ptr("Test Child"), ParentID: &root.ID,
})
if err != nil {
t.Fatalf("create child: %v", err)
}
if child.Level != 1 || child.ParentID == nil || *child.ParentID != root.ID {
t.Fatalf("child hierarchy wrong: %+v", child)
}
// Deleting a node with children must fail.
if err := s.DeleteCategory(ctx, root.ID, "tester"); !errors.Is(err, ErrCategoryHasChildren) {
t.Fatalf("expected ErrCategoryHasChildren, got %v", err)
}
// Rename child.
renamed, err := s.UpdateCategory(ctx, child.ID, "tester", CategoryInput{NameZH: "测试子-改名"})
if err != nil {
t.Fatalf("rename: %v", err)
}
if renamed.NameZH != "测试子-改名" {
t.Fatalf("rename not applied: %q", renamed.NameZH)
}
// Move child to a second root, descendants' path/level should follow.
root2, err := s.CreateCategory(ctx, "tester", CategoryInput{
NameZH: "测试根2", Slug: ptr("test_root2_" + randomHex(6)),
})
if err != nil {
t.Fatalf("create root2: %v", err)
}
t.Cleanup(func() { _, _ = s.pool.Exec(ctx, "DELETE FROM category WHERE path <@ $1::ltree", root2.Path) })
moved, err := s.UpdateCategory(ctx, child.ID, "tester", CategoryInput{NameZH: "测试子-改名", ParentID: &root2.ID})
if err != nil {
t.Fatalf("move: %v", err)
}
if moved.ParentID == nil || *moved.ParentID != root2.ID {
t.Fatalf("move parent wrong: %+v", moved)
}
if moved.Level != 1 {
t.Fatalf("moved level wrong: %d", moved.Level)
}
// Moving a node under itself must be rejected.
if _, err := s.UpdateCategory(ctx, root2.ID, "tester", CategoryInput{NameZH: "测试根2", ParentID: &child.ID}); !errors.Is(err, ErrInvalidParent) {
t.Fatalf("expected ErrInvalidParent for self-move, got %v", err)
}
// Duplicate path on create must be rejected.
if _, err := s.CreateCategory(ctx, "tester", CategoryInput{NameZH: "dup", Slug: ptr(root.Path)}); !errors.Is(err, ErrDuplicatePath) {
t.Fatalf("expected ErrDuplicatePath, got %v", err)
}
// Now the leaf can be deleted.
if err := s.DeleteCategory(ctx, child.ID, "tester"); err != nil {
t.Fatalf("delete leaf: %v", err)
}
}
func TestSlugify(t *testing.T) {
cases := map[string]string{
"Cooking Oil": "cooking_oil",
" Hello--Wld": "hello_wld",
"食品": "",
"a__b": "a_b",
}
for in, want := range cases {
if got := slugify(in); got != want {
t.Errorf("slugify(%q) = %q, want %q", in, got, want)
}
}
}
+101
View File
@@ -0,0 +1,101 @@
package adminstore
import "context"
// DefaultKind is used for products whose category has no archive kind (or no
// category at all).
const DefaultKind = "generic"
// FoodKind keeps the mature, dedicated food_detail path; every other kind is
// driven generically by kind_field + product.attributes.
const FoodKind = "food"
// genericBaseFields are the core completeness fields for any non-food kind.
// Food keeps its own richer CompletenessFields list.
var genericBaseFields = []string{"name", "gtin", "brand", "category", "image"}
// KindField describes one editable spec field for an archive kind. It drives
// both the dynamic admin form and the kind-aware completeness computation.
type KindField struct {
Kind string `json:"kind"`
FieldKey string `json:"field_key"`
GroupLabel string `json:"group_label"`
LabelZH string `json:"label_zh"`
FieldType string `json:"field_type"`
Unit *string `json:"unit"`
Options []string `json:"options"`
Placeholder *string `json:"placeholder"`
SortOrder int `json:"sort_order"`
Qualified bool `json:"qualified"`
}
// ListKindFields returns the ordered field template for one archive kind.
func (s *Store) ListKindFields(ctx context.Context, kind string) ([]KindField, error) {
rows, err := s.pool.Query(ctx, `
SELECT kind, field_key, group_label, label_zh, field_type, unit, options,
placeholder, sort_order, qualified
FROM kind_field WHERE kind = $1 ORDER BY sort_order, field_key`, kind)
if err != nil {
return nil, err
}
defer rows.Close()
out := []KindField{}
for rows.Next() {
var f KindField
if err := rows.Scan(&f.Kind, &f.FieldKey, &f.GroupLabel, &f.LabelZH,
&f.FieldType, &f.Unit, &f.Options, &f.Placeholder, &f.SortOrder,
&f.Qualified); err != nil {
return nil, err
}
out = append(out, f)
}
return out, rows.Err()
}
// kindQualifiedKeys returns, per kind, the attribute keys that count toward the
// completeness/qualified score. Loaded in one query so list views stay cheap.
func (s *Store) kindQualifiedKeys(ctx context.Context, q queryer) (map[string][]string, error) {
rows, err := s.pool.Query(ctx,
"SELECT kind, field_key FROM kind_field WHERE qualified ORDER BY sort_order, field_key")
if err != nil {
return nil, err
}
defer rows.Close()
m := map[string][]string{}
for rows.Next() {
var kind, key string
if err := rows.Scan(&kind, &key); err != nil {
return nil, err
}
m[kind] = append(m[kind], key)
}
return m, rows.Err()
}
// completenessKeys returns the ordered list of field keys that define a full
// archive for the given kind.
func completenessKeys(kind string, qualifiedAttrKeys []string) []string {
if kind == FoodKind {
return CompletenessFields
}
keys := make([]string, 0, len(genericBaseFields)+len(qualifiedAttrKeys))
keys = append(keys, genericBaseFields...)
keys = append(keys, qualifiedAttrKeys...)
return keys
}
// attrPresent reports whether an attribute value is meaningfully filled in.
func attrPresent(attrs map[string]any, key string) bool {
v, ok := attrs[key]
if !ok || v == nil {
return false
}
switch t := v.(type) {
case string:
return t != ""
case []any:
return len(t) > 0
default:
return true
}
}
+119
View File
@@ -0,0 +1,119 @@
package adminstore
import (
"context"
"testing"
)
// contains reports whether s holds v.
func contains(s []string, v string) bool {
for _, x := range s {
if x == v {
return true
}
}
return false
}
// electronicsCategoryID returns the seeded electronics.phone category id,
// skipping the test when the archive-kind migration is not applied.
func electronicsCategoryID(t *testing.T, s *Store) string {
t.Helper()
ctx := context.Background()
var hasTable bool
if err := s.pool.QueryRow(ctx, "SELECT to_regclass('public.kind_field') IS NOT NULL").Scan(&hasTable); err != nil || !hasTable {
t.Skip("archive-kind migration not applied (kind_field missing)")
}
var id string
err := s.pool.QueryRow(ctx, "SELECT id FROM category WHERE path = 'electronics.phone'::ltree").Scan(&id)
if err != nil {
t.Skipf("electronics.phone category not seeded: %v", err)
}
return id
}
func TestListKindFieldsElectronics(t *testing.T) {
s := newTestStore(t)
electronicsCategoryID(t, s) // ensures migration applied
fields, err := s.ListKindFields(context.Background(), "electronics")
if err != nil {
t.Fatalf("list kind fields: %v", err)
}
if len(fields) == 0 {
t.Fatal("expected seeded electronics fields, got none")
}
var sawQualified bool
for _, f := range fields {
if f.FieldKey == "model_number" && f.Qualified {
sawQualified = true
}
}
if !sawQualified {
t.Fatal("expected model_number to be a qualified field")
}
}
// TestElectronicsArchiveKind verifies a non-food product uses the electronics
// completeness rules: food fields (nutriments/ingredients) are not required,
// and qualified spec fields drive both "missing" and the quality score.
func TestElectronicsArchiveKind(t *testing.T) {
s := newTestStore(t)
ctx := context.Background()
catID := electronicsCategoryID(t, s)
created, err := s.CreateProduct(ctx, "tester", ProductInput{
Name: "测试手机 " + randomHex(6),
CategoryID: &catID,
})
if err != nil {
t.Fatalf("create product: %v", err)
}
t.Cleanup(func() { _, _ = s.pool.Exec(ctx, "DELETE FROM product WHERE id = $1", created.ID) })
if created.ArchiveKind != "electronics" {
t.Fatalf("archive_kind = %q, want electronics", created.ArchiveKind)
}
// Food-only completeness fields must not be required for electronics.
if contains(created.Missing, "nutriments") || contains(created.Missing, "ingredients") {
t.Fatalf("electronics product should not require food fields: missing=%v", created.Missing)
}
// Qualified spec fields should appear as missing while empty.
for _, k := range []string{"model_number", "ccc_cert", "screen_size"} {
if !contains(created.Missing, k) {
t.Fatalf("expected %q in missing, got %v", k, created.Missing)
}
}
scoreBefore := created.QualityScore
gtin := "69" + randomHex(11)
brand := "TestPhoneCo"
updated, err := s.UpdateProduct(ctx, created.ID, "tester", ProductInput{
Name: created.Name,
GTIN: &gtin,
BrandName: &brand,
CategoryID: &catID,
Status: "active",
Attributes: map[string]any{
"model_number": "X-100",
"ccc_cert": "2024010101234567",
"screen_size": "6.1",
"color": "黑色",
},
})
if err != nil {
t.Fatalf("update product: %v", err)
}
if got := updated.Attributes["model_number"]; got != "X-100" {
t.Fatalf("attributes not persisted: %v", updated.Attributes)
}
for _, k := range []string{"model_number", "ccc_cert", "screen_size"} {
if contains(updated.Missing, k) {
t.Fatalf("%q should be filled, still missing: %v", k, updated.Missing)
}
}
if updated.QualityScore <= scoreBefore {
t.Fatalf("quality should rise after filling fields: before=%v after=%v", scoreBefore, updated.QualityScore)
}
}
+37 -15
View File
@@ -2,6 +2,7 @@ package adminstore
import (
"context"
"encoding/json"
"math"
"time"
@@ -58,35 +59,56 @@ func (s *Store) computeQuality(ctx context.Context, q queryer, productID string)
var netCanonical *float64
var ingredients *string
var hasNutri, hasImage bool
var kind string
var attributes []byte
err := q.QueryRow(ctx, `
SELECT p.name, p.gtin, p.brand_id, p.category_id, p.net_content_canonical,
p.country_of_origin, f.ingredients_text,
p.country_of_origin, COALESCE(c.archive_kind, 'generic'), p.attributes,
f.ingredients_text,
(f.nutriments IS NOT NULL AND f.nutriments::text <> '{}'),
EXISTS (SELECT 1 FROM product_image pi WHERE pi.product_id = p.id)
FROM product p LEFT JOIN food_detail f ON f.product_id = p.id
FROM product p
LEFT JOIN category c ON c.id = p.category_id
LEFT JOIN food_detail f ON f.product_id = p.id
WHERE p.id = $1`, productID).Scan(
&name, &gtin, &brandID, &categoryID, &netCanonical, &country,
&ingredients, &hasNutri, &hasImage)
&kind, &attributes, &ingredients, &hasNutri, &hasImage)
if err != nil {
return 0, err
}
attrs := map[string]any{}
if len(attributes) > 0 {
_ = json.Unmarshal(attributes, &attrs)
}
qualified, err := s.kindQualifiedKeys(ctx, s.pool)
if err != nil {
return 0, err
}
qkeys := qualified[kind]
known := map[string]bool{
"name": name != nil && *name != "",
"gtin": gtin != nil && *gtin != "",
"brand": brandID != nil,
"category": categoryID != nil,
"net_content": netCanonical != nil,
"country_of_origin": country != nil && *country != "",
"nutriments": hasNutri,
"ingredients": ingredients != nil && *ingredients != "",
"image": hasImage,
}
for _, k := range qkeys {
known[k] = attrPresent(attrs, k)
}
keys := completenessKeys(kind, qkeys)
present := 0
bump := func(ok bool) {
if ok {
for _, k := range keys {
if known[k] {
present++
}
}
bump(name != nil && *name != "")
bump(gtin != nil && *gtin != "")
bump(brandID != nil)
bump(categoryID != nil)
bump(netCanonical != nil)
bump(country != nil && *country != "")
bump(hasNutri)
bump(ingredients != nil && *ingredients != "")
bump(hasImage)
completeness := float64(present) / float64(len(CompletenessFields))
completeness := float64(present) / float64(len(keys))
var sourceCount int
var sourceTrust *float64
+61
View File
@@ -0,0 +1,61 @@
package adminstore
import "context"
// QualifiedMinScore is the quality_score threshold at or above which a product
// counts as "qualified" (合格) in admin and public stats.
const QualifiedMinScore = 0.6
// AdminStats summarizes the catalog for the admin overview dashboard.
type AdminStats struct {
Products int `json:"products"`
Qualified int `json:"qualified"`
MinScore float64 `json:"min_score"`
ByStatus map[string]int `json:"by_status"`
Brands int `json:"brands"`
Categories int `json:"categories"`
Pending int `json:"pending_submissions"`
AvgQuality float64 `json:"avg_quality"`
}
// Stats gathers the dashboard counters in a handful of aggregate queries.
func (s *Store) Stats(ctx context.Context) (*AdminStats, error) {
out := &AdminStats{MinScore: QualifiedMinScore, ByStatus: map[string]int{}}
if err := s.pool.QueryRow(ctx, `
SELECT count(*),
count(*) FILTER (WHERE quality_score >= $1 AND status = 'active'),
COALESCE(avg(quality_score), 0)
FROM product`, QualifiedMinScore).Scan(&out.Products, &out.Qualified, &out.AvgQuality); err != nil {
return nil, err
}
rows, err := s.pool.Query(ctx, "SELECT status, count(*) FROM product GROUP BY status")
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var st string
var n int
if err := rows.Scan(&st, &n); err != nil {
return nil, err
}
out.ByStatus[st] = n
}
if err := rows.Err(); err != nil {
return nil, err
}
if err := s.pool.QueryRow(ctx, "SELECT count(*) FROM brand").Scan(&out.Brands); err != nil {
return nil, err
}
if err := s.pool.QueryRow(ctx, "SELECT count(*) FROM category").Scan(&out.Categories); err != nil {
return nil, err
}
if err := s.pool.QueryRow(ctx,
"SELECT count(*) FROM submission WHERE status = 'pending'").Scan(&out.Pending); err != nil {
return nil, err
}
return out, nil
}
@@ -0,0 +1,71 @@
package adminstore
import (
"context"
"testing"
)
func TestStatsAndBulk(t *testing.T) {
s := newTestStore(t)
ctx := context.Background()
base, err := s.Stats(ctx)
if err != nil {
t.Fatalf("stats: %v", err)
}
var p1, p2 string
if err := s.pool.QueryRow(ctx,
"INSERT INTO product (name, status) VALUES ($1,'active') RETURNING id",
"批量测试1 "+randomHex(4)).Scan(&p1); err != nil {
t.Fatalf("insert p1: %v", err)
}
if err := s.pool.QueryRow(ctx,
"INSERT INTO product (name, status) VALUES ($1,'active') RETURNING id",
"批量测试2 "+randomHex(4)).Scan(&p2); err != nil {
t.Fatalf("insert p2: %v", err)
}
t.Cleanup(func() { _, _ = s.pool.Exec(ctx, "DELETE FROM product WHERE id = ANY($1)", []string{p1, p2}) })
after, err := s.Stats(ctx)
if err != nil {
t.Fatalf("stats after: %v", err)
}
if after.Products != base.Products+2 {
t.Fatalf("product count: got %d want %d", after.Products, base.Products+2)
}
// Bulk set status to deprecated.
n, err := s.BulkSetStatus(ctx, "tester", []string{p1, p2}, "deprecated")
if err != nil || n != 2 {
t.Fatalf("bulk status: n=%d err=%v", n, err)
}
var deprecated int
if err := s.pool.QueryRow(ctx,
"SELECT count(*) FROM product WHERE id = ANY($1) AND status='deprecated'",
[]string{p1, p2}).Scan(&deprecated); err != nil {
t.Fatalf("verify: %v", err)
}
if deprecated != 2 {
t.Fatalf("expected 2 deprecated, got %d", deprecated)
}
// Invalid status rejected.
if _, err := s.BulkSetStatus(ctx, "tester", []string{p1}, "nope"); err != ErrInvalidStatus {
t.Fatalf("expected ErrInvalidStatus, got %v", err)
}
// Empty selection rejected.
if _, err := s.BulkSetStatus(ctx, "tester", nil, "active"); err != ErrNoTargets {
t.Fatalf("expected ErrNoTargets, got %v", err)
}
// Global audit log should contain the bulk_status entry.
rows, total, err := s.ListAllAudit(ctx, 10, 0)
if err != nil {
t.Fatalf("audit: %v", err)
}
if total == 0 || len(rows) == 0 {
t.Fatalf("expected audit rows, got total=%d", total)
}
}
+178 -34
View File
@@ -27,6 +27,9 @@ type ProductInput struct {
NutritionBasis *string `json:"nutrition_basis"`
ServingSize *string `json:"serving_size"`
NutriScore *string `json:"nutri_score"`
// Attributes carries non-food spec values (driven by kind_field) for the
// generic archive kinds. Nil means "leave unchanged".
Attributes map[string]any `json:"attributes"`
}
func normBrand(name string) string { return strings.Join(strings.Fields(strings.ToLower(name)), " ") }
@@ -86,10 +89,11 @@ func (s *Store) UpdateProduct(ctx context.Context, id, actor string, in ProductI
brandID = &bid
}
// Resolve category gpc brick code.
// Resolve category gpc brick code + archive kind.
var gpc *string
kind := DefaultKind
if in.CategoryID != nil && *in.CategoryID != "" {
if err := tx.QueryRow(ctx, "SELECT gpc_brick_code FROM category WHERE id = $1", *in.CategoryID).Scan(&gpc); err != nil && !errors.Is(err, pgx.ErrNoRows) {
if err := tx.QueryRow(ctx, "SELECT gpc_brick_code, archive_kind FROM category WHERE id = $1", *in.CategoryID).Scan(&gpc, &kind); err != nil && !errors.Is(err, pgx.ErrNoRows) {
return nil, err
}
}
@@ -116,19 +120,28 @@ WHERE id=$11`,
return nil, err
}
var nutriJSON []byte
if in.Nutriments != nil {
nutriJSON, _ = json.Marshal(in.Nutriments)
// Non-food spec values live in product.attributes (nil means unchanged).
if in.Attributes != nil {
attrJSON, _ := json.Marshal(in.Attributes)
if _, err = tx.Exec(ctx, "UPDATE product SET attributes=$1 WHERE id=$2", attrJSON, id); err != nil {
return nil, err
}
}
allergens := in.Allergens
if allergens == nil {
allergens = []string{}
}
additives := in.Additives
if additives == nil {
additives = []string{}
}
_, err = tx.Exec(ctx, `
if kind == FoodKind {
var nutriJSON []byte
if in.Nutriments != nil {
nutriJSON, _ = json.Marshal(in.Nutriments)
}
allergens := in.Allergens
if allergens == nil {
allergens = []string{}
}
additives := in.Additives
if additives == nil {
additives = []string{}
}
_, err = tx.Exec(ctx, `
INSERT INTO food_detail (product_id, ingredients_text, allergens, additives,
nutriments, nutrition_basis, serving_size, nutri_score)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8)
@@ -140,10 +153,11 @@ ON CONFLICT (product_id) DO UPDATE SET
nutrition_basis=EXCLUDED.nutrition_basis,
serving_size=EXCLUDED.serving_size,
nutri_score=EXCLUDED.nutri_score`,
id, in.IngredientsText, allergens, additives,
nutriJSON, in.NutritionBasis, in.ServingSize, in.NutriScore)
if err != nil {
return nil, err
id, in.IngredientsText, allergens, additives,
nutriJSON, in.NutritionBasis, in.ServingSize, in.NutriScore)
if err != nil {
return nil, err
}
}
if _, err := s.recomputeQualityTx(ctx, tx, id); err != nil {
@@ -170,6 +184,79 @@ ON CONFLICT (product_id) DO UPDATE SET
return after, nil
}
// ErrDuplicateGTIN is returned when a product GTIN already exists.
var ErrDuplicateGTIN = errors.New("duplicate gtin")
// CreateProduct inserts a new product from the admin UI. Only the core fields
// are required; the operator completes the rest in the detail editor.
func (s *Store) CreateProduct(ctx context.Context, actor string, in ProductInput) (*ProductDetail, error) {
if strings.TrimSpace(in.Name) == "" {
return nil, errors.New("name required")
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
brandID := in.BrandID
if in.BrandName != nil && strings.TrimSpace(*in.BrandName) != "" {
bid, err := s.ensureBrand(ctx, tx, strings.TrimSpace(*in.BrandName))
if err != nil {
return nil, err
}
brandID = &bid
}
var gpc *string
if in.CategoryID != nil && *in.CategoryID != "" {
if err := tx.QueryRow(ctx, "SELECT gpc_brick_code FROM category WHERE id = $1", *in.CategoryID).Scan(&gpc); err != nil && !errors.Is(err, pgx.ErrNoRows) {
return nil, err
}
}
canonical, err := s.netCanonical(ctx, tx, in.NetContentValue, in.NetContentUnit)
if err != nil {
return nil, err
}
status := in.Status
if status == "" {
status = "active"
}
var id string
err = tx.QueryRow(ctx, `
INSERT INTO product (gtin, name, brand_id, category_id, gpc_brick_code,
net_content_value, net_content_unit, net_content_canonical,
country_of_origin, status)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)
RETURNING id`,
in.GTIN, in.Name, brandID, in.CategoryID, gpc,
in.NetContentValue, in.NetContentUnit, canonical,
in.CountryOfOrigin, status).Scan(&id)
if isUniqueViolation(err) {
return nil, ErrDuplicateGTIN
}
if err != nil {
return nil, err
}
if _, err := s.recomputeQualityTx(ctx, tx, id); err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
after, err := s.GetProduct(ctx, id)
if err != nil {
return nil, err
}
_ = s.writeAudit(ctx, actor, "create", "product", &id, []string{"name"}, nil, after)
return after, nil
}
func strEq(a, b *string) bool {
if a == nil && b == nil {
return true
@@ -211,6 +298,9 @@ func diffFields(a, b *ProductDetail) []string {
add("nutrition_basis", strEq(a.NutritionBasis, b.NutritionBasis))
add("serving_size", strEq(a.ServingSize, b.ServingSize))
add("nutri_score", strEq(a.NutriScore, b.NutriScore))
aa, _ := json.Marshal(a.Attributes)
ab, _ := json.Marshal(b.Attributes)
add("attributes", string(aa) == string(ab))
return changed
}
@@ -330,15 +420,21 @@ func (s *Store) DeleteMSRP(ctx context.Context, productID, msrpID, actor string)
// ---------- dictionaries ----------
// Brand is a brand option for the edit form.
// Brand is a brand option for the edit form and the management view.
type Brand struct {
ID string `json:"id"`
Name string `json:"name"`
ID string `json:"id"`
Name string `json:"name"`
ProductCount int `json:"product_count"`
}
// ListBrands returns all brands ordered by name.
// ListBrands returns all brands ordered by name, with the number of products
// referencing each one.
func (s *Store) ListBrands(ctx context.Context) ([]Brand, error) {
rows, err := s.pool.Query(ctx, "SELECT id, name FROM brand ORDER BY name")
rows, err := s.pool.Query(ctx, `
SELECT b.id, b.name,
(SELECT count(*) FROM product p WHERE p.brand_id = b.id) AS product_count
FROM brand b
ORDER BY b.name`)
if err != nil {
return nil, err
}
@@ -346,7 +442,7 @@ func (s *Store) ListBrands(ctx context.Context) ([]Brand, error) {
out := []Brand{}
for rows.Next() {
var b Brand
if err := rows.Scan(&b.ID, &b.Name); err != nil {
if err := rows.Scan(&b.ID, &b.Name, &b.ProductCount); err != nil {
return nil, err
}
out = append(out, b)
@@ -354,19 +450,28 @@ func (s *Store) ListBrands(ctx context.Context) ([]Brand, error) {
return out, rows.Err()
}
// Category is a category option for the edit form.
// Category is a category option for the edit form and the management view.
type Category struct {
ID string `json:"id"`
NameZH string `json:"name_zh"`
NameEN *string `json:"name_en"`
Path string `json:"path"`
Level int `json:"level"`
ID string `json:"id"`
NameZH string `json:"name_zh"`
NameEN *string `json:"name_en"`
Path string `json:"path"`
Level int `json:"level"`
ParentID *string `json:"parent_id"`
GPCBrickCode *string `json:"gpc_brick_code"`
ArchiveKind string `json:"archive_kind"`
ProductCount int `json:"product_count"`
}
// ListCategories returns the full category tree.
// ListCategories returns the full category tree (path order) with the number of
// products directly assigned to each node.
func (s *Store) ListCategories(ctx context.Context) ([]Category, error) {
rows, err := s.pool.Query(ctx,
"SELECT id, name_zh, name_en, path::text, level FROM category ORDER BY path")
rows, err := s.pool.Query(ctx, `
SELECT c.id, c.name_zh, c.name_en, c.path::text, c.level, c.parent_id::text,
c.gpc_brick_code, c.archive_kind,
(SELECT count(*) FROM product p WHERE p.category_id = c.id) AS product_count
FROM category c
ORDER BY c.path`)
if err != nil {
return nil, err
}
@@ -374,7 +479,8 @@ func (s *Store) ListCategories(ctx context.Context) ([]Category, error) {
out := []Category{}
for rows.Next() {
var c Category
if err := rows.Scan(&c.ID, &c.NameZH, &c.NameEN, &c.Path, &c.Level); err != nil {
if err := rows.Scan(&c.ID, &c.NameZH, &c.NameEN, &c.Path, &c.Level,
&c.ParentID, &c.GPCBrickCode, &c.ArchiveKind, &c.ProductCount); err != nil {
return nil, err
}
out = append(out, c)
@@ -393,6 +499,44 @@ type AuditEntry struct {
CreatedAt string `json:"created_at"`
}
// AuditLogRow is one global audit-log row for the operations log view.
type AuditLogRow struct {
ID string `json:"id"`
Actor string `json:"actor"`
Action string `json:"action"`
Entity string `json:"entity"`
EntityID *string `json:"entity_id"`
Fields []string `json:"fields"`
CreatedAt string `json:"created_at"`
}
// ListAllAudit returns a page of the global audit log, newest first, along with
// the total row count.
func (s *Store) ListAllAudit(ctx context.Context, limit, offset int) ([]AuditLogRow, int, error) {
var total int
if err := s.pool.QueryRow(ctx, "SELECT count(*) FROM audit_log").Scan(&total); err != nil {
return nil, 0, err
}
rows, err := s.pool.Query(ctx, `
SELECT id, actor, action, entity, entity_id::text, fields, created_at::text
FROM audit_log
ORDER BY created_at DESC
LIMIT $1 OFFSET $2`, limit, offset)
if err != nil {
return nil, 0, err
}
defer rows.Close()
out := []AuditLogRow{}
for rows.Next() {
var e AuditLogRow
if err := rows.Scan(&e.ID, &e.Actor, &e.Action, &e.Entity, &e.EntityID, &e.Fields, &e.CreatedAt); err != nil {
return nil, 0, err
}
out = append(out, e)
}
return out, total, rows.Err()
}
// ListAudit returns audit history for one product, newest first.
func (s *Store) ListAudit(ctx context.Context, productID string, limit int) ([]AuditEntry, error) {
rows, err := s.pool.Query(ctx, `
+126
View File
@@ -0,0 +1,126 @@
// Package cache is a Redis-backed, fail-open read cache for the public API.
//
// It caches hot product details and search results so repeated reads avoid
// PostgreSQL. Like the ratelimit package, every operation fails open: if Redis
// is unavailable or misconfigured the caller simply falls back to the database,
// so the cache can never take the API down or serve stale data after Redis loss.
//
// Invalidation is global and O(1): keys are namespaced by an epoch counter
// stored in Redis (og:cache:epoch). The Python ingestion bumps that counter
// after a write run, which logically invalidates every cached entry at once
// while old keys age out via their TTL. The epoch is read at most once per
// refresh interval per process, so it adds no per-request round trip.
package cache
import (
"context"
"encoding/json"
"errors"
"log"
"strconv"
"sync"
"time"
"github.com/redis/go-redis/v9"
)
// epochKey is the Redis key holding the global cache generation counter.
const epochKey = "og:cache:epoch"
// epochRefresh bounds how often a process re-reads the epoch from Redis.
const epochRefresh = 10 * time.Second
// opTimeout caps any single Redis operation so a slow backend never blocks a
// request beyond this; on timeout the cache fails open.
const opTimeout = 150 * time.Millisecond
// Cache wraps a Redis client. A nil-backed Cache (Redis unconfigured) disables
// caching: every Get misses and every Set is a no-op.
type Cache struct {
rdb *redis.Client
mu sync.RWMutex
epoch int64
epochSetAt time.Time
epochOK bool
}
// New builds a Cache from a redis:// URL. On a parse error it logs and returns a
// disabled (fail-open) cache so the server still boots.
func New(redisURL string) *Cache {
opt, err := redis.ParseURL(redisURL)
if err != nil {
log.Printf("cache: invalid redis url %q: %v (caching disabled)", redisURL, err)
return &Cache{}
}
return &Cache{rdb: redis.NewClient(opt)}
}
// Enabled reports whether a Redis backend is configured.
func (c *Cache) Enabled() bool { return c != nil && c.rdb != nil }
// epochNow returns the current cache generation, reading it from Redis at most
// once per epochRefresh. On any Redis error it keeps the last known value and
// throttles re-reads so a down backend cannot slow the hot path.
func (c *Cache) epochNow(ctx context.Context) int64 {
c.mu.RLock()
if c.epochOK && time.Since(c.epochSetAt) < epochRefresh {
e := c.epoch
c.mu.RUnlock()
return e
}
c.mu.RUnlock()
cctx, cancel := context.WithTimeout(ctx, opTimeout)
defer cancel()
n, err := c.rdb.Get(cctx, epochKey).Int64()
c.mu.Lock()
defer c.mu.Unlock()
switch {
case err == nil:
c.epoch = n
case errors.Is(err, redis.Nil):
c.epoch = 0
}
c.epochSetAt = time.Now()
c.epochOK = true
return c.epoch
}
// key namespaces a logical suffix under the current epoch.
func (c *Cache) key(ctx context.Context, suffix string) string {
return "og:v" + strconv.FormatInt(c.epochNow(ctx), 10) + ":" + suffix
}
// GetJSON unmarshals the cached value for suffix into dest and reports a hit.
// Any miss, decode error, or Redis error returns false (fail-open).
func (c *Cache) GetJSON(ctx context.Context, suffix string, dest any) bool {
if !c.Enabled() {
return false
}
k := c.key(ctx, suffix)
cctx, cancel := context.WithTimeout(ctx, opTimeout)
defer cancel()
b, err := c.rdb.Get(cctx, k).Bytes()
if err != nil {
return false
}
return json.Unmarshal(b, dest) == nil
}
// SetJSON stores val (JSON-encoded) for suffix with the given TTL. Best effort:
// marshal or Redis errors are ignored.
func (c *Cache) SetJSON(ctx context.Context, suffix string, val any, ttl time.Duration) {
if !c.Enabled() {
return
}
b, err := json.Marshal(val)
if err != nil {
return
}
k := c.key(ctx, suffix)
cctx, cancel := context.WithTimeout(ctx, opTimeout)
defer cancel()
_ = c.rdb.Set(cctx, k, b, ttl).Err()
}
+84
View File
@@ -0,0 +1,84 @@
package cache
import (
"context"
"fmt"
"os"
"testing"
"time"
)
// TestDisabledFailsOpen verifies a Cache without a Redis backend never panics,
// always misses, and silently drops writes.
func TestDisabledFailsOpen(t *testing.T) {
c := New("not-a-valid-url") // parse error => disabled
if c.Enabled() {
t.Fatal("expected cache to be disabled for invalid url")
}
c.SetJSON(context.Background(), "k", map[string]int{"a": 1}, time.Minute)
var dst map[string]int
if c.GetJSON(context.Background(), "k", &dst) {
t.Fatalf("disabled cache must always miss, got %+v", dst)
}
}
func testCache(t *testing.T) *Cache {
t.Helper()
url := os.Getenv("OPENGOODS_REDIS_URL")
if url == "" {
url = "redis://localhost:6379/0"
}
c := New(url)
if !c.Enabled() {
t.Skip("redis not configured")
}
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
if err := c.rdb.Ping(ctx).Err(); err != nil {
t.Skipf("redis not reachable: %v", err)
}
return c
}
// TestRoundTrip stores then reads a value back.
func TestRoundTrip(t *testing.T) {
c := testCache(t)
ctx := context.Background()
suffix := fmt.Sprintf("test:rt:%d", time.Now().UnixNano())
c.SetJSON(ctx, suffix, map[string]any{"name": "foo", "n": float64(3)}, time.Minute)
got := map[string]any{}
if !c.GetJSON(ctx, suffix, &got) {
t.Fatal("expected cache hit after set")
}
if got["name"] != "foo" || got["n"] != float64(3) {
t.Fatalf("unexpected payload: %+v", got)
}
}
// TestEpochInvalidation verifies that bumping the epoch counter logically drops
// every previously cached entry.
func TestEpochInvalidation(t *testing.T) {
c := testCache(t)
ctx := context.Background()
suffix := fmt.Sprintf("test:epoch:%d", time.Now().UnixNano())
c.SetJSON(ctx, suffix, map[string]int{"v": 1}, time.Minute)
var dst map[string]int
if !c.GetJSON(ctx, suffix, &dst) {
t.Fatal("expected hit before epoch bump")
}
// Simulate an ingestion write bumping the global epoch.
if err := c.rdb.Incr(ctx, epochKey).Err(); err != nil {
t.Fatalf("incr epoch: %v", err)
}
// Force the process to re-read the epoch rather than use its cached value.
c.mu.Lock()
c.epochOK = false
c.mu.Unlock()
if c.GetJSON(ctx, suffix, &dst) {
t.Fatal("entry should be invisible after epoch bump")
}
}
+14 -8
View File
@@ -9,19 +9,25 @@ import (
// Values are read from environment variables with sensible defaults so the
// server can boot in a local Docker Compose setup without extra configuration.
type Config struct {
Addr string
DatabaseURL string
RedisURL string
AnonRateLimitPerMin int
Addr string
DatabaseURL string
RedisURL string
AnonRateLimitPerMin int
AnonTotalQuota int
RegisteredRateLimitPerMin int
RegisteredQuotaTotal int
}
// Load reads configuration from the environment.
func Load() Config {
return Config{
Addr: getenv("OPENGOODS_ADDR", ":8080"),
DatabaseURL: getenv("OPENGOODS_DATABASE_URL", "postgres://opengoods:opengoods@localhost:5432/opengoods?sslmode=disable"),
RedisURL: getenv("OPENGOODS_REDIS_URL", "redis://localhost:6379/0"),
AnonRateLimitPerMin: getenvInt("OPENGOODS_ANON_RATE_LIMIT_PER_MIN", 60),
Addr: getenv("OPENGOODS_ADDR", ":8080"),
DatabaseURL: getenv("OPENGOODS_DATABASE_URL", "postgres://opengoods:opengoods@localhost:5432/opengoods?sslmode=disable"),
RedisURL: getenv("OPENGOODS_REDIS_URL", "redis://localhost:6379/0"),
AnonRateLimitPerMin: getenvInt("OPENGOODS_ANON_RATE_LIMIT_PER_MIN", 60),
AnonTotalQuota: getenvInt("OPENGOODS_ANON_TOTAL_QUOTA", 1000),
RegisteredRateLimitPerMin: getenvInt("OPENGOODS_REGISTERED_RATE_LIMIT_PER_MIN", 300),
RegisteredQuotaTotal: getenvInt("OPENGOODS_REGISTERED_QUOTA_TOTAL", 100000),
}
}
+128
View File
@@ -0,0 +1,128 @@
package handler
import (
"encoding/json"
"errors"
"net/http"
"regexp"
"strings"
"github.com/baicai2026-baicai/goods/api/internal/store"
)
// emailRe is a deliberately permissive sanity check; real validation is the
// unique constraint plus the user being able to receive their own key.
var emailRe = regexp.MustCompile(`^[^@\s]+@[^@\s]+\.[^@\s]+$`)
const minPasswordLen = 8
type credentials struct {
Email string `json:"email"`
Password string `json:"password"`
}
func decodeCredentials(w http.ResponseWriter, r *http.Request) (credentials, bool) {
var c credentials
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&c); err != nil {
writeError(w, r, http.StatusBadRequest, "invalid_body", "请求格式无效")
return credentials{}, false
}
c.Email = strings.TrimSpace(c.Email)
if !emailRe.MatchString(c.Email) {
writeError(w, r, http.StatusBadRequest, "invalid_email", "邮箱格式无效")
return credentials{}, false
}
if len(c.Password) < minPasswordLen {
writeError(w, r, http.StatusBadRequest, "weak_password", "密码至少需要 8 位")
return credentials{}, false
}
return c, true
}
// keyResponse is returned whenever a fresh plaintext key is issued; the key is
// shown exactly once and cannot be recovered afterwards.
type keyResponse struct {
Email string `json:"email"`
APIKey string `json:"api_key"`
KeyPrefix string `json:"key_prefix"`
RateLimitPerMin int `json:"rate_limit_per_min"`
QuotaTotal int64 `json:"quota_total"`
}
// Register creates an account and issues its first API key. POST {email, password}.
func (h *Handler) Register(w http.ResponseWriter, r *http.Request) {
c, ok := decodeCredentials(w, r)
if !ok {
return
}
key, acct, err := h.store.RegisterUser(r.Context(), c.Email, c.Password, h.regRatePerMin, h.regQuotaTotal)
if errors.Is(err, store.ErrEmailTaken) {
writeError(w, r, http.StatusConflict, "email_taken", "该邮箱已注册,请直接登录查看或重置密钥")
return
}
if h.handleErr(w, r, err) {
return
}
writeJSON(w, http.StatusCreated, keyResponse{
Email: acct.Email,
APIKey: key,
KeyPrefix: acct.KeyPrefix,
RateLimitPerMin: acct.RateLimitPerMin,
QuotaTotal: acct.QuotaTotal,
})
}
// AccountInfo verifies credentials and returns the account's key metadata plus
// cumulative usage. POST {email, password}. The plaintext key is not returned.
func (h *Handler) AccountInfo(w http.ResponseWriter, r *http.Request) {
c, ok := decodeCredentials(w, r)
if !ok {
return
}
acct, err := h.store.Authenticate(r.Context(), c.Email, c.Password)
if errors.Is(err, store.ErrNotFound) {
writeError(w, r, http.StatusUnauthorized, "invalid_credentials", "邮箱或密码错误")
return
}
if h.handleErr(w, r, err) {
return
}
used := h.limiter.TotalUsed(r.Context(), acct.KeyID)
remaining := acct.QuotaTotal - used
if remaining < 0 {
remaining = 0
}
writeJSON(w, http.StatusOK, map[string]any{
"email": acct.Email,
"key_prefix": acct.KeyPrefix,
"rate_limit_per_min": acct.RateLimitPerMin,
"quota_total": acct.QuotaTotal,
"quota_used": used,
"quota_remaining": remaining,
})
}
// RegenerateKey revokes the account's current key and issues a new one, carrying
// over cumulative usage so the quota cannot be reset. POST {email, password}.
func (h *Handler) RegenerateKey(w http.ResponseWriter, r *http.Request) {
c, ok := decodeCredentials(w, r)
if !ok {
return
}
key, acct, oldKeyID, err := h.store.RegenerateKey(r.Context(), c.Email, c.Password, h.regRatePerMin, h.regQuotaTotal)
if errors.Is(err, store.ErrNotFound) {
writeError(w, r, http.StatusUnauthorized, "invalid_credentials", "邮箱或密码错误")
return
}
if h.handleErr(w, r, err) {
return
}
h.limiter.CopyTotal(r.Context(), oldKeyID, acct.KeyID)
writeJSON(w, http.StatusOK, keyResponse{
Email: acct.Email,
APIKey: key,
KeyPrefix: acct.KeyPrefix,
RateLimitPerMin: acct.RateLimitPerMin,
QuotaTotal: acct.QuotaTotal,
})
}
+149
View File
@@ -0,0 +1,149 @@
package handler
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/redis/go-redis/v9"
"github.com/baicai2026-baicai/goods/api/internal/ratelimit"
"github.com/baicai2026-baicai/goods/api/internal/store"
)
func cleanupCounter(t *testing.T, subject string) {
t.Helper()
redisURL := os.Getenv("OPENGOODS_REDIS_URL")
if redisURL == "" {
redisURL = "redis://localhost:6379/0"
}
opt, err := redis.ParseURL(redisURL)
if err != nil {
return
}
rdb := redis.NewClient(opt)
defer rdb.Close()
rdb.Del(context.Background(), "usage:total:"+subject)
}
// newQuotaHandler builds a handler backed by the test DB and a live Redis
// limiter, with a small anonymous quota so exhaustion is cheap to exercise.
func newQuotaHandler(t *testing.T, anonQuota int) *Handler {
t.Helper()
dsn := os.Getenv("OPENGOODS_DATABASE_URL")
if dsn == "" {
dsn = "postgres://opengoods:opengoods@localhost:5432/opengoods?sslmode=disable"
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Skipf("no database: %v", err)
}
if err := pool.Ping(ctx); err != nil {
pool.Close()
t.Skipf("database not reachable: %v", err)
}
var hasUser bool
if err := pool.QueryRow(ctx, "SELECT to_regclass('public.app_user') IS NOT NULL").Scan(&hasUser); err != nil || !hasUser {
pool.Close()
t.Skip("migrations not applied")
}
redisURL := os.Getenv("OPENGOODS_REDIS_URL")
if redisURL == "" {
redisURL = "redis://localhost:6379/0"
}
limiter := ratelimit.New(redisURL)
pingCtx, pingCancel := context.WithTimeout(context.Background(), time.Second)
defer pingCancel()
if err := limiter.Ping(pingCtx); err != nil {
pool.Close()
t.Skipf("redis not reachable: %v", err)
}
t.Cleanup(pool.Close)
return New(store.New(pool), nil).
WithRateLimit(limiter, 1000).
WithQuotas(anonQuota, 300, 100000)
}
func cleanupAccount(t *testing.T, email string) {
t.Helper()
dsn := os.Getenv("OPENGOODS_DATABASE_URL")
if dsn == "" {
dsn = "postgres://opengoods:opengoods@localhost:5432/opengoods?sslmode=disable"
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
return
}
defer pool.Close()
_, _ = pool.Exec(ctx, "DELETE FROM app_user WHERE lower(email)=lower($1)", email)
_, _ = pool.Exec(ctx, "DELETE FROM api_key WHERE owner_email=$1", email)
}
func TestAnonTotalQuotaExhausts(t *testing.T) {
h := newQuotaHandler(t, 3)
// Unique client IP so the lifetime counter starts fresh for this test; the
// counter never expires, so drop it afterwards to keep runs independent.
n := time.Now().UnixNano()
ip := fmt.Sprintf("203.%d.%d.%d", n/65536%256, n/256%256, n%256)
t.Cleanup(func() { cleanupCounter(t, "ip:"+ip) })
call := func() *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodGet, "/api/"+APIVersion+"/stats", nil)
req.RemoteAddr = ip + ":12345"
rec := httptest.NewRecorder()
h.Router().ServeHTTP(rec, req)
return rec
}
for i := 1; i <= 3; i++ {
if rec := call(); rec.Code != http.StatusOK {
t.Fatalf("call %d should be allowed, got %d (%s)", i, rec.Code, rec.Body.String())
}
}
rec := call()
if rec.Code != http.StatusForbidden {
t.Fatalf("4th call should be 403 quota_exhausted, got %d (%s)", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "quota_exhausted") {
t.Fatalf("expected quota_exhausted error, got %s", rec.Body.String())
}
}
func TestRegisterIssuesHigherQuotaKey(t *testing.T) {
h := newQuotaHandler(t, 1000)
email := fmt.Sprintf("h-user-%d@example.com", time.Now().UnixNano())
t.Cleanup(func() { cleanupAccount(t, email) })
body := fmt.Sprintf(`{"email":%q,"password":"supersecret"}`, email)
req := httptest.NewRequest(http.MethodPost, "/api/"+APIVersion+"/register", strings.NewReader(body))
req.RemoteAddr = "198.51.100.7:9999"
rec := httptest.NewRecorder()
h.Router().ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("register status = %d (%s)", rec.Code, rec.Body.String())
}
var resp keyResponse
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatal(err)
}
if resp.APIKey == "" || resp.QuotaTotal != 100000 || resp.RateLimitPerMin != 300 {
t.Fatalf("unexpected register response: %+v", resp)
}
// A second registration with the same email conflicts.
req2 := httptest.NewRequest(http.MethodPost, "/api/"+APIVersion+"/register", strings.NewReader(body))
req2.RemoteAddr = "198.51.100.7:9999"
rec2 := httptest.NewRecorder()
h.Router().ServeHTTP(rec2, req2)
if rec2.Code != http.StatusConflict {
t.Fatalf("duplicate register status = %d (%s)", rec2.Code, rec2.Body.String())
}
}
+68 -5
View File
@@ -26,6 +26,10 @@ var openAPISpec []byte
// APIVersion is the current public API version prefix.
const APIVersion = "v1"
// QualifiedMinScore is the quality_score threshold at or above which a product
// record is considered "qualified" (合格) for public stats.
const QualifiedMinScore = 0.6
const (
defaultPageSize = 20
maxPageSize = 100
@@ -33,20 +37,43 @@ const (
// defaultAnonLimit is the per-minute request budget for unauthenticated
// callers (identified by client IP) when none is configured.
defaultAnonLimit = 60
// defaultAnonTotalQuota is the lifetime number of calls an anonymous caller
// (by IP) may make before being asked to register for a higher quota.
defaultAnonTotalQuota = 1000
// defaultRegRatePerMin / defaultRegQuotaTotal are the per-minute budget and
// cumulative quota granted to a self-registered API key.
defaultRegRatePerMin = 300
defaultRegQuotaTotal = 100000
// registerRatePerMin caps account registration/login attempts per IP to
// curb abuse; these endpoints sit outside the metered quota group.
registerRatePerMin = 10
)
// Handler holds dependencies shared by the HTTP routes.
type Handler struct {
store *store.Store
spa fs.FS
limiter *ratelimit.Limiter
anonLimit int
store *store.Store
spa fs.FS
limiter *ratelimit.Limiter
anonLimit int
anonTotalQuota int64
regRatePerMin int
regQuotaTotal int64
}
// New constructs a Handler backed by the given store. spa may be nil (JSON-only).
// Rate limiting is disabled until WithRateLimit is called.
func New(s *store.Store, spa fs.FS) *Handler {
return &Handler{store: s, spa: spa, anonLimit: defaultAnonLimit}
return &Handler{
store: s,
spa: spa,
anonLimit: defaultAnonLimit,
anonTotalQuota: defaultAnonTotalQuota,
regRatePerMin: defaultRegRatePerMin,
regQuotaTotal: defaultRegQuotaTotal,
}
}
// WithRateLimit attaches a Redis-backed limiter and the anonymous per-minute
@@ -60,6 +87,22 @@ func (h *Handler) WithRateLimit(l *ratelimit.Limiter, anonPerMin int) *Handler {
return h
}
// WithQuotas configures the cumulative free quota for anonymous callers and the
// per-minute rate + cumulative quota self-registered keys receive. Non-positive
// values keep the defaults.
func (h *Handler) WithQuotas(anonTotal, regPerMin, regTotal int) *Handler {
if anonTotal > 0 {
h.anonTotalQuota = int64(anonTotal)
}
if regPerMin > 0 {
h.regRatePerMin = regPerMin
}
if regTotal > 0 {
h.regQuotaTotal = int64(regTotal)
}
return h
}
// Router builds the top-level HTTP handler with middleware and routes mounted.
func (h *Handler) Router() http.Handler {
r := chi.NewRouter()
@@ -85,6 +128,17 @@ func (h *Handler) Router() http.Handler {
r.Get("/brands", h.ListBrands)
r.Get("/categories", h.ListCategories)
r.Get("/sources/{id}", h.SourceByID)
r.Get("/stats", h.Stats)
})
// Self-service account routes. Lightly IP-throttled to curb abuse but
// outside the metered quota group so a user can always register or
// check their key even after exhausting the free anonymous quota.
r.Group(func(r chi.Router) {
r.Use(h.registerLimit)
r.Post("/register", h.Register)
r.Post("/account", h.AccountInfo)
r.Post("/account/regenerate", h.RegenerateKey)
})
})
@@ -122,6 +176,15 @@ func (h *Handler) Healthz(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
// Stats returns catalog totals and the count of qualified records.
func (h *Handler) Stats(w http.ResponseWriter, r *http.Request) {
st, err := h.store.Stats(r.Context(), QualifiedMinScore)
if h.handleErr(w, r, err) {
return
}
writeJSON(w, http.StatusOK, st)
}
// OpenAPI serves the embedded OpenAPI 3 specification for the public API.
func (h *Handler) OpenAPI(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
+53 -1
View File
@@ -24,8 +24,11 @@ const apiKeyIDKey ctxKey = 0
// are set on every response; over-budget callers get 429 + Retry-After.
func (h *Handler) rateLimit(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := "ip:" + clientIP(r)
ip := clientIP(r)
id := "ip:" + ip
subject := "ip:" + ip // cumulative-quota counter subject
limit := h.anonLimit
quota := h.anonTotalQuota
keyID := ""
if raw := presentedKey(r); raw != "" {
@@ -45,6 +48,8 @@ func (h *Handler) rateLimit(next http.Handler) http.Handler {
keyID = k.ID
limit = k.RateLimitPerMin
id = "key:" + k.ID
subject = k.ID
quota = k.QuotaTotal
}
res := h.limiter.Allow(r.Context(), id, limit, time.Minute)
@@ -61,8 +66,36 @@ func (h *Handler) rateLimit(next http.Handler) http.Handler {
return
}
// Attribute one call to the caller's lifetime counter, then enforce the
// cumulative quota (quota <= 0 means unlimited). Keys also get daily and
// last-used stats recorded for the admin console.
var used int64
if keyID != "" {
h.limiter.RecordUsage(r.Context(), keyID)
used = h.limiter.TotalUsed(r.Context(), keyID)
} else {
used = h.limiter.IncrTotal(r.Context(), subject)
}
if quota > 0 {
remaining := quota - used
if remaining < 0 {
remaining = 0
}
w.Header().Set("X-Quota-Limit", strconv.FormatInt(quota, 10))
w.Header().Set("X-Quota-Used", strconv.FormatInt(used, 10))
w.Header().Set("X-Quota-Remaining", strconv.FormatInt(remaining, 10))
if used > quota {
if keyID == "" {
writeError(w, r, http.StatusForbidden, "quota_exhausted",
"免费额度(共 "+strconv.FormatInt(quota, 10)+" 次)已用尽,请注册账号获取更高配额的 API 密钥")
} else {
writeError(w, r, http.StatusForbidden, "quota_exhausted", "API 密钥配额已用尽")
}
return
}
}
if keyID != "" {
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), apiKeyIDKey, keyID)))
return
}
@@ -70,6 +103,25 @@ func (h *Handler) rateLimit(next http.Handler) http.Handler {
})
}
// registerLimit throttles self-service account endpoints per client IP without
// consuming the metered free quota, so a caller can still register or recover
// their key after exhausting the anonymous quota.
func (h *Handler) registerLimit(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
res := h.limiter.Allow(r.Context(), "register:"+clientIP(r), h.regRatePerMin, time.Minute)
if !res.Allowed {
retry := res.ResetUnix - time.Now().Unix()
if retry < 1 {
retry = 1
}
w.Header().Set("Retry-After", strconv.FormatInt(retry, 10))
writeError(w, r, http.StatusTooManyRequests, "rate_limited", "操作过于频繁,请稍后再试")
return
}
next.ServeHTTP(w, r)
})
}
// presentedKey extracts an API key from the X-API-Key header or a Bearer token.
func presentedKey(r *http.Request) string {
if v := strings.TrimSpace(r.Header.Get("X-API-Key")); v != "" {
+31 -1
View File
@@ -10,7 +10,8 @@
"tags": [
{ "name": "products" },
{ "name": "catalog" },
{ "name": "meta" }
{ "name": "meta" },
{ "name": "account" }
],
"security": [{ "ApiKeyHeader": [] }, { "BearerKey": [] }, {}],
"paths": {
@@ -114,6 +115,35 @@
"parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } }],
"responses": { "200": { "description": "Source" }, "404": { "$ref": "#/components/responses/NotFound" } }
}
},
"/register": {
"post": {
"tags": ["account"],
"summary": "Register an account and issue an API key",
"description": "Self-service registration; returns the plaintext API key exactly once.",
"security": [],
"requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": ["email", "password"], "properties": { "email": { "type": "string", "format": "email" }, "password": { "type": "string", "minLength": 8 } } } } } },
"responses": { "201": { "description": "Account created; plaintext key returned once" }, "400": { "description": "Invalid email or weak password" }, "409": { "description": "Email already registered" } }
}
},
"/account": {
"post": {
"tags": ["account"],
"summary": "View account key metadata and cumulative quota usage",
"security": [],
"requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": ["email", "password"], "properties": { "email": { "type": "string", "format": "email" }, "password": { "type": "string" } } } } } },
"responses": { "200": { "description": "Account info with quota usage" }, "401": { "description": "Invalid credentials" } }
}
},
"/account/regenerate": {
"post": {
"tags": ["account"],
"summary": "Revoke the current key and issue a new one",
"description": "Cumulative usage carries over; returns the plaintext key exactly once.",
"security": [],
"requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": ["email", "password"], "properties": { "email": { "type": "string", "format": "email" }, "password": { "type": "string" } } } } } },
"responses": { "200": { "description": "New plaintext key returned once" }, "401": { "description": "Invalid credentials" } }
}
}
},
"components": {
+39
View File
@@ -116,6 +116,45 @@ func (l *Limiter) RecordUsage(ctx context.Context, keyID string) {
_, _ = pipe.Exec(ctx)
}
// IncrTotal increments the lifetime call counter for subject and returns the
// new total. The counter never expires; it is the cumulative number of calls
// attributed to a caller (an API key id, or "ip:<addr>" for anonymous callers).
// Fails open returning 0 on any error so quota enforcement never takes the API
// down.
func (l *Limiter) IncrTotal(ctx context.Context, subject string) int64 {
if !l.Enabled() || subject == "" {
return 0
}
n, err := l.rdb.Incr(ctx, "usage:total:"+subject).Result()
if err != nil {
return 0
}
return n
}
// CopyTotal carries a lifetime counter from one subject to another, used when a
// key is regenerated so a caller cannot reset their cumulative quota. Best
// effort: a missing or zero source counter is a no-op.
func (l *Limiter) CopyTotal(ctx context.Context, from, to string) {
if !l.Enabled() || from == "" || to == "" {
return
}
n, err := l.rdb.Get(ctx, "usage:total:"+from).Int64()
if err != nil || n == 0 {
return
}
l.rdb.Set(ctx, "usage:total:"+to, n, 0)
}
// TotalUsed reads the lifetime call counter for subject without incrementing.
func (l *Limiter) TotalUsed(ctx context.Context, subject string) int64 {
if !l.Enabled() || subject == "" {
return 0
}
n, _ := l.rdb.Get(ctx, "usage:total:"+subject).Int64()
return n
}
// Usage reads aggregated usage for a key. Returns a zero-value stat on error.
func (l *Limiter) Usage(ctx context.Context, keyID string) UsageStat {
var st UsageStat
+169
View File
@@ -0,0 +1,169 @@
package store
import (
"context"
"errors"
"strings"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"golang.org/x/crypto/bcrypt"
"github.com/baicai2026-baicai/goods/api/internal/apikey"
)
// ErrEmailTaken is returned when registering an email that already exists.
var ErrEmailTaken = errors.New("email already registered")
// Account is a self-registered public-API user and its current key metadata.
type Account struct {
ID string `json:"id"`
Email string `json:"email"`
KeyID string `json:"-"`
KeyPrefix string `json:"key_prefix"`
RateLimitPerMin int `json:"rate_limit_per_min"`
QuotaTotal int64 `json:"quota_total"`
}
// bcryptDummyHash is compared against on unknown-email logins to keep timing
// roughly constant and avoid leaking which emails are registered.
const bcryptDummyHash = "$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy"
func isUniqueViolation(err error) bool {
var pgErr *pgconn.PgError
return errors.As(err, &pgErr) && pgErr.Code == "23505"
}
// RegisterUser creates an account plus a self-issued API key with the given
// per-minute rate and cumulative quota, returning the plaintext key (shown
// once). Email uniqueness is case-insensitive; ErrEmailTaken signals a dupe.
func (s *Store) RegisterUser(ctx context.Context, email, password string, ratePerMin int, quotaTotal int64) (plaintext string, acct Account, err error) {
email = strings.TrimSpace(email)
pwHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return "", Account{}, err
}
key, keyHash, prefix, err := apikey.Generate()
if err != nil {
return "", Account{}, err
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return "", Account{}, err
}
defer func() { _ = tx.Rollback(ctx) }()
var keyID string
if err = tx.QueryRow(ctx,
`INSERT INTO api_key (name, key_prefix, key_hash, owner_email, tier, rate_limit_per_min, quota_total, created_by)
VALUES ($1,$2,$3,$4,'registered',$5,$6,'self-register') RETURNING id`,
"user:"+strings.ToLower(email), prefix, keyHash, email, ratePerMin, quotaTotal,
).Scan(&keyID); err != nil {
return "", Account{}, err
}
var userID string
if err = tx.QueryRow(ctx,
`INSERT INTO app_user (email, password_hash, api_key_id) VALUES ($1,$2,$3) RETURNING id`,
email, string(pwHash), keyID,
).Scan(&userID); err != nil {
if isUniqueViolation(err) {
return "", Account{}, ErrEmailTaken
}
return "", Account{}, err
}
if err = tx.Commit(ctx); err != nil {
return "", Account{}, err
}
return key, Account{
ID: userID, Email: email, KeyID: keyID, KeyPrefix: prefix,
RateLimitPerMin: ratePerMin, QuotaTotal: quotaTotal,
}, nil
}
// Authenticate verifies an email/password pair and returns the account with its
// current (non-revoked) key metadata. Returns ErrNotFound on unknown email or
// wrong password.
func (s *Store) Authenticate(ctx context.Context, email, password string) (Account, error) {
email = strings.TrimSpace(email)
var (
userID, pwHash string
keyID *string
)
err := s.pool.QueryRow(ctx,
`SELECT id, password_hash, api_key_id FROM app_user WHERE lower(email) = lower($1)`, email,
).Scan(&userID, &pwHash, &keyID)
if errors.Is(err, pgx.ErrNoRows) {
_ = bcrypt.CompareHashAndPassword([]byte(bcryptDummyHash), []byte(password))
return Account{}, ErrNotFound
}
if err != nil {
return Account{}, err
}
if err := bcrypt.CompareHashAndPassword([]byte(pwHash), []byte(password)); err != nil {
return Account{}, ErrNotFound
}
acct := Account{ID: userID, Email: email}
if keyID != nil {
acct.KeyID = *keyID
_ = s.pool.QueryRow(ctx,
`SELECT key_prefix, rate_limit_per_min, quota_total
FROM api_key WHERE id = $1 AND revoked_at IS NULL`, *keyID,
).Scan(&acct.KeyPrefix, &acct.RateLimitPerMin, &acct.QuotaTotal)
}
return acct, nil
}
// RegenerateKey verifies credentials, revokes the account's current key, and
// issues a fresh one with the same rate/quota, returning the plaintext key and
// the previous key id (so cumulative usage can be carried over). Returns
// ErrNotFound on bad credentials.
func (s *Store) RegenerateKey(ctx context.Context, email, password string, ratePerMin int, quotaTotal int64) (plaintext string, acct Account, oldKeyID string, err error) {
cur, err := s.Authenticate(ctx, email, password)
if err != nil {
return "", Account{}, "", err
}
key, keyHash, prefix, err := apikey.Generate()
if err != nil {
return "", Account{}, "", err
}
oldKeyID = cur.KeyID
tx, err := s.pool.Begin(ctx)
if err != nil {
return "", Account{}, "", err
}
defer func() { _ = tx.Rollback(ctx) }()
if oldKeyID != "" {
if _, err = tx.Exec(ctx,
`UPDATE api_key SET revoked_at = now() WHERE id = $1`, oldKeyID); err != nil {
return "", Account{}, "", err
}
}
var newKeyID string
if err = tx.QueryRow(ctx,
`INSERT INTO api_key (name, key_prefix, key_hash, owner_email, tier, rate_limit_per_min, quota_total, created_by)
VALUES ($1,$2,$3,$4,'registered',$5,$6,'self-register') RETURNING id`,
"user:"+strings.ToLower(cur.Email), prefix, keyHash, cur.Email, ratePerMin, quotaTotal,
).Scan(&newKeyID); err != nil {
return "", Account{}, "", err
}
if _, err = tx.Exec(ctx,
`UPDATE app_user SET api_key_id = $1 WHERE id = $2`, newKeyID, cur.ID); err != nil {
return "", Account{}, "", err
}
if err = tx.Commit(ctx); err != nil {
return "", Account{}, "", err
}
cur.KeyID = newKeyID
cur.KeyPrefix = prefix
cur.RateLimitPerMin = ratePerMin
cur.QuotaTotal = quotaTotal
return key, cur, oldKeyID, nil
}
+99
View File
@@ -0,0 +1,99 @@
package store
import (
"context"
"errors"
"fmt"
"os"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/baicai2026-baicai/goods/api/internal/apikey"
)
func testStore(t *testing.T) *Store {
t.Helper()
dsn := os.Getenv("OPENGOODS_DATABASE_URL")
if dsn == "" {
dsn = "postgres://opengoods:opengoods@localhost:5432/opengoods?sslmode=disable"
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Skipf("no database: %v", err)
}
if err := pool.Ping(ctx); err != nil {
pool.Close()
t.Skipf("database not reachable: %v", err)
}
var hasUser bool
if err := pool.QueryRow(ctx, "SELECT to_regclass('public.app_user') IS NOT NULL").Scan(&hasUser); err != nil || !hasUser {
pool.Close()
t.Skip("migrations not applied")
}
t.Cleanup(pool.Close)
return New(pool)
}
func TestRegisterAuthenticateRegenerate(t *testing.T) {
s := testStore(t)
ctx := context.Background()
email := fmt.Sprintf("user-%d@example.com", time.Now().UnixNano())
t.Cleanup(func() {
_, _ = s.pool.Exec(ctx, "DELETE FROM app_user WHERE lower(email)=lower($1)", email)
_, _ = s.pool.Exec(ctx, "DELETE FROM api_key WHERE owner_email=$1", email)
})
key, acct, err := s.RegisterUser(ctx, email, "supersecret", 300, 100000)
if err != nil {
t.Fatalf("register: %v", err)
}
if key == "" || acct.KeyPrefix == "" || acct.QuotaTotal != 100000 || acct.RateLimitPerMin != 300 {
t.Fatalf("unexpected account: %+v key=%q", acct, key)
}
// The issued key resolves via the public auth path with its quota attached.
k, err := s.APIKeyByHash(ctx, apikey.Hash(key))
if err != nil {
t.Fatalf("APIKeyByHash: %v", err)
}
if k.QuotaTotal != 100000 || k.RateLimitPerMin != 300 {
t.Fatalf("key metadata mismatch: %+v", k)
}
// Duplicate email is rejected.
if _, _, err := s.RegisterUser(ctx, email, "anotherpass", 300, 100000); !errors.Is(err, ErrEmailTaken) {
t.Fatalf("expected ErrEmailTaken, got %v", err)
}
// Wrong password fails; correct password authenticates.
if _, err := s.Authenticate(ctx, email, "wrong"); !errors.Is(err, ErrNotFound) {
t.Fatalf("expected ErrNotFound for bad password, got %v", err)
}
got, err := s.Authenticate(ctx, email, "supersecret")
if err != nil {
t.Fatalf("authenticate: %v", err)
}
if got.KeyPrefix != acct.KeyPrefix {
t.Fatalf("authenticate key prefix = %q want %q", got.KeyPrefix, acct.KeyPrefix)
}
// Regeneration revokes the old key and issues a new one.
newKey, regen, oldKeyID, err := s.RegenerateKey(ctx, email, "supersecret", 300, 100000)
if err != nil {
t.Fatalf("regenerate: %v", err)
}
if newKey == key || oldKeyID != acct.KeyID || regen.KeyID == oldKeyID {
t.Fatalf("regenerate did not rotate key: old=%s new=%+v", oldKeyID, regen)
}
if _, err := s.APIKeyByHash(ctx, apikey.Hash(key)); !errors.Is(err, ErrNotFound) {
t.Fatalf("old key should be revoked, got %v", err)
}
if _, err := s.APIKeyByHash(ctx, apikey.Hash(newKey)); err != nil {
t.Fatalf("new key should be active: %v", err)
}
}
+183 -10
View File
@@ -4,19 +4,35 @@ package store
import (
"context"
"crypto/sha1"
"encoding/hex"
"encoding/json"
"errors"
"strconv"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/baicai2026-baicai/goods/api/internal/cache"
)
// Cache TTLs for the public read cache. Product details are far less volatile
// than search result sets, so they live longer; both are also invalidated
// wholesale whenever ingestion bumps the cache epoch.
const (
productCacheTTL = 24 * time.Hour
searchCacheTTL = time.Hour
)
// ErrNotFound is returned when a requested row does not exist.
var ErrNotFound = errors.New("not found")
// Store wraps a PostgreSQL connection pool.
// Store wraps a PostgreSQL connection pool and an optional read cache.
type Store struct {
pool *pgxpool.Pool
pool *pgxpool.Pool
cache *cache.Cache
}
// New constructs a Store from an existing pgx pool.
@@ -24,11 +40,53 @@ func New(pool *pgxpool.Pool) *Store {
return &Store{pool: pool}
}
// WithCache attaches a Redis-backed read cache. A nil or disabled cache leaves
// the Store reading straight from PostgreSQL.
func (s *Store) WithCache(c *cache.Cache) *Store {
s.cache = c
return s
}
// cacheGet reads a cached JSON value into dest, reporting a hit. It is a no-op
// miss when no cache is attached.
func (s *Store) cacheGet(ctx context.Context, suffix string, dest any) bool {
if s.cache == nil {
return false
}
return s.cache.GetJSON(ctx, suffix, dest)
}
// cacheSet stores a JSON value when a cache is attached.
func (s *Store) cacheSet(ctx context.Context, suffix string, val any, ttl time.Duration) {
if s.cache == nil {
return
}
s.cache.SetJSON(ctx, suffix, val, ttl)
}
// Ping verifies database connectivity.
func (s *Store) Ping(ctx context.Context) error {
return s.pool.Ping(ctx)
}
// PublicStats summarizes the public catalog for the homepage.
type PublicStats struct {
Total int `json:"total"`
Qualified int `json:"qualified"`
MinScore float64 `json:"min_score"`
}
// Stats returns active-product totals and the number of qualified records whose
// quality_score meets minScore.
func (s *Store) Stats(ctx context.Context, minScore float64) (PublicStats, error) {
st := PublicStats{MinScore: minScore}
err := s.pool.QueryRow(ctx, `
SELECT count(*) FILTER (WHERE status = 'active'),
count(*) FILTER (WHERE status = 'active' AND quality_score >= $1)
FROM product`, minScore).Scan(&st.Total, &st.Qualified)
return st, err
}
// Barcode is one GS1 trade item number attached to a product.
type Barcode struct {
GTIN string `json:"gtin"`
@@ -38,6 +96,15 @@ type Barcode struct {
IsPrimary bool `json:"is_primary"`
}
// ProductSpec is one labeled spec line for a non-food product, rendered from
// the product's attributes JSONB against its archive kind's field template.
type ProductSpec struct {
Key string `json:"key"`
Label string `json:"label"`
Value string `json:"value"`
Unit string `json:"unit,omitempty"`
}
// Product is the full public view of a product.
type Product struct {
ID string `json:"id"`
@@ -46,11 +113,13 @@ type Product struct {
Brand *string `json:"brand"`
CategoryPath *string `json:"category_path"`
GPCBrickCode *string `json:"gpc_brick_code"`
ArchiveKind string `json:"archive_kind"`
NetContentValue *float64 `json:"net_content_value"`
NetContentUnit *string `json:"net_content_unit"`
CountryOfOrigin *string `json:"country_of_origin"`
QualityScore float64 `json:"quality_score"`
Barcodes []Barcode `json:"barcodes"`
Specs []ProductSpec `json:"specs,omitempty"`
Nutriments map[string]any `json:"nutriments,omitempty"`
NutritionBasis *string `json:"nutrition_basis,omitempty"`
NutriScore *string `json:"nutri_score,omitempty"`
@@ -102,6 +171,7 @@ type SearchFilters struct {
const productSelect = `
SELECT p.id, p.gtin, p.name, b.name, c.path::text, p.gpc_brick_code,
COALESCE(c.archive_kind, 'generic'), p.attributes,
p.net_content_value, p.net_content_unit, p.country_of_origin, p.quality_score,
f.nutriments, f.nutrition_basis, f.nutri_score, f.ingredients_text,
f.allergens, f.additives
@@ -111,51 +181,127 @@ LEFT JOIN category c ON c.id = p.category_id
LEFT JOIN food_detail f ON f.product_id = p.id
`
func scanProduct(row pgx.Row) (*Product, error) {
func scanProduct(row pgx.Row) (*Product, []byte, error) {
var p Product
var attributes []byte
err := row.Scan(
&p.ID, &p.GTIN, &p.Name, &p.Brand, &p.CategoryPath, &p.GPCBrickCode,
&p.ArchiveKind, &attributes,
&p.NetContentValue, &p.NetContentUnit, &p.CountryOfOrigin, &p.QualityScore,
&p.Nutriments, &p.NutritionBasis, &p.NutriScore, &p.Ingredients,
&p.Allergens, &p.Additives,
)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
return nil, nil, ErrNotFound
}
if err != nil {
return nil, nil, err
}
return &p, attributes, nil
}
// buildSpecs renders the labeled, ordered spec list for a non-food product from
// its attributes JSONB against its archive kind's field template.
func (s *Store) buildSpecs(ctx context.Context, kind string, attributes []byte) ([]ProductSpec, error) {
if kind == "" || kind == "food" || len(attributes) == 0 {
return nil, nil
}
attrs := map[string]any{}
if err := json.Unmarshal(attributes, &attrs); err != nil || len(attrs) == 0 {
return nil, nil
}
rows, err := s.pool.Query(ctx,
"SELECT field_key, label_zh, COALESCE(unit, '') FROM kind_field WHERE kind = $1 ORDER BY sort_order, field_key", kind)
if err != nil {
return nil, err
}
return &p, nil
defer rows.Close()
specs := []ProductSpec{}
for rows.Next() {
var key, label, unit string
if err := rows.Scan(&key, &label, &unit); err != nil {
return nil, err
}
v, ok := attrs[key]
if !ok || v == nil {
continue
}
val := stringifyAttr(v)
if val == "" {
continue
}
specs = append(specs, ProductSpec{Key: key, Label: label, Value: val, Unit: unit})
}
return specs, rows.Err()
}
// stringifyAttr renders a JSON attribute value as display text.
func stringifyAttr(v any) string {
switch t := v.(type) {
case string:
return t
case float64:
return strconv.FormatFloat(t, 'f', -1, 64)
case bool:
if t {
return "是"
}
return "否"
case []any:
parts := make([]string, 0, len(t))
for _, e := range t {
parts = append(parts, stringifyAttr(e))
}
return strings.Join(parts, "、")
default:
return ""
}
}
// ProductByGTIN looks up an active product by any of its barcodes.
func (s *Store) ProductByGTIN(ctx context.Context, gtin string) (*Product, error) {
const suffix = "prod:gtin:"
if cached := new(Product); s.cacheGet(ctx, suffix+gtin, cached) {
return cached, nil
}
row := s.pool.QueryRow(ctx, productSelect+`
WHERE p.status = 'active'
AND (p.gtin = $1 OR EXISTS (
SELECT 1 FROM product_barcode pb
WHERE pb.product_id = p.id AND pb.gtin = $1))
LIMIT 1`, gtin)
p, err := scanProduct(row)
p, attrs, err := scanProduct(row)
if err != nil {
return nil, err
}
if p.Barcodes, err = s.ProductBarcodes(ctx, p.ID); err != nil {
return nil, err
}
if p.Specs, err = s.buildSpecs(ctx, p.ArchiveKind, attrs); err != nil {
return nil, err
}
s.cacheSet(ctx, suffix+gtin, p, productCacheTTL)
return p, nil
}
// ProductByID looks up a product by its UUID.
func (s *Store) ProductByID(ctx context.Context, id string) (*Product, error) {
const suffix = "prod:id:"
if cached := new(Product); s.cacheGet(ctx, suffix+id, cached) {
return cached, nil
}
row := s.pool.QueryRow(ctx, productSelect+" WHERE p.id = $1", id)
p, err := scanProduct(row)
p, attrs, err := scanProduct(row)
if err != nil {
return nil, err
}
if p.Barcodes, err = s.ProductBarcodes(ctx, p.ID); err != nil {
return nil, err
}
if p.Specs, err = s.buildSpecs(ctx, p.ArchiveKind, attrs); err != nil {
return nil, err
}
s.cacheSet(ctx, suffix+id, p, productCacheTTL)
return p, nil
}
@@ -170,6 +316,11 @@ const fuzzyThreshold = "0.42"
// similarity blended with quality_score so the best, most-complete records
// surface first. Without a query, results are ordered by quality_score.
func (s *Store) SearchProducts(ctx context.Context, f SearchFilters, limit, offset int) ([]ProductSummary, int, error) {
suffix := searchCacheSuffix(f, limit, offset)
if entry := new(searchCacheEntry); s.cacheGet(ctx, suffix, entry) {
return entry.Items, entry.Total, nil
}
args := []any{}
where := "WHERE p.status = 'active'"
@@ -236,7 +387,28 @@ LEFT JOIN category c ON c.id = p.category_id `
}
out = append(out, ps)
}
return out, total, rows.Err()
if err := rows.Err(); err != nil {
return nil, 0, err
}
s.cacheSet(ctx, suffix, searchCacheEntry{Items: out, Total: total}, searchCacheTTL)
return out, total, nil
}
// searchCacheEntry is the cached payload for a SearchProducts call.
type searchCacheEntry struct {
Items []ProductSummary `json:"items"`
Total int `json:"total"`
}
// searchCacheSuffix derives a stable cache key from the full filter set and
// paging window so distinct queries never collide.
func searchCacheSuffix(f SearchFilters, limit, offset int) string {
raw := strings.Join([]string{
f.Query, f.Category, f.Brand, f.Country,
strconv.Itoa(limit), strconv.Itoa(offset),
}, "\x1f")
sum := sha1.Sum([]byte(raw))
return "search:" + hex.EncodeToString(sum[:])
}
// Nutriments returns just the nutrition payload for a product.
@@ -355,6 +527,7 @@ type APIKey struct {
ID string
Name string
RateLimitPerMin int
QuotaTotal int64
}
// APIKeyByHash returns the active (non-revoked) key matching a SHA-256 hash,
@@ -362,9 +535,9 @@ type APIKey struct {
func (s *Store) APIKeyByHash(ctx context.Context, hash string) (*APIKey, error) {
var k APIKey
err := s.pool.QueryRow(ctx,
`SELECT id, name, rate_limit_per_min
`SELECT id, name, rate_limit_per_min, quota_total
FROM api_key WHERE key_hash = $1 AND revoked_at IS NULL`, hash,
).Scan(&k.ID, &k.Name, &k.RateLimitPerMin)
).Scan(&k.ID, &k.Name, &k.RateLimitPerMin, &k.QuotaTotal)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
+112
View File
@@ -0,0 +1,112 @@
# 可扩展性设计与路线图 (Scalability Roadmap)
本文档回答一个长期问题:随着商品越来越多、品类越来越杂(食品 / 电子 3C / 药品 /
……),**检索与新增会不会压垮数据库?要不要按品类「分表」?**
> 结论先行:**现阶段不要按品类手动分表。** 现有「单表 + JSONB + archive_kind 框架」
> 的设计方向是对的。扩展应当靠 **分区(非分表) + 读副本 + 缓存 + 专用搜索引擎**,
> 按数据量分阶段推进,避免提前过度设计。
---
## 1. 现状盘点
### 1.1 数据模型
- 所有商品落在**一张 `product` 表**;非食品的领域字段存 `product.attributes`(JSONB)。
- 食品有独立明细表 `food_detail`(配料 / 营养 / 过敏原等结构化字段)。
- `0010_archive_kinds` 引入 **archive_kind 框架**:每个品类带一个 `archive_kind`
(`food` / `electronics` / `generic`),`kind_field` 表按 kind 定义字段模板,
驱动后台动态表单与合格度评分。
- **加新品类(如药品)不需要新建表**:只要新增一组 `kind_field` 行 + 一棵品类子树;
仅当某品类有大量需被独立筛选/排序的结构化字段时,才考虑像 `food_detail` 那样补一张
明细表。
### 1.2 已有索引(检索性能的基础)
| 对象 | 索引 | 用途 |
|------|------|------|
| `product.gtin` | 唯一索引 | 条码精确查 |
| `product.name` | trigram GIN (`pg_trgm`) | 名称模糊/相似匹配 |
| `product.search_tsv` | 全文 GIN (`tsvector`) | 全文检索 |
| `product.attributes` | JSONB GIN | 属性过滤 |
| `brand.name` | trigram GIN | 品牌模糊匹配 |
| `product.country_of_origin` | btree | 产地精确/前缀过滤 |
| category / brand / updated_at | btree | 关联与增量 |
### 1.3 检索方式
- 有关键词时:`name ILIKE` `word_similarity ≥ 阈值(~0.42)` 条码匹配,
排序按 `相似度 × (0.5 + quality_score)`
- 无关键词时:按 `quality_score` 排序。
- 翻页:`LIMIT / OFFSET`
### 1.4 写入特征
- 写库**只有** Python ingestion 一条路径(批量 ETL),不是高并发 OLTP。
- 插入瓶颈极低;`search_tsv` 由触发器逐行重算,正常增量下开销可忽略。
---
## 2. 为什么不建议按品类「分表」
1. **核心场景是全局检索**:用户通常不知道商品属于哪个品类,搜索要跨所有品类。
按品类拆成多表后,一次搜索得 `UNION ALL` 所有表,**更慢、代码更复杂、排序更难统一**。
2. **单表足够能打**:Postgres 单表配好索引,**几千万行**量级的检索完全可承载。
"表大"很少是真正瓶颈,"搜索方式"和"读并发"才是。
3. **分表会侵蚀框架优势**:archive_kind 框架的价值就是"加品类零建表";手动分表等于
把这套通用能力又拆碎。
> 区分两个概念:**分表(sharding,应用层拆多张表)** ≠ **分区(Postgres 原生
> declarative partitioning,对上层透明的一张逻辑表)**。后者在超大规模时才有意义,
> 见第 3 阶段。
---
## 3. 分阶段路线图(按数据量触发,不提前做)
### 阶段 0 — 现在 ~ 数百万条:维持现状 + 低成本优化
触发:当前规模。改动小、收益稳,建议尽早做:
- **深翻页改 keyset 分页**:`OFFSET` 越翻越慢(需扫描并丢弃前 N 行);改用
`WHERE (score, id) < (:last_score, :last_id)` 形式的游标分页。
- **部分索引**:绝大多数查询限定 `status='active'`,可建
`CREATE INDEX ... WHERE status='active'` 缩小索引、提速。
- **常用筛选复合索引**:如 `(category_id, quality_score DESC)`
`(archive_kind, quality_score DESC)` 配合域内列表。
- **Redis 缓存**(已在技术栈内):缓存热门搜索结果与商品详情,挡住重复读。
### 阶段 1 — 千万级以上:读扩展 + 调优
触发:单库读 QPS 升高、P99 变慢。
- **只读副本(read replica)**:本服务是**只读公益 API**,天然适合一主多从,
把检索/详情读流量分到副本,主库只承接 ingestion 写入。
- **索引与查询调优**:按慢查询日志补/删索引,`EXPLAIN ANALYZE` 校核计划。
- **(可选)Postgres 原生分区**:若多数检索"限定单一域"(只搜药品 / 只搜食品),
可按 `archive_kind`**LIST 分区**(对上层透明,仍是一张逻辑表)。主要利好
**维护**(分区级 vacuum / 归档)与**域内查询裁剪**;对真正的全局搜索帮助有限。
### 阶段 2 — 搜索相关性/规模成为痛点:引入专用搜索引擎
触发:`pg_trgm`/`tsvector` 在相关性排序、跨字段检索、规模上吃力。
- 把**检索**迁到专用倒排引擎:**OpenSearch / Meilisearch / Typesense**,
或 Postgres 内的 **ParadeDB(`pg_search`,BM25)**
- **Postgres 仍是唯一事实来源**;搜索引擎只做索引,由 ingestion 在写库后同步。
- 这才是"搜索量大"的正解,**比分表有效得多**。
---
## 4. 大批量导入的建议
- 海量初始化/回填用 `COPY` 而非逐行 `INSERT`
- 超大批量时可"先停建二级索引 → COPY → 重建索引",比边插边维护索引快得多。
- ETL 控制并发与批大小,避免与在线读争抢。
---
## 5. 药品档案怎么落地(回到最初的问题)
在上述设计下,加"药品"属于**阶段 0 的常规扩展**,不触动架构:
1. 新增 `drug``kind_field` 模板(批准文号 / 通用名 / 商品名 / 剂型 / 规格 /
生产企业 / OTC 分类 / 适应症 / 用法用量 / 不良反应 / 禁忌 / 注意事项 / 贮藏 /
有效期 等),`qualified` 标记关键字段参与合格度评分。
2. 加一棵药品品类子树,并把这些品类的 `archive_kind` 置为 `drug`
3. 仅当药品需要**被独立筛选/排序的强结构化字段**(如按批准文号精确查、按 OTC 分类
过滤)时,才考虑补一张 `drug_detail` 明细表;否则继续走 `attributes` JSONB。
---
## 6. 版本
- 本路线图随规模演进更新;任何落地改动需同步:迁移(SQL) + 本文档 +(涉及对外字段时)
`docs/data-contract.md` / `docs/openapi.yaml`
+116
View File
@@ -0,0 +1,116 @@
"""Adapter for the bypos-collector output (central product library zc.bypos.net).
The ``bypos-collector`` tool (see ``tools/bypos-collector``) queries the same
central product library that the 云店 POS uses when adding a product by barcode,
and writes one JSON object per line (JSONL) with these fields::
barcode name spec unit area manufacturer license
in_price sell_price status retmsg fetched_at source
This module turns one such record into the internal product shape consumed by
:func:`opengoods.etl.load.load_bypos_record`. It is a pure function (no DB, no
network) so it is easy to unit-test.
The central library is a Chinese retail catalogue: it provides 品名/规格/单位/
产地/厂商/建议价 but **not** ingredients or nutrition, so no ``food`` block is
produced.
"""
from __future__ import annotations
from decimal import Decimal, InvalidOperation
from opengoods.etl.transform import _clamp, is_valid_gtin, parse_quantity
from opengoods.units import UnitError, normalize
SOURCE_NAME = "bypos中心库"
SOURCE_HOMEPAGE = "https://zc.bypos.net"
# Vendor catalogue data; not an open-licensed dataset. Display facts only.
SOURCE_LICENSE = "proprietary"
SOURCE_TRUST = 0.6
def _to_price(text: str | None) -> Decimal | None:
"""Parse a price string to a positive Decimal, or None for empty/zero."""
if not text:
return None
try:
amount = Decimal(str(text).strip())
except (InvalidOperation, ValueError):
return None
if amount <= 0:
return None
return amount
def _net_content(spec: str | None) -> tuple[Decimal | None, str | None, Decimal | None]:
"""Best-effort parse a spec like '500mL'/'5kg' to (value, unit, canonical).
Packaging-style specs ('20支', '1X24', '') have no mass/volume unit and
yield ``(None, None, None)`` — the raw spec is kept in attributes instead.
"""
parsed = parse_quantity(spec or "")
if not parsed:
return None, None, None
value, unit = parsed
try:
norm = normalize(value, unit)
except UnitError:
return None, None, None
return norm.value, norm.unit, norm.canonical_value
def transform_bypos(rec: dict) -> dict | None:
"""Transform one bypos-collector JSONL record into an internal product dict.
Returns ``None`` for non-hit rows or rows without a usable name.
"""
if rec.get("status") != "hit":
return None
name = (rec.get("name") or "").strip()
if not name:
return None
barcode = str(rec.get("barcode") or "").strip()
gtin = barcode if barcode and is_valid_gtin(barcode) else None
net_value, net_unit, net_canonical = _net_content(rec.get("spec"))
spec = (rec.get("spec") or "").strip()
pack_unit = (rec.get("unit") or "").strip()
area = (rec.get("area") or "").strip()
manufacturer = (rec.get("manufacturer") or "").strip() or None
license_no = (rec.get("license") or "").strip()
in_price = _to_price(rec.get("in_price"))
sell_price = _to_price(rec.get("sell_price"))
attributes: dict[str, object] = {}
if spec:
attributes["spec"] = spec
if pack_unit:
attributes["pack_unit"] = pack_unit
if area:
attributes["origin_area"] = area
if license_no:
attributes["production_license"] = license_no
if in_price is not None:
attributes["suggested_in_price"] = float(in_price)
if sell_price is not None:
attributes["suggested_retail_price"] = float(sell_price)
# Domestic GS1-China barcodes (69x) are China-made; area is a province/city,
# kept separately in attributes.origin_area.
country = "中国" if gtin and gtin.startswith("69") else None
return {
"gtin": gtin,
"name": name,
"manufacturer": manufacturer,
"net_content_value": net_value,
"net_content_unit": _clamp(net_unit, 16),
"net_content_canonical": net_canonical,
"country_of_origin": country,
"attributes": attributes,
"msrp": sell_price,
"fetched_at": (rec.get("fetched_at") or "").strip() or None,
}
+52
View File
@@ -0,0 +1,52 @@
"""Read-cache invalidation signal for the public API.
The Go API caches hot product details and search results in Redis, namespacing
every key by a global generation counter (``og:cache:epoch``). Bumping that
counter logically invalidates the entire cache in one O(1) operation while the
old keys age out via their TTL.
Ingestion is the only writer to the database, so after a run that changed data
it calls :func:`bump_cache_epoch` to make those changes visible immediately
instead of waiting for per-key TTLs to expire.
Like the API's cache, this is strictly best effort and fails open: if Redis is
unconfigured or unreachable the ingestion run still succeeds, and stale entries
simply expire on their own.
"""
from __future__ import annotations
import logging
import os
logger = logging.getLogger(__name__)
EPOCH_KEY = "og:cache:epoch"
def default_redis_url() -> str | None:
"""Return the configured Redis URL, or ``None`` when caching is disabled."""
return os.environ.get("OPENGOODS_REDIS_URL") or None
def bump_cache_epoch(url: str | None = None) -> bool:
"""Increment the API cache generation counter.
Returns ``True`` if the counter was bumped, ``False`` if caching is disabled
or Redis was unreachable. Never raises: invalidation failures must not fail
an ingestion run.
"""
url = url or default_redis_url()
if not url:
return False
try:
import redis # imported lazily so the dependency is optional at runtime
client = redis.Redis.from_url(url, socket_timeout=2, socket_connect_timeout=2)
new_epoch = client.incr(EPOCH_KEY)
client.close()
logger.info("bumped API cache epoch to %s", new_epoch)
return True
except Exception as exc: # noqa: BLE001 - invalidation is best effort
logger.warning("cache epoch bump skipped: %s", exc)
return False
+155
View File
@@ -14,6 +14,10 @@ from typing import Any
import psycopg
from psycopg.types.json import Jsonb
from opengoods.adapters.bypos import SOURCE_HOMEPAGE as SOURCE_HOMEPAGE_BYPOS
from opengoods.adapters.bypos import SOURCE_LICENSE as SOURCE_LICENSE_BYPOS
from opengoods.adapters.bypos import SOURCE_NAME as SOURCE_NAME_BYPOS
from opengoods.adapters.bypos import SOURCE_TRUST as SOURCE_TRUST_BYPOS
from opengoods.adapters.openfoodfacts import OFF_LICENSE, SOURCE_NAME
from opengoods.etl.quality import update_quality
@@ -73,6 +77,23 @@ def _ensure_brand(conn: psycopg.Connection, name: str | None) -> str | None:
return row[0]
def _ensure_manufacturer(
conn: psycopg.Connection, name: str | None, country: str | None = None
) -> str | None:
if not name:
return None
row = conn.execute(
"""
INSERT INTO manufacturer (name, normalized_name, country)
VALUES (%s, %s, %s)
ON CONFLICT (normalized_name) DO UPDATE SET name = manufacturer.name
RETURNING id
""",
(name, _normalize_brand(name), country),
).fetchone()
return row[0]
def _category_id(conn: psycopg.Connection, path: str | None) -> tuple[str | None, str | None]:
if not path:
return None, None
@@ -218,6 +239,140 @@ def load_record_safe(
return False
def ensure_bypos_source(conn: psycopg.Connection) -> str:
"""Upsert the bypos central-library source row and return its id."""
return ensure_source_named(
conn,
SOURCE_NAME_BYPOS,
SOURCE_HOMEPAGE_BYPOS,
SOURCE_LICENSE_BYPOS,
SOURCE_TRUST_BYPOS,
)
def load_bypos_record(
conn: psycopg.Connection, rec: dict[str, Any], source_id: str, raw: dict
) -> str:
"""Upsert one transformed bypos record; return the product id.
Unlike OFF records these have no ingredients/nutrition, so no ``food_detail``
row is written. The suggested retail price (if any) is stored as a CNY MSRP
snapshot, and provenance/MSRP rows are keyed by source so a re-import
refreshes rather than duplicates them.
"""
manufacturer_id = _ensure_manufacturer(
conn, rec.get("manufacturer"), rec.get("country_of_origin")
)
attrs = rec.get("attributes") or {}
fields = ["name", "net_content", "country_of_origin"]
if manufacturer_id:
fields.append("manufacturer")
if attrs:
fields.append("attributes")
if rec.get("gtin"):
fields.append("gtin")
prod = conn.execute(
"""
INSERT INTO product (gtin, name, manufacturer_id,
net_content_value, net_content_unit, net_content_canonical,
country_of_origin, attributes)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s)
ON CONFLICT (gtin) WHERE gtin IS NOT NULL DO UPDATE SET
name = EXCLUDED.name,
manufacturer_id = COALESCE(EXCLUDED.manufacturer_id, product.manufacturer_id),
net_content_value = COALESCE(EXCLUDED.net_content_value, product.net_content_value),
net_content_unit = COALESCE(EXCLUDED.net_content_unit, product.net_content_unit),
net_content_canonical = COALESCE(
EXCLUDED.net_content_canonical, product.net_content_canonical),
country_of_origin = COALESCE(EXCLUDED.country_of_origin, product.country_of_origin),
attributes = product.attributes || EXCLUDED.attributes
RETURNING id
""",
(
rec["gtin"],
rec["name"],
manufacturer_id,
rec.get("net_content_value"),
rec.get("net_content_unit"),
rec.get("net_content_canonical"),
rec.get("country_of_origin"),
Jsonb(attrs),
),
).fetchone()
else:
prod = conn.execute(
"""
INSERT INTO product (name, manufacturer_id,
net_content_value, net_content_unit, net_content_canonical,
country_of_origin, attributes)
VALUES (%s,%s,%s,%s,%s,%s,%s)
RETURNING id
""",
(
rec["name"],
manufacturer_id,
rec.get("net_content_value"),
rec.get("net_content_unit"),
rec.get("net_content_canonical"),
rec.get("country_of_origin"),
Jsonb(attrs),
),
).fetchone()
product_id = prod[0]
# Refresh this source's MSRP snapshot (suggested retail price, CNY).
conn.execute(
"DELETE FROM product_msrp WHERE product_id = %s AND source_id = %s",
(product_id, source_id),
)
if rec.get("msrp") is not None:
conn.execute(
"""
INSERT INTO product_msrp (product_id, amount, currency, region, source_id, source_url)
VALUES (%s,%s,'CNY','CN',%s,%s)
""",
(product_id, rec["msrp"], source_id, SOURCE_HOMEPAGE_BYPOS),
)
fields.append("msrp")
# Refresh this source's provenance row (one per source for idempotency).
conn.execute(
"DELETE FROM product_source WHERE product_id = %s AND source_id = %s",
(product_id, source_id),
)
conn.execute(
"""
INSERT INTO product_source (product_id, source_id, url, fields, fetched_at, raw)
VALUES (%s,%s,%s,%s, COALESCE(%s::timestamptz, now()), %s)
""",
(
product_id,
source_id,
SOURCE_HOMEPAGE_BYPOS,
fields,
rec.get("fetched_at"),
Jsonb(_jsonable(raw)),
),
)
update_quality(conn, product_id)
return product_id
def load_bypos_record_safe(
conn: psycopg.Connection, rec: dict[str, Any], source_id: str, raw: dict
) -> bool:
"""Load one bypos record inside a savepoint (see :func:`load_record_safe`)."""
try:
with conn.transaction():
load_bypos_record(conn, rec, source_id, raw)
return True
except Exception as exc: # noqa: BLE001 - per-record isolation is intentional
logger.warning("skipping bypos record gtin=%s: %s", rec.get("gtin"), exc)
return False
def _jsonable(raw: dict) -> dict:
"""Drop values that are not JSON-serializable from a raw record."""
try:
+3
View File
@@ -12,6 +12,7 @@ import sys
import psycopg
from opengoods.cache import bump_cache_epoch
from opengoods.etl.dedup import dedup_all
from opengoods.etl.load import default_dsn
@@ -23,6 +24,8 @@ def run(args: argparse.Namespace) -> int:
conn.rollback()
else:
conn.commit()
if not args.dry_run and summary["merged"]:
bump_cache_epoch()
mode = "dry-run" if args.dry_run else "applied"
print(f"{mode} groups={summary['groups']} merged={summary['merged']}")
return 0
+80
View File
@@ -0,0 +1,80 @@
"""Import products collected by the bypos-collector tool into OpenGoods.
The ``tools/bypos-collector`` program writes one product per line (JSONL). This
job reads such a file, transforms each ``hit`` record into the internal product
shape, and upserts it into the database under the ``bypos中心库`` source with
field-level provenance.
Usage::
python -m opengoods.jobs.import_bypos --input products.jsonl
python -m opengoods.jobs.import_bypos --input products.jsonl --limit 500
Re-running is safe: products are upserted by GTIN and the source's MSRP/
provenance rows are refreshed rather than duplicated.
"""
from __future__ import annotations
import argparse
import gzip
import json
import sys
from collections.abc import Iterator
import psycopg
from opengoods.adapters.bypos import transform_bypos
from opengoods.cache import bump_cache_epoch
from opengoods.etl.load import default_dsn, ensure_bypos_source, load_bypos_record_safe
def _read_jsonl(path: str) -> Iterator[dict]:
opener = gzip.open if path.endswith(".gz") else open
with opener(path, "rt", encoding="utf-8") as fh:
for line in fh:
line = line.strip()
if not line:
continue
try:
yield json.loads(line)
except json.JSONDecodeError:
continue
def run(args: argparse.Namespace) -> int:
loaded = skipped = errored = 0
with psycopg.connect(args.dsn, autocommit=False) as conn:
source_id = ensure_bypos_source(conn)
yielded = 0
for raw in _read_jsonl(args.input):
if args.limit and yielded >= args.limit:
break
rec = transform_bypos(raw)
if rec is None:
skipped += 1
continue
yielded += 1
if load_bypos_record_safe(conn, rec, source_id, raw):
loaded += 1
else:
errored += 1
conn.commit()
if loaded:
bump_cache_epoch()
print(f"loaded={loaded} skipped={skipped} errored={errored}")
return 0
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="Import bypos-collector JSONL into OpenGoods")
parser.add_argument(
"--input", required=True, help="path to a bypos-collector JSONL (.jsonl or .jsonl.gz)"
)
parser.add_argument("--limit", type=int, default=0, help="max hit records to load (0 = all)")
parser.add_argument("--dsn", default=default_dsn(), help="PostgreSQL DSN")
return run(parser.parse_args(argv))
if __name__ == "__main__":
sys.exit(main())
+3
View File
@@ -28,6 +28,7 @@ from opengoods.adapters.openfoodfacts import (
is_cn_gs1,
read_dump,
)
from opengoods.cache import bump_cache_epoch
from opengoods.etl.load import default_dsn, ensure_source, load_record_safe
from opengoods.etl.transform import transform
@@ -67,6 +68,8 @@ def run(args: argparse.Namespace) -> int:
else:
errored += 1
conn.commit()
if loaded:
bump_cache_epoch()
print(f"loaded={loaded} skipped={skipped} errored={errored}")
return 0
+3
View File
@@ -17,6 +17,7 @@ import sys
import psycopg
from opengoods.adapters.openfoodfacts import SOURCE_NAME, OpenFoodFactsAdapter
from opengoods.cache import bump_cache_epoch
from opengoods.etl.load import default_dsn, ensure_source, load_record_safe
from opengoods.etl.state import get_watermark, set_watermark
from opengoods.etl.transform import transform
@@ -49,6 +50,8 @@ def run(args: argparse.Namespace) -> int:
stats={"loaded": loaded, "skipped": skipped, "errored": errored, "since": since},
)
conn.commit()
if loaded:
bump_cache_epoch()
print(
f"since={since} loaded={loaded} skipped={skipped} "
f"errored={errored} watermark={high_watermark}"
+1
View File
@@ -6,6 +6,7 @@ requires-python = ">=3.11"
dependencies = [
"httpx>=0.27",
"psycopg[binary]>=3.2",
"redis>=5.0,<6",
]
[project.optional-dependencies]
+155
View File
@@ -0,0 +1,155 @@
"""Tests for the bypos-collector adapter/transform and DB loader.
The pure-function tests run anywhere; the DB roundtrip is skipped automatically
when no database is reachable or migrations are not applied.
"""
from __future__ import annotations
from decimal import Decimal
import pytest
from opengoods.adapters.bypos import transform_bypos
psycopg = pytest.importorskip("psycopg")
# A real central-library "hit" row as emitted by the collector.
HIT = {
"barcode": "6901028941068",
"name": "泰山合悦",
"spec": "20支",
"unit": "",
"area": "广东",
"manufacturer": "",
"license": "",
"in_price": "22.50",
"sell_price": "28.00",
"status": "hit",
"retmsg": "获取商品信息成功",
"fetched_at": "2026-06-24T02:08:34Z",
"source": "zc.bypos.net",
}
# A hit with a real mass/volume spec that should normalize to net content.
HIT_VOLUME = {
"barcode": "6920459905012",
"name": "康师傅冰红茶490ml",
"spec": "490毫升",
"unit": "",
"area": "浙江杭州",
"manufacturer": "",
"license": "",
"in_price": "2.20",
"sell_price": "3.00",
"status": "hit",
"fetched_at": "2026-06-24T02:08:34Z",
"source": "zc.bypos.net",
}
def test_transform_skips_non_hit():
assert transform_bypos({**HIT, "status": "miss", "name": ""}) is None
assert transform_bypos({**HIT, "status": "invalid"}) is None
def test_transform_skips_missing_name():
assert transform_bypos({**HIT, "name": " "}) is None
def test_transform_packaging_spec_kept_raw():
rec = transform_bypos(HIT)
assert rec is not None
assert rec["gtin"] == "6901028941068"
assert rec["name"] == "泰山合悦"
# '20支' is a count, not mass/volume -> no net_content, raw spec retained.
assert rec["net_content_value"] is None
assert rec["net_content_unit"] is None
assert rec["attributes"]["spec"] == "20支"
assert rec["attributes"]["pack_unit"] == ""
assert rec["attributes"]["origin_area"] == "广东"
assert rec["attributes"]["suggested_in_price"] == 22.5
assert rec["attributes"]["suggested_retail_price"] == 28.0
assert rec["msrp"] == Decimal("28.00")
assert rec["country_of_origin"] == "中国"
def test_transform_volume_spec_normalized():
rec = transform_bypos(HIT_VOLUME)
assert rec is not None
assert rec["net_content_value"] == Decimal("490")
assert rec["net_content_unit"] == "ml"
assert rec["net_content_canonical"] == Decimal("490")
assert rec["attributes"]["origin_area"] == "浙江杭州"
def test_transform_zero_price_dropped():
rec = transform_bypos({**HIT, "in_price": "0.00", "sell_price": "0.00"})
assert rec is not None
assert rec["msrp"] is None
assert "suggested_in_price" not in rec["attributes"]
assert "suggested_retail_price" not in rec["attributes"]
def test_transform_invalid_barcode_no_gtin():
rec = transform_bypos({**HIT, "barcode": "123"})
assert rec is not None
assert rec["gtin"] is None
assert rec["country_of_origin"] is None
# --- DB roundtrip (skipped without a database) ---------------------------------
@pytest.fixture()
def conn():
from opengoods.etl.load import default_dsn
try:
c = psycopg.connect(default_dsn(), connect_timeout=3)
except psycopg.OperationalError as exc: # pragma: no cover - env dependent
pytest.skip(f"no database available: {exc}")
has_product = c.execute("SELECT to_regclass('public.product') IS NOT NULL").fetchone()[0]
if not has_product:
c.close()
pytest.skip("migrations not applied")
yield c
c.rollback()
c.close()
def test_load_bypos_roundtrip(conn):
from opengoods.etl.load import ensure_bypos_source, load_bypos_record
source_id = ensure_bypos_source(conn)
rec = transform_bypos(HIT_VOLUME)
product_id = load_bypos_record(conn, rec, source_id, HIT_VOLUME)
row = conn.execute(
"SELECT name, gtin, net_content_unit, country_of_origin, attributes ->> 'pack_unit' "
"FROM product WHERE id = %s",
(product_id,),
).fetchone()
assert row[0] == "康师傅冰红茶490ml"
assert row[1] == "6920459905012"
assert row[2] == "ml"
assert row[3] == "中国"
assert row[4] == ""
msrp = conn.execute(
"SELECT amount, currency FROM product_msrp WHERE product_id = %s AND source_id = %s",
(product_id, source_id),
).fetchone()
assert msrp[0] == Decimal("3.00")
assert msrp[1] == "CNY"
# Re-import is idempotent: still one MSRP and one provenance row per source.
load_bypos_record(conn, rec, source_id, HIT_VOLUME)
counts = conn.execute(
"SELECT (SELECT count(*) FROM product_msrp WHERE product_id=%s AND source_id=%s), "
"(SELECT count(*) FROM product_source WHERE product_id=%s AND source_id=%s)",
(product_id, source_id, product_id, source_id),
).fetchone()
assert counts == (1, 1)
conn.rollback()
+44
View File
@@ -0,0 +1,44 @@
"""Tests for the best-effort API cache invalidation signal."""
from __future__ import annotations
import sys
import opengoods.cache as cache
def test_disabled_when_no_url(monkeypatch):
monkeypatch.delenv("OPENGOODS_REDIS_URL", raising=False)
assert cache.default_redis_url() is None
assert cache.bump_cache_epoch() is False
def test_bump_fails_open_when_unreachable(monkeypatch):
# An unroutable URL must not raise; the run still succeeds.
monkeypatch.setenv("OPENGOODS_REDIS_URL", "redis://127.0.0.1:1/0")
assert cache.bump_cache_epoch() is False
def test_bump_increments_epoch(monkeypatch):
calls = {}
class FakeClient:
def incr(self, key):
calls["key"] = key
return 7
def close(self):
calls["closed"] = True
class FakeRedis:
@staticmethod
def from_url(url, **kwargs):
calls["url"] = url
return FakeClient()
monkeypatch.setitem(sys.modules, "redis", type("M", (), {"Redis": FakeRedis}))
assert cache.bump_cache_epoch("redis://example:6379/0") is True
assert calls["key"] == "og:cache:epoch"
assert calls["url"] == "redis://example:6379/0"
assert calls["closed"] is True
+9
View File
@@ -0,0 +1,9 @@
-- Detach any products from the seeded 3C categories, then remove them.
UPDATE product SET category_id = NULL
WHERE category_id IN (SELECT id FROM category WHERE path <@ 'electronics');
DELETE FROM category WHERE path <@ 'electronics';
DROP TABLE IF EXISTS kind_field;
ALTER TABLE category DROP COLUMN IF EXISTS archive_kind;
+64
View File
@@ -0,0 +1,64 @@
-- Generic, extensible "archive kind" (档案模式) framework.
-- Instead of a dedicated detail table per domain (food/electronics/drug/...),
-- each category belongs to an archive_kind, and a metadata table (kind_field)
-- describes the editable spec fields for that kind. Non-food spec values live
-- in the existing product.attributes JSONB. Adding a new domain later (drug,
-- machinery, ...) is just: seed kind_field rows + a category subtree.
ALTER TABLE category ADD COLUMN archive_kind VARCHAR(24) NOT NULL DEFAULT 'generic';
-- The existing seeded tree is the food domain.
UPDATE category SET archive_kind = 'food' WHERE path <@ 'food';
-- Field template per archive kind: drives the dynamic admin form and the
-- kind-aware completeness/qualified computation.
CREATE TABLE kind_field (
kind VARCHAR(24) NOT NULL,
field_key VARCHAR(64) NOT NULL,
group_label TEXT NOT NULL DEFAULT '',
label_zh TEXT NOT NULL,
field_type VARCHAR(16) NOT NULL DEFAULT 'text',
unit TEXT,
options TEXT[] NOT NULL DEFAULT '{}',
placeholder TEXT,
sort_order INT NOT NULL DEFAULT 0,
qualified BOOLEAN NOT NULL DEFAULT false,
PRIMARY KEY (kind, field_key),
CONSTRAINT kind_field_type_chk CHECK (field_type IN ('text','number','textarea','select','list'))
);
-- Seed the electronics (3C) field template.
INSERT INTO kind_field (kind, field_key, group_label, label_zh, field_type, unit, sort_order, qualified) VALUES
('electronics','model_number', '基础规格','型号', 'text', NULL, 10, true),
('electronics','ccc_cert', '基础规格','3C认证号(CCC)','text', NULL, 20, true),
('electronics','color', '基础规格','颜色', 'text', NULL, 30, false),
('electronics','release_year', '基础规格','发布年份', 'number', NULL, 40, false),
('electronics','warranty_months','基础规格','保修期', 'number', '', 50, false),
('electronics','dimensions', '外形','尺寸(长x宽x高)', 'text', 'mm', 60, false),
('electronics','weight', '外形','重量', 'number', 'g', 70, false),
('electronics','power', '外形','电源/功率', 'text', NULL, 80, false),
('electronics','os', '关键参数','操作系统', 'text', NULL, 90, false),
('electronics','cpu', '关键参数','处理器', 'text', NULL, 100, false),
('electronics','ram', '关键参数','内存', 'text', NULL, 110, false),
('electronics','storage', '关键参数','存储', 'text', NULL, 120, false),
('electronics','screen_size', '关键参数','屏幕尺寸', 'text', NULL, 130, true),
('electronics','battery', '关键参数','电池容量', 'text', NULL, 140, false),
('electronics','ports', '关键参数','接口', 'text', NULL, 150, false);
-- Seed the 3C category tree.
INSERT INTO category (name_zh, name_en, parent_id, path, level, archive_kind)
VALUES ('电子数码', 'Electronics', NULL, 'electronics', 0, 'electronics');
INSERT INTO category (name_zh, name_en, parent_id, path, level, archive_kind)
SELECT v.name_zh, v.name_en, c.id, v.path::ltree, 1, 'electronics'
FROM (VALUES
('手机', 'Smartphone', 'electronics.phone'),
('笔记本电脑', 'Laptop', 'electronics.laptop'),
('平板电脑', 'Tablet', 'electronics.tablet'),
('智能手表', 'Smartwatch', 'electronics.watch'),
('耳机', 'Headphone', 'electronics.headphone'),
('相机', 'Camera', 'electronics.camera'),
('电视', 'TV', 'electronics.tv'),
('家用电器', 'Home appliance', 'electronics.appliance')
) AS v(name_zh, name_en, path)
JOIN category c ON c.path = 'electronics';
+10
View File
@@ -0,0 +1,10 @@
DROP TABLE IF EXISTS app_user;
-- Demote any self-registered keys before restoring the narrower tier check.
UPDATE api_key SET tier = 'free' WHERE tier = 'registered';
ALTER TABLE api_key DROP CONSTRAINT IF EXISTS api_key_tier_chk;
ALTER TABLE api_key ADD CONSTRAINT api_key_tier_chk
CHECK (tier IN ('free', 'partner', 'internal'));
ALTER TABLE api_key DROP COLUMN IF EXISTS quota_total;
+27
View File
@@ -0,0 +1,27 @@
-- Cumulative usage quotas + self-service user registration for the public API.
--
-- The free anonymous tier is capped at a lifetime number of calls (enforced in
-- Redis, keyed by client IP). To keep calling beyond the free quota a caller
-- registers an account and self-issues an API key with a higher quota.
-- quota_total = 0 means unlimited.
--
-- Registration is the one place the public server writes to PostgreSQL (it
-- inserts an app_user and its api_key); every other public route stays
-- read-only. Request counting still lives entirely in Redis.
ALTER TABLE api_key ADD COLUMN IF NOT EXISTS quota_total BIGINT NOT NULL DEFAULT 0;
ALTER TABLE api_key DROP CONSTRAINT IF EXISTS api_key_tier_chk;
ALTER TABLE api_key ADD CONSTRAINT api_key_tier_chk
CHECK (tier IN ('free', 'registered', 'partner', 'internal'));
CREATE TABLE IF NOT EXISTS app_user (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT NOT NULL,
password_hash TEXT NOT NULL,
api_key_id UUID REFERENCES api_key (id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Case-insensitive uniqueness so each email registers at most once.
CREATE UNIQUE INDEX IF NOT EXISTS idx_app_user_email ON app_user (lower(email));
+2
View File
@@ -0,0 +1,2 @@
DROP INDEX IF EXISTS idx_product_active_category;
DROP INDEX IF EXISTS idx_product_active_quality;
+15
View File
@@ -0,0 +1,15 @@
-- 检索优化(可扩展性路线图 阶段0):为最常见的"浏览"路径补部分/复合索引。
-- 搜索查询恒带 WHERE status = 'active';无关键词时按 quality_score DESC, name 排序。
-- 现有索引无法同时满足"过滤 active + 按 quality_score/name 排序",深翻页时需要对全部
-- active 行排序。下面的部分复合索引让规划器直接走索引顺序扫描,省掉排序、加速深翻页与
-- count(*)。索引只覆盖 active 行,体积更小。
-- 默认浏览(无关键词、无品类):ORDER BY quality_score DESC, name
CREATE INDEX IF NOT EXISTS idx_product_active_quality
ON product (quality_score DESC, name)
WHERE status = 'active';
-- 品类内浏览:先按 category_id 收敛,再按 quality_score 排序
CREATE INDEX IF NOT EXISTS idx_product_active_category
ON product (category_id, quality_score DESC)
WHERE status = 'active';
+12 -1
View File
@@ -3,7 +3,18 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>天工商品档案公共仓</title>
<meta name="theme-color" content="#059669" />
<meta
name="description"
content="天工商品档案公共仓——公益、开放、可溯源的商品事实查询与开放 API。"
/>
<title>天工 · 商品档案公共仓</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap"
rel="stylesheet"
/>
</head>
<body>
<div id="root"></div>
@@ -0,0 +1 @@
9ef4c7d0ea804c11bc637f4e82198e98af908894
+86 -44
View File
@@ -1,65 +1,86 @@
import { useState } from "react";
import { Boxes, Search, PlusCircle, Code2 } from "lucide-react";
import { useEffect, useState } from "react";
import { Boxes, Search, PlusCircle, Code2, KeyRound, Headset } from "lucide-react";
import Home from "./components/Home";
import ProductView from "./components/ProductView";
import Contribute from "./components/Contribute";
import ApiDocs from "./components/ApiDocs";
import Account from "./components/Account";
import Contact from "./components/Contact";
import { api } from "./api";
type View =
| { name: "home" }
| { name: "product"; id: string }
| { name: "contribute" }
| { name: "api" };
| { name: "api" }
| { name: "account" }
| { name: "contact" };
const NAV: { key: View["name"]; label: string; icon: typeof Search }[] = [
{ key: "home", label: "检索", icon: Search },
{ key: "contribute", label: "贡献档案", icon: PlusCircle },
{ key: "api", label: "API", icon: Code2 },
{ key: "account", label: "API 密钥", icon: KeyRound },
{ key: "contact", label: "联系我们", icon: Headset },
];
export default function App() {
const [view, setView] = useState<View>({ name: "home" });
const [qualified, setQualified] = useState<number | null>(null);
useEffect(() => {
api
.stats()
.then((s) => setQualified(s.qualified))
.catch(() => setQualified(null));
}, []);
return (
<div className="min-h-full flex flex-col">
<header className="bg-white border-b">
<div className="max-w-5xl mx-auto px-4 h-14 flex items-center justify-between">
<header className="sticky top-0 z-30 border-b border-gray-200/70 bg-white/80 backdrop-blur-md">
<div className="max-w-5xl mx-auto px-4 h-16 flex items-center justify-between gap-4">
<button
className="flex items-center gap-2 font-semibold text-gray-800"
className="flex items-center gap-2.5 font-semibold text-gray-800 group"
onClick={() => setView({ name: "home" })}
>
<Boxes className="w-6 h-6 text-emerald-600" />
<span className="text-gray-400 font-normal text-sm"></span>
<span className="grid h-9 w-9 place-items-center rounded-xl bg-gradient-to-br from-brand-500 to-brand-700 text-white shadow-glow transition group-hover:scale-105">
<Boxes className="w-5 h-5" />
</span>
<span className="flex items-baseline gap-1.5">
<span className="text-lg tracking-tight"></span>
<span className="hidden sm:inline text-gray-400 font-normal text-xs">
</span>
</span>
</button>
<nav className="flex items-center gap-1 text-sm">
<button
className={`px-3 py-1.5 rounded-md flex items-center gap-1.5 ${
view.name === "home" ? "bg-emerald-50 text-emerald-700" : "text-gray-600 hover:bg-gray-100"
}`}
onClick={() => setView({ name: "home" })}
>
<Search className="w-4 h-4" />
</button>
<button
className={`px-3 py-1.5 rounded-md flex items-center gap-1.5 ${
view.name === "contribute" ? "bg-emerald-50 text-emerald-700" : "text-gray-600 hover:bg-gray-100"
}`}
onClick={() => setView({ name: "contribute" })}
>
<PlusCircle className="w-4 h-4" />
</button>
<button
className={`px-3 py-1.5 rounded-md flex items-center gap-1.5 ${
view.name === "api" ? "bg-emerald-50 text-emerald-700" : "text-gray-600 hover:bg-gray-100"
}`}
onClick={() => setView({ name: "api" })}
>
<Code2 className="w-4 h-4" /> API
</button>
<nav className="flex items-center gap-0.5 text-sm">
{NAV.map(({ key, label, icon: Icon }) => {
const active =
view.name === key || (key === "home" && view.name === "product");
return (
<button
key={key}
className={`px-3 py-1.5 rounded-lg flex items-center gap-1.5 font-medium transition ${
active
? "bg-brand-50 text-brand-700"
: "text-gray-600 hover:bg-gray-100"
}`}
onClick={() => setView({ name: key } as View)}
>
<Icon className="w-4 h-4" />
<span className="hidden sm:inline">{label}</span>
</button>
);
})}
</nav>
</div>
</header>
<main className="flex-1 max-w-5xl w-full mx-auto px-4 py-6">
<main className="flex-1 max-w-5xl w-full mx-auto px-4 py-8">
{view.name === "home" && (
<Home
onOpen={(id) => setView({ name: "product", id })}
onContribute={() => setView({ name: "contribute" })}
onApi={() => setView({ name: "api" })}
/>
)}
{view.name === "product" && (
@@ -68,17 +89,38 @@ export default function App() {
{view.name === "contribute" && (
<Contribute onDone={() => setView({ name: "home" })} />
)}
{view.name === "api" && <ApiDocs />}
{view.name === "api" && <ApiDocs onRegister={() => setView({ name: "account" })} />}
{view.name === "account" && <Account />}
{view.name === "contact" && <Contact />}
</main>
<footer className="border-t bg-white">
<div className="max-w-5xl mx-auto px-4 py-4 text-xs text-gray-400 leading-relaxed">
/
稿
<button onClick={() => setView({ name: "api" })} className="ml-1 text-emerald-600 hover:underline">
API
</button>
<div className="mt-2">
<footer className="border-t border-gray-200/70 bg-white/60">
<div className="max-w-5xl mx-auto px-4 py-6 text-xs text-gray-400 leading-relaxed text-center">
{qualified != null && (
<div className="mb-3 inline-flex items-center gap-1.5 rounded-full border border-brand-100 bg-brand-50 px-3 py-1 text-gray-500">
<span className="font-semibold text-brand-600">
{qualified.toLocaleString()}
</span>
</div>
)}
<p className="max-w-2xl mx-auto">
<button
onClick={() => setView({ name: "api" })}
className="ml-1 text-brand-600 font-medium hover:underline"
>
API
</button>
<button
onClick={() => setView({ name: "contact" })}
className="ml-1 text-brand-600 font-medium hover:underline"
>
</button>
</p>
<div className="mt-3">
<a
href="https://beian.miit.gov.cn/"
target="_blank"
+39
View File
@@ -30,7 +30,31 @@ export interface SearchFilters {
country?: string;
}
export interface Stats {
total: number;
qualified: number;
min_score: number;
}
export interface KeyResponse {
email: string;
api_key: string;
key_prefix: string;
rate_limit_per_min: number;
quota_total: number;
}
export interface AccountInfo {
email: string;
key_prefix: string;
rate_limit_per_min: number;
quota_total: number;
quota_used: number;
quota_remaining: number;
}
export const api = {
stats: () => req<Stats>(`/api/v1/stats`),
search: (q: string, page = 1, size = 20, filters: SearchFilters = {}) => {
const params = new URLSearchParams({
q,
@@ -48,4 +72,19 @@ export const api = {
method: "POST",
body: JSON.stringify(input),
}),
register: (email: string, password: string) =>
req<KeyResponse>(`/api/v1/register`, {
method: "POST",
body: JSON.stringify({ email, password }),
}),
account: (email: string, password: string) =>
req<AccountInfo>(`/api/v1/account`, {
method: "POST",
body: JSON.stringify({ email, password }),
}),
regenerate: (email: string, password: string) =>
req<KeyResponse>(`/api/v1/account/regenerate`, {
method: "POST",
body: JSON.stringify({ email, password }),
}),
};
+192
View File
@@ -0,0 +1,192 @@
import { useState } from "react";
import { Check, Copy, KeyRound, AlertTriangle } from "lucide-react";
import { api, type AccountInfo, type KeyResponse } from "../api";
function KeyReveal({ data }: { data: KeyResponse }) {
const [copied, setCopied] = useState(false);
return (
<div className="mt-4 rounded-lg border border-brand-100 bg-brand-50 p-4">
<div className="flex items-start gap-2 text-amber-700 text-sm">
<AlertTriangle className="w-4 h-4 mt-0.5 shrink-0" />
<span></span>
</div>
<div className="mt-3 flex items-center gap-2">
<code className="flex-1 break-all bg-white border rounded-md px-3 py-2 text-sm font-mono text-gray-800">
{data.api_key}
</code>
<button
onClick={async () => {
try {
await navigator.clipboard.writeText(data.api_key);
setCopied(true);
setTimeout(() => setCopied(false), 1200);
} catch {
/* clipboard unavailable */
}
}}
className="text-gray-400 hover:text-gray-600 shrink-0"
title="复制"
>
{copied ? <Check className="w-5 h-5 text-brand-600" /> : <Copy className="w-5 h-5" />}
</button>
</div>
<div className="mt-3 text-sm text-gray-600">
<strong>{data.rate_limit_per_min}</strong> · {" "}
<strong>{data.quota_total.toLocaleString()}</strong>
</div>
<div className="mt-2 text-xs text-gray-500">
<code className="font-mono">X-API-Key: {data.api_key.slice(0, 12)}</code>
</div>
</div>
);
}
export default function Account() {
const [mode, setMode] = useState<"register" | "manage">("register");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [issued, setIssued] = useState<KeyResponse | null>(null);
const [info, setInfo] = useState<AccountInfo | null>(null);
const reset = () => {
setError(null);
setIssued(null);
setInfo(null);
};
async function submit(e: React.FormEvent) {
e.preventDefault();
reset();
if (password.length < 8) {
setError("密码至少需要 8 位");
return;
}
setLoading(true);
try {
if (mode === "register") {
setIssued(await api.register(email, password));
} else {
setInfo(await api.account(email, password));
}
} catch (err) {
setError(err instanceof Error ? err.message : "操作失败");
} finally {
setLoading(false);
}
}
async function regenerate() {
reset();
setLoading(true);
try {
setIssued(await api.regenerate(email, password));
} catch (err) {
setError(err instanceof Error ? err.message : "操作失败");
} finally {
setLoading(false);
}
}
return (
<div className="max-w-xl mx-auto space-y-5">
<div className="card p-5">
<h1 className="flex items-center gap-2 text-2xl font-bold text-gray-800">
<KeyRound className="w-6 h-6 text-brand-600" /> API
</h1>
<p className="mt-2 text-sm text-gray-600 leading-relaxed">
IP <strong>1000</strong> API
</p>
<div className="mt-4 inline-flex rounded-md border bg-gray-50 p-0.5 text-sm">
<button
className={`px-4 py-1.5 rounded ${
mode === "register" ? "bg-white shadow-sm text-brand-700" : "text-gray-500"
}`}
onClick={() => {
setMode("register");
reset();
}}
>
</button>
<button
className={`px-4 py-1.5 rounded ${
mode === "manage" ? "bg-white shadow-sm text-brand-700" : "text-gray-500"
}`}
onClick={() => {
setMode("manage");
reset();
}}
>
/
</button>
</div>
<form onSubmit={submit} className="mt-4 space-y-3">
<div>
<label className="block text-sm text-gray-600 mb-1"></label>
<input
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
className="w-full border rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-500"
/>
</div>
<div>
<label className="block text-sm text-gray-600 mb-1"> 8 </label>
<input
type="password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
className="w-full border rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-500"
/>
</div>
{error && <div className="text-sm text-red-600">{error}</div>}
<button
type="submit"
disabled={loading}
className="w-full bg-brand-600 text-white rounded-md py-2 text-sm font-medium hover:bg-brand-700 disabled:opacity-50"
>
{loading ? "处理中…" : mode === "register" ? "注册并领取密钥" : "查询账号"}
</button>
</form>
{issued && <KeyReveal data={issued} />}
{info && (
<div className="mt-4 rounded-lg border bg-gray-50 p-4 text-sm text-gray-700 space-y-1.5">
<div>
<span className="font-medium">{info.email}</span>
</div>
<div>
<code className="font-mono">{info.key_prefix}</code>
</div>
<div>
<strong>{info.rate_limit_per_min}</strong>
</div>
<div>
<strong>{info.quota_used.toLocaleString()}</strong> /{" "}
{info.quota_total.toLocaleString()} {" "}
<strong className="text-brand-600">{info.quota_remaining.toLocaleString()}</strong>
</div>
<button
onClick={regenerate}
disabled={loading}
className="mt-2 text-brand-600 hover:underline disabled:opacity-50"
>
</button>
</div>
)}
</div>
</div>
);
}
+99 -14
View File
@@ -20,7 +20,7 @@ function CopyBtn({ text }: { text: string }) {
className="text-gray-400 hover:text-gray-600"
title="复制"
>
{done ? <Check className="w-4 h-4 text-emerald-600" /> : <Copy className="w-4 h-4" />}
{done ? <Check className="w-4 h-4 text-brand-600" /> : <Copy className="w-4 h-4" />}
</button>
);
}
@@ -39,7 +39,7 @@ function Code({ children }: { children: string }) {
}
function Method({ m }: { m: string }) {
const color = m === "GET" ? "bg-sky-100 text-sky-700" : "bg-emerald-100 text-emerald-700";
const color = m === "GET" ? "bg-sky-100 text-sky-700" : "bg-brand-100 text-brand-700";
return <span className={`text-xs font-mono font-semibold rounded px-1.5 py-0.5 ${color}`}>{m}</span>;
}
@@ -63,7 +63,7 @@ function Endpoint({
response: string;
}) {
return (
<div className="bg-white border rounded-lg p-5">
<div className="card p-5">
<div className="flex items-center gap-2 flex-wrap">
<Method m={method} />
<code className="text-sm text-gray-800 font-mono break-all">{path}</code>
@@ -102,10 +102,10 @@ function Endpoint({
);
}
export default function ApiDocs() {
export default function ApiDocs({ onRegister }: { onRegister?: () => void }) {
return (
<div className="space-y-5">
<div className="bg-white border rounded-lg p-5">
<div className="card p-5">
<h1 className="text-2xl font-bold text-gray-800">API </h1>
<p className="mt-2 text-gray-600 text-sm leading-relaxed">
<strong></strong> REST API
@@ -117,7 +117,11 @@ export default function ApiDocs() {
<code className="font-mono bg-gray-100 rounded px-1.5 py-0.5">{BASE}</code>
</div>
<ul className="mt-2 list-disc pl-5 text-gray-600 space-y-1">
<li> API Key / Token GET Key </li>
<li>
API Key / Token GET IP <strong>1000</strong>
<button onClick={onRegister} className="text-brand-600 hover:underline"></button>
</li>
<li>
<code className="font-mono">page</code> 1
<code className="font-mono">size</code> 20 100
@@ -131,11 +135,14 @@ export default function ApiDocs() {
</div>
</div>
<div className="bg-white border rounded-lg p-5">
<h2 className="text-lg font-semibold text-gray-800"></h2>
<div className="card p-5">
<h2 className="text-lg font-semibold text-gray-800"></h2>
<p className="mt-2 text-gray-600 text-sm leading-relaxed">
API <strong></strong> IP
API Key
API <strong></strong> IP
<strong> 1000 </strong>
<code className="font-mono">403</code> <code className="font-mono">quota_exhausted</code>
<button onClick={onRegister} className="text-brand-600 hover:underline"></button>
API Key
</p>
<div className="mt-3">
<Code>{`# 二选一
@@ -143,7 +150,7 @@ curl -H "X-API-Key: og_live_xxxxxxxx" ${BASE}/products/search?q=牛奶
curl -H "Authorization: Bearer og_live_xxxxxxxx" ${BASE}/products/search?q=牛奶`}</Code>
</div>
<p className="mt-3 text-gray-600 text-sm leading-relaxed">
<strong></strong>便
<strong></strong><strong></strong>便
</p>
<table className="mt-3 w-full text-sm">
<thead className="text-gray-400 text-left">
@@ -169,12 +176,26 @@ curl -H "Authorization: Bearer og_live_xxxxxxxx" ${BASE}/products/search?q=牛
<td className="pr-4 py-0.5 font-mono text-gray-700">Retry-After</td>
<td className="py-0.5 text-gray-600"></td>
</tr>
<tr>
<td className="pr-4 py-0.5 font-mono text-gray-700">X-Quota-Limit</td>
<td className="py-0.5 text-gray-600"></td>
</tr>
<tr>
<td className="pr-4 py-0.5 font-mono text-gray-700">X-Quota-Used</td>
<td className="py-0.5 text-gray-600">使</td>
</tr>
<tr>
<td className="pr-4 py-0.5 font-mono text-gray-700">X-Quota-Remaining</td>
<td className="py-0.5 text-gray-600"></td>
</tr>
</tbody>
</table>
<p className="mt-3 text-gray-600 text-sm leading-relaxed">
<code className="font-mono">429 Too Many Requests</code>
<code className="font-mono">rate_limited</code> Key
<code className="font-mono">401</code> <code className="font-mono">invalid_api_key</code>
<code className="font-mono">429 Too Many Requests</code>
<code className="font-mono">rate_limited</code>
<code className="font-mono">403</code> <code className="font-mono">quota_exhausted</code>
Key <code className="font-mono">401</code>
<code className="font-mono">invalid_api_key</code>
</p>
</div>
@@ -322,6 +343,70 @@ curl -H "Authorization: Bearer og_live_xxxxxxxx" ${BASE}/products/search?q=牛
}`}
/>
<Endpoint
method="POST"
path="/api/v1/register"
title="注册账号并领取 API 密钥"
desc="用邮箱 + 密码(至少 8 位)注册,自助领取一枚更高配额的 API 密钥。明文密钥只在本次响应返回一次,请妥善保存。"
params={[
{ name: "email", required: true, desc: "邮箱(唯一)" },
{ name: "password", required: true, desc: "密码,至少 8 位" },
]}
example={`curl -X POST ${BASE}/register \\
-H "Content-Type: application/json" \\
-d '{"email":"you@example.com","password":"your-password"}'`}
response={`{
"email": "you@example.com",
"api_key": "og_live_xxxxxxxxxxxx",
"key_prefix": "og_live_xxxx",
"rate_limit_per_min": 300,
"quota_total": 100000
}`}
/>
<Endpoint
method="POST"
path="/api/v1/account"
title="查看账号与配额用量"
desc="用邮箱 + 密码查询当前密钥前缀、频率/累计配额上限及已用量(不返回明文密钥)。"
params={[
{ name: "email", required: true, desc: "注册邮箱" },
{ name: "password", required: true, desc: "账号密码" },
]}
example={`curl -X POST ${BASE}/account \\
-H "Content-Type: application/json" \\
-d '{"email":"you@example.com","password":"your-password"}'`}
response={`{
"email": "you@example.com",
"key_prefix": "og_live_xxxx",
"rate_limit_per_min": 300,
"quota_total": 100000,
"quota_used": 1234,
"quota_remaining": 98766
}`}
/>
<Endpoint
method="POST"
path="/api/v1/account/regenerate"
title="重置 API 密钥"
desc="吊销当前密钥并生成新密钥(累计用量会延续,不会因重置而清零)。明文新密钥只返回一次。"
params={[
{ name: "email", required: true, desc: "注册邮箱" },
{ name: "password", required: true, desc: "账号密码" },
]}
example={`curl -X POST ${BASE}/account/regenerate \\
-H "Content-Type: application/json" \\
-d '{"email":"you@example.com","password":"your-password"}'`}
response={`{
"email": "you@example.com",
"api_key": "og_live_yyyyyyyyyyyy",
"key_prefix": "og_live_yyyy",
"rate_limit_per_min": 300,
"quota_total": 100000
}`}
/>
<div className="text-xs text-gray-400 leading-relaxed">
/
OpenFoodFacts ODbL
+113
View File
@@ -0,0 +1,113 @@
import { useState } from "react";
import { Phone, Mail, Globe, MessageSquare, Copy, Check, Headset } from "lucide-react";
type Channel = {
key: string;
icon: typeof Phone;
label: string;
value: string;
href?: string;
copy: string;
};
const CHANNELS: Channel[] = [
{
key: "phone",
icon: Phone,
label: "电话",
value: "188 6595 7520",
href: "tel:18865957520",
copy: "18865957520",
},
{
key: "email",
icon: Mail,
label: "邮箱",
value: "1115084741@qq.com",
href: "mailto:1115084741@qq.com",
copy: "1115084741@qq.com",
},
{
key: "site",
icon: Globe,
label: "官网",
value: "www.wenyaoyu.com",
href: "https://www.wenyaoyu.com",
copy: "https://www.wenyaoyu.com",
},
{
key: "wechat",
icon: MessageSquare,
label: "微信",
value: "s-b-m-y",
copy: "s-b-m-y",
},
];
export default function Contact() {
const [copied, setCopied] = useState<string | null>(null);
async function copy(channel: Channel) {
try {
await navigator.clipboard.writeText(channel.copy);
setCopied(channel.key);
setTimeout(() => setCopied((k) => (k === channel.key ? null : k)), 1500);
} catch {
/* clipboard unavailable */
}
}
return (
<div className="max-w-3xl mx-auto animate-fade-up">
<div className="text-center">
<span className="mx-auto grid h-14 w-14 place-items-center rounded-2xl bg-gradient-to-br from-brand-500 to-brand-700 text-white shadow-glow">
<Headset className="w-7 h-7" />
</span>
<h1 className="mt-4 text-2xl font-semibold tracking-tight text-gray-900"></h1>
<p className="mx-auto mt-2 max-w-xl text-sm text-gray-500">
</p>
</div>
<div className="mt-8 grid grid-cols-1 sm:grid-cols-2 gap-4">
{CHANNELS.map((c) => {
const Icon = c.icon;
return (
<div key={c.key} className="card p-5 flex items-center gap-4">
<span className="grid h-11 w-11 shrink-0 place-items-center rounded-xl bg-brand-50 text-brand-600">
<Icon className="w-5 h-5" />
</span>
<div className="min-w-0 flex-1">
<div className="text-xs font-medium text-gray-400">{c.label}</div>
{c.href ? (
<a
href={c.href}
target={c.key === "site" ? "_blank" : undefined}
rel={c.key === "site" ? "noreferrer" : undefined}
className="block truncate font-medium text-gray-800 hover:text-brand-600 hover:underline"
>
{c.value}
</a>
) : (
<div className="truncate font-medium text-gray-800">{c.value}</div>
)}
</div>
<button
type="button"
onClick={() => copy(c)}
title="复制"
className="shrink-0 grid h-9 w-9 place-items-center rounded-lg border border-gray-200 text-gray-400 transition hover:bg-gray-50 hover:text-brand-600"
>
{copied === c.key ? (
<Check className="w-4 h-4 text-brand-600" />
) : (
<Copy className="w-4 h-4" />
)}
</button>
</div>
);
})}
</div>
</div>
);
}
+16 -22
View File
@@ -89,38 +89,36 @@ export default function Contribute({ onDone }: { onDone: () => void }) {
if (done) {
return (
<div className="max-w-xl mx-auto text-center py-16">
<CheckCircle2 className="w-14 h-14 text-emerald-500 mx-auto" />
<div className="max-w-xl mx-auto text-center py-16 animate-fade-up">
<span className="mx-auto grid h-16 w-16 place-items-center rounded-2xl bg-brand-50">
<CheckCircle2 className="w-9 h-9 text-brand-600" />
</span>
<h1 className="mt-4 text-xl font-semibold text-gray-800"></h1>
<p className="mt-2 text-gray-500">
</p>
<button
onClick={onDone}
className="mt-6 px-5 py-2 rounded-lg bg-emerald-600 text-white hover:bg-emerald-700"
>
<button onClick={onDone} className="btn-primary mt-6">
</button>
</div>
);
}
const input =
"w-full border rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-400";
const label = "block text-xs text-gray-500 mb-1";
const input = "input";
const label = "block text-xs font-medium text-gray-500 mb-1";
return (
<form onSubmit={submit} className="max-w-3xl mx-auto">
<h1 className="text-xl font-semibold text-gray-800"></h1>
<form onSubmit={submit} className="max-w-3xl mx-auto animate-fade-up">
<h1 className="text-2xl font-semibold tracking-tight text-gray-900"></h1>
<p className="mt-1 text-sm text-gray-500">
<b></b> *
</p>
{error && (
<div className="mt-4 bg-red-50 text-red-700 text-sm rounded-md px-4 py-2">{error}</div>
<div className="mt-4 rounded-xl bg-red-50 px-4 py-2.5 text-sm text-red-700">{error}</div>
)}
<div className="bg-white border rounded-lg p-5 mt-4">
<div className="card p-5 mt-4">
<h2 className="font-medium text-gray-700 mb-3"></h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="sm:col-span-2">
@@ -167,7 +165,7 @@ export default function Contribute({ onDone }: { onDone: () => void }) {
</div>
</div>
<div className="bg-white border rounded-lg p-5 mt-4">
<div className="card p-5 mt-4">
<h2 className="font-medium text-gray-700 mb-3"></h2>
<label className={label}></label>
<textarea
@@ -201,7 +199,7 @@ export default function Contribute({ onDone }: { onDone: () => void }) {
</div>
</div>
<div className="bg-white border rounded-lg p-5 mt-4">
<div className="card p-5 mt-4">
<h2 className="font-medium text-gray-700 mb-3"> URL</h2>
{images.length > 0 && (
<ul className="mb-3 space-y-1">
@@ -234,14 +232,14 @@ export default function Contribute({ onDone }: { onDone: () => void }) {
setImageURL("");
}
}}
className="shrink-0 px-3 rounded-md border text-sm flex items-center gap-1 hover:bg-gray-50"
className="shrink-0 px-3 rounded-xl border border-gray-200 text-sm flex items-center gap-1 hover:bg-gray-50"
>
<PlusCircle className="w-4 h-4" />
</button>
</div>
</div>
<div className="bg-white border rounded-lg p-5 mt-4">
<div className="card p-5 mt-4">
<h2 className="font-medium text-gray-700 mb-3"></h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
@@ -260,11 +258,7 @@ export default function Contribute({ onDone }: { onDone: () => void }) {
</div>
<div className="mt-5 flex items-center gap-3">
<button
type="submit"
disabled={submitting}
className="px-6 py-2.5 rounded-lg bg-emerald-600 text-white font-medium hover:bg-emerald-700 disabled:opacity-60"
>
<button type="submit" disabled={submitting} className="btn-primary px-6">
{submitting ? "提交中…" : "提交审核"}
</button>
<button type="button" onClick={onDone} className="text-sm text-gray-500 hover:underline">
+102 -72
View File
@@ -1,35 +1,50 @@
import { useState } from "react";
import { Search, PlusCircle, Code2 } from "lucide-react";
import { Search, PlusCircle, Code2, ScanBarcode, ShieldCheck, ArrowRight } from "lucide-react";
import { api } from "../api";
import type { ProductSummary } from "../types";
const EXAMPLES = ["可乐", "Nutella", "牛奶", "5449000000996"];
const FEATURES = [
{
icon: Search,
title: "客观可查",
desc: "按名称或条码检索商品的成分、营养、规格等官方事实。",
},
{
icon: ShieldCheck,
title: "可溯源",
desc: "每条资料标注数据来源与质量分,公开透明、人人可核。",
},
{
icon: Code2,
title: "开放 API",
desc: "免鉴权只读 REST 接口,开发者可直接接入商品档案。",
},
];
export default function Home({
onOpen,
onContribute,
onApi,
}: {
onOpen: (id: string) => void;
onContribute: () => void;
onApi: () => void;
}) {
const [q, setQ] = useState("");
const [brand, setBrand] = useState("");
const [country, setCountry] = useState("");
const [items, setItems] = useState<ProductSummary[]>([]);
const [total, setTotal] = useState(0);
const [searched, setSearched] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
async function run(e?: React.FormEvent) {
async function run(term?: string, e?: React.FormEvent) {
e?.preventDefault();
const keyword = (term ?? q).trim();
if (term !== undefined) setQ(term);
setLoading(true);
setError("");
try {
const res = await api.search(q.trim(), 1, 30, {
brand: brand.trim() || undefined,
country: country.trim() || undefined,
});
const res = await api.search(keyword, 1, 30);
setItems(res.items);
setTotal(res.total);
setSearched(true);
@@ -41,101 +56,116 @@ export default function Home({
}
return (
<div>
<div className="text-center py-10">
<h1 className="text-3xl font-bold text-gray-800"></h1>
<p className="mt-2 text-gray-500">
</p>
<form onSubmit={run} className="mt-6 max-w-2xl mx-auto flex gap-2">
<div className="flex-1 flex items-center gap-2 bg-white border rounded-lg px-3 shadow-sm focus-within:ring-2 focus-within:ring-emerald-400">
<Search className="w-5 h-5 text-gray-400" />
<input
autoFocus
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="例如:可乐、Nutella、5449000000996"
className="flex-1 py-3 outline-none bg-transparent"
/>
<div className="animate-fade-up">
<section className="relative overflow-hidden rounded-3xl border border-gray-100 bg-gradient-to-b from-white to-brand-50/40 px-6 py-14 text-center shadow-card">
<div
aria-hidden
className="pointer-events-none absolute -top-24 left-1/2 h-72 w-[42rem] -translate-x-1/2 rounded-full bg-brand-200/30 blur-3xl"
/>
<div className="relative">
<span className="inline-flex items-center gap-1.5 rounded-full border border-brand-200 bg-white/70 px-3 py-1 text-xs font-medium text-brand-700">
<ShieldCheck className="w-3.5 h-3.5" /> · ·
</span>
<h1 className="mt-5 text-4xl sm:text-5xl font-extrabold tracking-tight text-gray-900">
<span className="bg-gradient-to-r from-brand-600 to-emerald-500 bg-clip-text text-transparent"></span>
</h1>
<p className="mx-auto mt-3 max-w-xl text-gray-500">
</p>
<form onSubmit={(e) => run(undefined, e)} className="mx-auto mt-7 flex max-w-2xl gap-2">
<div className="flex flex-1 items-center gap-2 rounded-xl border border-gray-200 bg-white px-3.5 shadow-sm transition focus-within:border-brand-400 focus-within:ring-4 focus-within:ring-brand-500/10">
<Search className="h-5 w-5 shrink-0 text-gray-400" />
<input
autoFocus
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="例如:可乐、Nutella、5449000000996"
className="flex-1 bg-transparent py-3.5 outline-none"
/>
</div>
<button type="submit" disabled={loading} className="btn-primary px-7">
{loading ? "检索中…" : "检索"}
</button>
</form>
<div className="mt-4 flex flex-wrap items-center justify-center gap-2">
<span className="text-xs text-gray-400"></span>
{EXAMPLES.map((ex) => (
<button key={ex} type="button" className="chip" onClick={() => run(ex)}>
<ScanBarcode className="w-3 h-3" /> {ex}
</button>
))}
</div>
<button
type="submit"
disabled={loading}
className="px-6 rounded-lg bg-emerald-600 text-white font-medium hover:bg-emerald-700 disabled:opacity-60"
>
{loading ? "检索中…" : "检索"}
</button>
</form>
<div className="mt-3 max-w-2xl mx-auto flex flex-wrap items-center justify-center gap-2 text-sm">
<input
value={brand}
onChange={(e) => setBrand(e.target.value)}
placeholder="按品牌筛选(如 Ferrero"
className="flex-1 min-w-[14rem] bg-white border rounded-lg px-3 py-2 outline-none focus:ring-2 focus:ring-emerald-300"
/>
<input
value={country}
onChange={(e) => setCountry(e.target.value)}
placeholder="按产地筛选(如 China"
className="flex-1 min-w-[14rem] bg-white border rounded-lg px-3 py-2 outline-none focus:ring-2 focus:ring-emerald-300"
/>
</div>
<button
onClick={onApi}
className="mt-4 inline-flex items-center gap-1.5 text-sm text-emerald-700 hover:underline"
>
<Code2 className="w-4 h-4" /> API
</button>
</div>
</section>
{error && (
<div className="max-w-2xl mx-auto bg-red-50 text-red-700 text-sm rounded-md px-4 py-2">
<div className="mx-auto mt-6 max-w-2xl rounded-xl bg-red-50 px-4 py-2.5 text-sm text-red-700">
{error}
</div>
)}
{searched && (
<div className="mt-2">
<div className="text-sm text-gray-500 mb-2">
{total} {q ? `(关键词:${q}` : ""}
{searched ? (
<div className="mt-8">
<div className="mb-3 text-sm text-gray-500">
<span className="font-semibold text-gray-700">{total}</span>
{q ? `(关键词:${q}` : ""}
</div>
{items.length === 0 ? (
<div className="bg-white border rounded-lg p-8 text-center text-gray-500">
<div className="card p-10 text-center text-gray-500">
<p></p>
<button
onClick={onContribute}
className="mt-3 inline-flex items-center gap-1.5 text-emerald-700 hover:underline"
className="mt-3 inline-flex items-center gap-1.5 font-medium text-brand-700 hover:underline"
>
<PlusCircle className="w-4 h-4" />
</button>
</div>
) : (
<ul className="bg-white border rounded-lg divide-y">
<ul className="card divide-y divide-gray-100 overflow-hidden">
{items.map((p) => (
<li key={p.id}>
<button
onClick={() => onOpen(p.id)}
className="w-full text-left px-4 py-3 hover:bg-gray-50 flex items-center justify-between gap-4"
className="group flex w-full items-center justify-between gap-4 px-4 py-3.5 text-left transition hover:bg-brand-50/50"
>
<div>
<div className="font-medium text-gray-800">{p.name}</div>
<div className="text-xs text-gray-500 mt-0.5">
<div className="min-w-0">
<div className="truncate font-medium text-gray-800 group-hover:text-brand-700">
{p.name}
</div>
<div className="mt-0.5 text-xs text-gray-500">
{p.brand || "未知品牌"}
{p.gtin ? ` · ${p.gtin}` : ""}
</div>
</div>
<span className="text-xs text-gray-400 text-right">
<span className="block">{p.category_path || ""}</span>
{p.country_of_origin ? (
<span className="block text-gray-400">{p.country_of_origin}</span>
) : null}
</span>
<div className="flex shrink-0 items-center gap-3">
<span className="text-right text-xs text-gray-400">
<span className="block">{p.category_path || ""}</span>
{p.country_of_origin ? (
<span className="block">{p.country_of_origin}</span>
) : null}
</span>
<ArrowRight className="h-4 w-4 text-gray-300 transition group-hover:translate-x-0.5 group-hover:text-brand-500" />
</div>
</button>
</li>
))}
</ul>
)}
</div>
) : (
<div className="mt-8 grid gap-4 sm:grid-cols-3">
{FEATURES.map(({ icon: Icon, title, desc }) => (
<div key={title} className="card p-5 transition hover:shadow-card-hover">
<span className="grid h-10 w-10 place-items-center rounded-xl bg-brand-50 text-brand-600">
<Icon className="h-5 w-5" />
</span>
<h3 className="mt-3 font-semibold text-gray-800">{title}</h3>
<p className="mt-1 text-sm leading-relaxed text-gray-500">{desc}</p>
</div>
))}
</div>
)}
</div>
);
+24 -7
View File
@@ -28,11 +28,11 @@ export default function ProductView({ id, onBack }: { id: string; onBack: () =>
<button onClick={onBack} className="text-sm text-gray-500 flex items-center gap-1 mb-4">
<ArrowLeft className="w-4 h-4" />
</button>
<div className="bg-red-50 text-red-700 text-sm rounded-md px-4 py-3">{error}</div>
<div className="rounded-xl bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div>
</div>
);
}
if (!p) return <div className="text-gray-400"></div>;
if (!p) return <div className="text-gray-400 animate-pulse"></div>;
const basisLabel: Record<string, string> = {
per_100g: "每 100g",
@@ -45,14 +45,14 @@ export default function ProductView({ id, onBack }: { id: string; onBack: () =>
return (
<div>
<button onClick={onBack} className="text-sm text-gray-500 flex items-center gap-1 mb-4">
<button onClick={onBack} className="mb-4 inline-flex items-center gap-1 text-sm text-gray-500 hover:text-gray-700">
<ArrowLeft className="w-4 h-4" />
</button>
<div className="bg-white border rounded-lg p-5">
<div className="card p-6 animate-fade-up">
<div className="flex items-start justify-between gap-4">
<h1 className="text-xl font-semibold text-gray-800">{p.name}</h1>
<span className="shrink-0 text-xs bg-emerald-50 text-emerald-700 rounded px-2 py-1">
<h1 className="text-2xl font-semibold tracking-tight text-gray-900">{p.name}</h1>
<span className="shrink-0 rounded-full border border-brand-100 bg-brand-50 px-2.5 py-1 text-xs font-medium text-brand-700">
{Math.round(p.quality_score * 100)}
</span>
</div>
@@ -106,8 +106,25 @@ export default function ProductView({ id, onBack }: { id: string; onBack: () =>
</div>
</div>
{p.specs && p.specs.length > 0 && (
<div className="card p-6 mt-4">
<h2 className="font-medium text-gray-700 mb-2"></h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 text-sm">
{p.specs.map((s) => (
<div key={s.key} className="bg-gray-50 rounded px-3 py-2">
<div className="text-gray-400 text-xs">{s.label}</div>
<div className="text-gray-800">
{s.value}
{s.unit ? ` ${s.unit}` : ""}
</div>
</div>
))}
</div>
</div>
)}
{nutriEntries.length > 0 && (
<div className="bg-white border rounded-lg p-5 mt-4">
<div className="card p-6 mt-4">
<h2 className="font-medium text-gray-700 mb-2">
{p.nutrition_basis ? `${basisLabel[p.nutrition_basis] || p.nutrition_basis}` : ""}
+32 -3
View File
@@ -10,7 +10,36 @@ body,
body {
margin: 0;
background: #f3f4f6;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue",
Arial, "PingFang SC", "Microsoft YaHei", sans-serif;
color: #1f2937;
font-family: theme("fontFamily.sans");
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
background-color: #f6f8f7;
background-image:
radial-gradient(60rem 60rem at 100% -10%, rgba(16, 185, 129, 0.10), transparent 60%),
radial-gradient(50rem 50rem at -10% 0%, rgba(45, 212, 191, 0.10), transparent 55%);
background-attachment: fixed;
}
@layer components {
.card {
@apply bg-white rounded-2xl border border-gray-100;
box-shadow: theme("boxShadow.card");
}
.input {
@apply w-full rounded-xl border border-gray-200 bg-white px-3.5 py-2.5 text-sm text-gray-800 placeholder:text-gray-400 transition focus:outline-none focus:border-brand-400 focus:ring-4 focus:ring-brand-500/10;
}
.btn-primary {
@apply inline-flex items-center justify-center gap-1.5 rounded-xl bg-brand-600 px-5 py-2.5 text-sm font-semibold text-white shadow-sm transition hover:bg-brand-700 active:bg-brand-800 disabled:opacity-60;
}
.btn-ghost {
@apply inline-flex items-center justify-center gap-1.5 rounded-xl px-3.5 py-2 text-sm font-medium text-gray-600 transition hover:bg-gray-100;
}
.chip {
@apply inline-flex items-center gap-1 rounded-full border border-gray-200 bg-white/70 px-3 py-1 text-xs font-medium text-gray-600 transition hover:border-brand-300 hover:text-brand-700 hover:bg-brand-50;
}
}
+9
View File
@@ -17,6 +17,13 @@ export interface Barcode {
is_primary: boolean;
}
export interface ProductSpec {
key: string;
label: string;
value: string;
unit?: string;
}
export interface Product {
id: string;
gtin: string | null;
@@ -24,6 +31,8 @@ export interface Product {
name: string;
brand: string | null;
category_path: string | null;
archive_kind?: string;
specs?: ProductSpec[] | null;
net_content_value: number | null;
net_content_unit: string | null;
country_of_origin: string | null;
+50 -1
View File
@@ -1,6 +1,55 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ["./index.html", "./src/**/*.{ts,tsx}"],
theme: { extend: {} },
theme: {
extend: {
colors: {
brand: {
50: "#ecfdf5",
100: "#d1fae5",
200: "#a7f3d0",
300: "#6ee7b7",
400: "#34d399",
500: "#10b981",
600: "#059669",
700: "#047857",
800: "#065f46",
900: "#064e3b",
},
},
fontFamily: {
sans: [
"Inter",
"system-ui",
"-apple-system",
"Segoe UI",
"Roboto",
"Helvetica Neue",
"Arial",
"PingFang SC",
"Microsoft YaHei",
"sans-serif",
],
},
boxShadow: {
card: "0 1px 2px rgba(16,24,40,0.04), 0 4px 16px -8px rgba(16,24,40,0.10)",
"card-hover": "0 10px 30px -10px rgba(16,24,40,0.18)",
glow: "0 12px 40px -12px rgba(5,150,105,0.45)",
},
borderRadius: {
"2xl": "1rem",
"3xl": "1.5rem",
},
keyframes: {
"fade-up": {
"0%": { opacity: "0", transform: "translateY(8px)" },
"100%": { opacity: "1", transform: "translateY(0)" },
},
},
animation: {
"fade-up": "fade-up 0.4s ease-out both",
},
},
},
plugins: [],
};
+15
View File
@@ -62,6 +62,21 @@ GET http://zc.bypos.net/byGoodsService/byMessage.asmx/GetGoodsinfo
`collect.go``defaultSdogID`,也可用 `-sdogid` 参数或控制台覆盖。
**这是账号级凭证**——若本仓库对外公开,建议改为从环境变量/外部配置读取。
## 导入 goods/天工库
采集得到的 JSONL 用 ingestion 里的导入任务入库(只导入 `status=hit` 的记录,
按 GTIN 去重 upsert,重复导入幂等):
```bash
cd ingestion
python -m opengoods.jobs.import_bypos --input products.jsonl
# 可选:--limit N 限制条数;--dsn 指定数据库
```
字段映射:`barcode→gtin``name→name``spec`(可解析的质量/体积如 500mL/5kg)
`→net_content`、其余规格/单位/产地/许可/进价留存到 `attributes``sell_price→`
CNY MSRP 快照。来源记为 `bypos中心库`,带字段级 provenance。
## 注意
批量自动查询比页面逐条更"重",上游可能对账号限频。请低速、分前缀/品类分批采集。