Compare commits

..

1 Commits

Author SHA1 Message Date
lixu 54175238ad docs: 归档天工·商品标签(OpenGoods)规划文档
新增 docs/planning/ 规划文档归档:
- 00-final-plan: 最终规划(决策+架构+M0~M5任务清单)
- 01-detailed-design-v2.0: 分类/单位/数据库/API/治理/采集/部署/众包
- 02-advanced-topics-v3.0: OpenAPI/全表DDL/数据契约/OFF映射/中国合规/测试/安全/图片/SLA/竞品
- history/: v0.1~v1.0 演进记录
更新 README 为项目介绍并链接规划文档。

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-06-08 06:05:29 +00:00
160 changed files with 1486 additions and 19677 deletions
-14
View File
@@ -1,14 +0,0 @@
.git
**/node_modules
admin-frontend/dist
public-frontend/dist
api/server
*.test
*.out
__pycache__
.venv
.pytest_cache
.ruff_cache
.env
.env.*
!.env.example
-12
View File
@@ -1,12 +0,0 @@
# Copy to .env and fill in real values before running docker-compose.prod.yml.
# Used by docker-compose.prod.yml for production deployment.
POSTGRES_USER=opengoods
POSTGRES_PASSWORD=change-me
POSTGRES_DB=opengoods
MINIO_ROOT_USER=opengoods
MINIO_ROOT_PASSWORD=change-me
# Admin console (served at /ping). Set a strong password and a random JWT secret.
GOODS_ADMIN_USER=admin
GOODS_ADMIN_PASSWORD=change-me
GOODS_ADMIN_JWT_SECRET=change-me-to-a-long-random-string
-89
View File
@@ -1,89 +0,0 @@
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
go:
name: Go (api)
runs-on: ubuntu-latest
defaults:
run:
working-directory: api
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: opengoods
POSTGRES_PASSWORD: opengoods
POSTGRES_DB: opengoods
options: >-
--health-cmd "pg_isready -U opengoods"
--health-interval 5s --health-timeout 5s --health-retries 10
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: Apply migrations
working-directory: .
run: |
go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@v4.18.1
migrate -path migrations -database "$OPENGOODS_DATABASE_URL" up
- name: Verify gofmt
run: test -z "$(gofmt -l .)"
- run: go vet ./...
- run: go build ./...
- run: go test ./...
python:
name: Python (ingestion)
runs-on: ubuntu-latest
defaults:
run:
working-directory: ingestion
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install
run: pip install -e ".[dev]"
- name: Ruff lint
run: ruff check .
- name: Ruff format check
run: ruff format --check .
- name: Pytest
run: pytest -q
migrations:
name: Migrations (postgres)
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: opengoods
POSTGRES_PASSWORD: opengoods
POSTGRES_DB: opengoods
options: >-
--health-cmd "pg_isready -U opengoods"
--health-interval 5s --health-timeout 5s --health-retries 10
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: Install golang-migrate
run: go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@v4.18.1
- name: Migrate up
run: migrate -path migrations -database "$DBURL" up
- name: Migrate down (reversibility)
run: migrate -path migrations -database "$DBURL" down -all
-34
View File
@@ -1,34 +0,0 @@
# Go
/api/server
*.test
*.out
# Python
__pycache__/
*.py[cod]
.venv/
.pytest_cache/
.ruff_cache/
*.egg-info/
build/
dist/
# Env / local
.env
.env.*
!.env.example
# Node / admin frontend
node_modules/
# Keep the embedded SPA placeholders (real builds are injected during Docker build)
!api/internal/adminweb/dist/
!api/internal/adminweb/dist/index.html
!api/internal/publicweb/dist/
!api/internal/publicweb/dist/index.html
# OS / editors
.DS_Store
*.swp
.idea/
.vscode/
+24 -2
View File
@@ -1,2 +1,24 @@
# goods
商品档案公开API
# 天工·商品标签 (OpenGoods)
商品档案公开 API —— 公益网站/服务:**采集全网商品信息,对外提供商品参数查询 API**。
> 核心原则:**只采集 + 只提供信息,绝不涉及任何购买/下单/比价导购。**
## 这是什么
- 开放、中立、可溯源的「商品参数百科 + 开放 API」
- 首批聚焦 **食品快消**,对外提供按条码/名称查询商品参数(成分、营养、规格、官方建议零售价等)
- 技术栈:**Go**(对外只读 API) + **Python**(采集/ETL),经 PostgreSQL + Redis/队列解耦
## 项目状态
规划阶段。完整方案见 [`docs/planning/`](./docs/planning/README.md)
- [最终规划](./docs/planning/00-final-plan.md)(决策 + 架构 + 任务清单)
- [详细设计 v2.0](./docs/planning/01-detailed-design-v2.0.md)(分类/单位/数据库/API/治理/采集/部署)
- [进阶专题 v3.0](./docs/planning/02-advanced-topics-v3.0.md)OpenAPI/DDL/合规/测试/安全/SLA/竞品)
## 许可
- 代码:拟用 Apache-2.0 / MIT(待定)
- 数据:拟用 **ODbL + 署名**(因采用 Open Food Facts 等开放数据源)
-12
View File
@@ -1,12 +0,0 @@
<!doctype html>
<html lang="zh">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>天工商品档案公共仓 · 管理后台</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
-2690
View File
File diff suppressed because it is too large Load Diff
-26
View File
@@ -1,26 +0,0 @@
{
"name": "opengoods-admin-frontend",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"lucide-react": "^0.344.0"
},
"devDependencies": {
"@types/react": "^18.2.55",
"@types/react-dom": "^18.2.19",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.17",
"postcss": "^8.4.35",
"tailwindcss": "^3.4.1",
"typescript": "^5.3.3",
"vite": "^5.1.0"
}
}
-6
View File
@@ -1,6 +0,0 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
-200
View File
@@ -1,200 +0,0 @@
import { useEffect, useState } from "react";
import { api, clearToken, getToken } from "./api";
import Login from "./components/Login";
import ProductList from "./components/ProductList";
import ProductDetail from "./components/ProductDetail";
import SubmissionsPage from "./components/SubmissionsPage";
import ApiKeysPage from "./components/ApiKeysPage";
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 =
| "overview"
| "products"
| "submissions"
| "categories"
| "brands"
| "audit"
| "keys";
type View = { name: "list" } | { name: "detail"; id: string };
export default function App() {
const [authed, setAuthed] = useState(false);
const [checking, setChecking] = useState(true);
const [username, setUsername] = useState("");
const [tab, setTab] = useState<Tab>("products");
const [pending, setPending] = useState<number | null>(null);
const [view, setView] = useState<View>({ name: "list" });
useEffect(() => {
if (!authed) return;
api
.listSubmissions("pending", 1, 1)
.then((r) => setPending(r.pending))
.catch(() => undefined);
}, [authed]);
useEffect(() => {
if (!getToken()) {
setChecking(false);
return;
}
api
.me()
.then((r) => {
setUsername(r.username);
setAuthed(true);
})
.catch(() => clearToken())
.finally(() => setChecking(false));
}, []);
function onLoggedIn(name: string) {
setUsername(name);
setAuthed(true);
setView({ name: "list" });
}
function logout() {
clearToken();
setAuthed(false);
setUsername("");
}
if (checking) {
return (
<div className="flex h-full items-center justify-center text-gray-500">
</div>
);
}
if (!authed) return <Login onLoggedIn={onLoggedIn} />;
return (
<div className="flex h-full flex-col">
<header className="flex items-center justify-between bg-white px-6 py-3 shadow-sm">
<div className="flex items-center gap-6">
<div className="flex items-center gap-2 text-lg font-semibold text-gray-800">
<Package className="h-5 w-5 text-emerald-600" />
·
</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");
setView({ name: "list" });
}}
className={`px-3 py-1.5 rounded-md flex items-center gap-1.5 ${
tab === "products"
? "bg-emerald-50 text-emerald-700"
: "text-gray-600 hover:bg-gray-100"
}`}
>
<Package className="h-4 w-4" />
</button>
<button
onClick={() => setTab("submissions")}
className={`px-3 py-1.5 rounded-md flex items-center gap-1.5 ${
tab === "submissions"
? "bg-emerald-50 text-emerald-700"
: "text-gray-600 hover:bg-gray-100"
}`}
>
<Inbox className="h-4 w-4" /> 稿
{pending != null && pending > 0 && (
<span className="ml-1 text-xs bg-amber-500 text-white rounded-full px-1.5">
{pending}
</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 ${
tab === "keys"
? "bg-emerald-50 text-emerald-700"
: "text-gray-600 hover:bg-gray-100"
}`}
>
<KeyRound className="h-4 w-4" /> API
</button>
</nav>
</div>
<div className="flex items-center gap-4 text-sm text-gray-600">
<span>{username}</span>
<button
onClick={logout}
className="flex items-center gap-1 rounded px-2 py-1 text-gray-500 hover:bg-gray-100 hover:text-gray-800"
>
<LogOut className="h-4 w-4" /> 退
</button>
</div>
</header>
<main className="flex-1 overflow-auto p-6">
{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 })} />
) : (
<ProductDetail id={view.id} onBack={() => setView({ name: "list" })} />
)}
</main>
</div>
);
}
-192
View File
@@ -1,192 +0,0 @@
// API base derives from Vite's BASE_URL (/ping/) so it matches the nginx prefix.
const API_BASE = `${import.meta.env.BASE_URL}api`;
const TOKEN_KEY = "opengoods_admin_token";
export function getToken(): string | null {
return localStorage.getItem(TOKEN_KEY);
}
export function setToken(token: string) {
localStorage.setItem(TOKEN_KEY, token);
}
export function clearToken() {
localStorage.removeItem(TOKEN_KEY);
}
export class ApiError extends Error {
status: number;
constructor(status: number, message: string) {
super(message);
this.status = status;
}
}
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
const headers: Record<string, string> = {
"Content-Type": "application/json",
...(options.headers as Record<string, string>),
};
const token = getToken();
if (token) headers.Authorization = `Bearer ${token}`;
const res = await fetch(`${API_BASE}${path}`, { ...options, headers });
if (res.status === 401) {
clearToken();
throw new ApiError(401, "登录已过期,请重新登录");
}
const text = await res.text();
const data = text ? JSON.parse(text) : null;
if (!res.ok) {
const msg = data?.error?.message || `请求失败 (${res.status})`;
throw new ApiError(res.status, msg);
}
return data as T;
}
export const api = {
login: (username: string, password: string) =>
request<{ token: string; username: string }>("/login", {
method: "POST",
body: JSON.stringify({ username, password }),
}),
me: () => request<{ username: string }>("/me"),
listProducts: (q: string, page: number, size: number) =>
request<{
items: import("./types").ProductRow[];
page: number;
size: number;
total: number;
completeness_fields: string[];
}>(`/products?q=${encodeURIComponent(q)}&page=${page}&size=${size}`),
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",
body: JSON.stringify(body),
}),
listAudit: (id: string) =>
request<{ items: import("./types").AuditEntry[] }>(`/products/${id}/audit`),
addImage: (id: string, url: string, kind: string) =>
request<import("./types").ProductImage>(`/products/${id}/images`, {
method: "POST",
body: JSON.stringify({ url, kind }),
}),
deleteImage: (id: string, imageId: string) =>
request<{ status: string }>(`/products/${id}/images/${imageId}`, {
method: "DELETE",
}),
addMsrp: (id: string, body: unknown) =>
request<import("./types").MSRP>(`/products/${id}/msrp`, {
method: "POST",
body: JSON.stringify(body),
}),
deleteMsrp: (id: string, msrpId: string) =>
request<{ status: string }>(`/products/${id}/msrp/${msrpId}`, {
method: "DELETE",
}),
addBarcode: (id: string, body: unknown) =>
request<import("./types").Barcode>(`/products/${id}/barcodes`, {
method: "POST",
body: JSON.stringify(body),
}),
deleteBarcode: (id: string, barcodeId: string) =>
request<{ status: string }>(`/products/${id}/barcodes/${barcodeId}`, {
method: "DELETE",
}),
setPrimaryBarcode: (id: string, barcodeId: string) =>
request<import("./types").Barcode>(
`/products/${id}/barcodes/${barcodeId}/primary`,
{ method: "POST" },
),
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" }),
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[];
page: number;
size: number;
total: number;
pending: number;
}>(`/submissions?status=${encodeURIComponent(status)}&page=${page}&size=${size}`),
getSubmission: (id: string) =>
request<import("./types").SubmissionDetail>(`/submissions/${id}`),
approveSubmission: (id: string) =>
request<import("./types").ProductDetail>(`/submissions/${id}/approve`, {
method: "POST",
}),
rejectSubmission: (id: string, note: string) =>
request<{ status: string }>(`/submissions/${id}/reject`, {
method: "POST",
body: JSON.stringify({ note }),
}),
listApiKeys: () =>
request<{ items: import("./types").ApiKey[] }>("/keys"),
createApiKey: (body: {
name: string;
owner_email?: string;
tier?: string;
rate_limit_per_min?: number;
}) =>
request<{ key: string; item: import("./types").ApiKey; warning: string }>(
"/keys",
{ method: "POST", body: JSON.stringify(body) },
),
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),
}),
};
@@ -1,278 +0,0 @@
import { useEffect, useState } from "react";
import { api, ApiError } from "../api";
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 },
];
function tierLabel(tier: string): string {
return TIERS.find((t) => t.key === tier)?.label ?? tier;
}
export default function ApiKeysPage() {
const [rows, setRows] = useState<ApiKey[]>([]);
const [error, setError] = useState("");
const [creating, setCreating] = useState(false);
const [newKey, setNewKey] = useState<string | null>(null);
async function load() {
setError("");
try {
const res = await api.listApiKeys();
setRows(res.items);
} catch (e) {
setError(e instanceof ApiError ? e.message : "加载失败");
}
}
useEffect(() => {
load();
}, []);
async function revoke(id: string, name: string) {
if (!confirm(`确认吊销密钥「${name}」?使用该密钥的请求将立即被拒绝。`)) return;
try {
await api.revokeApiKey(id);
await load();
} catch (e) {
setError(e instanceof ApiError ? e.message : "操作失败");
}
}
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">
<KeyRound className="h-5 w-5 text-emerald-600" /> API
</h2>
<p className="text-sm text-gray-500 mt-1">
API
</p>
</div>
<button
onClick={() => setCreating(true)}
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>
)}
{newKey && (
<div className="mb-4 bg-amber-50 border border-amber-200 rounded-lg p-4">
<div className="text-sm font-medium text-amber-800 mb-1">
</div>
<div className="flex items-center gap-2">
<code className="flex-1 bg-white border rounded px-3 py-2 text-sm break-all">
{newKey}
</code>
<button
onClick={() => navigator.clipboard?.writeText(newKey)}
className="px-3 py-2 rounded border text-sm text-gray-600 hover:bg-gray-50 flex items-center gap-1"
>
<Copy className="h-4 w-4" />
</button>
<button
onClick={() => setNewKey(null)}
className="px-3 py-2 rounded text-sm text-gray-500 hover:bg-gray-100"
>
</button>
</div>
</div>
)}
{creating && (
<CreateKeyForm
onClose={() => setCreating(false)}
onCreated={(plaintext) => {
setCreating(false);
setNewKey(plaintext);
load();
}}
/>
)}
<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>
<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={7} className="px-4 py-8 text-center text-gray-400">
</td>
</tr>
) : (
rows.map((k) => (
<tr key={k.id} className={k.revoked_at ? "opacity-50" : ""}>
<td className="px-4 py-2 text-gray-800">
{k.name}
{k.owner_email && (
<span className="block text-xs text-gray-400">{k.owner_email}</span>
)}
</td>
<td className="px-4 py-2 text-gray-500">
<code>{k.key_prefix}</code>
</td>
<td className="px-4 py-2 text-gray-600">{tierLabel(k.tier)}</td>
<td className="px-4 py-2 text-gray-600">{k.rate_limit_per_min}</td>
<td className="px-4 py-2 text-gray-600">
{k.usage.today} / {k.usage.total}
</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">
</span>
) : (
<span className="text-xs rounded px-2 py-0.5 bg-emerald-50 text-emerald-700">
</span>
)}
</td>
<td className="px-4 py-2 text-right">
{!k.revoked_at && (
<button
onClick={() => revoke(k.id, k.name)}
className="text-gray-400 hover:text-red-600"
title="吊销"
>
<Trash2 className="h-4 w-4" />
</button>
)}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
);
}
function CreateKeyForm({
onClose,
onCreated,
}: {
onClose: () => void;
onCreated: (plaintext: string) => void;
}) {
const [name, setName] = useState("");
const [ownerEmail, setOwnerEmail] = useState("");
const [tier, setTier] = useState("free");
const [rate, setRate] = useState(120);
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);
}
async function submit() {
if (!name.trim()) {
setError("名称不能为空");
return;
}
setBusy(true);
setError("");
try {
const res = await api.createApiKey({
name: name.trim(),
owner_email: ownerEmail.trim() || undefined,
tier,
rate_limit_per_min: rate,
});
onCreated(res.key);
} catch (e) {
setError(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"></h3>
{error && <div className="mb-3 bg-red-50 text-red-700 text-sm rounded px-3 py-2">{error}</div>}
<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={name}
onChange={(e) => setName(e.target.value)}
placeholder="例如:我的 App / 合作方 X"
/>
</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={ownerEmail}
onChange={(e) => setOwnerEmail(e.target.value)}
placeholder="owner@example.com"
/>
</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={tier}
onChange={(e) => pickTier(e.target.value)}
>
{TIERS.map((t) => (
<option key={t.key} value={t.key}>
{t.label}
</option>
))}
</select>
</label>
<label className="block">
<span className="text-xs text-gray-500">/</span>
<input
type="number"
min={1}
className="w-full border rounded-md px-3 py-2 text-sm mt-1"
value={rate}
onChange={(e) => setRate(Math.max(1, parseInt(e.target.value || "1", 10)))}
/>
</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>
);
}
@@ -1,115 +0,0 @@
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>
);
}
@@ -1,305 +0,0 @@
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>
);
}
@@ -1,311 +0,0 @@
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>
);
}
-75
View File
@@ -1,75 +0,0 @@
import { useState } from "react";
import { api, setToken } from "../api";
import { Package } from "lucide-react";
export default function Login({
onLoggedIn,
}: {
onLoggedIn: (username: string) => void;
}) {
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
async function submit(e: React.FormEvent) {
e.preventDefault();
setError("");
setLoading(true);
try {
const r = await api.login(username, password);
setToken(r.token);
onLoggedIn(r.username);
} catch (err) {
setError(err instanceof Error ? err.message : "登录失败");
} finally {
setLoading(false);
}
}
return (
<div className="flex h-full items-center justify-center">
<form
onSubmit={submit}
className="w-80 rounded-xl bg-white p-8 shadow-md"
>
<div className="mb-6 flex flex-col items-center gap-2">
<Package className="h-8 w-8 text-emerald-600" />
<h1 className="text-lg font-semibold text-gray-800">
·
</h1>
</div>
{error && (
<div className="mb-4 rounded bg-red-50 px-3 py-2 text-sm text-red-600">
{error}
</div>
)}
<label className="mb-3 block">
<span className="mb-1 block text-sm text-gray-600"></span>
<input
value={username}
onChange={(e) => setUsername(e.target.value)}
className="w-full rounded border border-gray-300 px-3 py-2 text-sm focus:border-emerald-500 focus:outline-none"
autoFocus
/>
</label>
<label className="mb-5 block">
<span className="mb-1 block text-sm text-gray-600"></span>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full rounded border border-gray-300 px-3 py-2 text-sm focus:border-emerald-500 focus:outline-none"
/>
</label>
<button
type="submit"
disabled={loading}
className="w-full rounded bg-emerald-600 py-2 text-sm font-medium text-white hover:bg-emerald-700 disabled:opacity-60"
>
{loading ? "登录中…" : "登录"}
</button>
</form>
</div>
);
}
@@ -1,806 +0,0 @@
import { useEffect, useMemo, useState } from "react";
import { api } from "../api";
import {
AuditEntry,
Brand,
Category,
FIELD_LABELS,
ProductDetail as Detail,
} from "../types";
import {
ArrowLeft,
Plus,
Save,
Trash2,
AlertCircle,
History,
Star,
} from "lucide-react";
const GTIN_TYPES = ["EAN13", "EAN8", "UPC", "ITF14", "GTIN14"];
const PACK_LEVELS: { value: string; label: string }[] = [
{ value: "each", label: "消费单元" },
{ value: "case", label: "箱" },
{ value: "pallet", label: "托盘" },
];
const NUTRIMENT_KEYS: { key: string; label: string }[] = [
{ key: "energy_kcal", label: "能量 (kcal)" },
{ key: "energy_kj", label: "能量 (kJ)" },
{ key: "fat", label: "脂肪 (g)" },
{ key: "saturated_fat", label: "饱和脂肪 (g)" },
{ key: "carbohydrates", label: "碳水 (g)" },
{ key: "sugars", label: "糖 (g)" },
{ key: "proteins", label: "蛋白质 (g)" },
{ key: "salt", label: "盐 (g)" },
];
const STATUS_OPTIONS = [
{ value: "active", label: "在用" },
{ value: "merged", label: "已合并" },
{ value: "deprecated", label: "已停用" },
];
const ACTION_LABEL: Record<string, string> = {
update: "编辑",
add_image: "新增图片",
delete_image: "删除图片",
add_msrp: "新增建议零售价",
delete_msrp: "删除建议零售价",
add_barcode: "新增条码",
delete_barcode: "删除条码",
set_primary_barcode: "设为主条码",
};
function Card({
title,
children,
}: {
title: string;
children: React.ReactNode;
}) {
return (
<div className="rounded-lg border border-gray-200 bg-white p-5">
<h3 className="mb-4 text-sm font-semibold text-gray-700">{title}</h3>
{children}
</div>
);
}
function Field({
label,
children,
}: {
label: string;
children: React.ReactNode;
}) {
return (
<label className="block">
<span className="mb-1 block text-xs text-gray-500">{label}</span>
{children}
</label>
);
}
const inputCls =
"w-full rounded border border-gray-300 px-3 py-2 text-sm focus:border-emerald-500 focus:outline-none";
export default function ProductDetail({
id,
onBack,
}: {
id: string;
onBack: () => void;
}) {
const [d, setD] = useState<Detail | null>(null);
const [brands, setBrands] = useState<Brand[]>([]);
const [categories, setCategories] = useState<Category[]>([]);
const [audit, setAudit] = useState<AuditEntry[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [msg, setMsg] = useState("");
const [error, setError] = useState("");
// editable form state
const [name, setName] = useState("");
const [gtin, setGtin] = useState("");
const [brandName, setBrandName] = useState("");
const [categoryId, setCategoryId] = useState("");
const [netValue, setNetValue] = useState("");
const [netUnit, setNetUnit] = useState("");
const [country, setCountry] = useState("");
const [status, setStatus] = useState("active");
const [ingredients, setIngredients] = useState("");
const [allergens, setAllergens] = useState("");
const [additives, setAdditives] = useState("");
const [nutriments, setNutriments] = useState<Record<string, string>>({});
const [basis, setBasis] = useState("");
const [serving, setServing] = useState("");
const [nutriScore, setNutriScore] = useState("");
function hydrate(detail: Detail) {
setD(detail);
setName(detail.name);
setGtin(detail.gtin || "");
setBrandName(detail.brand || "");
setCategoryId(detail.category_id || "");
setNetValue(detail.net_content_value?.toString() || "");
setNetUnit(detail.net_content_unit || "");
setCountry(detail.country_of_origin || "");
setStatus(detail.status);
setIngredients(detail.ingredients_text || "");
setAllergens(detail.allergens.join(", "));
setAdditives(detail.additives.join(", "));
const nm: Record<string, string> = {};
if (detail.nutriments) {
for (const [k, v] of Object.entries(detail.nutriments)) nm[k] = String(v);
}
setNutriments(nm);
setBasis(detail.nutrition_basis || "");
setServing(detail.serving_size || "");
setNutriScore(detail.nutri_score || "");
}
function reload() {
setLoading(true);
Promise.all([api.getProduct(id), api.listAudit(id)])
.then(([detail, a]) => {
hydrate(detail);
setAudit(a.items);
})
.catch((e) => setError(e.message))
.finally(() => setLoading(false));
}
useEffect(() => {
reload();
api.listBrands().then((r) => setBrands(r.items)).catch(() => {});
api.listCategories().then((r) => setCategories(r.items)).catch(() => {});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id]);
const missing = useMemo(() => d?.missing ?? [], [d]);
function parseList(s: string): string[] {
return s
.split(",")
.map((x) => x.trim())
.filter(Boolean);
}
async function save() {
setSaving(true);
setMsg("");
setError("");
const nm: Record<string, number> = {};
for (const [k, v] of Object.entries(nutriments)) {
const n = parseFloat(v);
if (!Number.isNaN(n)) nm[k] = n;
}
const body = {
gtin: gtin.trim() || null,
name: name.trim(),
brand_name: brandName.trim() || null,
brand_id: brandName.trim() ? undefined : null,
category_id: categoryId || null,
net_content_value: netValue.trim() ? parseFloat(netValue) : null,
net_content_unit: netUnit.trim() || null,
country_of_origin: country.trim() || null,
status,
ingredients_text: ingredients.trim() || null,
allergens: parseList(allergens),
additives: parseList(additives),
nutriments: nm,
nutrition_basis: basis || null,
serving_size: serving.trim() || null,
nutri_score: nutriScore || null,
};
try {
const updated = await api.updateProduct(id, body);
hydrate(updated);
const a = await api.listAudit(id);
setAudit(a.items);
setMsg("已保存");
setTimeout(() => setMsg(""), 2500);
} catch (e) {
setError(e instanceof Error ? e.message : "保存失败");
} finally {
setSaving(false);
}
}
if (loading) {
return <div className="text-gray-400"></div>;
}
if (!d) {
return (
<div>
<button onClick={onBack} className="text-emerald-600">
</button>
<p className="mt-4 text-red-600">{error || "未找到商品"}</p>
</div>
);
}
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-3">
{msg && <span className="text-sm text-emerald-600">{msg}</span>}
{error && <span className="text-sm text-red-600">{error}</span>}
<span className="text-xs text-gray-400">
{Math.round(d.quality_score * 100)}
</span>
<button
onClick={save}
disabled={saving}
className="flex items-center gap-1 rounded bg-emerald-600 px-4 py-2 text-sm text-white hover:bg-emerald-700 disabled:opacity-60"
>
<Save className="h-4 w-4" /> {saving ? "保存中…" : "保存"}
</button>
</div>
</div>
{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("、")}
</div>
)}
<Card title="基础信息">
<div className="grid grid-cols-2 gap-4">
<Field label="名称 *">
<input
className={inputCls}
value={name}
onChange={(e) => setName(e.target.value)}
/>
</Field>
<Field label="条码 (GTIN)">
<input
className={inputCls}
value={gtin}
onChange={(e) => setGtin(e.target.value)}
/>
</Field>
<Field label="品牌(不存在将自动创建)">
<input
className={inputCls}
list="brand-list"
value={brandName}
onChange={(e) => setBrandName(e.target.value)}
/>
<datalist id="brand-list">
{brands.map((b) => (
<option key={b.id} value={b.name} />
))}
</datalist>
</Field>
<Field label="品类">
<select
className={inputCls}
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>
</Field>
<Field label="净含量">
<input
className={inputCls}
type="number"
step="any"
value={netValue}
onChange={(e) => setNetValue(e.target.value)}
/>
</Field>
<Field label="净含量单位 (g/ml/cl…)">
<input
className={inputCls}
value={netUnit}
onChange={(e) => setNetUnit(e.target.value)}
/>
</Field>
<Field label="产地">
<input
className={inputCls}
value={country}
onChange={(e) => setCountry(e.target.value)}
/>
</Field>
<Field label="状态">
<select
className={inputCls}
value={status}
onChange={(e) => setStatus(e.target.value)}
>
{STATUS_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</Field>
</div>
</Card>
<Card title="配料与营养">
<div className="mb-4 grid grid-cols-2 gap-4">
<Field label="配料表">
<textarea
className={inputCls}
rows={3}
value={ingredients}
onChange={(e) => setIngredients(e.target.value)}
/>
</Field>
<div className="grid grid-cols-2 gap-4">
<Field label="过敏原(逗号分隔)">
<input
className={inputCls}
value={allergens}
onChange={(e) => setAllergens(e.target.value)}
/>
</Field>
<Field label="添加剂(逗号分隔)">
<input
className={inputCls}
value={additives}
onChange={(e) => setAdditives(e.target.value)}
/>
</Field>
<Field label="营养基准">
<select
className={inputCls}
value={basis}
onChange={(e) => setBasis(e.target.value)}
>
<option value=""></option>
<option value="per_100g"> 100g</option>
<option value="per_100ml"> 100ml</option>
<option value="per_serving"></option>
</select>
</Field>
<Field label="份量">
<input
className={inputCls}
value={serving}
onChange={(e) => setServing(e.target.value)}
/>
</Field>
<Field label="Nutri-Score (A-E)">
<input
className={inputCls}
maxLength={1}
value={nutriScore}
onChange={(e) =>
setNutriScore(e.target.value.toUpperCase())
}
/>
</Field>
</div>
</div>
<div className="grid grid-cols-4 gap-3">
{NUTRIMENT_KEYS.map((n) => (
<Field key={n.key} label={n.label}>
<input
className={inputCls}
type="number"
step="any"
value={nutriments[n.key] ?? ""}
onChange={(e) =>
setNutriments((prev) => ({ ...prev, [n.key]: e.target.value }))
}
/>
</Field>
))}
</div>
</Card>
<BarcodesCard product={d} onChange={reload} onError={setError} />
<ImagesCard
product={d}
onChange={reload}
onError={setError}
/>
<MsrpCard product={d} onChange={reload} onError={setError} />
<Card title="操作记录">
{audit.length === 0 ? (
<p className="text-sm text-gray-400"></p>
) : (
<ul className="space-y-2 text-sm">
{audit.map((a) => (
<li
key={a.id}
className="flex items-center gap-3 text-gray-600"
>
<History className="h-3.5 w-3.5 text-gray-400" />
<span className="text-gray-400">{a.created_at}</span>
<span className="font-medium text-gray-700">{a.actor}</span>
<span>{ACTION_LABEL[a.action] || a.action}</span>
{a.fields.length > 0 && (
<span className="text-gray-400">
[{a.fields.map((f) => FIELD_LABELS[f] || f).join("、")}]
</span>
)}
</li>
))}
</ul>
)}
</Card>
</div>
);
}
function BarcodesCard({
product,
onChange,
onError,
}: {
product: Detail;
onChange: () => void;
onError: (m: string) => void;
}) {
const [gtin, setGtin] = useState("");
const [gtinType, setGtinType] = useState("EAN13");
const [packLevel, setPackLevel] = useState("each");
const [region, setRegion] = useState("");
const [busy, setBusy] = useState(false);
async function add() {
if (!gtin.trim()) return;
setBusy(true);
try {
await api.addBarcode(product.id, {
gtin: gtin.trim(),
gtin_type: gtinType,
pack_level: packLevel,
region: region.trim() || null,
is_primary: false,
});
setGtin("");
setRegion("");
onChange();
} catch (e) {
onError(e instanceof Error ? e.message : "添加失败");
} finally {
setBusy(false);
}
}
async function remove(barcodeId: string) {
try {
await api.deleteBarcode(product.id, barcodeId);
onChange();
} catch (e) {
onError(e instanceof Error ? e.message : "删除失败");
}
}
async function makePrimary(barcodeId: string) {
try {
await api.setPrimaryBarcode(product.id, barcodeId);
onChange();
} catch (e) {
onError(e instanceof Error ? e.message : "设置失败");
}
}
return (
<Card title="条码(一品多码,主条码镜像到 GTIN)">
<div className="mb-3 space-y-2">
{product.barcodes.length === 0 && (
<span className="text-sm text-gray-400"></span>
)}
{product.barcodes.map((b) => (
<div
key={b.id}
className="flex items-center gap-3 rounded border border-gray-100 bg-gray-50 px-3 py-2 text-sm"
>
<button
onClick={() => !b.is_primary && makePrimary(b.id)}
title={b.is_primary ? "主条码" : "设为主条码"}
disabled={b.is_primary}
className={
b.is_primary
? "text-amber-500"
: "text-gray-300 hover:text-amber-500"
}
>
<Star
className="h-4 w-4"
fill={b.is_primary ? "currentColor" : "none"}
/>
</button>
<span className="font-mono font-medium text-gray-800">
{b.gtin}
</span>
<span className="rounded bg-gray-200 px-1.5 py-0.5 text-[11px] text-gray-600">
{b.gtin_type}
</span>
<span className="text-gray-500">
{PACK_LEVELS.find((p) => p.value === b.pack_level)?.label ||
b.pack_level}
</span>
<span className="flex-1 text-gray-400">{b.region || ""}</span>
<button
onClick={() => remove(b.id)}
className="text-gray-400 hover:text-red-600"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
))}
</div>
<div className="flex flex-wrap items-end gap-2">
<Field label="条码 (GTIN)">
<input
className="w-44 rounded border border-gray-300 px-3 py-2 text-sm"
value={gtin}
onChange={(e) => setGtin(e.target.value)}
placeholder="8/12/13/14 位"
/>
</Field>
<Field label="类型">
<select
className="rounded border border-gray-300 px-2 py-2 text-sm"
value={gtinType}
onChange={(e) => setGtinType(e.target.value)}
>
{GTIN_TYPES.map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</select>
</Field>
<Field label="包装层级">
<select
className="rounded border border-gray-300 px-2 py-2 text-sm"
value={packLevel}
onChange={(e) => setPackLevel(e.target.value)}
>
{PACK_LEVELS.map((p) => (
<option key={p.value} value={p.value}>
{p.label}
</option>
))}
</select>
</Field>
<Field label="地区(可选)">
<input
className="w-20 rounded border border-gray-300 px-3 py-2 text-sm"
value={region}
onChange={(e) => setRegion(e.target.value.toUpperCase())}
/>
</Field>
<button
onClick={add}
disabled={busy}
className="flex items-center gap-1 rounded bg-gray-700 px-3 py-2 text-sm text-white hover:bg-gray-800 disabled:opacity-60"
>
<Plus className="h-4 w-4" />
</button>
</div>
</Card>
);
}
function ImagesCard({
product,
onChange,
onError,
}: {
product: Detail;
onChange: () => void;
onError: (m: string) => void;
}) {
const [url, setUrl] = useState("");
const [kind, setKind] = useState("front");
async function add() {
if (!url.trim()) return;
try {
await api.addImage(product.id, url.trim(), kind);
setUrl("");
onChange();
} catch (e) {
onError(e instanceof Error ? e.message : "添加失败");
}
}
async function remove(imageId: string) {
try {
await api.deleteImage(product.id, imageId);
onChange();
} catch (e) {
onError(e instanceof Error ? e.message : "删除失败");
}
}
return (
<Card title="图片(仅存 URL">
<div className="mb-3 flex flex-wrap gap-3">
{product.images.length === 0 && (
<span className="text-sm text-gray-400"></span>
)}
{product.images.map((im) => (
<div
key={im.id}
className="relative h-24 w-24 overflow-hidden rounded border border-gray-200"
>
<img
src={im.url}
alt={im.kind}
className="h-full w-full object-cover"
/>
<button
onClick={() => remove(im.id)}
className="absolute right-1 top-1 rounded bg-black/50 p-1 text-white hover:bg-black/70"
>
<Trash2 className="h-3 w-3" />
</button>
<span className="absolute bottom-0 left-0 bg-black/50 px-1 text-[10px] text-white">
{im.kind}
</span>
</div>
))}
</div>
<div className="flex items-center gap-2">
<input
className={inputCls}
placeholder="图片 URL"
value={url}
onChange={(e) => setUrl(e.target.value)}
/>
<select
className="rounded border border-gray-300 px-2 py-2 text-sm"
value={kind}
onChange={(e) => setKind(e.target.value)}
>
<option value="front"></option>
<option value="ingredients"></option>
<option value="nutrition"></option>
<option value="other"></option>
</select>
<button
onClick={add}
className="flex items-center gap-1 whitespace-nowrap rounded bg-gray-700 px-3 py-2 text-sm text-white hover:bg-gray-800"
>
<Plus className="h-4 w-4" />
</button>
</div>
</Card>
);
}
function MsrpCard({
product,
onChange,
onError,
}: {
product: Detail;
onChange: () => void;
onError: (m: string) => void;
}) {
const [amount, setAmount] = useState("");
const [currency, setCurrency] = useState("CNY");
const [region, setRegion] = useState("CN");
const [date, setDate] = useState("");
const [note, setNote] = useState("");
async function add() {
const a = parseFloat(amount);
if (Number.isNaN(a)) return;
try {
await api.addMsrp(product.id, {
amount: a,
currency,
region,
effective_date: date || null,
note: note.trim() || null,
});
setAmount("");
setNote("");
setDate("");
onChange();
} catch (e) {
onError(e instanceof Error ? e.message : "添加失败");
}
}
async function remove(msrpId: string) {
try {
await api.deleteMsrp(product.id, msrpId);
onChange();
} catch (e) {
onError(e instanceof Error ? e.message : "删除失败");
}
}
return (
<Card title="官方建议零售价(MSRP 快照,非售卖)">
<div className="mb-3 space-y-2">
{product.msrp.length === 0 && (
<span className="text-sm text-gray-400"></span>
)}
{product.msrp.map((m) => (
<div
key={m.id}
className="flex items-center gap-3 rounded border border-gray-100 bg-gray-50 px-3 py-2 text-sm"
>
<span className="font-medium text-gray-800">
{m.amount} {m.currency}
</span>
<span className="text-gray-500">{m.region}</span>
<span className="text-gray-400">{m.effective_date || ""}</span>
<span className="flex-1 text-gray-400">{m.note || ""}</span>
<button
onClick={() => remove(m.id)}
className="text-gray-400 hover:text-red-600"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
))}
</div>
<div className="flex flex-wrap items-end gap-2">
<Field label="金额">
<input
className="w-28 rounded border border-gray-300 px-3 py-2 text-sm"
type="number"
step="any"
value={amount}
onChange={(e) => setAmount(e.target.value)}
/>
</Field>
<Field label="币种">
<input
className="w-20 rounded border border-gray-300 px-3 py-2 text-sm"
value={currency}
onChange={(e) => setCurrency(e.target.value.toUpperCase())}
/>
</Field>
<Field label="地区">
<input
className="w-20 rounded border border-gray-300 px-3 py-2 text-sm"
value={region}
onChange={(e) => setRegion(e.target.value.toUpperCase())}
/>
</Field>
<Field label="生效日期">
<input
className="rounded border border-gray-300 px-3 py-2 text-sm"
type="date"
value={date}
onChange={(e) => setDate(e.target.value)}
/>
</Field>
<Field label="备注">
<input
className="w-40 rounded border border-gray-300 px-3 py-2 text-sm"
value={note}
onChange={(e) => setNote(e.target.value)}
/>
</Field>
<button
onClick={add}
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>
</div>
</Card>
);
}
@@ -1,515 +0,0 @@
import { useEffect, useState } from "react";
import { api, ApiError } from "../api";
import { Brand, Category, FIELD_LABELS, ProductRow } from "../types";
import { Search, AlertCircle, Plus } from "lucide-react";
const STATUS_LABEL: Record<string, string> = {
active: "在用",
merged: "已合并",
deprecated: "已停用",
};
function QualityBadge({ score }: { score: number }) {
const pct = Math.round(score * 100);
const color =
score >= 0.8
? "bg-emerald-100 text-emerald-700"
: score >= 0.5
? "bg-amber-100 text-amber-700"
: "bg-red-100 text-red-700";
return (
<span className={`rounded px-2 py-0.5 text-xs font-medium ${color}`}>
{pct}
</span>
);
}
export default function ProductList({
onOpen,
}: {
onOpen: (id: string) => void;
}) {
const [q, setQ] = useState("");
const [input, setInput] = useState("");
const [page, setPage] = useState(1);
const [size, setSize] = useState(20);
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);
function reload() {
setLoading(true);
setError("");
api
.listProducts(q, page, size)
.then((r) => {
setRows(r.items);
setTotal(r.total);
})
.catch((e) => setError(e.message))
.finally(() => setLoading(false));
}
useEffect(() => {
setSelected(new Set());
reload();
}, [q, page, size]);
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));
return (
<div className="mx-auto max-w-6xl">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-xl font-semibold text-gray-800">
<span className="text-sm font-normal text-gray-400"> {total} </span>
</h2>
<form
onSubmit={(e) => {
e.preventDefault();
setPage(1);
setQ(input.trim());
}}
className="flex items-center gap-2"
>
<div className="relative">
<Search className="absolute left-2 top-2.5 h-4 w-4 text-gray-400" />
<input
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="按名称 / 条码搜索"
className="w-64 rounded border border-gray-300 py-2 pl-8 pr-3 text-sm focus:border-emerald-500 focus:outline-none"
/>
</div>
<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);
}}
/>
)}
{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="w-10 px-4 py-3">
<input
type="checkbox"
checked={rows.length > 0 && selected.size === rows.length}
onChange={toggleAll}
aria-label="全选"
/>
</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="px-4 py-3"></th>
<th className="px-4 py-3"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{loading ? (
<tr>
<td colSpan={8} className="px-4 py-8 text-center text-gray-400">
</td>
</tr>
) : rows.length === 0 ? (
<tr>
<td colSpan={8} className="px-4 py-8 text-center text-gray-400">
</td>
</tr>
) : (
rows.map((r) => (
<tr
key={r.id}
onClick={() => onOpen(r.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">
{r.gtin || "—"}
</td>
<td className="px-4 py-3 text-xs text-gray-500">
{r.category_path || "—"}
</td>
<td className="px-4 py-3 text-gray-600">
{STATUS_LABEL[r.status] || r.status}
</td>
<td className="px-4 py-3">
<QualityBadge score={r.quality_score} />
</td>
<td className="px-4 py-3">
{r.missing.length === 0 ? (
<span className="text-xs text-emerald-600"></span>
) : (
<span className="flex items-center gap-1 text-xs text-amber-600">
<AlertCircle className="h-3.5 w-3.5" />
{r.missing
.map((f) => FIELD_LABELS[f] || f)
.join("、")}
</span>
)}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
<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)}
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>
<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
@@ -1,125 +0,0 @@
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>
);
}
@@ -1,314 +0,0 @@
import { useEffect, useState } from "react";
import { api, ApiError } from "../api";
import type { SubmissionDetail, SubmissionRow } from "../types";
import { FIELD_LABELS } from "../types";
import { ArrowLeft, Check, X } from "lucide-react";
const STATUS_TABS = [
{ key: "pending", label: "待审核" },
{ key: "approved", label: "已通过" },
{ key: "rejected", label: "已驳回" },
];
const STATUS_BADGE: Record<string, string> = {
pending: "bg-amber-50 text-amber-700",
approved: "bg-emerald-50 text-emerald-700",
rejected: "bg-red-50 text-red-700",
};
const STATUS_TEXT: Record<string, string> = {
pending: "待审核",
approved: "已通过",
rejected: "已驳回",
};
export default function SubmissionsPage({ onPending }: { onPending?: (n: number) => void }) {
const [tab, setTab] = useState("pending");
const [rows, setRows] = useState<SubmissionRow[]>([]);
const [openId, setOpenId] = useState<string | null>(null);
const [error, setError] = useState("");
async function load() {
setError("");
try {
const res = await api.listSubmissions(tab, 1, 50);
setRows(res.items);
onPending?.(res.pending);
} catch (e) {
setError(e instanceof ApiError ? e.message : "加载失败");
}
}
useEffect(() => {
load();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tab]);
if (openId) {
return (
<SubmissionView
id={openId}
onBack={() => {
setOpenId(null);
load();
}}
/>
);
}
return (
<div className="mx-auto max-w-5xl">
<div className="flex items-center gap-2 mb-4">
{STATUS_TABS.map((t) => (
<button
key={t.key}
onClick={() => setTab(t.key)}
className={`px-3 py-1.5 rounded-md text-sm ${
tab === t.key
? "bg-emerald-600 text-white"
: "bg-white border text-gray-600 hover:bg-gray-50"
}`}
>
{t.label}
</button>
))}
</div>
{error && (
<div className="mb-3 bg-red-50 text-red-700 text-sm rounded px-4 py-2">{error}</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>
<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={6} className="px-4 py-8 text-center text-gray-400">
稿
</td>
</tr>
) : (
rows.map((r) => (
<tr
key={r.id}
onClick={() => setOpenId(r.id)}
className="cursor-pointer hover:bg-gray-50"
>
<td className="px-4 py-2 text-gray-800">{r.name}</td>
<td className="px-4 py-2 text-gray-500">{r.gtin || "—"}</td>
<td className="px-4 py-2 text-gray-500">{r.submitter_name || "匿名"}</td>
<td className="px-4 py-2">
<span className="text-xs text-gray-500">
{r.matched ? "补全已有商品" : "新建商品"}
</span>
</td>
<td className="px-4 py-2 text-gray-500">
{new Date(r.created_at).toLocaleString()}
</td>
<td className="px-4 py-2">
<span className={`text-xs rounded px-2 py-0.5 ${STATUS_BADGE[r.status]}`}>
{STATUS_TEXT[r.status]}
</span>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
);
}
function Field({ label, value }: { label: string; value: React.ReactNode }) {
if (value === null || value === undefined || value === "") return null;
return (
<div className="flex py-1.5 text-sm">
<div className="w-28 shrink-0 text-gray-400">{label}</div>
<div className="text-gray-800 break-all">{value}</div>
</div>
);
}
function SubmissionView({ id, onBack }: { id: string; onBack: () => void }) {
const [d, setD] = useState<SubmissionDetail | null>(null);
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
const [rejecting, setRejecting] = useState(false);
const [reason, setReason] = useState("");
useEffect(() => {
api.getSubmission(id).then(setD).catch((e) => setError(e.message));
}, [id]);
async function approve() {
if (!confirm("确认通过该投稿?将写入正式商品库并记录来源 community。")) return;
setBusy(true);
setError("");
try {
await api.approveSubmission(id);
onBack();
} catch (e) {
setError(e instanceof ApiError ? e.message : "操作失败");
} finally {
setBusy(false);
}
}
async function reject() {
setBusy(true);
setError("");
try {
await api.rejectSubmission(id, reason.trim());
onBack();
} catch (e) {
setError(e instanceof ApiError ? e.message : "操作失败");
} finally {
setBusy(false);
}
}
if (error && !d) {
return (
<div>
<button onClick={onBack} className="text-sm text-gray-500 flex items-center gap-1 mb-4">
<ArrowLeft className="h-4 w-4" />
</button>
<div className="bg-red-50 text-red-700 text-sm rounded px-4 py-3">{error}</div>
</div>
);
}
if (!d) return <div className="text-gray-400"></div>;
const p = d.payload;
const nutri = Object.entries(p.nutriments || {});
return (
<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>
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-gray-800">{p.name}</h2>
<span className={`text-xs rounded px-2 py-0.5 ${STATUS_BADGE[d.status]}`}>
{STATUS_TEXT[d.status]}
</span>
</div>
{error && <div className="mt-3 bg-red-50 text-red-700 text-sm rounded px-4 py-2">{error}</div>}
{d.target_product_id && (
<div className="mt-3 text-sm bg-blue-50 text-blue-700 rounded px-4 py-2">
<b></b>
</div>
)}
<div className="bg-white border rounded-lg p-5 mt-4">
<h3 className="font-medium text-gray-700 mb-2">稿</h3>
<Field label="商品名称" value={p.name} />
<Field label="条码" value={p.gtin} />
<Field label="品牌" value={p.brand_name} />
<Field
label="净含量"
value={p.net_content_value != null ? `${p.net_content_value} ${p.net_content_unit || ""}` : null}
/>
<Field label="产地" value={p.country_of_origin} />
<Field label="配料" value={p.ingredients_text} />
{nutri.length > 0 && (
<Field
label="营养成分"
value={
<span>
{p.nutrition_basis ? `(${p.nutrition_basis}) ` : ""}
{nutri.map(([k, v]) => `${FIELD_LABELS[k] || k}:${v}`).join("")}
</span>
}
/>
)}
{p.images && p.images.length > 0 && (
<Field
label="图片"
value={
<div className="flex flex-wrap gap-2">
{p.images.map((im, i) => (
<a key={i} href={im.url} target="_blank" rel="noreferrer">
<img src={im.url} alt="" className="h-20 w-20 object-cover rounded border" />
</a>
))}
</div>
}
/>
)}
</div>
<div className="bg-white border rounded-lg p-5 mt-4">
<h3 className="font-medium text-gray-700 mb-2">稿 / </h3>
<Field label="称呼" value={p.submitter_name || "匿名"} />
<Field label="联系方式" value={p.submitter_contact} />
<Field label="备注" value={p.note} />
<Field label="提交时间" value={new Date(d.created_at).toLocaleString()} />
{d.reviewed_by && <Field label="审核人" value={d.reviewed_by} />}
{d.review_note && <Field label="驳回原因" value={d.review_note} />}
{d.result_product_id && <Field label="收录商品ID" value={d.result_product_id} />}
</div>
{d.status === "pending" && (
<div className="mt-5">
{rejecting ? (
<div className="bg-white border rounded-lg p-4">
<label className="block text-xs text-gray-500 mb-1"></label>
<input
className="w-full border rounded-md px-3 py-2 text-sm"
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="例如:资料无法核实 / 重复投稿"
/>
<div className="mt-3 flex gap-2">
<button
onClick={reject}
disabled={busy}
className="px-4 py-2 rounded bg-red-600 text-white text-sm hover:bg-red-700 disabled:opacity-60"
>
</button>
<button
onClick={() => setRejecting(false)}
className="px-4 py-2 rounded border text-sm text-gray-600"
>
</button>
</div>
</div>
) : (
<div className="flex gap-3">
<button
onClick={approve}
disabled={busy}
className="px-5 py-2.5 rounded-lg bg-emerald-600 text-white font-medium hover:bg-emerald-700 disabled:opacity-60 flex items-center gap-1.5"
>
<Check className="h-4 w-4" />
</button>
<button
onClick={() => setRejecting(true)}
disabled={busy}
className="px-5 py-2.5 rounded-lg border text-gray-700 font-medium hover:bg-gray-50 flex items-center gap-1.5"
>
<X className="h-4 w-4" />
</button>
</div>
)}
</div>
)}
</div>
);
}
-16
View File
@@ -1,16 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
html,
body,
#root {
height: 100%;
}
body {
margin: 0;
background: #f3f4f6;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue",
Arial, "PingFang SC", "Microsoft YaHei", sans-serif;
}
-10
View File
@@ -1,10 +0,0 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import "./index.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);
-202
View File
@@ -1,202 +0,0 @@
export interface ProductRow {
id: string;
gtin: string | null;
name: string;
brand: string | null;
category_path: string | null;
status: string;
quality_score: number;
missing: string[];
updated_at: string;
}
export interface Barcode {
id: string;
gtin: string;
gtin_type: string;
pack_level: string;
region: string | null;
is_primary: boolean;
}
export interface ProductImage {
id: string;
url: string;
kind: string;
license: string | null;
}
export interface MSRP {
id: string;
amount: number;
currency: string;
region: string;
effective_date: string | null;
source_url: string | null;
note: string | null;
}
export interface ProductDetail {
id: string;
gtin: string | null;
name: string;
brand_id: string | null;
brand: string | null;
category_id: string | null;
category_path: string | null;
net_content_value: number | null;
net_content_unit: string | null;
country_of_origin: string | null;
status: string;
quality_score: number;
ingredients_text: string | null;
allergens: string[];
additives: string[];
nutriments: Record<string, number> | null;
nutrition_basis: string | null;
serving_size: string | null;
nutri_score: string | null;
barcodes: Barcode[];
images: ProductImage[];
msrp: MSRP[];
missing: string[];
updated_at: string;
}
export interface Brand {
id: string;
name: string;
product_count: number;
}
export interface Category {
id: string;
name_zh: string;
name_en: string | null;
path: string;
level: number;
parent_id: string | null;
gpc_brick_code: string | null;
product_count: number;
}
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 {
id: string;
actor: string;
action: string;
fields: string[];
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;
name: string;
status: string;
submitter_name: string | null;
matched: boolean;
created_at: string;
reviewed_at: string | null;
}
export interface SubmissionImage {
url: string;
kind: string;
}
export interface SubmissionPayload {
gtin: string | null;
name: string;
brand_name: string | null;
category_id: string | null;
net_content_value: number | null;
net_content_unit: string | null;
country_of_origin: string | null;
ingredients_text: string | null;
nutriments: Record<string, number> | null;
nutrition_basis: string | null;
serving_size: string | null;
nutri_score: string | null;
images: SubmissionImage[] | null;
submitter_name: string | null;
submitter_contact: string | null;
note: string | null;
}
export interface SubmissionDetail {
id: string;
status: string;
gtin: string | null;
name: string;
submitter_name: string | null;
submitter_contact: string | null;
note: string | null;
review_note: string | null;
reviewed_by: string | null;
reviewed_at: string | null;
created_at: string;
target_product_id: string | null;
result_product_id: string | null;
payload: SubmissionPayload;
existing_product?: ProductDetail;
}
export interface ApiKeyUsage {
total: number;
today: number;
last_used_at?: number | null;
}
export interface ApiKey {
id: string;
name: string;
key_prefix: string;
owner_email: string | null;
tier: string;
rate_limit_per_min: number;
revoked_at: string | null;
created_by: string | null;
created_at: string;
usage: ApiKeyUsage;
}
export const FIELD_LABELS: Record<string, string> = {
name: "名称",
gtin: "条码",
brand: "品牌",
category: "品类",
net_content: "净含量",
country_of_origin: "产地",
nutriments: "营养成分",
ingredients: "配料",
image: "图片",
};
-1
View File
@@ -1 +0,0 @@
/// <reference types="vite/client" />
-6
View File
@@ -1,6 +0,0 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ["./index.html", "./src/**/*.{ts,tsx}"],
theme: { extend: {} },
plugins: [],
};
-21
View File
@@ -1,21 +0,0 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}
-11
View File
@@ -1,11 +0,0 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}
-9
View File
@@ -1,9 +0,0 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
// Served under /ping by the admin Go binary; base must match the nginx prefix.
export default defineConfig({
base: "/ping/",
plugins: [react()],
build: { outDir: "dist", emptyOutDir: true },
});
-13
View File
@@ -1,13 +0,0 @@
# Build stage
FROM golang:1.23-alpine AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /out/server ./cmd/server
# Runtime stage
FROM gcr.io/distroless/static-debian12
COPY --from=build /out/server /server
EXPOSE 8080
ENTRYPOINT ["/server"]
-29
View File
@@ -1,29 +0,0 @@
# Admin console image: builds the SPA (node), embeds it into the Go admin
# binary, and ships a static scratch runtime. Build context is the repo root.
# Stage 1: build the admin SPA.
FROM node:22-alpine AS web
WORKDIR /web
ENV npm_config_registry=https://registry.npmmirror.com
COPY admin-frontend/package.json admin-frontend/package-lock.json* ./
RUN npm ci || npm install
COPY admin-frontend/ ./
RUN npm run build
# Stage 2: build the Go admin binary with the SPA embedded.
FROM golang:1.23-alpine AS build
ENV GOPROXY=https://goproxy.cn,direct
WORKDIR /src
COPY api/go.mod api/go.sum ./
RUN go mod download
COPY api/ ./
RUN rm -rf internal/adminweb/dist && mkdir -p internal/adminweb/dist
COPY --from=web /web/dist/ internal/adminweb/dist/
RUN CGO_ENABLED=0 go build -o /out/admin ./cmd/admin
# Stage 3: minimal runtime.
FROM scratch
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=build /out/admin /admin
EXPOSE 8080
ENTRYPOINT ["/admin"]
-31
View File
@@ -1,31 +0,0 @@
# Public API image: builds the public SPA (homepage + search + contribute),
# embeds it into the Go read-only server binary, and ships a static scratch
# runtime (used where gcr.io/distroless is not reachable). Build context is the
# repo root.
# Stage 1: build the public SPA.
FROM node:22-alpine AS web
WORKDIR /web
ENV npm_config_registry=https://registry.npmmirror.com
COPY public-frontend/package.json public-frontend/package-lock.json* ./
RUN npm ci || npm install
COPY public-frontend/ ./
RUN npm run build
# Stage 2: build the Go server binary with the SPA embedded.
FROM golang:1.23-alpine AS build
ENV GOPROXY=https://goproxy.cn,direct
WORKDIR /src
COPY api/go.mod api/go.sum ./
RUN go mod download
COPY api/ ./
RUN rm -rf internal/publicweb/dist && mkdir -p internal/publicweb/dist
COPY --from=web /web/dist/ internal/publicweb/dist/
RUN CGO_ENABLED=0 go build -o /out/server ./cmd/server
# Stage 3: minimal runtime.
FROM scratch
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=build /out/server /server
EXPOSE 8080
ENTRYPOINT ["/server"]
-86
View File
@@ -1,86 +0,0 @@
// Command admin starts the OpenGoods admin console (authenticated write API +
// embedded SPA), served under a base path (default /ping).
package main
import (
"context"
"crypto/rand"
"log"
"net/http"
"os"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"golang.org/x/crypto/bcrypt"
"github.com/baicai2026-baicai/goods/api/internal/adminhandler"
"github.com/baicai2026-baicai/goods/api/internal/adminstore"
"github.com/baicai2026-baicai/goods/api/internal/adminweb"
"github.com/baicai2026-baicai/goods/api/internal/auth"
"github.com/baicai2026-baicai/goods/api/internal/ratelimit"
)
func getenv(key, fallback string) string {
if v, ok := os.LookupEnv(key); ok && v != "" {
return v
}
return fallback
}
func main() {
addr := getenv("GOODS_ADMIN_ADDR", ":8080")
dbURL := getenv("OPENGOODS_DATABASE_URL", "postgres://opengoods:opengoods@localhost:5432/opengoods?sslmode=disable")
basePath := getenv("GOODS_ADMIN_BASE_PATH", "/ping")
username := getenv("GOODS_ADMIN_USER", "admin")
// Password: prefer a bcrypt hash; otherwise hash a plaintext password.
var passwordHash []byte
if h := os.Getenv("GOODS_ADMIN_PASSWORD_HASH"); h != "" {
passwordHash = []byte(h)
} else if p := os.Getenv("GOODS_ADMIN_PASSWORD"); p != "" {
hashed, err := bcrypt.GenerateFromPassword([]byte(p), bcrypt.DefaultCost)
if err != nil {
log.Fatalf("failed to hash admin password: %v", err)
}
passwordHash = hashed
} else {
log.Fatal("set GOODS_ADMIN_PASSWORD or GOODS_ADMIN_PASSWORD_HASH")
}
secret := []byte(os.Getenv("GOODS_ADMIN_JWT_SECRET"))
if len(secret) == 0 {
secret = make([]byte, 32)
if _, err := rand.Read(secret); err != nil {
log.Fatalf("failed to generate jwt secret: %v", err)
}
log.Print("warning: GOODS_ADMIN_JWT_SECRET not set; using a random secret (tokens invalidate on restart)")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, dbURL)
if err != nil {
log.Fatalf("failed to create db pool: %v", err)
}
defer pool.Close()
pingCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
if err := pool.Ping(pingCtx); err != nil {
log.Printf("warning: database not reachable at startup: %v", err)
}
authn := auth.New(username, passwordHash, secret, 12*time.Hour)
usage := ratelimit.New(getenv("OPENGOODS_REDIS_URL", "redis://localhost:6379/0"))
h := adminhandler.New(adminstore.New(pool), authn, basePath, adminweb.Dist()).
WithUsage(usage)
srv := &http.Server{
Addr: addr,
Handler: h.Router(),
ReadHeaderTimeout: 10 * time.Second,
}
log.Printf("OpenGoods admin console listening on %s (base path %s)", addr, basePath)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("server error: %v", err)
}
}
-52
View File
@@ -1,52 +0,0 @@
// Command server starts the OpenGoods public read-only API.
package main
import (
"context"
"log"
"net/http"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/baicai2026-baicai/goods/api/internal/config"
"github.com/baicai2026-baicai/goods/api/internal/handler"
"github.com/baicai2026-baicai/goods/api/internal/publicweb"
"github.com/baicai2026-baicai/goods/api/internal/ratelimit"
"github.com/baicai2026-baicai/goods/api/internal/store"
)
func main() {
cfg := config.Load()
ctx := context.Background()
pool, err := pgxpool.New(ctx, cfg.DatabaseURL)
if err != nil {
log.Fatalf("failed to create db pool: %v", err)
}
defer pool.Close()
pingCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
if err := pool.Ping(pingCtx); err != nil {
log.Printf("warning: database not reachable at startup: %v", err)
}
limiter := ratelimit.New(cfg.RedisURL)
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)
srv := &http.Server{
Addr: cfg.Addr,
Handler: h.Router(),
ReadHeaderTimeout: 10 * time.Second,
}
log.Printf("OpenGoods API listening on %s", cfg.Addr)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("server error: %v", err)
}
}
-21
View File
@@ -1,21 +0,0 @@
module github.com/baicai2026-baicai/goods/api
go 1.23.4
require (
github.com/go-chi/chi/v5 v5.1.0
github.com/jackc/pgx/v5 v5.7.2
github.com/redis/go-redis/v9 v9.18.0
golang.org/x/crypto v0.31.0
)
require (
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
go.uber.org/atomic v1.11.0 // indirect
golang.org/x/sync v0.10.0 // indirect
golang.org/x/text v0.21.0 // indirect
)
-46
View File
@@ -1,46 +0,0 @@
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.7.2 h1:mLoDLV6sonKlvjIEsV56SkWNCnuNv531l94GaIzO+XI=
github.com/jackc/pgx/v5 v5.7.2/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs=
github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
-70
View File
@@ -1,70 +0,0 @@
package adminhandler
import (
"encoding/json"
"net/http"
"strings"
"github.com/go-chi/chi/v5"
"github.com/baicai2026-baicai/goods/api/internal/adminstore"
"github.com/baicai2026-baicai/goods/api/internal/auth"
"github.com/baicai2026-baicai/goods/api/internal/ratelimit"
)
// apiKeyView is an issued key plus its usage counters.
type apiKeyView struct {
adminstore.APIKeyRow
Usage ratelimit.UsageStat `json:"usage"`
}
// ListAPIKeys returns all issued keys with usage stats merged in.
func (h *Handler) ListAPIKeys(w http.ResponseWriter, r *http.Request) {
keys, err := h.store.ListAPIKeys(r.Context())
if h.handleErr(w, err) {
return
}
views := make([]apiKeyView, 0, len(keys))
for _, k := range keys {
v := apiKeyView{APIKeyRow: k}
if h.usage != nil {
v.Usage = h.usage.Usage(r.Context(), k.ID)
}
views = append(views, v)
}
writeJSON(w, http.StatusOK, map[string]any{"items": views})
}
// CreateAPIKey issues a new key and returns its plaintext exactly once.
func (h *Handler) CreateAPIKey(w http.ResponseWriter, r *http.Request) {
var in adminstore.APIKeyInput
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
}
if in.Tier != "" && in.Tier != "free" && in.Tier != "partner" && in.Tier != "internal" {
writeError(w, http.StatusBadRequest, "bad_request", "tier 取值无效")
return
}
plaintext, row, err := h.store.CreateAPIKey(r.Context(), in, auth.UserFrom(r.Context()))
if h.handleErr(w, err) {
return
}
writeJSON(w, http.StatusCreated, map[string]any{
"key": plaintext,
"item": row,
"warning": "请立即复制保存此密钥,它只显示这一次,无法再次查看。",
})
}
// RevokeAPIKey disables a key. Subsequent requests with it are rejected.
func (h *Handler) RevokeAPIKey(w http.ResponseWriter, r *http.Request) {
if err := h.store.RevokeAPIKey(r.Context(), chi.URLParam(r, "id")); h.handleErr(w, err) {
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "revoked"})
}
-731
View File
@@ -1,731 +0,0 @@
// Package adminhandler wires up the authenticated admin console: a JSON write
// API mounted under a base path (default /ping) plus the embedded SPA.
package adminhandler
import (
"encoding/json"
"errors"
"io/fs"
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/baicai2026-baicai/goods/api/internal/adminstore"
"github.com/baicai2026-baicai/goods/api/internal/auth"
"github.com/baicai2026-baicai/goods/api/internal/gtin"
"github.com/baicai2026-baicai/goods/api/internal/ratelimit"
)
// Handler holds the admin dependencies.
type Handler struct {
store *adminstore.Store
authn *auth.Authenticator
basePath string
spa fs.FS
submitLimit *rateLimiter
usage *ratelimit.Limiter
}
// New constructs an admin Handler. basePath is e.g. "/ping" (no trailing slash).
func New(store *adminstore.Store, authn *auth.Authenticator, basePath string, spa fs.FS) *Handler {
basePath = "/" + strings.Trim(basePath, "/")
return &Handler{
store: store,
authn: authn,
basePath: basePath,
spa: spa,
submitLimit: newRateLimiter(5, 10*time.Minute),
}
}
// WithUsage attaches a Redis-backed limiter used to read per-key usage counters
// for the API-key management view. Optional; without it usage shows as zero.
func (h *Handler) WithUsage(l *ratelimit.Limiter) *Handler {
h.usage = l
return h
}
// Router builds the HTTP handler.
func (h *Handler) Router() http.Handler {
r := chi.NewRouter()
r.Use(middleware.RequestID)
r.Use(middleware.RealIP)
r.Use(middleware.Recoverer)
r.Route(h.basePath, func(r chi.Router) {
r.Get("/healthz", func(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
})
r.Post("/api/login", h.Login)
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)
r.Post("/api/products/{id}/images", h.AddImage)
r.Delete("/api/products/{id}/images/{imageID}", h.DeleteImage)
r.Post("/api/products/{id}/msrp", h.AddMSRP)
r.Delete("/api/products/{id}/msrp/{msrpID}", h.DeleteMSRP)
r.Post("/api/products/{id}/barcodes", h.AddBarcode)
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/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)
r.Post("/api/submissions/{id}/approve", h.ApproveSubmission)
r.Post("/api/submissions/{id}/reject", h.RejectSubmission)
r.Get("/api/keys", h.ListAPIKeys)
r.Post("/api/keys", h.CreateAPIKey)
r.Delete("/api/keys/{id}", h.RevokeAPIKey)
})
r.Handle("/*", http.HandlerFunc(h.serveSPA))
})
// Public, unauthenticated contribution endpoint (proxied at /api/public/*).
// Submissions enter a moderation queue and never touch products until an
// admin approves them.
r.Post("/api/public/submissions", h.CreateSubmission)
return r
}
func (h *Handler) serveSPA(w http.ResponseWriter, r *http.Request) {
rel := strings.TrimPrefix(r.URL.Path, h.basePath)
rel = strings.TrimPrefix(rel, "/")
if rel == "" {
rel = "index.html"
}
if f, err := h.spa.Open(rel); err == nil {
f.Close()
http.StripPrefix(h.basePath+"/", http.FileServer(http.FS(h.spa))).ServeHTTP(w, r)
return
}
// SPA fallback: serve index.html for client-side routes.
index, err := h.spa.Open("index.html")
if err != nil {
http.NotFound(w, r)
return
}
defer index.Close()
data, _ := fs.ReadFile(h.spa, "index.html")
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write(data)
}
// ---------- auth ----------
// Login authenticates and returns a bearer token.
func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
var body struct {
Username string `json:"username"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "invalid body")
return
}
token, err := h.authn.Login(body.Username, body.Password)
if err != nil {
writeError(w, http.StatusUnauthorized, "unauthorized", "用户名或密码错误")
return
}
writeJSON(w, http.StatusOK, map[string]string{"token": token, "username": body.Username})
}
// Me returns the current authenticated user.
func (h *Handler) Me(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"username": auth.UserFrom(r.Context())})
}
// ---------- products ----------
// ListProducts returns a paginated product list.
func (h *Handler) ListProducts(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query().Get("q")
page, size := pageParams(r)
items, total, err := h.store.ListProducts(r.Context(), q, 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,
"completeness_fields": adminstore.CompletenessFields,
})
}
// GetProduct returns full editable detail.
func (h *Handler) GetProduct(w http.ResponseWriter, r *http.Request) {
d, err := h.store.GetProduct(r.Context(), chi.URLParam(r, "id"))
if h.handleErr(w, err) {
return
}
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
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.UpdateProduct(r.Context(), chi.URLParam(r, "id"), auth.UserFrom(r.Context()), in)
if h.handleErr(w, err) {
return
}
writeJSON(w, http.StatusOK, d)
}
// ListAudit returns audit history for a product.
func (h *Handler) ListAudit(w http.ResponseWriter, r *http.Request) {
items, err := h.store.ListAudit(r.Context(), chi.URLParam(r, "id"), 100)
if h.handleErr(w, err) {
return
}
writeJSON(w, http.StatusOK, map[string]any{"items": items})
}
// AddImage adds an image URL.
func (h *Handler) AddImage(w http.ResponseWriter, r *http.Request) {
var body struct {
URL string `json:"url"`
Kind string `json:"kind"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || strings.TrimSpace(body.URL) == "" {
writeError(w, http.StatusBadRequest, "bad_request", "图片 URL 不能为空")
return
}
im, err := h.store.AddImage(r.Context(), chi.URLParam(r, "id"), auth.UserFrom(r.Context()), body.URL, body.Kind)
if h.handleErr(w, err) {
return
}
writeJSON(w, http.StatusCreated, im)
}
// DeleteImage removes an image.
func (h *Handler) DeleteImage(w http.ResponseWriter, r *http.Request) {
err := h.store.DeleteImage(r.Context(), chi.URLParam(r, "id"), chi.URLParam(r, "imageID"), auth.UserFrom(r.Context()))
if h.handleErr(w, err) {
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
}
// AddMSRP adds a suggested-retail-price snapshot.
func (h *Handler) AddMSRP(w http.ResponseWriter, r *http.Request) {
var in adminstore.MSRPInput
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "invalid body")
return
}
m, err := h.store.AddMSRP(r.Context(), chi.URLParam(r, "id"), auth.UserFrom(r.Context()), in)
if h.handleErr(w, err) {
return
}
writeJSON(w, http.StatusCreated, m)
}
// DeleteMSRP removes an MSRP snapshot.
func (h *Handler) DeleteMSRP(w http.ResponseWriter, r *http.Request) {
err := h.store.DeleteMSRP(r.Context(), chi.URLParam(r, "id"), chi.URLParam(r, "msrpID"), auth.UserFrom(r.Context()))
if h.handleErr(w, err) {
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
}
// ---------- barcodes ----------
// AddBarcode validates and attaches a barcode to a product. A code already
// owned by another product yields 409 with the conflicting product so the
// operator can de-duplicate; an invalid GTIN yields 400.
func (h *Handler) AddBarcode(w http.ResponseWriter, r *http.Request) {
var in adminstore.BarcodeInput
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "invalid body")
return
}
b, err := h.store.AddBarcode(r.Context(), chi.URLParam(r, "id"), auth.UserFrom(r.Context()), in)
if h.handleBarcodeErr(w, err) {
return
}
writeJSON(w, http.StatusCreated, b)
}
// DeleteBarcode removes a barcode; a primary one is replaced automatically.
func (h *Handler) DeleteBarcode(w http.ResponseWriter, r *http.Request) {
err := h.store.DeleteBarcode(r.Context(), chi.URLParam(r, "id"), chi.URLParam(r, "barcodeID"), auth.UserFrom(r.Context()))
if h.handleErr(w, err) {
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
}
// SetPrimaryBarcode marks one barcode primary and mirrors it to product.gtin.
func (h *Handler) SetPrimaryBarcode(w http.ResponseWriter, r *http.Request) {
b, err := h.store.SetPrimaryBarcode(r.Context(), chi.URLParam(r, "id"), chi.URLParam(r, "barcodeID"), auth.UserFrom(r.Context()))
if h.handleErr(w, err) {
return
}
writeJSON(w, http.StatusOK, b)
}
// handleBarcodeErr maps barcode-specific errors (GTIN validation, ownership
// conflict) to client-facing statuses, falling back to handleErr otherwise.
func (h *Handler) handleBarcodeErr(w http.ResponseWriter, err error) bool {
if err == nil {
return false
}
var conflict *adminstore.ConflictError
if errors.As(err, &conflict) {
writeJSON(w, http.StatusConflict, map[string]any{
"error": map[string]string{"code": "barcode_conflict", "message": err.Error()},
"conflict": map[string]string{
"gtin": conflict.GTIN,
"product_id": conflict.ProductID,
"product_name": conflict.ProductName,
},
})
return true
}
if errors.Is(err, gtin.ErrEmpty) || errors.Is(err, gtin.ErrFormat) ||
errors.Is(err, gtin.ErrCheck) || errors.Is(err, gtin.ErrRestricted) {
writeError(w, http.StatusBadRequest, "invalid_gtin", err.Error())
return true
}
return h.handleErr(w, err)
}
// ListBrands returns brand options.
func (h *Handler) ListBrands(w http.ResponseWriter, r *http.Request) {
items, err := h.store.ListBrands(r.Context())
if h.handleErr(w, err) {
return
}
writeJSON(w, http.StatusOK, map[string]any{"items": items})
}
// ListCategories returns category options.
func (h *Handler) ListCategories(w http.ResponseWriter, r *http.Request) {
items, err := h.store.ListCategories(r.Context())
if h.handleErr(w, err) {
return
}
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.
func (h *Handler) CreateSubmission(w http.ResponseWriter, r *http.Request) {
if !h.submitLimit.allow(realIP(r)) {
writeError(w, http.StatusTooManyRequests, "rate_limited", "提交过于频繁,请稍后再试")
return
}
var in adminstore.SubmissionInput
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).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
}
id, err := h.store.CreateSubmission(r.Context(), in, realIP(r))
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
return
}
writeJSON(w, http.StatusCreated, map[string]string{"id": id, "status": "pending"})
}
// ListSubmissions returns the moderation queue (admin).
func (h *Handler) ListSubmissions(w http.ResponseWriter, r *http.Request) {
status := r.URL.Query().Get("status")
page, size := pageParams(r)
items, total, err := h.store.ListSubmissions(r.Context(), status, size, (page-1)*size)
if h.handleErr(w, err) {
return
}
pending, _ := h.store.PendingSubmissionCount(r.Context())
writeJSON(w, http.StatusOK, map[string]any{
"items": items, "page": page, "size": size, "total": total, "pending": pending,
})
}
// GetSubmission returns full submission detail (admin).
func (h *Handler) GetSubmission(w http.ResponseWriter, r *http.Request) {
d, err := h.store.GetSubmission(r.Context(), chi.URLParam(r, "id"))
if h.handleErr(w, err) {
return
}
writeJSON(w, http.StatusOK, d)
}
// ApproveSubmission applies a contribution to the product store (admin).
func (h *Handler) ApproveSubmission(w http.ResponseWriter, r *http.Request) {
d, err := h.store.ApproveSubmission(r.Context(), chi.URLParam(r, "id"), auth.UserFrom(r.Context()))
if h.handleErr(w, err) {
return
}
writeJSON(w, http.StatusOK, d)
}
// RejectSubmission rejects a contribution with a reviewer note (admin).
func (h *Handler) RejectSubmission(w http.ResponseWriter, r *http.Request) {
var body struct {
Note string `json:"note"`
}
_ = json.NewDecoder(r.Body).Decode(&body)
err := h.store.RejectSubmission(r.Context(), chi.URLParam(r, "id"), auth.UserFrom(r.Context()), body.Note)
if h.handleErr(w, err) {
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "rejected"})
}
// ---------- helpers ----------
func realIP(r *http.Request) string {
if ip := r.Header.Get("X-Forwarded-For"); ip != "" {
if i := strings.IndexByte(ip, ','); i >= 0 {
return strings.TrimSpace(ip[:i])
}
return strings.TrimSpace(ip)
}
if ip := r.Header.Get("X-Real-IP"); ip != "" {
return ip
}
return r.RemoteAddr
}
func (h *Handler) handleErr(w http.ResponseWriter, err error) bool {
if err == nil {
return false
}
if errors.Is(err, adminstore.ErrNotFound) {
writeError(w, http.StatusNotFound, "not_found", "资源不存在")
return true
}
if errors.Is(err, adminstore.ErrConflict) {
writeError(w, http.StatusConflict, "conflict", "该投稿已被处理")
return true
}
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
return true
}
func pageParams(r *http.Request) (page, size int) {
page = atoiDefault(r.URL.Query().Get("page"), 1)
if page < 1 {
page = 1
}
size = atoiDefault(r.URL.Query().Get("size"), 20)
if size < 1 {
size = 20
}
if size > 100 {
size = 100
}
return page, size
}
func atoiDefault(s string, fallback int) int {
if s == "" {
return fallback
}
n := 0
for _, c := range s {
if c < '0' || c > '9' {
return fallback
}
n = n*10 + int(c-'0')
}
return n
}
func writeJSON(w http.ResponseWriter, status int, body any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(body)
}
func writeError(w http.ResponseWriter, status int, code, message string) {
writeJSON(w, status, map[string]any{"error": map[string]string{"code": code, "message": message}})
}
-41
View File
@@ -1,41 +0,0 @@
package adminhandler
import (
"sync"
"time"
)
// rateLimiter is a simple fixed-window per-key limiter used to throttle
// anonymous public submissions (basic anti-spam; captcha can be added later).
type rateLimiter struct {
mu sync.Mutex
hits map[string][]time.Time
limit int
window time.Duration
}
func newRateLimiter(limit int, window time.Duration) *rateLimiter {
return &rateLimiter{hits: map[string][]time.Time{}, limit: limit, window: window}
}
// allow reports whether the key may proceed, recording the hit if so.
func (r *rateLimiter) allow(key string) bool {
now := time.Now()
cutoff := now.Add(-r.window)
r.mu.Lock()
defer r.mu.Unlock()
kept := r.hits[key][:0]
for _, t := range r.hits[key] {
if t.After(cutoff) {
kept = append(kept, t)
}
}
if len(kept) >= r.limit {
r.hits[key] = kept
return false
}
r.hits[key] = append(kept, now)
return true
}
-289
View File
@@ -1,289 +0,0 @@
// Package adminstore is the read/write data-access layer for the admin console.
// Unlike the public store (read-only), it performs INSERT/UPDATE/DELETE and
// records field-level provenance (source = "manual") plus an audit_log entry
// for every write, then recomputes product.quality_score.
package adminstore
import (
"context"
"encoding/json"
"errors"
"strconv"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// ErrNotFound is returned when a requested row does not exist.
var ErrNotFound = errors.New("not found")
// Store wraps a pgx pool for admin operations.
type Store struct {
pool *pgxpool.Pool
}
// New constructs an admin Store.
func New(pool *pgxpool.Pool) *Store { return &Store{pool: pool} }
// Ping verifies DB connectivity.
func (s *Store) Ping(ctx context.Context) error { return s.pool.Ping(ctx) }
// CompletenessFields mirrors ingestion/opengoods/etl/quality.py COMPLETENESS_FIELDS.
var CompletenessFields = []string{
"name", "gtin", "brand", "category", "net_content",
"country_of_origin", "nutriments", "ingredients", "image",
}
// ---------- list ----------
// ProductRow is a list-view row for the admin product table.
type ProductRow struct {
ID string `json:"id"`
GTIN *string `json:"gtin"`
Name string `json:"name"`
Brand *string `json:"brand"`
CategoryPath *string `json:"category_path"`
Status string `json:"status"`
QualityScore float64 `json:"quality_score"`
Missing []string `json:"missing"`
UpdatedAt string `json:"updated_at"`
}
// ListProducts returns a paginated, optionally name/gtin-filtered list.
func (s *Store) ListProducts(ctx context.Context, q string, limit, offset int) ([]ProductRow, int, error) {
args := []any{}
where := "WHERE 1=1"
if q != "" {
args = append(args, q)
where += " AND (p.name ILIKE '%' || $1 || '%' OR p.gtin ILIKE '%' || $1 || '%')"
}
var total int
if err := s.pool.QueryRow(ctx, "SELECT count(*) FROM product p "+where, args...).Scan(&total); 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.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,
(p.country_of_origin IS NOT NULL AND p.country_of_origin <> '') AS has_country,
(f.nutriments IS NOT NULL AND f.nutriments::text <> '{}') AS has_nutri,
(f.ingredients_text IS NOT NULL AND f.ingredients_text <> '') AS has_ing,
EXISTS (SELECT 1 FROM product_image pi WHERE pi.product_id = p.id) AS has_img
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))
rows, err := s.pool.Query(ctx, sql, args...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
out := []ProductRow{}
for rows.Next() {
var r ProductRow
var hasBrand, hasCat, hasNet, hasCountry, hasNutri, hasIng, hasImg bool
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,
&hasNutri, &hasIng, &hasImg); err != nil {
return nil, 0, err
}
r.UpdatedAt = updated.Format(time.RFC3339)
present := map[string]bool{
"name": r.Name != "",
"gtin": r.GTIN != nil && *r.GTIN != "",
"brand": hasBrand,
"category": hasCat,
"net_content": hasNet,
"country_of_origin": hasCountry,
"nutriments": hasNutri,
"ingredients": hasIng,
"image": hasImg,
}
r.Missing = []string{}
for _, f := range CompletenessFields {
if !present[f] {
r.Missing = append(r.Missing, f)
}
}
out = append(out, r)
}
return out, total, rows.Err()
}
// ---------- detail ----------
// ProductImage is one image row.
type ProductImage struct {
ID string `json:"id"`
URL string `json:"url"`
Kind string `json:"kind"`
License *string `json:"license"`
}
// MSRP is one suggested-retail-price snapshot.
type MSRP struct {
ID string `json:"id"`
Amount float64 `json:"amount"`
Currency string `json:"currency"`
Region string `json:"region"`
EffectiveDate *string `json:"effective_date"`
SourceURL *string `json:"source_url"`
Note *string `json:"note"`
}
// ProductDetail is the full editable view of a product.
type ProductDetail struct {
ID string `json:"id"`
GTIN *string `json:"gtin"`
Name string `json:"name"`
BrandID *string `json:"brand_id"`
Brand *string `json:"brand"`
CategoryID *string `json:"category_id"`
CategoryPath *string `json:"category_path"`
NetContentValue *float64 `json:"net_content_value"`
NetContentUnit *string `json:"net_content_unit"`
CountryOfOrigin *string `json:"country_of_origin"`
Status string `json:"status"`
QualityScore float64 `json:"quality_score"`
IngredientsText *string `json:"ingredients_text"`
Allergens []string `json:"allergens"`
Additives []string `json:"additives"`
Nutriments map[string]any `json:"nutriments"`
NutritionBasis *string `json:"nutrition_basis"`
ServingSize *string `json:"serving_size"`
NutriScore *string `json:"nutri_score"`
Barcodes []Barcode `json:"barcodes"`
Images []ProductImage `json:"images"`
MSRP []MSRP `json:"msrp"`
Missing []string `json:"missing"`
UpdatedAt string `json:"updated_at"`
}
// GetProduct returns the full editable detail for one product.
func (s *Store) GetProduct(ctx context.Context, id string) (*ProductDetail, error) {
var d ProductDetail
var nutriments []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,
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,
f.nutrition_basis, f.serving_size, f.nutri_score
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 p.id = $1`, id).Scan(
&d.ID, &d.GTIN, &d.Name, &d.BrandID, &d.Brand, &d.CategoryID, &d.CategoryPath,
&d.NetContentValue, &d.NetContentUnit, &d.CountryOfOrigin, &d.Status,
&d.QualityScore, &updated,
&d.IngredientsText, &d.Allergens, &d.Additives, &nutriments,
&d.NutritionBasis, &d.ServingSize, &d.NutriScore,
)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
d.UpdatedAt = updated.Format(time.RFC3339)
if len(nutriments) > 0 {
_ = json.Unmarshal(nutriments, &d.Nutriments)
}
if d.Allergens == nil {
d.Allergens = []string{}
}
if d.Additives == nil {
d.Additives = []string{}
}
bcs, err := s.listBarcodes(ctx, id)
if err != nil {
return nil, err
}
d.Barcodes = bcs
imgs, err := s.listImages(ctx, id)
if err != nil {
return nil, err
}
d.Images = imgs
msrps, err := s.listMSRP(ctx, id)
if err != nil {
return nil, err
}
d.MSRP = msrps
d.Missing = missingFromDetail(&d)
return &d, nil
}
func missingFromDetail(d *ProductDetail) []string {
present := map[string]bool{
"name": d.Name != "",
"gtin": d.GTIN != nil && *d.GTIN != "",
"brand": d.BrandID != nil,
"category": d.CategoryID != nil,
"net_content": d.NetContentValue != nil,
"country_of_origin": d.CountryOfOrigin != nil && *d.CountryOfOrigin != "",
"nutriments": len(d.Nutriments) > 0,
"ingredients": d.IngredientsText != nil && *d.IngredientsText != "",
"image": len(d.Images) > 0,
}
missing := []string{}
for _, f := range CompletenessFields {
if !present[f] {
missing = append(missing, f)
}
}
return missing
}
func (s *Store) listImages(ctx context.Context, productID string) ([]ProductImage, error) {
rows, err := s.pool.Query(ctx,
"SELECT id, url, kind, license FROM product_image WHERE product_id = $1 ORDER BY id", productID)
if err != nil {
return nil, err
}
defer rows.Close()
out := []ProductImage{}
for rows.Next() {
var im ProductImage
if err := rows.Scan(&im.ID, &im.URL, &im.Kind, &im.License); err != nil {
return nil, err
}
out = append(out, im)
}
return out, rows.Err()
}
func (s *Store) listMSRP(ctx context.Context, productID string) ([]MSRP, error) {
rows, err := s.pool.Query(ctx, `
SELECT id, amount, currency, region, effective_date::text, source_url, note
FROM product_msrp WHERE product_id = $1 ORDER BY effective_date DESC NULLS LAST`, productID)
if err != nil {
return nil, err
}
defer rows.Close()
out := []MSRP{}
for rows.Next() {
var m MSRP
if err := rows.Scan(&m.ID, &m.Amount, &m.Currency, &m.Region, &m.EffectiveDate, &m.SourceURL, &m.Note); err != nil {
return nil, err
}
out = append(out, m)
}
return out, rows.Err()
}
-130
View File
@@ -1,130 +0,0 @@
package adminstore
import (
"context"
"errors"
"strings"
"time"
"github.com/jackc/pgx/v5/pgconn"
"github.com/baicai2026-baicai/goods/api/internal/apikey"
)
// APIKeyRow is an admin-facing view of an issued API key (never the secret).
type APIKeyRow struct {
ID string `json:"id"`
Name string `json:"name"`
KeyPrefix string `json:"key_prefix"`
OwnerEmail *string `json:"owner_email"`
Tier string `json:"tier"`
RateLimitPerMin int `json:"rate_limit_per_min"`
RevokedAt *string `json:"revoked_at"`
CreatedBy *string `json:"created_by"`
CreatedAt string `json:"created_at"`
}
// APIKeyInput holds the fields accepted when issuing a key.
type APIKeyInput struct {
Name string `json:"name"`
OwnerEmail string `json:"owner_email"`
Tier string `json:"tier"`
RateLimitPerMin int `json:"rate_limit_per_min"`
}
// CreateAPIKey issues a new key, returning the one-time plaintext alongside the
// stored row. Only the SHA-256 hash and a short display prefix are persisted.
func (s *Store) CreateAPIKey(ctx context.Context, in APIKeyInput, createdBy string) (plaintext string, row APIKeyRow, err error) {
tier := in.Tier
if tier == "" {
tier = "free"
}
rate := in.RateLimitPerMin
if rate <= 0 {
rate = 120
}
var owner *string
if e := strings.TrimSpace(in.OwnerEmail); e != "" {
owner = &e
}
key, hash, prefix, err := apikey.Generate()
if err != nil {
return "", row, err
}
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,
).Scan(&row.ID, &row.Name, &row.KeyPrefix, &row.OwnerEmail, &row.Tier,
&row.RateLimitPerMin, &revoked, &createdByOut, &created)
if err != nil {
return "", row, err
}
row.CreatedBy = createdByOut
if created != nil {
row.CreatedAt = created.Format(time.RFC3339)
}
return key, row, nil
}
// 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
FROM api_key
ORDER BY (revoked_at IS NULL) DESC, created_at DESC`)
if err != nil {
return nil, err
}
defer rows.Close()
out := []APIKeyRow{}
for rows.Next() {
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 {
return nil, err
}
if revoked != nil {
v := revoked.Format(time.RFC3339)
r.RevokedAt = &v
}
if created != nil {
r.CreatedAt = created.Format(time.RFC3339)
}
out = append(out, r)
}
return out, rows.Err()
}
// RevokeAPIKey marks a key revoked. Revoking an already-revoked or missing key
// returns ErrNotFound.
func (s *Store) RevokeAPIKey(ctx context.Context, id string) error {
tag, err := s.pool.Exec(ctx,
"UPDATE api_key SET revoked_at = now() WHERE id = $1 AND revoked_at IS NULL", id)
if err != nil {
if isInvalidUUID(err) {
return ErrNotFound
}
return err
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
// isInvalidUUID reports whether err is a Postgres invalid-UUID-text error,
// which happens when a non-UUID id is supplied.
func isInvalidUUID(err error) bool {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
return pgErr.Code == "22P02"
}
return false
}
-270
View File
@@ -1,270 +0,0 @@
package adminstore
import (
"context"
"errors"
"github.com/jackc/pgx/v5"
"github.com/baicai2026-baicai/goods/api/internal/gtin"
)
// Barcode is one GS1 trade item number attached to a product.
type Barcode struct {
ID string `json:"id"`
GTIN string `json:"gtin"`
GTINType string `json:"gtin_type"`
PackLevel string `json:"pack_level"`
Region *string `json:"region"`
IsPrimary bool `json:"is_primary"`
}
// BarcodeInput is the payload for attaching a barcode to a product.
type BarcodeInput struct {
GTIN string `json:"gtin"`
GTINType string `json:"gtin_type"`
PackLevel string `json:"pack_level"`
Region *string `json:"region"`
IsPrimary bool `json:"is_primary"`
}
// ConflictError signals that a barcode is already attached to another product,
// so the operator must de-duplicate instead of creating a clash.
type ConflictError struct {
GTIN string
ProductID string
ProductName string
}
func (e *ConflictError) Error() string { return "条码已被其他商品占用:" + e.GTIN }
func validPackLevel(p string) string {
switch p {
case "each", "case", "pallet":
return p
default:
return "each"
}
}
func validGTINType(t, normalized string) string {
switch t {
case "EAN8", "UPC", "EAN13", "ITF14", "GTIN14":
return t
default:
return gtin.InferType(normalized)
}
}
func (s *Store) listBarcodes(ctx context.Context, productID string) ([]Barcode, error) {
rows, err := s.pool.Query(ctx,
`SELECT id, gtin, gtin_type, pack_level, region, is_primary
FROM product_barcode WHERE product_id = $1
ORDER BY is_primary DESC, gtin`, productID)
if err != nil {
return nil, err
}
defer rows.Close()
out := []Barcode{}
for rows.Next() {
var b Barcode
if err := rows.Scan(&b.ID, &b.GTIN, &b.GTINType, &b.PackLevel, &b.Region, &b.IsPrimary); err != nil {
return nil, err
}
out = append(out, b)
}
return out, rows.Err()
}
// barcodeOwner returns the product currently owning a barcode, if any.
func barcodeOwner(ctx context.Context, q pgx.Tx, code string) (productID, productName string, found bool, err error) {
err = q.QueryRow(ctx,
`SELECT pb.product_id, p.name FROM product_barcode pb
JOIN product p ON p.id = pb.product_id WHERE pb.gtin = $1`, code).
Scan(&productID, &productName)
if errors.Is(err, pgx.ErrNoRows) {
return "", "", false, nil
}
if err != nil {
return "", "", false, err
}
return productID, productName, true, nil
}
// AddBarcode validates and attaches a barcode to a product, recording audit.
// A barcode already owned by another product yields a *ConflictError.
func (s *Store) AddBarcode(ctx context.Context, productID, actor string, in BarcodeInput) (*Barcode, error) {
code, err := gtin.Normalize(in.GTIN)
if err != nil {
return nil, err
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
// Product must exist.
var exists bool
if err := tx.QueryRow(ctx, "SELECT EXISTS(SELECT 1 FROM product WHERE id=$1)", productID).Scan(&exists); err != nil {
return nil, err
}
if !exists {
return nil, ErrNotFound
}
// Globally unique: a barcode owned by any product (this one included)
// is a conflict the operator must resolve by de-duplicating.
if owner, name, found, err := barcodeOwner(ctx, tx, code); err != nil {
return nil, err
} else if found {
return nil, &ConflictError{GTIN: code, ProductID: owner, ProductName: name}
}
// Make this the primary barcode when requested or when none exists yet.
makePrimary := in.IsPrimary
if !makePrimary {
var hasPrimary bool
if err := tx.QueryRow(ctx,
"SELECT EXISTS(SELECT 1 FROM product_barcode WHERE product_id=$1 AND is_primary)", productID).
Scan(&hasPrimary); err != nil {
return nil, err
}
makePrimary = !hasPrimary
}
if makePrimary {
if _, err := tx.Exec(ctx,
"UPDATE product_barcode SET is_primary=false WHERE product_id=$1 AND is_primary", productID); err != nil {
return nil, err
}
}
srcID, _ := s.manualSourceID(ctx, tx)
var srcArg any
if srcID != "" {
srcArg = srcID
}
var b Barcode
err = tx.QueryRow(ctx, `
INSERT INTO product_barcode (product_id, gtin, gtin_type, pack_level, region, is_primary, source_id)
VALUES ($1,$2,$3,$4,$5,$6,$7)
RETURNING id, gtin, gtin_type, pack_level, region, is_primary`,
productID, code, validGTINType(in.GTINType, code), validPackLevel(in.PackLevel),
in.Region, makePrimary, srcArg).
Scan(&b.ID, &b.GTIN, &b.GTINType, &b.PackLevel, &b.Region, &b.IsPrimary)
if err != nil {
return nil, err
}
if makePrimary {
if _, err := tx.Exec(ctx, "UPDATE product SET gtin=$2 WHERE id=$1", productID, code); err != nil {
return nil, err
}
}
if _, err := s.recomputeQualityTx(ctx, tx, productID); err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
_ = s.writeAudit(ctx, actor, "add_barcode", "product", &productID, []string{"gtin"}, nil, b)
return &b, nil
}
// DeleteBarcode removes a barcode; if it was primary, another is promoted.
func (s *Store) DeleteBarcode(ctx context.Context, productID, barcodeID, actor string) error {
tx, err := s.pool.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
var code string
var wasPrimary bool
err = tx.QueryRow(ctx,
"DELETE FROM product_barcode WHERE id=$1 AND product_id=$2 RETURNING gtin, is_primary",
barcodeID, productID).Scan(&code, &wasPrimary)
if errors.Is(err, pgx.ErrNoRows) {
return ErrNotFound
}
if err != nil {
return err
}
if wasPrimary {
var newID, newGTIN string
e := tx.QueryRow(ctx,
"SELECT id, gtin FROM product_barcode WHERE product_id=$1 ORDER BY gtin LIMIT 1", productID).
Scan(&newID, &newGTIN)
if e == nil {
if _, err := tx.Exec(ctx, "UPDATE product_barcode SET is_primary=true WHERE id=$1", newID); err != nil {
return err
}
if _, err := tx.Exec(ctx, "UPDATE product SET gtin=$2 WHERE id=$1", productID, newGTIN); err != nil {
return err
}
} else if errors.Is(e, pgx.ErrNoRows) {
if _, err := tx.Exec(ctx, "UPDATE product SET gtin=NULL WHERE id=$1", productID); err != nil {
return err
}
} else {
return e
}
}
if _, err := s.recomputeQualityTx(ctx, tx, productID); err != nil {
return err
}
if err := tx.Commit(ctx); err != nil {
return err
}
_ = s.writeAudit(ctx, actor, "delete_barcode", "product", &productID, []string{"gtin"},
map[string]string{"gtin": code}, nil)
return nil
}
// SetPrimaryBarcode marks one barcode primary and mirrors it to product.gtin.
func (s *Store) SetPrimaryBarcode(ctx context.Context, productID, barcodeID, actor string) (*Barcode, error) {
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
var code string
err = tx.QueryRow(ctx, "SELECT gtin FROM product_barcode WHERE id=$1 AND product_id=$2", barcodeID, productID).Scan(&code)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
if _, err := tx.Exec(ctx, "UPDATE product_barcode SET is_primary=false WHERE product_id=$1 AND is_primary", productID); err != nil {
return nil, err
}
if _, err := tx.Exec(ctx, "UPDATE product_barcode SET is_primary=true WHERE id=$1", barcodeID); err != nil {
return nil, err
}
if _, err := tx.Exec(ctx, "UPDATE product SET gtin=$2 WHERE id=$1", productID, code); err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
_ = s.writeAudit(ctx, actor, "set_primary_barcode", "product", &productID, []string{"gtin"}, nil,
map[string]string{"gtin": code})
bcs, err := s.listBarcodes(ctx, productID)
if err != nil {
return nil, err
}
for i := range bcs {
if bcs[i].ID == barcodeID {
return &bcs[i], nil
}
}
return nil, ErrNotFound
}
-154
View File
@@ -1,154 +0,0 @@
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
@@ -1,78 +0,0 @@
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
@@ -1,80 +0,0 @@
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
}
-297
View File
@@ -1,297 +0,0 @@
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
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 FROM category WHERE id = $1", *pid).Scan(&path, &level)
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)
VALUES ($1, $2, $3, $4::ltree, $5, $6)
RETURNING id, name_zh, name_en, path::text, level, parent_id::text, gpc_brick_code, 0`,
name, trimPtr(in.NameEN), parentID, path, trimPtr(in.GPCBrickCode), level).
Scan(&c.ID, &c.NameZH, &c.NameEN, &c.Path, &c.Level, &c.ParentID, &c.GPCBrickCode, &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,
(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.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
@@ -1,130 +0,0 @@
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)
}
}
}
-137
View File
@@ -1,137 +0,0 @@
package adminstore
import (
"context"
"math"
"time"
"github.com/jackc/pgx/v5"
)
// Quality weights mirror ingestion/opengoods/etl/quality.py.
const (
wCompleteness = 0.4
wSourceTrust = 0.3
wAgreement = 0.2
wFreshness = 0.1
)
// queryer is satisfied by both *pgxpool.Pool and pgx.Tx.
type queryer interface {
QueryRow(ctx context.Context, sql string, args ...any) pgx.Row
}
func agreementFromSources(n int) float64 {
switch {
case n <= 1:
return 0.5
case n == 2:
return 0.8
default:
return 1.0
}
}
func freshnessFromAge(ageDays *float64) float64 {
if ageDays == nil {
return 0.5
}
d := *ageDays
switch {
case d <= 30:
return 1.0
case d <= 180:
return 0.8
case d <= 365:
return 0.6
case d <= 730:
return 0.4
default:
return 0.2
}
}
func (s *Store) computeQuality(ctx context.Context, q queryer, productID string) (float64, error) {
var name, country *string
var gtin *string
var brandID, categoryID *string
var netCanonical *float64
var ingredients *string
var hasNutri, hasImage bool
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,
(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
WHERE p.id = $1`, productID).Scan(
&name, &gtin, &brandID, &categoryID, &netCanonical, &country,
&ingredients, &hasNutri, &hasImage)
if err != nil {
return 0, err
}
present := 0
bump := func(ok bool) {
if ok {
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))
var sourceCount int
var sourceTrust *float64
var lastFetched *time.Time
err = q.QueryRow(ctx, `
SELECT count(DISTINCT ps.source_id), COALESCE(max(s.trust_weight),0), max(ps.fetched_at)
FROM product_source ps LEFT JOIN source s ON s.id = ps.source_id
WHERE ps.product_id = $1`, productID).Scan(&sourceCount, &sourceTrust, &lastFetched)
if err != nil {
return 0, err
}
trust := 0.0
if sourceTrust != nil {
trust = *sourceTrust
}
var ageDays *float64
if lastFetched != nil {
d := time.Since(*lastFetched).Hours() / 24.0
if d < 0 {
d = 0
}
ageDays = &d
}
raw := wCompleteness*completeness + wSourceTrust*trust +
wAgreement*agreementFromSources(sourceCount) + wFreshness*freshnessFromAge(ageDays)
raw = math.Max(0, math.Min(1, raw))
return math.Round(raw*1000) / 1000, nil
}
func (s *Store) recomputeQualityTx(ctx context.Context, tx pgx.Tx, productID string) (float64, error) {
v, err := s.computeQuality(ctx, tx, productID)
if err != nil {
return 0, err
}
_, err = tx.Exec(ctx, "UPDATE product SET quality_score=$1 WHERE id=$2", v, productID)
return v, err
}
func (s *Store) recomputeQuality(ctx context.Context, productID string) (float64, error) {
v, err := s.computeQuality(ctx, s.pool, productID)
if err != nil {
return 0, err
}
_, err = s.pool.Exec(ctx, "UPDATE product SET quality_score=$1 WHERE id=$2", v, productID)
return v, err
}
-30
View File
@@ -1,30 +0,0 @@
package adminstore
import "testing"
func TestAgreementFromSources(t *testing.T) {
cases := map[int]float64{0: 0.5, 1: 0.5, 2: 0.8, 3: 1.0, 9: 1.0}
for n, want := range cases {
if got := agreementFromSources(n); got != want {
t.Errorf("agreementFromSources(%d) = %v, want %v", n, got, want)
}
}
}
func TestFreshnessFromAge(t *testing.T) {
mk := func(d float64) *float64 { return &d }
if got := freshnessFromAge(nil); got != 0.5 {
t.Errorf("nil age = %v, want 0.5", got)
}
cases := []struct {
days float64
want float64
}{
{10, 1.0}, {30, 1.0}, {100, 0.8}, {300, 0.6}, {500, 0.4}, {1000, 0.2},
}
for _, c := range cases {
if got := freshnessFromAge(mk(c.days)); got != c.want {
t.Errorf("freshnessFromAge(%v) = %v, want %v", c.days, got, c.want)
}
}
}
-61
View File
@@ -1,61 +0,0 @@
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
}
@@ -1,71 +0,0 @@
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)
}
}
-421
View File
@@ -1,421 +0,0 @@
package adminstore
import (
"context"
"encoding/json"
"errors"
"strconv"
"strings"
"time"
"github.com/jackc/pgx/v5"
)
// ErrConflict is returned when a submission has already been reviewed.
var ErrConflict = errors.New("conflict")
// SubmissionImage is one proposed image URL inside a contribution.
type SubmissionImage struct {
URL string `json:"url"`
Kind string `json:"kind"`
}
// SubmissionInput is the public contribution payload (no login required).
type SubmissionInput struct {
GTIN *string `json:"gtin"`
Name string `json:"name"`
BrandName *string `json:"brand_name"`
CategoryID *string `json:"category_id"`
NetContentValue *float64 `json:"net_content_value"`
NetContentUnit *string `json:"net_content_unit"`
CountryOfOrigin *string `json:"country_of_origin"`
IngredientsText *string `json:"ingredients_text"`
Nutriments map[string]any `json:"nutriments"`
NutritionBasis *string `json:"nutrition_basis"`
ServingSize *string `json:"serving_size"`
NutriScore *string `json:"nutri_score"`
Images []SubmissionImage `json:"images"`
MSRP []MSRPInput `json:"msrp"`
SubmitterName *string `json:"submitter_name"`
SubmitterContact *string `json:"submitter_contact"`
Note *string `json:"note"`
}
// SubmissionRow is a queue-list row for the admin review table.
type SubmissionRow struct {
ID string `json:"id"`
GTIN *string `json:"gtin"`
Name string `json:"name"`
Status string `json:"status"`
SubmitterName *string `json:"submitter_name"`
Matched bool `json:"matched"`
CreatedAt string `json:"created_at"`
ReviewedAt *string `json:"reviewed_at"`
}
// SubmissionDetail is the full review view of one contribution.
type SubmissionDetail struct {
ID string `json:"id"`
Status string `json:"status"`
GTIN *string `json:"gtin"`
Name string `json:"name"`
SubmitterName *string `json:"submitter_name"`
SubmitterContact *string `json:"submitter_contact"`
Note *string `json:"note"`
ReviewNote *string `json:"review_note"`
ReviewedBy *string `json:"reviewed_by"`
ReviewedAt *string `json:"reviewed_at"`
CreatedAt string `json:"created_at"`
TargetProductID *string `json:"target_product_id"`
ResultProductID *string `json:"result_product_id"`
Payload SubmissionInput `json:"payload"`
ExistingProduct *ProductDetail `json:"existing_product,omitempty"`
}
// CreateSubmission validates and stores a public contribution as pending.
func (s *Store) CreateSubmission(ctx context.Context, in SubmissionInput, remoteIP string) (string, error) {
in.Name = strings.TrimSpace(in.Name)
if in.Name == "" {
return "", errors.New("商品名称不能为空")
}
if in.GTIN != nil {
g := strings.TrimSpace(*in.GTIN)
if g == "" {
in.GTIN = nil
} else {
in.GTIN = &g
}
}
// Link to an existing product when the barcode already exists (supplement).
var target *string
if in.GTIN != nil {
var pid string
err := s.pool.QueryRow(ctx, "SELECT id FROM product WHERE gtin = $1", *in.GTIN).Scan(&pid)
if err == nil {
target = &pid
} else if !errors.Is(err, pgx.ErrNoRows) {
return "", err
}
}
payload, err := json.Marshal(in)
if err != nil {
return "", err
}
var id string
err = s.pool.QueryRow(ctx, `
INSERT INTO submission (gtin, name, payload, target_product_id, submitter_name, submitter_contact, note, remote_ip)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8) RETURNING id`,
in.GTIN, in.Name, payload, target, in.SubmitterName, in.SubmitterContact, in.Note, remoteIP).Scan(&id)
return id, err
}
// ListSubmissions returns submissions filtered by status (empty = all).
func (s *Store) ListSubmissions(ctx context.Context, status string, limit, offset int) ([]SubmissionRow, int, error) {
args := []any{}
where := "WHERE 1=1"
if status != "" {
args = append(args, status)
where += " AND status = $1"
}
var total int
if err := s.pool.QueryRow(ctx, "SELECT count(*) FROM submission "+where, args...).Scan(&total); err != nil {
return nil, 0, err
}
args = append(args, limit, offset)
sql := `
SELECT id, gtin, name, status, submitter_name, (target_product_id IS NOT NULL),
created_at, reviewed_at
FROM submission ` + where +
" ORDER BY (status='pending') DESC, created_at DESC LIMIT $" +
strconv.Itoa(len(args)-1) + " OFFSET $" + strconv.Itoa(len(args))
rows, err := s.pool.Query(ctx, sql, args...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
out := []SubmissionRow{}
for rows.Next() {
var r SubmissionRow
var created time.Time
var reviewed *time.Time
if err := rows.Scan(&r.ID, &r.GTIN, &r.Name, &r.Status, &r.SubmitterName, &r.Matched, &created, &reviewed); err != nil {
return nil, 0, err
}
r.CreatedAt = created.Format(time.RFC3339)
if reviewed != nil {
t := reviewed.Format(time.RFC3339)
r.ReviewedAt = &t
}
out = append(out, r)
}
return out, total, rows.Err()
}
// PendingSubmissionCount returns the number of submissions awaiting review.
func (s *Store) PendingSubmissionCount(ctx context.Context) (int, error) {
var n int
err := s.pool.QueryRow(ctx, "SELECT count(*) FROM submission WHERE status='pending'").Scan(&n)
return n, err
}
// GetSubmission returns the full review detail for one submission.
func (s *Store) GetSubmission(ctx context.Context, id string) (*SubmissionDetail, error) {
var d SubmissionDetail
var payload []byte
var created time.Time
var reviewed *time.Time
err := s.pool.QueryRow(ctx, `
SELECT id, status, gtin, name, submitter_name, submitter_contact, note,
review_note, reviewed_by, reviewed_at, created_at, target_product_id, result_product_id, payload
FROM submission WHERE id = $1`, id).Scan(
&d.ID, &d.Status, &d.GTIN, &d.Name, &d.SubmitterName, &d.SubmitterContact, &d.Note,
&d.ReviewNote, &d.ReviewedBy, &reviewed, &created, &d.TargetProductID, &d.ResultProductID, &payload,
)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
d.CreatedAt = created.Format(time.RFC3339)
if reviewed != nil {
t := reviewed.Format(time.RFC3339)
d.ReviewedAt = &t
}
if len(payload) > 0 {
_ = json.Unmarshal(payload, &d.Payload)
}
if d.TargetProductID != nil {
if ep, err := s.GetProduct(ctx, *d.TargetProductID); err == nil {
d.ExistingProduct = ep
}
}
return &d, nil
}
// RejectSubmission marks a pending submission as rejected with a reviewer note.
func (s *Store) RejectSubmission(ctx context.Context, id, actor, note string) error {
ct, err := s.pool.Exec(ctx, `
UPDATE submission SET status='rejected', review_note=$2, reviewed_by=$3, reviewed_at=now()
WHERE id=$1 AND status='pending'`, id, note, actor)
if err != nil {
return err
}
if ct.RowsAffected() == 0 {
// Distinguish missing vs already-reviewed.
var st string
if e := s.pool.QueryRow(ctx, "SELECT status FROM submission WHERE id=$1", id).Scan(&st); errors.Is(e, pgx.ErrNoRows) {
return ErrNotFound
}
return ErrConflict
}
_ = s.writeAudit(ctx, actor, "reject_submission", "submission", &id, []string{}, nil, map[string]string{"review_note": note})
return nil
}
// ApproveSubmission applies a pending contribution to the product store
// (creating or supplementing a product), records community provenance + audit,
// recomputes quality, and marks the submission approved.
func (s *Store) ApproveSubmission(ctx context.Context, id, actor string) (*ProductDetail, error) {
sub, err := s.GetSubmission(ctx, id)
if err != nil {
return nil, err
}
if sub.Status != "pending" {
return nil, ErrConflict
}
in := sub.Payload
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
communityID, err := s.sourceIDTx(ctx, tx, "community")
if err != nil {
return nil, err
}
// Resolve the target product (existing supplement vs new create).
productID := ""
if sub.TargetProductID != nil {
productID = *sub.TargetProductID
} else if in.GTIN != nil {
var pid string
if e := tx.QueryRow(ctx, "SELECT id FROM product WHERE gtin=$1", *in.GTIN).Scan(&pid); e == nil {
productID = pid
} else if !errors.Is(e, pgx.ErrNoRows) {
return nil, e
}
}
var brandID *string
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
}
fields := submissionFields(in)
if productID == "" {
// Create a new product from the contribution.
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,'active') RETURNING id`,
in.GTIN, in.Name, brandID, in.CategoryID, gpc,
in.NetContentValue, in.NetContentUnit, canonical, in.CountryOfOrigin).Scan(&productID)
if err != nil {
return nil, err
}
} else {
// Supplement an existing product: only overwrite fields the
// contribution actually provides (COALESCE keeps current values).
_, err = tx.Exec(ctx, `
UPDATE product SET
name=COALESCE(NULLIF($2,''), name),
brand_id=COALESCE($3, brand_id),
category_id=COALESCE($4, category_id),
gpc_brick_code=COALESCE($5, gpc_brick_code),
net_content_value=COALESCE($6, net_content_value),
net_content_unit=COALESCE($7, net_content_unit),
net_content_canonical=COALESCE($8, net_content_canonical),
country_of_origin=COALESCE($9, country_of_origin),
gtin=COALESCE($10, gtin)
WHERE id=$1`,
productID, in.Name, brandID, in.CategoryID, gpc,
in.NetContentValue, in.NetContentUnit, canonical, in.CountryOfOrigin, in.GTIN)
if err != nil {
return nil, err
}
}
// food_detail: upsert, preserving existing values where not provided.
var nutriJSON []byte
if len(in.Nutriments) > 0 {
nutriJSON, _ = json.Marshal(in.Nutriments)
}
_, err = tx.Exec(ctx, `
INSERT INTO food_detail (product_id, ingredients_text, nutriments, nutrition_basis, serving_size, nutri_score)
VALUES ($1,$2,$3,$4,$5,$6)
ON CONFLICT (product_id) DO UPDATE SET
ingredients_text=COALESCE(EXCLUDED.ingredients_text, food_detail.ingredients_text),
nutriments=COALESCE(EXCLUDED.nutriments, food_detail.nutriments),
nutrition_basis=COALESCE(EXCLUDED.nutrition_basis, food_detail.nutrition_basis),
serving_size=COALESCE(EXCLUDED.serving_size, food_detail.serving_size),
nutri_score=COALESCE(EXCLUDED.nutri_score, food_detail.nutri_score)`,
productID, in.IngredientsText, nutriJSON, in.NutritionBasis, in.ServingSize, in.NutriScore)
if err != nil {
return nil, err
}
for _, im := range in.Images {
url := strings.TrimSpace(im.URL)
if url == "" {
continue
}
kind := im.Kind
if kind != "front" && kind != "ingredients" && kind != "nutrition" {
kind = "other"
}
if _, err := tx.Exec(ctx, `
INSERT INTO product_image (product_id, url, kind, source_id) VALUES ($1,$2,$3,$4)`,
productID, url, kind, communityID); err != nil {
return nil, err
}
}
for _, m := range in.MSRP {
if m.Amount <= 0 {
continue
}
cur := m.Currency
if cur == "" {
cur = "CNY"
}
region := m.Region
if region == "" {
region = "CN"
}
if _, err := tx.Exec(ctx, `
INSERT INTO product_msrp (product_id, amount, currency, region, source_id, source_url, effective_date, note)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8)`,
productID, m.Amount, cur, region, communityID, m.SourceURL, m.EffectiveDate, m.Note); err != nil {
return nil, err
}
}
if _, err := s.recomputeQualityTx(ctx, tx, productID); err != nil {
return nil, err
}
if _, err := tx.Exec(ctx, `
UPDATE submission SET status='approved', reviewed_by=$2, reviewed_at=now(), result_product_id=$3
WHERE id=$1`, id, actor, productID); err != nil {
return nil, err
}
// Field-level provenance for the contributed fields (community source).
if len(fields) > 0 {
if _, err := tx.Exec(ctx, `
INSERT INTO product_source (product_id, source_id, url, fields, fetched_at, raw)
VALUES ($1,$2,NULL,$3,now(),NULL)`, productID, communityID, fields); err != nil {
return nil, err
}
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
_ = s.writeAudit(ctx, actor, "approve_submission", "product", &productID, fields,
map[string]string{"submission_id": id}, map[string]string{"product_id": productID})
return s.GetProduct(ctx, productID)
}
func (s *Store) sourceIDTx(ctx context.Context, tx pgx.Tx, name string) (string, error) {
var id string
err := tx.QueryRow(ctx, "SELECT id FROM source WHERE name=$1", name).Scan(&id)
return id, err
}
// submissionFields lists the product fields a contribution provides values for.
func submissionFields(in SubmissionInput) []string {
fields := []string{"name"}
add := func(name string, present bool) {
if present {
fields = append(fields, name)
}
}
add("gtin", in.GTIN != nil && *in.GTIN != "")
add("brand", in.BrandName != nil && strings.TrimSpace(*in.BrandName) != "")
add("category", in.CategoryID != nil && *in.CategoryID != "")
add("net_content", in.NetContentValue != nil)
add("country_of_origin", in.CountryOfOrigin != nil && *in.CountryOfOrigin != "")
add("ingredients", in.IngredientsText != nil && *in.IngredientsText != "")
add("nutriments", len(in.Nutriments) > 0)
add("image", len(in.Images) > 0)
return fields
}
-541
View File
@@ -1,541 +0,0 @@
package adminstore
import (
"context"
"encoding/json"
"errors"
"strings"
"github.com/jackc/pgx/v5"
)
// ProductInput is the editable payload accepted from the admin UI.
type ProductInput struct {
GTIN *string `json:"gtin"`
Name string `json:"name"`
BrandID *string `json:"brand_id"`
BrandName *string `json:"brand_name"`
CategoryID *string `json:"category_id"`
NetContentValue *float64 `json:"net_content_value"`
NetContentUnit *string `json:"net_content_unit"`
CountryOfOrigin *string `json:"country_of_origin"`
Status string `json:"status"`
IngredientsText *string `json:"ingredients_text"`
Allergens []string `json:"allergens"`
Additives []string `json:"additives"`
Nutriments map[string]any `json:"nutriments"`
NutritionBasis *string `json:"nutrition_basis"`
ServingSize *string `json:"serving_size"`
NutriScore *string `json:"nutri_score"`
}
func normBrand(name string) string { return strings.Join(strings.Fields(strings.ToLower(name)), " ") }
func (s *Store) ensureBrand(ctx context.Context, tx pgx.Tx, name string) (string, error) {
var id string
err := tx.QueryRow(ctx, `
INSERT INTO brand (name, normalized_name) VALUES ($1, $2)
ON CONFLICT (normalized_name) DO UPDATE SET name = brand.name
RETURNING id`, name, normBrand(name)).Scan(&id)
return id, err
}
func (s *Store) manualSourceID(ctx context.Context, tx pgx.Tx) (string, error) {
var id string
err := tx.QueryRow(ctx, "SELECT id FROM source WHERE name = 'manual'").Scan(&id)
return id, err
}
// netCanonical converts value+unit to the canonical base unit via the unit table.
func (s *Store) netCanonical(ctx context.Context, tx pgx.Tx, value *float64, unit *string) (*float64, error) {
if value == nil || unit == nil || *unit == "" {
return nil, nil
}
var factor *float64
err := tx.QueryRow(ctx, "SELECT to_canonical_factor FROM unit WHERE code = $1", *unit).Scan(&factor)
if errors.Is(err, pgx.ErrNoRows) || factor == nil {
return nil, nil
}
if err != nil {
return nil, err
}
c := *value * *factor
return &c, nil
}
// UpdateProduct applies an edit, records provenance + audit, and recomputes quality.
func (s *Store) UpdateProduct(ctx context.Context, id, actor string, in ProductInput) (*ProductDetail, error) {
before, err := s.GetProduct(ctx, id)
if err != nil {
return nil, err
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
// Resolve brand (create-by-name takes precedence over id).
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
}
// Resolve category gpc brick code.
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 = before.Status
}
_, err = tx.Exec(ctx, `
UPDATE product SET gtin=$1, name=$2, brand_id=$3, category_id=$4, gpc_brick_code=$5,
net_content_value=$6, net_content_unit=$7, net_content_canonical=$8,
country_of_origin=$9, status=$10
WHERE id=$11`,
in.GTIN, in.Name, brandID, in.CategoryID, gpc,
in.NetContentValue, in.NetContentUnit, canonical,
in.CountryOfOrigin, status, id)
if err != nil {
return nil, err
}
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)
ON CONFLICT (product_id) DO UPDATE SET
ingredients_text=EXCLUDED.ingredients_text,
allergens=EXCLUDED.allergens,
additives=EXCLUDED.additives,
nutriments=EXCLUDED.nutriments,
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
}
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
}
changed := diffFields(before, after)
if len(changed) > 0 {
if err := s.recordProvenance(ctx, id, changed); err != nil {
return nil, err
}
}
if err := s.writeAudit(ctx, actor, "update", "product", &id, changed, before, after); err != nil {
return nil, err
}
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
}
if a == nil || b == nil {
return false
}
return *a == *b
}
func floatEq(a, b *float64) bool {
if a == nil && b == nil {
return true
}
if a == nil || b == nil {
return false
}
return *a == *b
}
func diffFields(a, b *ProductDetail) []string {
changed := []string{}
add := func(name string, eq bool) {
if !eq {
changed = append(changed, name)
}
}
add("gtin", strEq(a.GTIN, b.GTIN))
add("name", a.Name == b.Name)
add("brand", strEq(a.BrandID, b.BrandID))
add("category", strEq(a.CategoryID, b.CategoryID))
add("net_content", floatEq(a.NetContentValue, b.NetContentValue) && strEq(a.NetContentUnit, b.NetContentUnit))
add("country_of_origin", strEq(a.CountryOfOrigin, b.CountryOfOrigin))
add("status", a.Status == b.Status)
add("ingredients", strEq(a.IngredientsText, b.IngredientsText))
ja, _ := json.Marshal(a.Nutriments)
jb, _ := json.Marshal(b.Nutriments)
add("nutriments", string(ja) == string(jb))
add("nutrition_basis", strEq(a.NutritionBasis, b.NutritionBasis))
add("serving_size", strEq(a.ServingSize, b.ServingSize))
add("nutri_score", strEq(a.NutriScore, b.NutriScore))
return changed
}
func (s *Store) recordProvenance(ctx context.Context, productID string, fields []string) error {
var srcID string
if err := s.pool.QueryRow(ctx, "SELECT id FROM source WHERE name = 'manual'").Scan(&srcID); err != nil {
return err
}
_, err := s.pool.Exec(ctx, `
INSERT INTO product_source (product_id, source_id, url, fields, fetched_at, raw)
VALUES ($1, $2, NULL, $3, now(), NULL)`, productID, srcID, fields)
return err
}
func (s *Store) writeAudit(ctx context.Context, actor, action, entity string, entityID *string, fields []string, before, after any) error {
bj, _ := json.Marshal(before)
aj, _ := json.Marshal(after)
if fields == nil {
fields = []string{}
}
_, err := s.pool.Exec(ctx, `
INSERT INTO audit_log (actor, action, entity, entity_id, fields, before, after)
VALUES ($1,$2,$3,$4,$5,$6,$7)`, actor, action, entity, entityID, fields, bj, aj)
return err
}
// ---------- images ----------
// AddImage inserts an image URL (manual source) and recomputes quality.
func (s *Store) AddImage(ctx context.Context, productID, actor, url, kind string) (*ProductImage, error) {
if kind == "" {
kind = "other"
}
var srcID string
if err := s.pool.QueryRow(ctx, "SELECT id FROM source WHERE name = 'manual'").Scan(&srcID); err != nil {
return nil, err
}
var im ProductImage
err := s.pool.QueryRow(ctx, `
INSERT INTO product_image (product_id, url, kind, license, source_id)
VALUES ($1,$2,$3,NULL,$4) RETURNING id, url, kind, license`,
productID, url, kind, srcID).Scan(&im.ID, &im.URL, &im.Kind, &im.License)
if err != nil {
return nil, err
}
if _, err := s.recomputeQuality(ctx, productID); err != nil {
return nil, err
}
_ = s.writeAudit(ctx, actor, "add_image", "product", &productID, []string{"image"}, nil, im)
return &im, nil
}
// DeleteImage removes an image and recomputes quality.
func (s *Store) DeleteImage(ctx context.Context, productID, imageID, actor string) error {
ct, err := s.pool.Exec(ctx, "DELETE FROM product_image WHERE id=$1 AND product_id=$2", imageID, productID)
if err != nil {
return err
}
if ct.RowsAffected() == 0 {
return ErrNotFound
}
if _, err := s.recomputeQuality(ctx, productID); err != nil {
return err
}
_ = s.writeAudit(ctx, actor, "delete_image", "product", &productID, []string{"image"}, map[string]string{"image_id": imageID}, nil)
return nil
}
// ---------- msrp ----------
// MSRPInput is the payload for adding an MSRP snapshot.
type MSRPInput struct {
Amount float64 `json:"amount"`
Currency string `json:"currency"`
Region string `json:"region"`
EffectiveDate *string `json:"effective_date"`
SourceURL *string `json:"source_url"`
Note *string `json:"note"`
}
// AddMSRP inserts a suggested-retail-price snapshot.
func (s *Store) AddMSRP(ctx context.Context, productID, actor string, in MSRPInput) (*MSRP, error) {
if in.Currency == "" {
in.Currency = "CNY"
}
if in.Region == "" {
in.Region = "CN"
}
var srcID string
_ = s.pool.QueryRow(ctx, "SELECT id FROM source WHERE name = 'manual'").Scan(&srcID)
var m MSRP
err := s.pool.QueryRow(ctx, `
INSERT INTO product_msrp (product_id, amount, currency, region, source_id, source_url, effective_date, note)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8)
RETURNING id, amount, currency, region, effective_date::text, source_url, note`,
productID, in.Amount, in.Currency, in.Region, srcID, in.SourceURL, in.EffectiveDate, in.Note).
Scan(&m.ID, &m.Amount, &m.Currency, &m.Region, &m.EffectiveDate, &m.SourceURL, &m.Note)
if err != nil {
return nil, err
}
_ = s.writeAudit(ctx, actor, "add_msrp", "product", &productID, []string{"msrp"}, nil, m)
return &m, nil
}
// DeleteMSRP removes an MSRP snapshot.
func (s *Store) DeleteMSRP(ctx context.Context, productID, msrpID, actor string) error {
ct, err := s.pool.Exec(ctx, "DELETE FROM product_msrp WHERE id=$1 AND product_id=$2", msrpID, productID)
if err != nil {
return err
}
if ct.RowsAffected() == 0 {
return ErrNotFound
}
_ = s.writeAudit(ctx, actor, "delete_msrp", "product", &productID, []string{"msrp"}, map[string]string{"msrp_id": msrpID}, nil)
return nil
}
// ---------- dictionaries ----------
// Brand is a brand option for the edit form and the management view.
type Brand struct {
ID string `json:"id"`
Name string `json:"name"`
ProductCount int `json:"product_count"`
}
// 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 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
}
defer rows.Close()
out := []Brand{}
for rows.Next() {
var b Brand
if err := rows.Scan(&b.ID, &b.Name, &b.ProductCount); err != nil {
return nil, err
}
out = append(out, b)
}
return out, rows.Err()
}
// 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"`
ParentID *string `json:"parent_id"`
GPCBrickCode *string `json:"gpc_brick_code"`
ProductCount int `json:"product_count"`
}
// 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 c.id, c.name_zh, c.name_en, c.path::text, c.level, c.parent_id::text,
c.gpc_brick_code,
(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
}
defer rows.Close()
out := []Category{}
for rows.Next() {
var c Category
if err := rows.Scan(&c.ID, &c.NameZH, &c.NameEN, &c.Path, &c.Level,
&c.ParentID, &c.GPCBrickCode, &c.ProductCount); err != nil {
return nil, err
}
out = append(out, c)
}
return out, rows.Err()
}
// ---------- audit ----------
// AuditEntry is one audit-log row for the history view.
type AuditEntry struct {
ID string `json:"id"`
Actor string `json:"actor"`
Action string `json:"action"`
Fields []string `json:"fields"`
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, `
SELECT id, actor, action, fields, created_at::text
FROM audit_log WHERE entity='product' AND entity_id=$1
ORDER BY created_at DESC LIMIT $2`, productID, limit)
if err != nil {
return nil, err
}
defer rows.Close()
out := []AuditEntry{}
for rows.Next() {
var e AuditEntry
if err := rows.Scan(&e.ID, &e.Actor, &e.Action, &e.Fields, &e.CreatedAt); err != nil {
return nil, err
}
out = append(out, e)
}
return out, rows.Err()
}
-7
View File
@@ -1,7 +0,0 @@
<!doctype html>
<html lang="zh">
<head><meta charset="utf-8" /><title>OpenGoods 管理后台</title></head>
<body>
<p>管理后台前端尚未构建。Docker 构建会在此处放入真正的前端产物。</p>
</body>
</html>
-21
View File
@@ -1,21 +0,0 @@
// Package adminweb embeds the built admin SPA (Vite dist). During Docker builds
// the real dist/ is produced by the node stage and copied in before go build;
// the committed placeholder keeps the package compilable for `go build ./...`.
package adminweb
import (
"embed"
"io/fs"
)
//go:embed all:dist
var distFS embed.FS
// Dist returns the embedded SPA filesystem rooted at dist/.
func Dist() fs.FS {
sub, err := fs.Sub(distFS, "dist")
if err != nil {
panic(err)
}
return sub
}
-49
View File
@@ -1,49 +0,0 @@
// Package apikey handles generation and hashing of public-API keys.
//
// A key looks like "og_live_<random>". Only the SHA-256 hash is ever persisted;
// the plaintext is returned once at creation time and cannot be recovered.
package apikey
import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"strings"
)
// Prefix is the human-readable scheme prefix every key carries.
const Prefix = "og_live_"
// prefixLen is how many leading characters (including Prefix) are stored in
// api_key.key_prefix for identifying a key without revealing its secret.
const prefixLen = 12
// Generate returns a new random key (plaintext), its SHA-256 hash, and a short
// display prefix. The plaintext must be shown to the caller exactly once.
func Generate() (key, hash, displayPrefix string, err error) {
buf := make([]byte, 24)
if _, err = rand.Read(buf); err != nil {
return "", "", "", err
}
// URL-safe, no padding => stable, copy-pasteable token body.
body := base64.RawURLEncoding.EncodeToString(buf)
key = Prefix + body
hash = Hash(key)
displayPrefix = key
if len(displayPrefix) > prefixLen {
displayPrefix = displayPrefix[:prefixLen]
}
return key, hash, displayPrefix, nil
}
// Hash returns the hex-encoded SHA-256 of a key, used for storage and lookup.
func Hash(key string) string {
sum := sha256.Sum256([]byte(strings.TrimSpace(key)))
return hex.EncodeToString(sum[:])
}
// Looks like a key issued by this service (cheap pre-check before hashing).
func IsWellFormed(key string) bool {
return strings.HasPrefix(key, Prefix) && len(key) > len(Prefix)+8
}
-60
View File
@@ -1,60 +0,0 @@
package apikey
import "testing"
func TestGenerate(t *testing.T) {
key, hash, prefix, err := Generate()
if err != nil {
t.Fatalf("Generate: %v", err)
}
if !IsWellFormed(key) {
t.Fatalf("generated key not well-formed: %q", key)
}
if Hash(key) != hash {
t.Fatalf("Hash(key) != returned hash")
}
if len(prefix) != prefixLen || key[:prefixLen] != prefix {
t.Fatalf("prefix %q not a %d-char prefix of key %q", prefix, prefixLen, key)
}
if len(hash) != 64 {
t.Fatalf("hash not hex sha-256: %q", hash)
}
}
func TestGenerateUnique(t *testing.T) {
seen := map[string]bool{}
for i := 0; i < 100; i++ {
k, _, _, err := Generate()
if err != nil {
t.Fatal(err)
}
if seen[k] {
t.Fatalf("duplicate key generated: %q", k)
}
seen[k] = true
}
}
func TestHashStableAndTrimmed(t *testing.T) {
if Hash("og_live_abc") != Hash(" og_live_abc ") {
t.Fatal("Hash should ignore surrounding whitespace")
}
if Hash("a") == Hash("b") {
t.Fatal("distinct inputs must hash differently")
}
}
func TestIsWellFormed(t *testing.T) {
cases := map[string]bool{
"og_live_abcdefghijkl": true, // body longer than 8 chars
"og_live_": false, // empty body
"og_live_abc": false, // body too short
"nope_abcdefghijkl": false, // wrong prefix
"": false,
}
for in, want := range cases {
if got := IsWellFormed(in); got != want {
t.Errorf("IsWellFormed(%q) = %v, want %v", in, got, want)
}
}
}
-126
View File
@@ -1,126 +0,0 @@
// Package auth provides minimal single-account authentication for the admin
// console: a bcrypt-verified login and a stdlib HMAC-SHA256 signed token
// (JWT-compatible) plus a chi middleware that guards write routes.
package auth
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"net/http"
"strings"
"time"
"golang.org/x/crypto/bcrypt"
)
// Authenticator holds the single admin credential and token signing secret.
type Authenticator struct {
username string
passwordHash []byte
secret []byte
ttl time.Duration
}
// New builds an Authenticator. passwordHash must be a bcrypt hash.
func New(username string, passwordHash, secret []byte, ttl time.Duration) *Authenticator {
return &Authenticator{username: username, passwordHash: passwordHash, secret: secret, ttl: ttl}
}
// ErrInvalidCredentials is returned when login fails.
var ErrInvalidCredentials = errors.New("invalid credentials")
// Login verifies the username/password and returns a signed token on success.
func (a *Authenticator) Login(username, password string) (string, error) {
if username != a.username {
// Still run bcrypt to keep timing roughly constant.
_ = bcrypt.CompareHashAndPassword(a.passwordHash, []byte(password))
return "", ErrInvalidCredentials
}
if err := bcrypt.CompareHashAndPassword(a.passwordHash, []byte(password)); err != nil {
return "", ErrInvalidCredentials
}
return a.issue(username)
}
type claims struct {
Sub string `json:"sub"`
Exp int64 `json:"exp"`
}
func b64(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) }
func (a *Authenticator) sign(signingInput string) string {
mac := hmac.New(sha256.New, a.secret)
mac.Write([]byte(signingInput))
return b64(mac.Sum(nil))
}
func (a *Authenticator) issue(sub string) (string, error) {
header := b64([]byte(`{"alg":"HS256","typ":"JWT"}`))
payloadJSON, err := json.Marshal(claims{Sub: sub, Exp: time.Now().Add(a.ttl).Unix()})
if err != nil {
return "", err
}
payload := b64(payloadJSON)
signingInput := header + "." + payload
return signingInput + "." + a.sign(signingInput), nil
}
// Verify checks a token's signature and expiry, returning the subject.
func (a *Authenticator) Verify(token string) (string, error) {
parts := strings.Split(token, ".")
if len(parts) != 3 {
return "", errors.New("malformed token")
}
signingInput := parts[0] + "." + parts[1]
if !hmac.Equal([]byte(a.sign(signingInput)), []byte(parts[2])) {
return "", errors.New("bad signature")
}
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return "", err
}
var c claims
if err := json.Unmarshal(payload, &c); err != nil {
return "", err
}
if time.Now().Unix() >= c.Exp {
return "", errors.New("token expired")
}
return c.Sub, nil
}
type ctxKey int
const userKey ctxKey = 0
// UserFrom returns the authenticated subject from the request context.
func UserFrom(ctx context.Context) string {
if v, ok := ctx.Value(userKey).(string); ok {
return v
}
return ""
}
// Middleware rejects requests without a valid Bearer token.
func (a *Authenticator) Middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := r.Header.Get("Authorization")
token := strings.TrimPrefix(h, "Bearer ")
if token == h || token == "" {
http.Error(w, `{"error":{"code":"unauthorized","message":"missing token"}}`, http.StatusUnauthorized)
return
}
sub, err := a.Verify(token)
if err != nil {
http.Error(w, `{"error":{"code":"unauthorized","message":"invalid token"}}`, http.StatusUnauthorized)
return
}
ctx := context.WithValue(r.Context(), userKey, sub)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
-62
View File
@@ -1,62 +0,0 @@
package auth
import (
"testing"
"time"
"golang.org/x/crypto/bcrypt"
)
func newTestAuth(t *testing.T, ttl time.Duration) *Authenticator {
t.Helper()
hash, err := bcrypt.GenerateFromPassword([]byte("s3cret"), bcrypt.MinCost)
if err != nil {
t.Fatalf("hash: %v", err)
}
return New("admin", hash, []byte("test-secret"), ttl)
}
func TestLoginAndVerify(t *testing.T) {
a := newTestAuth(t, time.Hour)
token, err := a.Login("admin", "s3cret")
if err != nil {
t.Fatalf("login: %v", err)
}
sub, err := a.Verify(token)
if err != nil {
t.Fatalf("verify: %v", err)
}
if sub != "admin" {
t.Fatalf("sub = %q, want admin", sub)
}
}
func TestLoginWrongCredentials(t *testing.T) {
a := newTestAuth(t, time.Hour)
if _, err := a.Login("admin", "nope"); err == nil {
t.Fatal("expected error for wrong password")
}
if _, err := a.Login("other", "s3cret"); err == nil {
t.Fatal("expected error for wrong username")
}
}
func TestVerifyRejectsTampered(t *testing.T) {
a := newTestAuth(t, time.Hour)
token, _ := a.Login("admin", "s3cret")
if _, err := a.Verify(token + "x"); err == nil {
t.Fatal("expected bad signature error")
}
if _, err := a.Verify("not.a.token"); err == nil {
t.Fatal("expected malformed/decoding error")
}
}
func TestVerifyRejectsExpired(t *testing.T) {
a := newTestAuth(t, -time.Minute)
token, _ := a.Login("admin", "s3cret")
if _, err := a.Verify(token); err == nil {
t.Fatal("expected expired token error")
}
}
-42
View File
@@ -1,42 +0,0 @@
package config
import (
"os"
"strconv"
)
// Config holds runtime configuration for the OpenGoods API server.
// 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
}
// 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),
}
}
func getenvInt(key string, fallback int) int {
if v, ok := os.LookupEnv(key); ok && v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
return n
}
}
return fallback
}
func getenv(key, fallback string) string {
if v, ok := os.LookupEnv(key); ok && v != "" {
return v
}
return fallback
}
-110
View File
@@ -1,110 +0,0 @@
// Package gtin validates and normalizes GS1 trade item numbers (GTIN-8/12/13/14).
// Only globally-unique GS1 codes are accepted: store-internal / variable-weight /
// coupon codes (which are not globally unique) are rejected on purpose.
package gtin
import (
"errors"
"strings"
)
// Validation errors.
var (
ErrEmpty = errors.New("条码不能为空")
ErrFormat = errors.New("条码必须为 8/12/13/14 位数字")
ErrCheck = errors.New("条码校验位不正确")
ErrRestricted = errors.New("店内码/变量重量码/优惠券码等非全球唯一码,不予收录")
)
// Normalize trims and validates a GTIN, returning the cleaned digit string.
// It enforces length, the GS1 mod-10 check digit, and rejects restricted
// (non-globally-unique) number ranges.
func Normalize(raw string) (string, error) {
s := strings.TrimSpace(raw)
if s == "" {
return "", ErrEmpty
}
for _, c := range s {
if c < '0' || c > '9' {
return "", ErrFormat
}
}
switch len(s) {
case 8, 12, 13, 14:
default:
return "", ErrFormat
}
if !validCheckDigit(s) {
return "", ErrCheck
}
if restricted(s) {
return "", ErrRestricted
}
return s, nil
}
// InferType returns the conventional GTIN type label for a normalized code.
func InferType(s string) string {
switch len(s) {
case 8:
return "EAN8"
case 12:
return "UPC"
case 14:
return "GTIN14"
default:
return "EAN13"
}
}
// validCheckDigit verifies the trailing GS1 mod-10 check digit. The digit
// immediately left of the check digit carries weight 3, then weights alternate.
func validCheckDigit(s string) bool {
n := len(s)
sum := 0
for i := 0; i < n-1; i++ {
d := int(s[i] - '0')
if (n-1-i)%2 == 1 {
sum += d * 3
} else {
sum += d
}
}
check := (10 - (sum % 10)) % 10
return check == int(s[n-1]-'0')
}
// restricted reports whether a (length/check-digit valid) code falls in a
// number range reserved for non-globally-unique use.
func restricted(s string) bool {
switch len(s) {
case 13:
p2 := s[:2]
switch {
case s[0] == '2': // 20-29 restricted distribution / in-store
return true
case p2 == "02": // 020-029 variable-measure within a store
return true
case p2 == "04": // 040-049 restricted circulation within a company
return true
case p2 == "05": // 050-059 coupons
return true
case p2 == "98" || p2 == "99": // 980-989/99 coupons & refund receipts
return true
}
case 12: // UPC-A: leading number-system digit
switch s[0] {
case '2': // in-store / random weight
return true
case '4': // unrestricted in-store use
return true
case '5': // coupons
return true
}
case 8: // EAN-8: 0/2 prefixes reserved for in-store use
if s[0] == '0' || s[0] == '2' {
return true
}
}
return false
}
-49
View File
@@ -1,49 +0,0 @@
package gtin
import "testing"
func TestNormalizeValid(t *testing.T) {
cases := []struct{ in, want, typ string }{
{" 5449000000996 ", "5449000000996", "EAN13"}, // Coca-Cola EAN-13
{"3017624010701", "3017624010701", "EAN13"}, // Nutella EAN-13
{"036000291452", "036000291452", "UPC"}, // UPC-A
{"96385074", "96385074", "EAN8"}, // EAN-8
{"00012345600012", "00012345600012", "GTIN14"},
{"6901234567892", "6901234567892", "EAN13"}, // China 690 prefix
}
for _, c := range cases {
got, err := Normalize(c.in)
if err != nil {
t.Errorf("Normalize(%q) unexpected error: %v", c.in, err)
continue
}
if got != c.want {
t.Errorf("Normalize(%q) = %q, want %q", c.in, got, c.want)
}
if InferType(got) != c.typ {
t.Errorf("InferType(%q) = %q, want %q", got, InferType(got), c.typ)
}
}
}
func TestNormalizeRejects(t *testing.T) {
cases := []struct {
in string
want error
}{
{"", ErrEmpty},
{"12ab5678", ErrFormat},
{"12345", ErrFormat},
{"5449000000997", ErrCheck}, // bad check digit
{"2012345678903", ErrRestricted}, // 20-29 in-store EAN-13
{"0212345678909", ErrRestricted}, // 02x variable measure
{"212345678909", ErrRestricted}, // UPC number system 2
{"02345673", ErrRestricted}, // EAN-8 in-store
}
for _, c := range cases {
_, err := Normalize(c.in)
if err != c.want {
t.Errorf("Normalize(%q) error = %v, want %v", c.in, err, c.want)
}
}
}
-291
View File
@@ -1,291 +0,0 @@
// Package handler wires up the public, read-only OpenGoods HTTP API.
// The OpenGoods service is a public-good product information API: it only
// collects and serves product facts. It exposes no purchase, checkout, or
// commerce endpoints by design.
package handler
import (
_ "embed"
"encoding/json"
"errors"
"io/fs"
"net/http"
"strconv"
"strings"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/baicai2026-baicai/goods/api/internal/ratelimit"
"github.com/baicai2026-baicai/goods/api/internal/store"
)
//go:embed openapi.json
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
// defaultAnonLimit is the per-minute request budget for unauthenticated
// callers (identified by client IP) when none is configured.
defaultAnonLimit = 60
)
// Handler holds dependencies shared by the HTTP routes.
type Handler struct {
store *store.Store
spa fs.FS
limiter *ratelimit.Limiter
anonLimit int
}
// 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}
}
// WithRateLimit attaches a Redis-backed limiter and the anonymous per-minute
// budget, enabling rate limiting + usage tracking on the public API routes.
// A non-positive anonPerMin keeps the default.
func (h *Handler) WithRateLimit(l *ratelimit.Limiter, anonPerMin int) *Handler {
h.limiter = l
if anonPerMin > 0 {
h.anonLimit = anonPerMin
}
return h
}
// Router builds the top-level HTTP handler with middleware and routes mounted.
func (h *Handler) Router() http.Handler {
r := chi.NewRouter()
r.Use(middleware.RequestID)
r.Use(middleware.RealIP)
r.Use(middleware.Recoverer)
r.Get("/healthz", h.Healthz)
r.Route("/api/"+APIVersion, func(r chi.Router) {
// Machine-readable spec; not rate limited so tooling can always fetch it.
r.Get("/openapi.json", h.OpenAPI)
r.Group(func(r chi.Router) {
r.Use(h.rateLimit)
r.Route("/products", func(r chi.Router) {
r.Get("/barcode/{gtin}", h.ProductByBarcode)
r.Get("/search", h.SearchProducts)
r.Get("/{id}", h.ProductByID)
r.Get("/{id}/nutriments", h.ProductNutriments)
r.Get("/{id}/msrp", h.ProductMSRP)
})
r.Get("/brands", h.ListBrands)
r.Get("/categories", h.ListCategories)
r.Get("/sources/{id}", h.SourceByID)
r.Get("/stats", h.Stats)
})
})
// Public SPA (homepage + search + contribute). API routes above take
// precedence; everything else falls back to the embedded single-page app.
if h.spa != nil {
r.Handle("/*", http.HandlerFunc(h.serveSPA))
}
return r
}
func (h *Handler) serveSPA(w http.ResponseWriter, r *http.Request) {
rel := strings.TrimPrefix(r.URL.Path, "/")
if rel == "" {
rel = "index.html"
}
if f, err := h.spa.Open(rel); err == nil {
f.Close()
http.FileServer(http.FS(h.spa)).ServeHTTP(w, r)
return
}
// SPA fallback: serve index.html for client-side routes.
data, err := fs.ReadFile(h.spa, "index.html")
if err != nil {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write(data)
}
// Healthz reports liveness of the service.
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")
_, _ = w.Write(openAPISpec)
}
// ProductByBarcode returns a product by its GTIN.
func (h *Handler) ProductByBarcode(w http.ResponseWriter, r *http.Request) {
p, err := h.store.ProductByGTIN(r.Context(), chi.URLParam(r, "gtin"))
if h.handleErr(w, r, err) {
return
}
writeJSON(w, http.StatusOK, p)
}
// ProductByID returns a product by its UUID.
func (h *Handler) ProductByID(w http.ResponseWriter, r *http.Request) {
p, err := h.store.ProductByID(r.Context(), chi.URLParam(r, "id"))
if h.handleErr(w, r, err) {
return
}
writeJSON(w, http.StatusOK, p)
}
// SearchProducts runs a trigram-fuzzy name search with optional
// category/brand/country filters, ranked by relevance, plus paging.
func (h *Handler) SearchProducts(w http.ResponseWriter, r *http.Request) {
qv := r.URL.Query()
filters := store.SearchFilters{
Query: strings.TrimSpace(qv.Get("q")),
Category: strings.TrimSpace(qv.Get("category")),
Brand: strings.TrimSpace(qv.Get("brand")),
Country: strings.TrimSpace(qv.Get("country")),
}
page, size := pageParams(r)
items, total, err := h.store.SearchProducts(r.Context(), filters, size, (page-1)*size)
if h.handleErr(w, r, err) {
return
}
writeJSON(w, http.StatusOK, map[string]any{
"items": items,
"page": page,
"size": size,
"total": total,
})
}
// ProductNutriments returns just the nutrition facts of a product.
func (h *Handler) ProductNutriments(w http.ResponseWriter, r *http.Request) {
n, err := h.store.Nutriments(r.Context(), chi.URLParam(r, "id"))
if h.handleErr(w, r, err) {
return
}
writeJSON(w, http.StatusOK, n)
}
// ProductMSRP returns official suggested retail price snapshots (no purchase link).
func (h *Handler) ProductMSRP(w http.ResponseWriter, r *http.Request) {
items, err := h.store.ListMSRP(r.Context(), chi.URLParam(r, "id"))
if h.handleErr(w, r, err) {
return
}
writeJSON(w, http.StatusOK, map[string]any{
"items": items,
"disclaimer": "厂商建议零售价历史快照,仅供参考,不构成购买建议,本服务不提供任何购买入口。",
})
}
// ListBrands returns a paginated list of brands.
func (h *Handler) ListBrands(w http.ResponseWriter, r *http.Request) {
page, size := pageParams(r)
items, total, err := h.store.ListBrands(r.Context(), size, (page-1)*size)
if h.handleErr(w, r, err) {
return
}
writeJSON(w, http.StatusOK, map[string]any{
"items": items, "page": page, "size": size, "total": total,
})
}
// ListCategories returns the full category tree.
func (h *Handler) ListCategories(w http.ResponseWriter, r *http.Request) {
items, err := h.store.ListCategories(r.Context())
if h.handleErr(w, r, err) {
return
}
writeJSON(w, http.StatusOK, map[string]any{"items": items})
}
// SourceByID returns a single data source.
func (h *Handler) SourceByID(w http.ResponseWriter, r *http.Request) {
src, err := h.store.SourceByID(r.Context(), chi.URLParam(r, "id"))
if h.handleErr(w, r, err) {
return
}
writeJSON(w, http.StatusOK, src)
}
// handleErr writes an appropriate error response; returns true if it handled one.
func (h *Handler) handleErr(w http.ResponseWriter, r *http.Request, err error) bool {
if err == nil {
return false
}
if errors.Is(err, store.ErrNotFound) {
writeError(w, r, http.StatusNotFound, "not_found", "resource not found")
return true
}
writeError(w, r, http.StatusInternalServerError, "internal_error", "internal server error")
return true
}
func pageParams(r *http.Request) (page, size int) {
page = atoiDefault(r.URL.Query().Get("page"), 1)
if page < 1 {
page = 1
}
size = atoiDefault(r.URL.Query().Get("size"), defaultPageSize)
if size < 1 {
size = defaultPageSize
}
if size > maxPageSize {
size = maxPageSize
}
return page, size
}
func atoiDefault(s string, fallback int) int {
if s == "" {
return fallback
}
v, err := strconv.Atoi(s)
if err != nil {
return fallback
}
return v
}
func writeJSON(w http.ResponseWriter, status int, body any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(body)
}
func writeError(w http.ResponseWriter, r *http.Request, status int, code, message string) {
writeJSON(w, status, map[string]any{
"error": map[string]string{
"code": code,
"message": message,
"request_id": middleware.GetReqID(r.Context()),
},
})
}
-229
View File
@@ -1,229 +0,0 @@
package handler
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/baicai2026-baicai/goods/api/internal/store"
)
// newTestHandler connects to the test database, skipping if unavailable or
// unmigrated. It inserts a known product (cleaned up via t.Cleanup) so the
// endpoint assertions are deterministic.
func newTestHandler(t *testing.T) (*Handler, string) {
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 hasProduct bool
if err := pool.QueryRow(ctx, "SELECT to_regclass('public.product') IS NOT NULL").Scan(&hasProduct); err != nil || !hasProduct {
pool.Close()
t.Skip("migrations not applied")
}
gtin := "4006381333931"
_, err = pool.Exec(context.Background(), `
INSERT INTO product (gtin, name, category_id, net_content_value, net_content_unit)
VALUES ($1, 'Test Cola', (SELECT id FROM category WHERE path='food.beverages.carbonated'), 330, 'ml')
ON CONFLICT (gtin) WHERE gtin IS NOT NULL DO UPDATE SET name = EXCLUDED.name`, gtin)
if err != nil {
pool.Close()
t.Fatalf("seed insert failed: %v", err)
}
var pid string
_ = pool.QueryRow(context.Background(), "SELECT id FROM product WHERE gtin=$1", gtin).Scan(&pid)
_, _ = pool.Exec(context.Background(), `
INSERT INTO food_detail (product_id, nutrition_basis, nutriments)
VALUES ($1, 'per_100ml', '{"energy_kcal": 42}'::jsonb)
ON CONFLICT (product_id) DO UPDATE SET nutriments = EXCLUDED.nutriments`, pid)
t.Cleanup(func() {
_, _ = pool.Exec(context.Background(), "DELETE FROM product WHERE gtin=$1", gtin)
pool.Close()
})
return New(store.New(pool), nil), gtin
}
func doGET(t *testing.T, h *Handler, path string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodGet, path, nil)
rec := httptest.NewRecorder()
h.Router().ServeHTTP(rec, req)
return rec
}
func TestProductByBarcode(t *testing.T) {
h, gtin := newTestHandler(t)
rec := doGET(t, h, "/api/"+APIVersion+"/products/barcode/"+gtin)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
}
var p store.Product
if err := json.NewDecoder(rec.Body).Decode(&p); err != nil {
t.Fatal(err)
}
if p.Name != "Test Cola" || p.GTIN == nil || *p.GTIN != gtin {
t.Fatalf("unexpected product: %+v", p)
}
if p.CategoryPath == nil || *p.CategoryPath != "food.beverages.carbonated" {
t.Fatalf("category not joined: %+v", p.CategoryPath)
}
}
func TestProductByBarcodeNotFound(t *testing.T) {
h, _ := newTestHandler(t)
rec := doGET(t, h, "/api/"+APIVersion+"/products/barcode/0000000000000")
if rec.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d", rec.Code)
}
}
func TestSearchProducts(t *testing.T) {
h, _ := newTestHandler(t)
rec := doGET(t, h, "/api/"+APIVersion+"/products/search?q=Cola&category=food.beverages")
if rec.Code != http.StatusOK {
t.Fatalf("status = %d", rec.Code)
}
var body struct {
Items []store.ProductSummary `json:"items"`
Total int `json:"total"`
}
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
t.Fatal(err)
}
if body.Total < 1 {
t.Fatalf("expected at least 1 result, got %d", body.Total)
}
}
func TestSearchFuzzyAndFilters(t *testing.T) {
h, _ := newTestHandler(t)
ctx := context.Background()
dsn := os.Getenv("OPENGOODS_DATABASE_URL")
if dsn == "" {
dsn = "postgres://opengoods:opengoods@localhost:5432/opengoods?sslmode=disable"
}
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Skipf("no database: %v", err)
}
defer pool.Close()
_, err = pool.Exec(ctx,
"INSERT INTO brand (name, normalized_name) VALUES ('ZZ Test Brand','zz test brand') ON CONFLICT DO NOTHING")
if err != nil {
t.Fatalf("seed brand: %v", err)
}
_, err = pool.Exec(ctx, `
INSERT INTO product (name, brand_id, country_of_origin, quality_score, status)
VALUES ('ZZ Hazelnut Chocolate', (SELECT id FROM brand WHERE name='ZZ Test Brand'), 'Testland', 0.5, 'active')`)
if err != nil {
t.Fatalf("seed product: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM product WHERE name='ZZ Hazelnut Chocolate'")
_, _ = pool.Exec(ctx, "DELETE FROM brand WHERE name='ZZ Test Brand'")
})
decode := func(path string) []store.ProductSummary {
rec := doGET(t, h, path)
if rec.Code != http.StatusOK {
t.Fatalf("%s -> status %d", path, rec.Code)
}
var body struct {
Items []store.ProductSummary `json:"items"`
}
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
t.Fatal(err)
}
return body.Items
}
has := func(items []store.ProductSummary, name string) *store.ProductSummary {
for i := range items {
if items[i].Name == name {
return &items[i]
}
}
return nil
}
// Typo "choclate" should fuzzy-match via word_similarity and carry a score.
got := has(decode("/api/"+APIVersion+"/products/search?q=choclate"), "ZZ Hazelnut Chocolate")
if got == nil {
t.Fatal("fuzzy query 'choclate' did not match 'ZZ Hazelnut Chocolate'")
}
if got.Score == nil || *got.Score <= 0 {
t.Fatalf("expected positive fuzzy score, got %v", got.Score)
}
// Brand filter.
if has(decode("/api/"+APIVersion+"/products/search?brand=ZZ+Test+Brand"), "ZZ Hazelnut Chocolate") == nil {
t.Fatal("brand filter did not return the product")
}
// Country filter (case-insensitive prefix).
if has(decode("/api/"+APIVersion+"/products/search?country=test"), "ZZ Hazelnut Chocolate") == nil {
t.Fatal("country filter did not return the product")
}
// Non-matching country excludes it.
if has(decode("/api/"+APIVersion+"/products/search?country=france"), "ZZ Hazelnut Chocolate") != nil {
t.Fatal("country filter 'france' should not return the product")
}
}
func TestOpenAPISpec(t *testing.T) {
h, _ := newTestHandler(t)
rec := doGET(t, h, "/api/"+APIVersion+"/openapi.json")
if rec.Code != http.StatusOK {
t.Fatalf("status = %d", rec.Code)
}
var spec struct {
OpenAPI string `json:"openapi"`
Paths map[string]any `json:"paths"`
}
if err := json.NewDecoder(rec.Body).Decode(&spec); err != nil {
t.Fatalf("openapi.json is not valid JSON: %v", err)
}
if spec.OpenAPI == "" || len(spec.Paths) == 0 {
t.Fatalf("unexpected spec: %+v", spec)
}
if _, ok := spec.Paths["/products/search"]; !ok {
t.Fatal("spec missing /products/search path")
}
}
func TestListCategories(t *testing.T) {
h, _ := newTestHandler(t)
rec := doGET(t, h, "/api/"+APIVersion+"/categories")
if rec.Code != http.StatusOK {
t.Fatalf("status = %d", rec.Code)
}
var body struct {
Items []store.Category `json:"items"`
}
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
t.Fatal(err)
}
if len(body.Items) < 20 {
t.Fatalf("expected seeded categories, got %d", len(body.Items))
}
}
-48
View File
@@ -1,48 +0,0 @@
package handler
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestHealthz(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
rec := httptest.NewRecorder()
New(nil, nil).Router().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d", http.StatusOK, rec.Code)
}
var body map[string]string
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
t.Fatalf("failed to decode body: %v", err)
}
if body["status"] != "ok" {
t.Fatalf("expected status ok, got %q", body["status"])
}
}
func TestPageParams(t *testing.T) {
cases := []struct {
query string
wantPage, wantSz int
}{
{"", 1, defaultPageSize},
{"page=3&size=10", 3, 10},
{"page=0&size=-5", 1, defaultPageSize},
{"size=1000", 1, maxPageSize},
{"page=abc", 1, defaultPageSize},
}
for _, c := range cases {
req := httptest.NewRequest(http.MethodGet, "/?"+c.query, nil)
page, size := pageParams(req)
if page != c.wantPage || size != c.wantSz {
t.Errorf("query %q: got page=%d size=%d, want page=%d size=%d",
c.query, page, size, c.wantPage, c.wantSz)
}
}
}
-90
View File
@@ -1,90 +0,0 @@
package handler
import (
"context"
"errors"
"net"
"net/http"
"strconv"
"strings"
"time"
"github.com/baicai2026-baicai/goods/api/internal/apikey"
"github.com/baicai2026-baicai/goods/api/internal/store"
)
type ctxKey int
const apiKeyIDKey ctxKey = 0
// rateLimit authenticates an optional API key and enforces a per-minute budget
// on the public API. Anonymous callers are limited by client IP at a lower
// budget; a valid key raises the budget and attributes usage. An API key that
// is present but invalid or revoked is rejected with 401. Rate-limit headers
// 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)
limit := h.anonLimit
keyID := ""
if raw := presentedKey(r); raw != "" {
if !apikey.IsWellFormed(raw) {
writeError(w, r, http.StatusUnauthorized, "invalid_api_key", "API key 格式无效")
return
}
k, err := h.store.APIKeyByHash(r.Context(), apikey.Hash(raw))
if errors.Is(err, store.ErrNotFound) {
writeError(w, r, http.StatusUnauthorized, "invalid_api_key", "API key 无效或已吊销")
return
}
if err != nil {
writeError(w, r, http.StatusInternalServerError, "internal_error", "internal server error")
return
}
keyID = k.ID
limit = k.RateLimitPerMin
id = "key:" + k.ID
}
res := h.limiter.Allow(r.Context(), id, limit, time.Minute)
w.Header().Set("X-RateLimit-Limit", strconv.Itoa(res.Limit))
w.Header().Set("X-RateLimit-Remaining", strconv.Itoa(res.Remaining))
w.Header().Set("X-RateLimit-Reset", strconv.FormatInt(res.ResetUnix, 10))
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
}
if keyID != "" {
h.limiter.RecordUsage(r.Context(), keyID)
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), apiKeyIDKey, keyID)))
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 != "" {
return v
}
if v := r.Header.Get("Authorization"); strings.HasPrefix(v, "Bearer ") {
return strings.TrimSpace(strings.TrimPrefix(v, "Bearer "))
}
return ""
}
// clientIP returns the caller IP, preferring chi's RealIP-normalized RemoteAddr.
func clientIP(r *http.Request) string {
if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
return host
}
return r.RemoteAddr
}
-120
View File
@@ -1,120 +0,0 @@
package handler
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"os"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/baicai2026-baicai/goods/api/internal/apikey"
"github.com/baicai2026-baicai/goods/api/internal/ratelimit"
"github.com/baicai2026-baicai/goods/api/internal/store"
)
// newRateLimitedHandler builds a handler backed by the test DB and a live Redis
// limiter, plus a freshly issued API key with the given per-minute limit. It
// skips when either backend is unavailable.
func newRateLimitedHandler(t *testing.T, keyLimit int) (h *Handler, plaintextKey string) {
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.api_key') IS NOT NULL").Scan(&hasTable); err != nil || !hasTable {
pool.Close()
t.Skip("migrations not applied (api_key missing)")
}
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)
}
key, hash, prefix, err := apikey.Generate()
if err != nil {
pool.Close()
t.Fatal(err)
}
name := fmt.Sprintf("test-key-%d", time.Now().UnixNano())
if _, err := pool.Exec(context.Background(),
`INSERT INTO api_key (name, key_prefix, key_hash, rate_limit_per_min) VALUES ($1,$2,$3,$4)`,
name, prefix, hash, keyLimit); err != nil {
pool.Close()
t.Fatalf("insert api_key: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(context.Background(), "DELETE FROM api_key WHERE key_hash=$1", hash)
pool.Close()
})
return New(store.New(pool), nil).WithRateLimit(limiter, 60), key
}
func TestRateLimitHeadersAndKeyAuth(t *testing.T) {
h, key := newRateLimitedHandler(t, 100)
req := httptest.NewRequest(http.MethodGet, "/api/"+APIVersion+"/categories", nil)
req.Header.Set("X-API-Key", key)
rec := httptest.NewRecorder()
h.Router().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
}
if got := rec.Header().Get("X-RateLimit-Limit"); got != "100" {
t.Fatalf("X-RateLimit-Limit = %q, want 100 (key limit)", got)
}
if rec.Header().Get("X-RateLimit-Remaining") == "" {
t.Fatal("missing X-RateLimit-Remaining header")
}
}
func TestInvalidKeyRejected(t *testing.T) {
h, _ := newRateLimitedHandler(t, 100)
req := httptest.NewRequest(http.MethodGet, "/api/"+APIVersion+"/categories", nil)
req.Header.Set("X-API-Key", "og_live_thiskeydoesnotexist123456")
rec := httptest.NewRecorder()
h.Router().ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401; body = %s", rec.Code, rec.Body.String())
}
}
func TestRateLimitExceeded(t *testing.T) {
h, key := newRateLimitedHandler(t, 1)
do := func() int {
req := httptest.NewRequest(http.MethodGet, "/api/"+APIVersion+"/categories", nil)
req.Header.Set("X-API-Key", key)
rec := httptest.NewRecorder()
h.Router().ServeHTTP(rec, req)
return rec.Code
}
if code := do(); code != http.StatusOK {
t.Fatalf("first request status = %d, want 200", code)
}
if code := do(); code != http.StatusTooManyRequests {
t.Fatalf("second request status = %d, want 429", code)
}
}
-186
View File
@@ -1,186 +0,0 @@
{
"openapi": "3.0.3",
"info": {
"title": "OpenGoods / 天工商品档案公共仓 API",
"version": "1.0.0",
"description": "Public, read-only product-facts REST API. Anonymous access is allowed at a lower per-minute rate; an optional API key grants a higher rate limit and attributes usage. No purchase or commerce endpoints by design.",
"license": { "name": "Data under each source's license (e.g. ODbL)" }
},
"servers": [{ "url": "https://goods.tangshasha.com/api/v1" }],
"tags": [
{ "name": "products" },
{ "name": "catalog" },
{ "name": "meta" }
],
"security": [{ "ApiKeyHeader": [] }, { "BearerKey": [] }, {}],
"paths": {
"/products/search": {
"get": {
"tags": ["products"],
"summary": "Search products",
"description": "Trigram-fuzzy name search (typo-tolerant) with optional category/brand/country filters, ranked by name similarity blended with data quality_score.",
"parameters": [
{ "name": "q", "in": "query", "schema": { "type": "string" }, "description": "Keyword (name or barcode); fuzzy-matched. Empty returns all, ordered by quality_score." },
{ "name": "category", "in": "query", "schema": { "type": "string" }, "description": "Category code (matches the subtree), e.g. food.beverages." },
{ "name": "brand", "in": "query", "schema": { "type": "string" }, "description": "Brand name (fuzzy)." },
{ "name": "country", "in": "query", "schema": { "type": "string" }, "description": "Country of origin (case-insensitive prefix)." },
{ "name": "page", "in": "query", "schema": { "type": "integer", "default": 1, "minimum": 1 } },
{ "name": "size", "in": "query", "schema": { "type": "integer", "default": 20, "maximum": 100 } }
],
"responses": {
"200": {
"description": "Paged search results.",
"headers": {
"X-RateLimit-Limit": { "schema": { "type": "integer" }, "description": "Max requests in the current window." },
"X-RateLimit-Remaining": { "schema": { "type": "integer" }, "description": "Remaining requests in the window." },
"X-RateLimit-Reset": { "schema": { "type": "integer" }, "description": "Unix timestamp when the window resets." }
},
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"items": { "type": "array", "items": { "$ref": "#/components/schemas/ProductSummary" } },
"page": { "type": "integer" },
"size": { "type": "integer" },
"total": { "type": "integer" }
}
}
}
}
},
"401": { "$ref": "#/components/responses/InvalidApiKey" },
"429": { "$ref": "#/components/responses/RateLimited" }
}
}
},
"/products/barcode/{gtin}": {
"get": {
"tags": ["products"],
"summary": "Get product by barcode (GTIN)",
"parameters": [{ "name": "gtin", "in": "path", "required": true, "schema": { "type": "string" } }],
"responses": {
"200": { "description": "Product", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Product" } } } },
"404": { "$ref": "#/components/responses/NotFound" },
"429": { "$ref": "#/components/responses/RateLimited" }
}
}
},
"/products/{id}": {
"get": {
"tags": ["products"],
"summary": "Get product detail by UUID",
"parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } }],
"responses": {
"200": { "description": "Product", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Product" } } } },
"404": { "$ref": "#/components/responses/NotFound" }
}
}
},
"/products/{id}/nutriments": {
"get": {
"tags": ["products"],
"summary": "Get product nutriments",
"parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } }],
"responses": { "200": { "description": "Nutriments" }, "404": { "$ref": "#/components/responses/NotFound" } }
}
},
"/products/{id}/msrp": {
"get": {
"tags": ["products"],
"summary": "Get manufacturer suggested retail price snapshots (reference only)",
"parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } }],
"responses": { "200": { "description": "MSRP snapshots" } }
}
},
"/brands": {
"get": {
"tags": ["catalog"],
"summary": "List brands",
"parameters": [
{ "name": "page", "in": "query", "schema": { "type": "integer", "default": 1 } },
{ "name": "size", "in": "query", "schema": { "type": "integer", "default": 20, "maximum": 100 } }
],
"responses": { "200": { "description": "Paged brands" } }
}
},
"/categories": {
"get": { "tags": ["catalog"], "summary": "List the category tree", "responses": { "200": { "description": "Category tree" } } }
},
"/sources/{id}": {
"get": {
"tags": ["meta"],
"summary": "Get a data source",
"parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } }],
"responses": { "200": { "description": "Source" }, "404": { "$ref": "#/components/responses/NotFound" } }
}
}
},
"components": {
"securitySchemes": {
"ApiKeyHeader": { "type": "apiKey", "in": "header", "name": "X-API-Key", "description": "API key, e.g. og_live_xxx. Optional." },
"BearerKey": { "type": "http", "scheme": "bearer", "description": "Authorization: Bearer og_live_xxx. Optional." }
},
"responses": {
"NotFound": {
"description": "Resource not found.",
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
},
"RateLimited": {
"description": "Rate limit exceeded.",
"headers": { "Retry-After": { "schema": { "type": "integer" }, "description": "Seconds to wait before retrying." } },
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
},
"InvalidApiKey": {
"description": "API key invalid or revoked.",
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
}
},
"schemas": {
"Error": {
"type": "object",
"properties": {
"error": {
"type": "object",
"properties": {
"code": { "type": "string" },
"message": { "type": "string" },
"request_id": { "type": "string" }
}
}
}
},
"ProductSummary": {
"type": "object",
"properties": {
"id": { "type": "string", "format": "uuid" },
"gtin": { "type": "string", "nullable": true },
"name": { "type": "string" },
"brand": { "type": "string", "nullable": true },
"category_path": { "type": "string", "nullable": true },
"country_of_origin": { "type": "string", "nullable": true },
"quality_score": { "type": "number", "format": "float" },
"score": { "type": "number", "format": "float", "nullable": true, "description": "Relevance (name word-similarity) when q is provided; null otherwise." }
}
},
"Product": {
"type": "object",
"properties": {
"id": { "type": "string", "format": "uuid" },
"gtin": { "type": "string", "nullable": true },
"name": { "type": "string" },
"brand": { "type": "string", "nullable": true },
"category_path": { "type": "string", "nullable": true },
"net_content_value": { "type": "number", "nullable": true },
"net_content_unit": { "type": "string", "nullable": true },
"country_of_origin": { "type": "string", "nullable": true },
"quality_score": { "type": "number", "format": "float" },
"nutriments": { "type": "object", "additionalProperties": true, "nullable": true },
"nutrition_basis": { "type": "string", "nullable": true },
"nutri_score": { "type": "string", "nullable": true },
"ingredients_text": { "type": "string", "nullable": true }
}
}
}
}
}
-10
View File
@@ -1,10 +0,0 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>OpenGoods</title>
</head>
<body>
<div id="root">OpenGoods public site placeholder. Built assets are injected during Docker build.</div>
</body>
</html>
-22
View File
@@ -1,22 +0,0 @@
// Package publicweb embeds the built public SPA (Vite dist). During Docker
// builds the real dist/ is produced by the node stage and copied in before go
// build; the committed placeholder keeps the package compilable for
// `go build ./...`.
package publicweb
import (
"embed"
"io/fs"
)
//go:embed all:dist
var distFS embed.FS
// Dist returns the embedded SPA filesystem rooted at dist/.
func Dist() fs.FS {
sub, err := fs.Sub(distFS, "dist")
if err != nil {
panic(err)
}
return sub
}
-132
View File
@@ -1,132 +0,0 @@
// Package ratelimit provides a Redis-backed fixed-window rate limiter and
// lightweight per-key usage counters for the public API.
//
// All state lives in Redis so it is shared across API replicas and visible to
// the admin console, and so the public server keeps its read-only contract
// against PostgreSQL. Every operation fails open: if Redis is unavailable the
// limiter allows the request rather than taking the API down.
package ratelimit
import (
"context"
"fmt"
"log"
"time"
"github.com/redis/go-redis/v9"
)
// Limiter throttles callers and records usage. A nil-backed Limiter (when Redis
// could not be configured) disables limiting and usage tracking.
type Limiter struct {
rdb *redis.Client
}
// Result describes the outcome of an Allow check and the headers to surface.
type Result struct {
Allowed bool
Limit int
Remaining int
ResetUnix int64
}
// UsageStat is the aggregated usage for a single API key.
type UsageStat struct {
Total int64 `json:"total"`
Today int64 `json:"today"`
LastUsedAt *int64 `json:"last_used_at,omitempty"`
}
// New builds a Limiter from a redis:// URL. On a parse error it logs and returns
// a fail-open limiter (Redis disabled) so the server still boots.
func New(redisURL string) *Limiter {
opt, err := redis.ParseURL(redisURL)
if err != nil {
log.Printf("ratelimit: invalid redis url %q: %v (rate limiting disabled)", redisURL, err)
return &Limiter{}
}
return &Limiter{rdb: redis.NewClient(opt)}
}
// Enabled reports whether a Redis backend is configured.
func (l *Limiter) Enabled() bool { return l != nil && l.rdb != nil }
// Ping verifies the Redis backend is reachable. Returns an error if disabled or
// unreachable.
func (l *Limiter) Ping(ctx context.Context) error {
if !l.Enabled() {
return redis.ErrClosed
}
return l.rdb.Ping(ctx).Err()
}
// Allow records a hit for id within a fixed window and reports whether the
// caller is under limit. Fails open (Allowed=true) on any Redis error.
func (l *Limiter) Allow(ctx context.Context, id string, limit int, window time.Duration) Result {
reset := func() int64 {
win := int64(window / time.Second)
if win < 1 {
win = 1
}
return (time.Now().Unix()/win + 1) * win
}
if !l.Enabled() {
return Result{Allowed: true, Limit: limit, Remaining: limit, ResetUnix: reset()}
}
win := int64(window / time.Second)
if win < 1 {
win = 1
}
bucket := time.Now().Unix() / win
key := fmt.Sprintf("rl:%s:%d", id, bucket)
n, err := l.rdb.Incr(ctx, key).Result()
if err != nil {
return Result{Allowed: true, Limit: limit, Remaining: limit, ResetUnix: (bucket + 1) * win}
}
if n == 1 {
l.rdb.Expire(ctx, key, time.Duration(win)*time.Second)
}
remaining := limit - int(n)
if remaining < 0 {
remaining = 0
}
return Result{
Allowed: int(n) <= limit,
Limit: limit,
Remaining: remaining,
ResetUnix: (bucket + 1) * win,
}
}
// RecordUsage increments total/daily counters and stamps last-used for a key.
// Best-effort: errors are ignored.
func (l *Limiter) RecordUsage(ctx context.Context, keyID string) {
if !l.Enabled() || keyID == "" {
return
}
now := time.Now()
day := now.Format("20060102")
pipe := l.rdb.Pipeline()
pipe.Incr(ctx, "usage:total:"+keyID)
dayKey := "usage:day:" + keyID + ":" + day
pipe.Incr(ctx, dayKey)
pipe.Expire(ctx, dayKey, 90*24*time.Hour)
pipe.Set(ctx, "usage:last:"+keyID, now.Unix(), 0)
_, _ = pipe.Exec(ctx)
}
// 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
if !l.Enabled() || keyID == "" {
return st
}
day := time.Now().Format("20060102")
st.Total, _ = l.rdb.Get(ctx, "usage:total:"+keyID).Int64()
st.Today, _ = l.rdb.Get(ctx, "usage:day:"+keyID+":"+day).Int64()
if v, err := l.rdb.Get(ctx, "usage:last:"+keyID).Int64(); err == nil {
st.LastUsedAt = &v
}
return st
}
-94
View File
@@ -1,94 +0,0 @@
package ratelimit
import (
"context"
"fmt"
"os"
"testing"
"time"
)
// TestDisabledFailsOpen verifies that a Limiter without a Redis backend allows
// all requests and reports usage as zero rather than erroring.
func TestDisabledFailsOpen(t *testing.T) {
l := New("not-a-valid-url") // parse error => disabled
if l.Enabled() {
t.Fatal("expected limiter to be disabled for invalid url")
}
res := l.Allow(context.Background(), "x", 1, time.Minute)
if !res.Allowed || res.Remaining != 1 {
t.Fatalf("disabled limiter must fail open: %+v", res)
}
// Must not panic and must return zero usage.
l.RecordUsage(context.Background(), "k1")
if u := l.Usage(context.Background(), "k1"); u.Total != 0 {
t.Fatalf("disabled usage should be zero, got %+v", u)
}
}
// TestNilReceiverSafe ensures a nil *Limiter is safe to use (handler default).
func TestNilReceiverSafe(t *testing.T) {
var l *Limiter
if l.Enabled() {
t.Fatal("nil limiter must report disabled")
}
res := l.Allow(context.Background(), "x", 5, time.Minute)
if !res.Allowed {
t.Fatal("nil limiter must fail open")
}
l.RecordUsage(context.Background(), "k")
_ = l.Usage(context.Background(), "k")
}
func testLimiter(t *testing.T) *Limiter {
t.Helper()
url := os.Getenv("OPENGOODS_REDIS_URL")
if url == "" {
url = "redis://localhost:6379/0"
}
l := New(url)
if !l.Enabled() {
t.Skip("redis not configured")
}
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
if err := l.rdb.Ping(ctx).Err(); err != nil {
t.Skipf("redis not reachable: %v", err)
}
return l
}
func TestAllowFixedWindow(t *testing.T) {
l := testLimiter(t)
ctx := context.Background()
id := fmt.Sprintf("test:%d", time.Now().UnixNano())
for i := 1; i <= 2; i++ {
if res := l.Allow(ctx, id, 2, time.Minute); !res.Allowed {
t.Fatalf("request %d should be allowed: %+v", i, res)
}
}
res := l.Allow(ctx, id, 2, time.Minute)
if res.Allowed {
t.Fatalf("3rd request over limit 2 should be denied: %+v", res)
}
if res.Remaining != 0 {
t.Fatalf("remaining should be 0 when over limit, got %d", res.Remaining)
}
}
func TestRecordAndReadUsage(t *testing.T) {
l := testLimiter(t)
ctx := context.Background()
key := fmt.Sprintf("usagekey:%d", time.Now().UnixNano())
l.RecordUsage(ctx, key)
l.RecordUsage(ctx, key)
u := l.Usage(ctx, key)
if u.Total != 2 || u.Today != 2 {
t.Fatalf("expected total=2 today=2, got %+v", u)
}
if u.LastUsedAt == nil {
t.Fatal("expected last-used timestamp to be set")
}
}
-417
View File
@@ -1,417 +0,0 @@
// Package store is the read-only data access layer for the OpenGoods API.
// It only issues SELECT queries; all writes happen in the Python ingestion path.
package store
import (
"context"
"errors"
"strconv"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// ErrNotFound is returned when a requested row does not exist.
var ErrNotFound = errors.New("not found")
// Store wraps a PostgreSQL connection pool.
type Store struct {
pool *pgxpool.Pool
}
// New constructs a Store from an existing pgx pool.
func New(pool *pgxpool.Pool) *Store {
return &Store{pool: pool}
}
// 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"`
GTINType string `json:"gtin_type"`
PackLevel string `json:"pack_level"`
Region *string `json:"region"`
IsPrimary bool `json:"is_primary"`
}
// Product is the full public view of a product.
type Product struct {
ID string `json:"id"`
GTIN *string `json:"gtin"`
Name string `json:"name"`
Brand *string `json:"brand"`
CategoryPath *string `json:"category_path"`
GPCBrickCode *string `json:"gpc_brick_code"`
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"`
Nutriments map[string]any `json:"nutriments,omitempty"`
NutritionBasis *string `json:"nutrition_basis,omitempty"`
NutriScore *string `json:"nutri_score,omitempty"`
Ingredients *string `json:"ingredients_text,omitempty"`
Allergens []string `json:"allergens,omitempty"`
Additives []string `json:"additives,omitempty"`
}
// ProductBarcodes returns every barcode attached to a product, primary first.
func (s *Store) ProductBarcodes(ctx context.Context, productID string) ([]Barcode, error) {
rows, err := s.pool.Query(ctx,
`SELECT gtin, gtin_type, pack_level, region, is_primary
FROM product_barcode WHERE product_id = $1
ORDER BY is_primary DESC, gtin`, productID)
if err != nil {
return nil, err
}
defer rows.Close()
out := []Barcode{}
for rows.Next() {
var b Barcode
if err := rows.Scan(&b.GTIN, &b.GTINType, &b.PackLevel, &b.Region, &b.IsPrimary); err != nil {
return nil, err
}
out = append(out, b)
}
return out, rows.Err()
}
// ProductSummary is a lightweight row used in search/listing responses.
type ProductSummary struct {
ID string `json:"id"`
GTIN *string `json:"gtin"`
Name string `json:"name"`
Brand *string `json:"brand"`
CategoryPath *string `json:"category_path"`
Country *string `json:"country_of_origin"`
QualityScore float64 `json:"quality_score"`
Score *float64 `json:"score,omitempty"`
}
// SearchFilters bundles the optional filters accepted by SearchProducts.
type SearchFilters struct {
Query string // fuzzy name / barcode query
Category string // ltree path; matches the subtree
Brand string // fuzzy brand name
Country string // country_of_origin prefix (case-insensitive)
}
const productSelect = `
SELECT p.id, p.gtin, p.name, b.name, c.path::text, p.gpc_brick_code,
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
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
`
func scanProduct(row pgx.Row) (*Product, error) {
var p Product
err := row.Scan(
&p.ID, &p.GTIN, &p.Name, &p.Brand, &p.CategoryPath, &p.GPCBrickCode,
&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
}
if err != nil {
return nil, err
}
return &p, nil
}
// ProductByGTIN looks up an active product by any of its barcodes.
func (s *Store) ProductByGTIN(ctx context.Context, gtin string) (*Product, error) {
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)
if err != nil {
return nil, err
}
if p.Barcodes, err = s.ProductBarcodes(ctx, p.ID); err != nil {
return nil, err
}
return p, nil
}
// ProductByID looks up a product by its UUID.
func (s *Store) ProductByID(ctx context.Context, id string) (*Product, error) {
row := s.pool.QueryRow(ctx, productSelect+" WHERE p.id = $1", id)
p, err := scanProduct(row)
if err != nil {
return nil, err
}
if p.Barcodes, err = s.ProductBarcodes(ctx, p.ID); err != nil {
return nil, err
}
return p, nil
}
// fuzzyThreshold is the minimum word_similarity for a name to be considered a
// fuzzy match. ~0.42 tolerates common typos (e.g. "choclate"→"Chocolate")
// without returning unrelated products.
const fuzzyThreshold = "0.42"
// SearchProducts runs a trigram-fuzzy name search with optional category /
// brand / country filters. When a query is present, matching is inclusive
// (substring OR trigram-similar OR barcode), and results are ranked by name
// 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) {
args := []any{}
where := "WHERE p.status = 'active'"
qIdx := 0
if f.Query != "" {
args = append(args, f.Query)
qIdx = len(args)
q := "$" + strconv.Itoa(qIdx)
where += ` AND (p.name ILIKE '%' || ` + q + ` || '%'
OR word_similarity(` + q + `, p.name) >= ` + fuzzyThreshold + `
OR EXISTS (SELECT 1 FROM product_barcode pb
WHERE pb.product_id = p.id AND pb.gtin ILIKE '%' || ` + q + ` || '%'))`
}
if f.Category != "" {
args = append(args, f.Category)
where += " AND c.path <@ $" + strconv.Itoa(len(args)) + "::ltree"
}
if f.Brand != "" {
args = append(args, f.Brand)
where += " AND b.name ILIKE '%' || $" + strconv.Itoa(len(args)) + " || '%'"
}
if f.Country != "" {
args = append(args, f.Country)
where += " AND p.country_of_origin ILIKE $" + strconv.Itoa(len(args)) + " || '%'"
}
from := `FROM product p
LEFT JOIN brand b ON b.id = p.brand_id
LEFT JOIN category c ON c.id = p.category_id `
var total int
if err := s.pool.QueryRow(ctx, "SELECT count(*) "+from+where, args...).Scan(&total); err != nil {
return nil, 0, err
}
// Ranking: when querying, similarity drives order, multiplied by a
// quality factor floored at 0.5 so low-quality records aren't zeroed out.
scoreExpr := "NULL::real"
orderBy := "p.quality_score DESC, p.name"
if f.Query != "" {
q := "$" + strconv.Itoa(qIdx)
scoreExpr = "word_similarity(" + q + ", p.name)"
orderBy = scoreExpr + " * (0.5 + p.quality_score) DESC, p.quality_score DESC, p.name"
}
args = append(args, limit, offset)
listSQL := "SELECT p.id, p.gtin, p.name, b.name, c.path::text, p.country_of_origin, p.quality_score, " +
scoreExpr + " AS score " + from + where +
" ORDER BY " + orderBy +
" LIMIT $" + strconv.Itoa(len(args)-1) + " OFFSET $" + strconv.Itoa(len(args))
rows, err := s.pool.Query(ctx, listSQL, args...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
out := []ProductSummary{}
for rows.Next() {
var ps ProductSummary
if err := rows.Scan(&ps.ID, &ps.GTIN, &ps.Name, &ps.Brand, &ps.CategoryPath,
&ps.Country, &ps.QualityScore, &ps.Score); err != nil {
return nil, 0, err
}
out = append(out, ps)
}
return out, total, rows.Err()
}
// Nutriments returns just the nutrition payload for a product.
type Nutriments struct {
ProductID string `json:"product_id"`
Basis *string `json:"basis"`
NutriScore *string `json:"nutri_score"`
Values map[string]any `json:"values"`
}
// Nutriments fetches the nutrition facts of a product.
func (s *Store) Nutriments(ctx context.Context, id string) (*Nutriments, error) {
var n Nutriments
n.ProductID = id
err := s.pool.QueryRow(ctx,
"SELECT nutriments, nutrition_basis, nutri_score FROM food_detail WHERE product_id = $1", id,
).Scan(&n.Values, &n.Basis, &n.NutriScore)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
return &n, nil
}
// MSRP is an official suggested retail price snapshot (never a purchase link).
type MSRP struct {
Amount float64 `json:"amount"`
Currency string `json:"currency"`
Region string `json:"region"`
EffectiveDate *string `json:"effective_date"`
SourceURL *string `json:"source_url"`
Note *string `json:"note"`
}
// ListMSRP returns all MSRP snapshots for a product.
func (s *Store) ListMSRP(ctx context.Context, id string) ([]MSRP, error) {
rows, err := s.pool.Query(ctx,
`SELECT amount, currency, region, effective_date::text, source_url, note
FROM product_msrp WHERE product_id = $1 ORDER BY effective_date DESC NULLS LAST`, id)
if err != nil {
return nil, err
}
defer rows.Close()
out := []MSRP{}
for rows.Next() {
var m MSRP
if err := rows.Scan(&m.Amount, &m.Currency, &m.Region, &m.EffectiveDate, &m.SourceURL, &m.Note); err != nil {
return nil, err
}
out = append(out, m)
}
return out, rows.Err()
}
// Brand is a public brand entry.
type Brand struct {
ID string `json:"id"`
Name string `json:"name"`
}
// ListBrands returns brands ordered by name.
func (s *Store) ListBrands(ctx context.Context, limit, offset int) ([]Brand, int, error) {
var total int
if err := s.pool.QueryRow(ctx, "SELECT count(*) FROM brand").Scan(&total); err != nil {
return nil, 0, err
}
rows, err := s.pool.Query(ctx, "SELECT id, name FROM brand ORDER BY name LIMIT $1 OFFSET $2", limit, offset)
if err != nil {
return nil, 0, err
}
defer rows.Close()
out := []Brand{}
for rows.Next() {
var b Brand
if err := rows.Scan(&b.ID, &b.Name); err != nil {
return nil, 0, err
}
out = append(out, b)
}
return out, total, rows.Err()
}
// Category is a node in the self-built category tree.
type Category struct {
ID string `json:"id"`
NameZH string `json:"name_zh"`
NameEN *string `json:"name_en"`
Path string `json:"path"`
GPCBrickCode *string `json:"gpc_brick_code"`
Level int `json:"level"`
}
// ListCategories returns the full category tree ordered by path.
func (s *Store) ListCategories(ctx context.Context) ([]Category, error) {
rows, err := s.pool.Query(ctx,
"SELECT id, name_zh, name_en, path::text, gpc_brick_code, level FROM category ORDER BY path")
if err != nil {
return nil, err
}
defer rows.Close()
out := []Category{}
for rows.Next() {
var c Category
if err := rows.Scan(&c.ID, &c.NameZH, &c.NameEN, &c.Path, &c.GPCBrickCode, &c.Level); err != nil {
return nil, err
}
out = append(out, c)
}
return out, rows.Err()
}
// APIKey is the minimal metadata the public API needs to authorize a caller.
type APIKey struct {
ID string
Name string
RateLimitPerMin int
}
// APIKeyByHash returns the active (non-revoked) key matching a SHA-256 hash,
// or ErrNotFound if no such active key exists.
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
FROM api_key WHERE key_hash = $1 AND revoked_at IS NULL`, hash,
).Scan(&k.ID, &k.Name, &k.RateLimitPerMin)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
return &k, nil
}
// Source describes a data source with its license and trust weight.
type Source struct {
ID string `json:"id"`
Name string `json:"name"`
Homepage *string `json:"homepage"`
License *string `json:"license"`
TrustWeight float64 `json:"trust_weight"`
}
// SourceByID fetches a single data source.
func (s *Store) SourceByID(ctx context.Context, id string) (*Source, error) {
var src Source
err := s.pool.QueryRow(ctx,
"SELECT id, name, homepage, license, trust_weight FROM source WHERE id = $1", id,
).Scan(&src.ID, &src.Name, &src.Homepage, &src.License, &src.TrustWeight)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
return &src, nil
}
-79
View File
@@ -1,79 +0,0 @@
name: goods
services:
postgres:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER}"]
interval: 5s
timeout: 5s
retries: 10
redis:
image: redis:7-alpine
restart: unless-stopped
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 5s
retries: 10
minio:
image: minio/minio:latest
restart: unless-stopped
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
volumes:
- miniodata:/data
healthcheck:
test: ["CMD", "mc", "ready", "local"]
interval: 5s
timeout: 5s
retries: 10
api:
build:
context: .
dockerfile: api/Dockerfile.prod
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
environment:
OPENGOODS_ADDR: ":8080"
OPENGOODS_DATABASE_URL: "postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}?sslmode=disable"
OPENGOODS_REDIS_URL: "redis://redis:6379/0"
ports:
- "127.0.0.1:8120:8080"
admin:
build:
context: .
dockerfile: api/Dockerfile.admin
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
environment:
GOODS_ADMIN_ADDR: ":8080"
OPENGOODS_DATABASE_URL: "postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}?sslmode=disable"
GOODS_ADMIN_BASE_PATH: "/ping"
GOODS_ADMIN_USER: "${GOODS_ADMIN_USER}"
GOODS_ADMIN_PASSWORD: "${GOODS_ADMIN_PASSWORD}"
GOODS_ADMIN_JWT_SECRET: "${GOODS_ADMIN_JWT_SECRET}"
ports:
- "127.0.0.1:8121:8080"
volumes:
pgdata:
miniodata:
-61
View File
@@ -1,61 +0,0 @@
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: opengoods
POSTGRES_PASSWORD: opengoods
POSTGRES_DB: opengoods
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U opengoods"]
interval: 5s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
ports:
- "6379:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 5s
retries: 5
minio:
image: minio/minio:latest
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: opengoods
MINIO_ROOT_PASSWORD: opengoods123
ports:
- "9000:9000"
- "9001:9001"
volumes:
- miniodata:/data
healthcheck:
test: ["CMD", "mc", "ready", "local"]
interval: 5s
timeout: 5s
retries: 5
api:
build: ./api
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
environment:
OPENGOODS_ADDR: ":8080"
OPENGOODS_DATABASE_URL: "postgres://opengoods:opengoods@postgres:5432/opengoods?sslmode=disable"
OPENGOODS_REDIS_URL: "redis://redis:6379/0"
ports:
- "8080:8080"
volumes:
pgdata:
miniodata:
-117
View File
@@ -1,117 +0,0 @@
# OpenGoods 公共 API 开发者文档
天工商品档案公共仓(OpenGoods)提供**公开、只读**的商品事实 REST API:按条码/名称查询商品的客观资料(品牌、品类、净含量、产地、配料、营养成分、Nutri-Score、厂商建议零售价快照等)。返回均为 JSON(UTF-8)。**本服务不含任何购买/交易接口。**
- 基础地址:`https://goods.tangshasha.com/api/v1`
- 机器可读规范(OpenAPI 3):`GET /api/v1/openapi.json`
- 交互式文档:站点「API 调用说明」页
## 鉴权
API 默认**匿名可用**,无需任何凭证即可调用。匿名请求按来源 IP 计入一个较低的默认每分钟额度。
如需更高额度并让用量归属到你,可向运营方申请一枚 **API Key**(形如 `og_live_xxxxxxxx`),请求时二选一携带:
```bash
curl -H "X-API-Key: og_live_xxxxxxxx" \
"https://goods.tangshasha.com/api/v1/products/search?q=牛奶"
# 或
curl -H "Authorization: Bearer og_live_xxxxxxxx" \
"https://goods.tangshasha.com/api/v1/products/search?q=牛奶"
```
> 仅在创建时返回一次明文 Key,请妥善保存。服务端只存储其 SHA-256 哈希。
## 限流
采用**固定窗口**限流(每分钟)。每个响应都会回写以下响应头:
| 响应头 | 含义 |
| --- | --- |
| `X-RateLimit-Limit` | 当前窗口允许的最大请求数 |
| `X-RateLimit-Remaining` | 当前窗口剩余可用次数 |
| `X-RateLimit-Reset` | 窗口重置的 Unix 时间戳(秒) |
| `Retry-After` | 仅在超额(429)时返回,建议等待的秒数 |
- 超过额度:`429 Too Many Requests`,错误码 `rate_limited`
- Key 无效或已吊销:`401 Unauthorized`,错误码 `invalid_api_key`
## 错误格式
非 2xx 响应体统一为:
```json
{ "error": { "code": "not_found", "message": "…", "request_id": "…" } }
```
## 分页
列表类接口支持 `page`(默认 `1`)与 `size`(默认 `20`,最大 `100`),响应含 `page`/`size`/`total`
## 端点
### `GET /products/search` — 搜索商品
按名称做三元组(trigram)模糊搜索,**可容忍错别字**;支持品类/品牌/产地过滤;结果按相关度(名称相似度 × 数据质量分)排序。
| 参数 | 必填 | 说明 |
| --- | --- | --- |
| `q` | 否 | 关键词(名称/条码),模糊匹配;留空则按质量分返回全部 |
| `category` | 否 | 品类编码(含子树),如 `food.beverages` |
| `brand` | 否 | 品牌名(模糊匹配),如 `Ferrero` |
| `country` | 否 | 产地前缀(不区分大小写),如 `China` |
| `page` | 否 | 页码,默认 1 |
| `size` | 否 | 每页条数,默认 20,最大 100 |
```bash
curl "https://goods.tangshasha.com/api/v1/products/search?q=nutela&country=Italy"
```
```json
{
"items": [
{
"id": "…",
"gtin": "3017624010701",
"name": "Nutella",
"brand": "Ferrero",
"category_path": "food.snacks.chocolate",
"country_of_origin": "Italy",
"quality_score": 0.81,
"score": 0.71
}
],
"page": 1,
"size": 20,
"total": 1
}
```
`score` 为名称相关度(提供 `q` 时返回,01),未提供 `q` 时为 `null`
### `GET /products/barcode/{gtin}` — 按条码查询
```bash
curl "https://goods.tangshasha.com/api/v1/products/barcode/5449000000996"
```
### `GET /products/{id}` — 商品详情
按商品 UUID 获取完整档案(含配料、营养、添加剂、图片、MSRP 等)。
### `GET /products/{id}/nutriments` — 商品营养成分
### `GET /products/{id}/msrp` — 厂商建议零售价快照
官方建议零售价历史快照,仅供参考,不含任何购买入口。
### `GET /brands` — 品牌列表(分页)
### `GET /categories` — 品类树
### `GET /sources/{id}` — 数据来源
## 免责声明
数据可能存在误差或滞后,按「现状」提供,不构成医疗/购买建议。商品资料版权归各原始来源所有,请遵循其许可(如 OpenFoodFacts 的 ODbL),引用时请注明天工商品档案公共仓及原始来源。
-36
View File
@@ -1,36 +0,0 @@
# 数据契约 (Data Contract) v0.1
本契约是 Go(API) 与 Python(ingestion) 两端共享的"事实约定",避免两端对字段含义理解不一致。
> 写入责任:**仅 Python (ingestion) 通过 ETL 写入数据库**Go (API) **只读**。所有写入必须经过单位归一化与字段级溯源。
## 1. 边界原则
- 系统只采集与提供**客观商品信息**;不包含任何购买/交易语义的字段或端点。
- 价格仅收录**官方建议零售价 (MSRP)** 的静态快照,必须带 `currency`/`region`/`source`/`effective_date`
## 2. 固定枚举
| 字段 | 取值 |
|------|------|
| `product.status` | `active` / `merged` / `deprecated` |
| `food_detail.nutrition_basis` | `per_100g` / `per_100ml` / `per_serving` |
| `unit.dimension` | `mass` / `volume` / `energy` / `count` / `ratio` / `length` / `duration` |
| `source.license` | `ODbL` / `CC0` / `proprietary` / ... |
| `product_image.kind` | `front` / `ingredients` / `nutrition` / `other` |
## 3. 单位规则
- 数值字段同时保存**原始值 + 单位**与**归一化值 + 基准单位**canonical)。
- 质量 → `g`,体积 → `ml`,能量 → `kJ`(同时保留 `kcal`)。
- 归一化逻辑由 `ingestion/opengoods/units.py` 提供(纯函数,含测试),换算因子是唯一事实来源。
- 营养成分统一折算到品类模板规定的基准(`per_100g` / `per_100ml`)。
## 4. 标识与可空性
- `product.gtin`8/12/13/14 位数字,可空(无条码商品),非空时全局唯一。
- `product.quality_score` ∈ [0, 1]。
- 货币用 ISO 4217`CNY` 等),国家/地区用简短代码(`CN` 等)。
## 5. 溯源 (Provenance)
- 每条数据通过 `product_source` 记录来源、URL、贡献字段、抓取时间与原始快照。
- 对外 API 在 `sources` 中透明返回来源与其许可。
## 6. 版本
- 本契约随 schema 演进版本化;任何 schema 变更需同步更新:迁移(SQL) + 本契约 + `docs/openapi.yaml`
-34
View File
@@ -1,34 +0,0 @@
# 生产部署 (Docker)
`docker-compose.prod.yml` 部署,与本地 `docker-compose.yml` 的区别:
-`api` 映射宿主端口,且绑定 `127.0.0.1:8120`(由外层 nginx 反代 + HTTPS);`postgres`/`redis`/`minio` 不对外暴露端口,仅容器内网互通。
- 所有服务 `restart: unless-stopped`
- 凭据从 `.env` 注入(见 `.env.example`),不写入仓库。
- `api` 使用 `api/Dockerfile.prod`:运行镜像用 `scratch`(从构建镜像拷贝 ca-certs),适用于 `gcr.io/distroless` 不可达的环境;Go 模块走 `goproxy.cn`
- `admin`(运营后台):带登录的写入服务 + 内嵌前端,绑定 `127.0.0.1:8121`,由 nginx 反代到公开站点的 `/ping` 路径。镜像 `api/Dockerfile.admin`(node 构建前端 → 内嵌进 Go 二进制 → scratch 运行)。仅 `admin` 可写库(人工编辑以 `source=manual` 记录字段级溯源 + `audit_log` 留痕),公开 `api` 仍只读。
## 步骤
```bash
cp .env.example .env # 填入真实随机密码
docker compose -f docker-compose.prod.yml up -d --build
# 迁移(migrate 容器接入同一网络,DSN 指向 postgres 服务)
set -a; . ./.env; set +a
DBURL="postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}?sslmode=disable"
docker run --rm --network goods_default -v "$PWD/migrations:/migrations" \
migrate/migrate -path=/migrations -database "$DBURL" up
curl -s http://127.0.0.1:8120/healthz # {"status":"ok"}
```
nginx 反代(子域 + HTTPS):80 端口 301 跳转到 443443 `proxy_pass http://127.0.0.1:8120`,证书用 acme.sh 签发并配 `--reloadcmd "nginx -s reload"` 自动续期。
运营后台 `/ping`(同域复用证书):在 443 server 块内加一段
```nginx
location /ping { proxy_pass http://127.0.0.1:8121; }
```
后台凭据见 `.env``GOODS_ADMIN_USER` / `GOODS_ADMIN_PASSWORD` / `GOODS_ADMIN_JWT_SECRET`。新增迁移 `0005_admin``audit_log` 表 + `manual` 来源)随 `migrate ... up` 自动应用。
-11
View File
@@ -1,11 +0,0 @@
# 免责声明 (Disclaimer)
天工·商品标签 (OpenGoods) 是一个**公益信息平台**。
- 本站**仅提供商品参数信息,不提供任何购买、下单、比价或导购服务**,不包含任何购买入口或交易链接。
- 商品参数(成分、营养、规格等)来自多个数据来源并标注出处,可能存在误差或滞后;**请以商品实物标签为准**。
- 价格字段仅为**官方建议零售价 (MSRP) 的历史快照**,标注来源与时间,实际售价以零售商为准,**不构成消费或购买建议**。
- 本站不提供医疗、健康或功效宣称。
- 数据按各来源许可使用(详见各条数据的 `sources` 字段与来源说明);权利方可通过公开渠道申请更正或下架。
> The OpenGoods service only collects and serves product information for public benefit. It provides **no purchase, checkout, price-comparison, or shopping-guide functionality**.
-38
View File
@@ -1,38 +0,0 @@
# ETL: Open Food Facts 导入 (M2)
把 Open Food Facts (OFF, ODbL 许可) 的食品数据采集、转换并入库。只有 Python 采集侧写库,每条记录都以 `openfoodfacts` 为来源记录**字段级溯源**。
## 流程
```
OFF API / dump(jsonl[.gz])
→ adapters/openfoodfacts.py # 读取(限速 + User-Agent) / 解析 dump
→ etl/transform.py # 字段映射 + 单位归一 + 营养 per_100g + 分类映射(关键词)
→ etl/load.py # psycopg upsert(product/food_detail/product_image) + product_source 溯源
```
## 运行
先确保本地依赖与迁移就绪:`docker compose up -d postgres` + `migrate ... up`
```bash
# 用 OFF API 拉指定条码(客户端限速, 默认 4s/次)
python -m opengoods.jobs.seed_off --barcodes 3017624010701 5449000000996
# 用下载好的 OFF dump 批量导入(可 .gz), 限制条数
python -m opengoods.jobs.seed_off --dump products.jsonl.gz --limit 1000
```
DSN 默认读 `OPENGOODS_DATABASE_URL`
## 字段映射要点
| OFF | OpenGoods | 处理 |
|-----|-----------|------|
| `code` | `product.gtin` | GTIN-8/12/13/14 校验位验证, 不合法则不作为 gtin |
| `product_name_zh/_/_en` | `product.name` | 优先中文 |
| `brands` | `brand` | 取第一个, normalized_name 去重 |
| `quantity` | `net_content_*` | 解析 "500 g"/"1,5 L" → 经 `units.py` 归一(原始+归一双存) |
| `nutriments.*_100g` | `food_detail.nutriments` | per_100g; 能量 kJ/kcal 双存, 缺一自动换算 |
| `allergens_tags`/`additives_tags` | `allergens`/`additives` | 去 `en:` 前缀 |
| `nutriscore_grade` | `nutri_score` | 大写单字母 |
| `categories*`/name | `category_id` | 关键词映射到自建品类树(起步版, 后续换 OFF 分类→GPC 映射表) |
| `image_front_url` | `product_image` | 标 CC-BY-SA 许可 |
> 全量 dump 约数 GBCI 与单测用 fixture 离线验证 transform,DB 集成测试在无库时自动跳过。
-76
View File
@@ -1,76 +0,0 @@
# 采集管理 (M4)
M4 在 M2Open Food Facts 首次导入)基础上,补齐"持续运营"所需的采集能力:
增量更新、第二数据源补全(GS1)、去重合并与字段级冲突解决、数据质量评分,
以及把这些串起来的定时调度。全部为 Python 侧(`ingestion/`),只写库、可单测。
## 组成
| 能力 | 模块 | 说明 |
|------|------|------|
| 增量采集 | `adapters/openfoodfacts.py: fetch_modified_since()` | 按 `last_modified_t` 拉取自上次水位后变更的商品 |
| 采集水位 | `etl/state.py` + `ingest_state` 表 | 每个源持久化 `last_modified_t`,只前进不回退 |
| GS1 补全 | `adapters/gs1.py` + `etl/supplement.py` | 用权威条码源补**缺失**字段(品牌/厂商/GPC/产地/净含量),不覆盖已有值 |
| 去重合并 | `etl/dedup.py` | 非 GTIN 重复(同名+品牌+净含量)合并到质量最高的主记录 |
| 冲突解决 | `etl/merge.py` | 多源同字段按"源权重 > 新鲜度"择优,保留字段级溯源 |
| 质量评分 | `etl/quality.py` | 0~1 分,落到 `product.quality_score` |
| 定时调度 | `jobs/schedule.py` | 固定周期跑"增量 + 去重"一轮,零额外依赖 |
## 质量评分
锁定公式(各分量均归一到 0~1):
```
quality = 0.4 * 完整度 + 0.3 * 源权重 + 0.2 * 多源一致 + 0.1 * 新鲜度
```
- **完整度**`name/gtin/brand/category/net_content/country/nutriments/ingredients/image` 9 项的命中比例。
- **源权重**:贡献该商品的源中最高 `source.trust_weight`OFF=0.7GS1=0.9)。
- **多源一致**:源数量代理——单源 0.5、两源 0.8、三源及以上 1.0(单源无法互证)。
- **新鲜度**:最近一次 `product_source.fetched_at` 的时间衰减(≤30d=1.0 … >730d=0.2)。
`load_record()``merge_products()` 写入后都会调 `update_quality()` 重算。
## 增量水位
`ingest_state`(迁移 `0004`)每源一行,记录 `last_modified_t``last_run_at``stats`
`set_watermark()``GREATEST(...)` 保证水位只前进,避免乱序/中断的运行回退进度。
## 运行
前置:`docker compose up -d postgres` 且迁移已 `up`(含 `0004`)。DSN 默认读 `OPENGOODS_DATABASE_URL`
```bash
# 增量更新 OFF(从持久化水位开始;--since 可覆盖)
python -m opengoods.jobs.update_off --max-pages 5
python -m opengoods.jobs.update_off --since 1700000000
# 去重合并(--dry-run 只报告不写库)
python -m opengoods.jobs.dedup --dry-run
python -m opengoods.jobs.dedup --actor nightly
# 定时调度:单轮 / 周期循环(增量 + 去重)
python -m opengoods.jobs.schedule --once
python -m opengoods.jobs.schedule --interval 3600
```
## GS1 补全
GS1 为付费、分区域的授权数据,适配器支持两种模式:
- **离线**(默认):从本地 JSON 映射 `{gtin: {...}}` 查(`GS1Adapter.from_file(path)`),
供测试与内网环境使用。
- **在线**:传 `base_url` + `client`+ `api_key`),`GET {base_url}/{gtin}`,按
Verified-by-GS1 风格字段解析。
补全只填**空缺**字段并在 `product_source` 记字段级溯源,源标记为 `gs1`
## 测试
```bash
cd ingestion && pip install -e ".[dev]"
ruff check . && ruff format --check . && pytest -q
```
纯函数测试(质量/冲突/增量分页)始终运行;依赖库的测试(水位/质量落库/去重/GS1 补全)
在无数据库或未应用 M4 迁移时自动跳过。
+115
View File
@@ -0,0 +1,115 @@
# 天工·商品标签 (OpenGoods) — 最终规划 (Final)
> 公益网站/服务:采集全网商品信息,提供商品参数查询 API。
> **核心原则:只采集 + 只提供信息,绝不涉及任何购买/下单/比价导购。**
> 本文档为前几版(v0.1 → v2.0)的最终收敛版,所有关键决策已锁定。详细设计见 v2.0 附件。
---
## 0. 项目标识
- **中文名**:天工·商品标签(呼应《天工开物》)
- **英文名**OpenGoods
- **定位**:开放、中立、可溯源的"商品参数百科 + 开放 API"
---
## 1. 已锁定的全部决策
| 维度 | 决策 |
|------|------|
| 首批品类 | **食品快消** |
| 价格 | 只收 **官方标准零售价 (MSRP)**:静态字段,带 currency/region/source/effective_date + 免责;**不收实时电商价、无购买入口** |
| 技术栈 | **Go**(对外只读 API/核心服务) + **Python**(采集/ETL/爬虫),经 **PostgreSQL + Redis/队列** 解耦 |
| 种子数据 | **Open Food Facts 食品 dump** 先导入,最快有真实数据 |
| 数据许可 | 对外数据库用 **ODbL + 署名**;CC0 来源(USDA)自由混入;每条数据按来源标注许可 |
| 商品分类 | **GS1 GPC 四层标准码 (Segment→Family→Class→Brick)** 为骨架 + **自建中文品类树** 映射 + 保留来源原始分类 |
| 单位管理 | 量纲字典;**原始值 + 归一化值双存**;营养统一折算到 `per_100g/per_100ml`;能量 **双存 kJ+kcal**;用十进制(NUMERIC)防误差 |
| 质量评分 | `0.4*完整度 + 0.3*来源权威 + 0.2*多源一致 + 0.1*新鲜度` |
| 众包 | **一期不做众包,先纯采集**;二期再开放贡献/纠错(带审核与版本化) |
| Go 框架 | `chi` + 标准库 `net/http`(轻量) |
| 迁移工具 | `golang-migrate`(纯 SQL,两端共享 schema |
| 部署 | 初期 Docker Composepostgres+redis+minio+go-api+python-worker)→ 后期 K8s |
| 地域 | 先用 OFF 全球食品库起步,后接 GS1-China 补强中国数据 |
---
## 2. 架构(定稿)
```
数据源: OFF dump / OFF API / USDA(CC0) / GS1-China
▼ Python: 采集 adapters → ETL(清洗/单位归一/分类映射/去重/质量评分)
│ 写入
┌────▼─────────┐ 图片 ┌──────────┐
│ PostgreSQL │◀───────▶│ MinIO/S3 │
│ (商品档案主库)│ └──────────┘
└────▲─────────┘
│ 只读 (+Redis 缓存/限流)
▼ Go: 公开 REST API + OpenAPI 文档
各种软件 / 开发者 (无任何交易端点)
```
两端不直接互调,通过共享 PostgreSQL schema + 《数据契约文档》协作。
---
## 3. 仓库结构(写代码时落地)
```
goods/ (OpenGoods 天工·商品标签)
├── README.md
├── LICENSE # 代码: Apache-2.0/MIT; 数据: ODbL 说明
├── docker-compose.yml
├── docs/{data-contract.md, openapi.yaml, disclaimer.md}
├── migrations/ # golang-migrate 共享 SQL
├── api/ # Go 只读 API (chi)
│ ├── cmd/server/main.go
│ └── internal/{handler,store,model,middleware}/
└── ingestion/ # Python 采集 + ETL
├── adapters/{openfoodfacts,usda,gs1}.py
├── etl/{normalize_units,map_category,dedup,quality}.py
└── jobs/{seed_off_dump,scheduler}.py
```
---
## 4. 最终可执行任务清单(按里程碑)
**M0 — 工程地基**~35d
- [ ] Go module + Python 项目骨架
- [ ] docker-composepostgres+redis+minio
- [ ] CIGo build/vet/testPython ruff/pytest
- [ ] `docs/data-contract.md``docs/disclaimer.md`(不提供购买声明)初版
**M1 — 数据模型 + 分类 + 单位**~46d
- [ ] migrationsproduct / food_detail / product_msrp / product_source / brand / manufacturer / category / category_schema / unit / attribute_definition / merge_log
- [ ] 导入 GS1 GPC 骨架 + 建自建中文品类树 + 映射表
- [ ] 单位字典 + 归一化规则
- [ ] 索引:gtin 唯一、name trigram、JSONB GIN、category ltree
**M2 — 种子数据 (Python)**~58d
- [ ] 下载 OFF 食品 dump → 字段映射(成分/营养/过敏原/图片)
- [ ] 单位归一 + 分类映射入库
- [ ] USDA(CC0) 营养补全(可选)
**M3 — MVP API (Go)**~58d
- [ ] 端点:barcode / id / search / nutriments / msrp / brands / categories / sources / healthz
- [ ] 统一响应信封、分页、`fields=` 裁剪、错误码
- [ ] Redis 缓存 + IP 限流 + OpenAPI 文档
**M4 — 采集管线 (Python)**~812d
- [ ] adapterOFF API 增量 + GS1 条码补全
- [ ] ETL:清洗/归一/去重合并/冲突解决/质量评分/字段级溯源
- [ ] 调度(定时增量更新)
**M5 — 开放与规模化**~1015d
- [ ] 搜索引擎(PG 全文 → OpenSearch)、CDN
- [ ] 免费 API Key(防滥用+统计)
- [ ] 众包贡献后台(提交/审核/版本/信誉)
- [ ] 开发者文档站 + 开放数据许可与免责声明上线
> 关键路径:M0→M1→M2→M3(最快拿到可查询 MVP);M4/M5 后续并行迭代。
---
## 5. 下一步
规划已全部定稿。你之前说"先不写代码",所以我**停在这里待命**。
等你说"开始",我从 **M0 工程地基** 动手,搭好骨架后开 PR 给你看(也可指定先只做某几个里程碑,例如 M0+M1)。
+373
View File
@@ -0,0 +1,373 @@
# 商品档案公益 API 系统 — 深化规划 (v2.0)
> 在 v1.0 定稿基础上,全面展开 8 个方向,并新增 **商品分类体系** 与 **单位管理体系** 两章。
> 不变原则:**只采集 + 只提供信息,绝不涉及购买/交易行为。** 全文仍为规划,未写代码。
**目录**
- A. 商品分类体系(新增)
- B. 单位管理体系(新增)
- 1. 数据库详细设计
- 2. API 详细契约
- 3. 数据治理(去重/冲突/质量评分/溯源)
- 4. 采集合规细则
- 5. 部署与运维
- 6. 众包贡献流程
- 7. 项目治理(域名/许可/免责)
- 8. 时间与里程碑估算
---
## A. 商品分类体系(Taxonomy
商品分类是整个档案库的骨架,直接影响搜索、参数模板、去重。建议**对齐国际标准 + 自建可读品类树**双轨。
### A.1 采用 GS1 GPC 作为标准骨架
GS1 **GPCGlobal Product Classification** 是四层、规则化的全球商品分类,8 位数字编码:
```
Segment(段) → Family(族) → Class(类) → Brick(砖)
47000000 47100000 47101800 10000xxx
清洁/卫生 清洁用品 ... 具体品类(GTIN挂这里)
```
- 全球 44 个 Segment,食品快消主要落在 **Food/Beverage/Tobacco****Cleaning/Hygiene** 等段。
- **Brick** 是最细粒度,商品(GTIN)挂在 brick 上;每个 brick 可带 ≤25 个属性,正好对应我们的"品类参数模板"。
- 好处:与 GS1/电商/数据池天然对齐,便于将来对接 OFF、USDA、GS1-China。
### A.2 三层映射策略
| 层 | 用途 | 来源 |
|----|------|------|
| **标准码 (gpc_brick_code)** | 机器对齐、跨源映射 | GS1 GPC |
| **自建品类树 (category)** | 人类可读、网站导航、中文友好 | 自建,映射到 GPC |
| **来源原始分类 (source_category)** | 保留溯源 | OFF categories / USDA / GS1 |
> OFF 有自己的 categories taxonomy(标签式、多语言),导入时做 `OFF category → 自建 category → GPC brick` 的映射表,未命中的进人工/众包校对队列。
### A.3 品类参数模板(Category Schema
每个叶子品类定义"应有哪些参数",用于:① 数据完整度评分 ② 录入/校验约束 ③ API 返回结构提示。
```jsonc
// category_schema 示例: 包装水
{
"category_id": "beverage/packaged_water",
"gpc_brick_code": "10000159",
"required_attributes": ["net_content", "shelf_life"],
"recommended_attributes": ["ph", "tds", "water_type"],
"nutriment_basis": "per_100ml"
}
```
### A.4 分类落地要点
- 分类树存为**邻接表 + 物化路径**(`path` 列,便于子树查询)。
- 多对一:一个商品归一个主品类(primary),可挂多个辅助标签(labels)。
- 分类可演进:用 `category_version` 管理重命名/合并,旧 ID 重定向不破坏 API。
---
## B. 单位管理体系(Units
食品参数单位混乱(g/kg/ml/L/份/%/kcal/kJ…),必须有统一的**单位字典 + 量纲 + 归一化**机制,否则无法比较和检索。
### B.1 量纲与基准单位
| 量纲 (dimension) | 基准单位 (canonical) | 常见单位 |
|------------------|----------------------|----------|
| 质量 mass | g | mg, g, kg, 斤, oz, lb |
| 体积 volume | ml | ml, L, cl, fl oz |
| 能量 energy | kJ | kJ, kcal(同时存两者) |
| 数量 count | 个 | 个/瓶/包/片/粒 |
| 比例 ratio | %(或无量纲) | %, ‰, mg/100g |
| 长度 length | mm | mm, cm, m, in |
| 时间(保质期) duration | 天 | 天/月/年 |
### B.2 单位字典 `unit`
```jsonc
{
"code": "kg",
"dimension": "mass",
"to_canonical_factor": 1000, // 1 kg = 1000 g
"canonical": "g",
"aliases": ["千克", "公斤", "kgs"],
"display": "kg"
}
```
### B.3 归一化规则
- **入库双存**:原始值/单位 `{value, unit}` + 归一化值 `{canonical_value, canonical_unit}`,原始保留供溯源与展示。
- **营养基准统一**:全部折算到 `per_100g``per_100ml`(按品类模板决定),并保留 `serving_size` 原值。
- **能量双单位**:同时存 kJ + kcal1 kcal ≈ 4.184 kJ),缺一个则自动换算并标记 `derived=true`
- **不可换算**:count(个/瓶)等不跨量纲换算;只做单位别名归一。
- **精度与舍入**:用十进制(`NUMERIC`)避免浮点误差;记录有效数字。
- **冲突处理**:单位无法识别 → 入"待清洗队列",不丢数据。
### B.4 单位与 API
- API 默认返回**原始单位 + 归一化值**两套;可加 `?unit_system=metric|original` 控制展示。
- 搜索/过滤一律基于 canonical 值(如"热量<200kcal/100g")。
---
## 1. 数据库详细设计
PostgreSQL。核心:关系表 + JSONB 灵活属性 + 结构化营养子表。以下为 DDL 草案(写代码时落到 `migrations/`)。
### 1.1 ER 概览
```
brand 1───* product *───1 category ───* category_schema
manufacturer 1───* product
product 1───1 food_detail
product 1───* product_msrp
product 1───* product_source (溯源, 字段级)
product *───* attribute (via product_attribute, 或 JSONB)
unit (字典) attribute_definition (参数字典)
contribution / merge_log / source (治理与登记)
```
### 1.2 关键建表草案(节选)
```sql
CREATE TABLE product (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
gtin VARCHAR(14) UNIQUE, -- 可空(无条码商品)
name TEXT NOT NULL,
brand_id UUID REFERENCES brand(id),
manufacturer_id UUID REFERENCES manufacturer(id),
category_id UUID REFERENCES category(id),
gpc_brick_code VARCHAR(8),
net_content_value NUMERIC,
net_content_unit VARCHAR(16),
net_content_canonical NUMERIC, -- 归一化(g/ml)
country_of_origin VARCHAR(64),
shelf_life_days INT,
storage TEXT,
attributes JSONB DEFAULT '{}', -- 灵活参数
quality_score NUMERIC(4,3) DEFAULT 0,
status VARCHAR(16) DEFAULT 'active',
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
CREATE TABLE food_detail (
product_id UUID PRIMARY KEY REFERENCES product(id) ON DELETE CASCADE,
ingredients_text TEXT,
ingredients JSONB, -- [{name,rank}]
allergens TEXT[],
additives TEXT[],
nutriments JSONB, -- 见单位章, 归一到 per_100g/ml
nutrition_basis VARCHAR(16),
serving_size VARCHAR(32),
nutri_score CHAR(1),
labels TEXT[]
);
CREATE TABLE product_msrp (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
product_id UUID REFERENCES product(id) ON DELETE CASCADE,
amount NUMERIC(12,2) NOT NULL,
currency CHAR(3) NOT NULL, -- ISO 4217
region VARCHAR(8) DEFAULT 'CN',
source_id UUID REFERENCES source(id),
source_url TEXT,
effective_date DATE,
note TEXT,
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE TABLE product_source ( -- 字段级溯源
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
product_id UUID REFERENCES product(id) ON DELETE CASCADE,
source_id UUID REFERENCES source(id),
url TEXT,
fields TEXT[], -- 该来源贡献了哪些字段
fetched_at TIMESTAMPTZ,
raw JSONB -- 原始快照
);
```
### 1.3 索引策略
- `product.gtin` 唯一索引;`product.name``pg_trgm` GIN(模糊搜索)。
- `product.attributes``food_detail.nutriments`**JSONB GIN** 索引(参数检索)。
- `category.path``ltree` 或前缀索引(子树查询)。
- 全文检索:初期 `tsvector`(name+brand+ingredients) GIN;规模化后迁 OpenSearch。
- 时间列 `updated_at` 索引(增量同步)。
---
## 2. API 详细契约
只读、版本化、统一信封。下面给核心端点的示例。
### 2.1 按条码查询(最常用)
```
GET /api/v1/products/barcode/3017624010701?fields=name,brand,nutriments,msrp
```
```jsonc
{
"data": {
"id": "…", "gtin": "3017624010701",
"name": "示例牌 巧克力榛子酱 400g", "brand": "示例牌",
"category": "食品/酱料/巧克力酱",
"net_content": {"value":400,"unit":"g","canonical":{"value":400,"unit":"g"}},
"food": {
"nutriments": {"energy_kcal":539,"energy_kj":2255,"fat_g":30.9,"sugars_g":56.3,"salt_g":0.107},
"nutrition_basis":"per_100g", "allergens":["坚果","乳","大豆"]
},
"msrp": {"amount":29.90,"currency":"CNY","region":"CN","effective_date":"2026-01-01",
"note":"官方建议零售价, 本站不提供购买"}
},
"meta": {"version":"v1"},
"sources": [{"source":"Open Food Facts","url":"…","fetched_at":"…","license":"ODbL"}]
}
```
### 2.2 搜索
```
GET /api/v1/products/search?q=巧克力&brand=示例牌&category=酱料&allergen_free=花生&page=1&size=20&fields=…
```
返回 `data:[…]` + `meta:{page,size,total,total_pages}`
### 2.3 端点清单 & 错误码
| 端点 | 说明 |
|------|------|
| `GET /products/barcode/{gtin}` | 条码查 |
| `GET /products/{id}` | ID 查 |
| `GET /products/search` | 搜索/过滤/分页 |
| `GET /products/{id}/nutriments` | 仅营养 |
| `GET /products/{id}/msrp` | 仅官方价(含免责) |
| `GET /brands` `GET /categories` | 品牌 / 品类树 |
| `GET /sources/{id}` | 数据来源透明说明 |
| `GET /healthz` `GET /openapi.json` | 健康检查 / 机读文档 |
错误码:`400`(参数错) `404`(未找到, 返回 `{error:{code:"not_found"}}`) `429`(限流, 带 `Retry-After`) `5xx`(服务端)。统一错误信封 `{error:{code,message,request_id}}`
### 2.4 跨切面
- **版本化** `/v1/`;破坏性变更升 `/v2/`,旧版保留过渡期。
- **限流**:匿名 IP 默认 60 req/min(可调);免费 API Key 提配额。响应头 `X-RateLimit-*`
- **缓存**`Cache-Control` + ETagCDN + Redis;条码查命中率高。
- **CORS**:开放 GET(公益 API)。
- **分页**`page/size`(上限 100);大结果集用 `search_after` 游标(OpenSearch 阶段)。
---
## 3. 数据治理
### 3.1 实体去重 / 匹配
1. **强匹配**:相同 `gtin` → 同一商品(条码是天然主键)。
2. **弱匹配**(无 gtin 或 gtin 缺失):`(规范化品牌 + 规范化型号/名称 + 净含量)` 相似度(trigram/编辑距离)+ 阈值;命中候选进**人工/众包确认**,不自动硬合并。
3. **合并**:保留一条 canonical,其余标 `status=merged` 并写 `merge_log`(可回滚)。
### 3.2 多源字段冲突解决
- 每个字段记录来源 + 时间 + 来源可信度权重。
- 冲突时:① 按**来源可信度**(GS1官方 > 厂商官网 > OFF众包 > 第三方)② 同级取**最新** ③ 数值类可取多数/中位数。
- 保留所有来源值于 `product_source.raw`,对外 `sources` 字段透明展示"该字段来自谁"。
### 3.3 质量评分公式(0~1
```
quality_score = 0.4*完整度 + 0.3*来源权威度 + 0.2*多源一致性 + 0.1*新鲜度
完整度 = 命中品类模板 required/recommended 字段的比例
权威度 = 贡献字段的来源权重加权
一致性 = 多源同字段一致的比例
新鲜度 = 最近更新时间衰减
```
低分商品在搜索中降权,并进入"待补全"队列(可派给众包)。
### 3.4 溯源(Provenance
字段级溯源:每条数据可回答"这个营养值/价格来自哪个来源、什么时间、什么许可"。这是公益项目可信度的核心,也用于许可合规标注。
---
## 4. 采集合规细则
### 4.1 通用护栏
- 严格遵守 `robots.txt` 与各源服务条款;礼貌限速(OFF 读 ≤15 req/min/IP);错峰;明确 `User-Agent` 标识本项目身份与联系方式。
- 只采**客观参数**;不照搬受版权保护的营销文案/评测原文(链接来源即可)。
- 增量优先:用 `last_modified`/dump 差异做增量,避免重复抓取。
### 4.2 各源接入步骤
| 源 | 步骤 | 许可 |
|----|------|------|
| **OFF dump**(首批种子) | 下载 `en.openfoodfacts.org.products.csv.gz`(~0.9G压缩) → 解析 → 映射字段 → 入库 | ODbL(衍生库需 ODbL+署名) |
| **OFF API**(增量) | 按 gtin 拉取/按更新时间增量;遵守限速 | 同上 |
| **USDA FoodData Central** | 申请免费 API key;或下载 Branded/Foundation JSON;补全营养 | CC0(最宽松) |
| **GS1 / 中国商品信息服务平台** | 条码→品牌/规格/厂商;API ≤1000 GTIN/次(需授权) | 受限,按授权使用 |
| **厂商官网** | 逐站 adapter,遵守 robots,取官方规格表/MSRP | 取客观参数 |
### 4.3 许可合规
- OFF=ODbL(传染性,衍生数据库须同样开放+署名 OFF);USDA=CC0。
- 对外数据库整体采用 **ODbL + 署名**;每条数据按 `sources[].license` 标注其来源许可,避免冲突。
---
## 5. 部署与运维
### 5.1 演进路径
- **初期**Docker Compose 一键起 `postgres + redis + minio + go-api + python-worker`,单机即可跑通 MVP。
- **成长期**:API 多副本 + 读副本数据库 + CDN;worker 横向扩展。
- **规模化**K8sAPI Deployment + HPA、worker Job/CronJob)、OpenSearch 集群、对象存储用云 S3。
### 5.2 可观测性
- 指标:Prometheus(QPS、延迟、缓存命中、限流计数、采集成功率)。
- 日志:结构化日志 + request_id 贯穿。
- 链路:OpenTelemetryAPI → DB)。
- 告警:错误率/延迟/采集失败/磁盘。
### 5.3 备份与可靠性
- Postgres 每日全量 + WAL 归档;定期恢复演练。
- 对象存储多版本/冗余。
- 采集 worker 幂等 + 重试 + 死信队列。
### 5.4 成本(量级估算,公益项目控成本)
- MVP:单台小型云主机(2C4G)+ 对象存储即可(月成本很低)。
- OFF 食品子集约数百万条,PG 单实例可承载;图片走对象存储 + CDN(按流量)。
- 详细预算待定(取决于云厂商与访问量),可后续出一版成本表。
---
## 6. 众包贡献流程
公益库靠社区补全/纠错。流程:
1. **提交**:用户对某商品提交新增/修改(带可选来源链接、照片)。
2. **校验**:单位/格式/品类模板校验 + 反垃圾(限频、信誉分、验证码)。
3. **审核**:低风险字段自动接受并标 `source=community`;高风险(价格、品牌)进人工/资深用户审核队列。
4. **版本化**:每次修改存历史版本,可 diff、可回滚(类似 wiki)。
5. **信誉系统**:贡献被采纳提升信誉;高信誉用户审核权更大。
6. **溯源透明**:众包数据与官方数据在 `sources` 中明确区分。
> 注意:众包内容也要遵守"只客观信息、不导购",并保留权利方下架通道。
---
## 7. 项目治理(域名/许可/免责)
- **品牌/域名**:建议中性、表意清晰的名字(如 *商品档案 / OpenGoods* 之类),后续选定。
- **代码许可**:开源(如 MIT/Apache-2.0),鼓励复用。
- **数据许可****ODbL + 署名**(因含 OFF);API 文档明示再利用条款。
- **隐私**:不收集个人数据(PII),只处理商品信息;众包账号信息最小化。
- **免责声明(站点显著位置)**
- "本站为公益信息平台,**仅提供商品参数信息,不提供任何购买/交易服务**。"
- "价格为官方建议零售价历史快照,实际售价以零售商为准,**不构成消费或购买建议**。"
- "数据来自多来源并标注出处,可能存在误差;欢迎纠错,权利方可申请更正/下架。"
- **下架/纠错渠道**:公开邮箱/表单,承诺响应时限。
---
## 8. 时间与里程碑估算
> 仅为相对工作量估算(以"理想工作日"计,非承诺排期);实际取决于投入人力与数据源接入难度。
| 里程碑 | 内容 | 估算 | 依赖 | 主要风险 |
|--------|------|------|------|----------|
| **M0 地基** | Go/Python 骨架、Compose、CI、数据契约 | 35 d | — | 低 |
| **M1 数据模型** | 迁移、分类树、单位字典、参数模板 | 4–6 d | M0 | 分类/单位建模需打磨 |
| **M2 种子数据** | OFF dump 导入 + 单位归一 + 分类映射 | 5–8 d | M1 | dump 体量大、字段映射脏 |
| **M3 MVP API(Go)** | 端点 + 缓存/限流 + OpenAPI | 58 d | M1,M2 | 检索性能调优 |
| **M4 采集管线(Python)** | OFF/GS1 adapter + ETL + 去重 + 质量分 + 调度 | 8–12 d | M2 | 去重/冲突算法、合规 |
| **M5 开放/规模化** | 搜索引擎、CDN、API Key、众包后台、文档站 | 1015 d | M3,M4 | 众包审核与防滥用 |
关键路径:M0→M1→M2→M3(最快拿到可查询 MVP);M4/M5 可与后续并行迭代。
---
## 9. 待确认(本版新增点)
1. **分类标准**:认同以 **GS1 GPC** 为标准骨架 + 自建中文品类树映射吗?
2. **单位策略**:营养统一折算到 `per_100g/per_100ml`、能量双存 kJ+kcal,认同吗?
3. **质量评分权重**:上面的 0.4/0.3/0.2/0.1 权重是否合适,或你有偏好?
4. **众包**:第一阶段就要做众包贡献,还是先纯采集、后期再开放众包?
5. **项目命名/域名**:有想好的名字吗?没有的话我可以提几个候选。
> 确认后我把 v2.0 收敛为可执行的工程任务清单。需要动手写代码时你说一声,我从 M0 开始搭骨架开 PR。
+408
View File
@@ -0,0 +1,408 @@
# 天工·商品标签 (OpenGoods) — 深化规划 (v3.0)
> 在最终版基础上,展开全部 10 个进阶方向。仍为规划,未写代码。
> 原则不变:**只采集 + 只提供信息,绝不涉及购买/交易。**
**目录**
1. 完整 OpenAPI 规范草案
2. 完整数据库 DDL
3. 数据契约文档
4. OFF 字段映射表
5. 中国合规专项
6. 测试与数据质量保障
7. 安全与反滥用
8. 商品图片处理
9. 可用性与 SLA
10. 竞品 / 同类项目分析
---
## 1. 完整 OpenAPI 规范草案(节选骨架,写代码时落到 `docs/openapi.yaml`
```yaml
openapi: 3.1.0
info:
title: OpenGoods API (天工·商品标签)
version: "1.0.0"
description: >
公益商品参数查询 API。只提供信息,不提供购买/交易。
数据采用 ODbL 许可并署名来源。
license: {name: ODbL-1.0, url: https://opendatacommons.org/licenses/odbl/}
servers:
- {url: https://api.opengoods.org/api/v1}
paths:
/products/barcode/{gtin}:
get:
summary: 按条码查询商品档案
parameters:
- {name: gtin, in: path, required: true, schema: {type: string, pattern: '^[0-9]{8,14}$'}}
- {name: fields, in: query, schema: {type: string}, description: 逗号分隔字段裁剪}
responses:
'200': {description: OK, content: {application/json: {schema: {$ref: '#/components/schemas/ProductEnvelope'}}}}
'404': {description: 未找到, content: {application/json: {schema: {$ref: '#/components/schemas/Error'}}}}
'429': {description: 限流, headers: {Retry-After: {schema: {type: integer}}}}
/products/{id}:
get: { summary: 按ID查询, parameters: [{name: id, in: path, required: true, schema: {type: string, format: uuid}}], responses: {'200': {description: OK}} }
/products/search:
get:
summary: 搜索/过滤/分页
parameters:
- {name: q, in: query, schema: {type: string}}
- {name: brand, in: query, schema: {type: string}}
- {name: category, in: query, schema: {type: string}}
- {name: allergen_free, in: query, schema: {type: string}}
- {name: page, in: query, schema: {type: integer, default: 1}}
- {name: size, in: query, schema: {type: integer, default: 20, maximum: 100}}
responses: {'200': {description: OK, content: {application/json: {schema: {$ref: '#/components/schemas/SearchEnvelope'}}}}}
/products/{id}/nutriments: {get: {summary: 仅营养}}
/products/{id}/msrp: {get: {summary: 仅官方零售价(含免责)}}
/brands: {get: {summary: 品牌列表}}
/categories: {get: {summary: 品类树}}
/sources/{id}:{get: {summary: 数据来源透明说明}}
/healthz: {get: {summary: 健康检查}}
components:
schemas:
ProductEnvelope:
type: object
properties:
data: {$ref: '#/components/schemas/Product'}
meta: {type: object}
sources: {type: array, items: {$ref: '#/components/schemas/SourceRef'}}
Product:
type: object
properties:
id: {type: string, format: uuid}
gtin: {type: string}
name: {type: string}
brand: {type: string}
category: {type: string}
net_content: {$ref: '#/components/schemas/Quantity'}
food: {$ref: '#/components/schemas/FoodDetail'}
msrp: {$ref: '#/components/schemas/Msrp'}
quality_score: {type: number}
Quantity:
type: object
properties: {value: {type: number}, unit: {type: string}, canonical: {type: object}}
FoodDetail:
type: object
properties:
ingredients_text: {type: string}
allergens: {type: array, items: {type: string}}
additives: {type: array, items: {type: string}}
nutriments: {type: object}
nutrition_basis: {type: string, enum: [per_100g, per_100ml, per_serving]}
nutri_score: {type: string}
Msrp:
type: object
properties:
amount: {type: number}
currency: {type: string}
region: {type: string}
effective_date: {type: string, format: date}
note: {type: string, default: "官方建议零售价, 本站不提供购买"}
SourceRef:
type: object
properties: {source: {type: string}, url: {type: string}, fetched_at: {type: string}, license: {type: string}}
Error:
type: object
properties: {error: {type: object, properties: {code: {type: string}, message: {type: string}, request_id: {type: string}}}}
```
> 该 `openapi.yaml` 既是契约也是文档源:Go 端用它做路由校验/生成 Swagger UI,客户端可由它生成 SDK。
---
## 2. 完整数据库 DDL(全部表)
```sql
-- 扩展
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE EXTENSION IF NOT EXISTS ltree;
-- gen_random_uuid() 由 pgcrypto 提供
CREATE TABLE source (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL, -- Open Food Facts / USDA / GS1-China ...
homepage TEXT,
license TEXT, -- ODbL / CC0 / proprietary
trust_weight NUMERIC(3,2) DEFAULT 0.5, -- 来源可信度(冲突解决用)
notes TEXT
);
CREATE TABLE brand (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
normalized_name TEXT, -- 规范化(去空格/大小写/全半角)用于匹配
aliases TEXT[],
UNIQUE(normalized_name)
);
CREATE TABLE manufacturer (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
normalized_name TEXT,
country VARCHAR(64),
UNIQUE(normalized_name)
);
CREATE TABLE category (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name_zh TEXT NOT NULL,
name_en TEXT,
parent_id UUID REFERENCES category(id),
path LTREE, -- 物化路径, 子树查询
gpc_brick_code VARCHAR(8), -- 映射到 GS1 GPC
level INT,
UNIQUE(path)
);
CREATE TABLE category_schema ( -- 品类参数模板
category_id UUID PRIMARY KEY REFERENCES category(id),
required_attributes TEXT[],
recommended_attributes TEXT[],
nutriment_basis VARCHAR(16)
);
CREATE TABLE unit ( -- 单位字典
code VARCHAR(16) PRIMARY KEY,
dimension VARCHAR(16) NOT NULL, -- mass/volume/energy/count/ratio/length/duration
canonical VARCHAR(16) NOT NULL,
to_canonical_factor NUMERIC, -- code -> canonical 的换算因子
aliases TEXT[],
display TEXT
);
CREATE TABLE attribute_definition ( -- 参数字典(标准名/别名/单位)
key VARCHAR(64) PRIMARY KEY,
label_zh TEXT, label_en TEXT,
dimension VARCHAR(16),
default_unit VARCHAR(16) REFERENCES unit(code),
aliases TEXT[]
);
CREATE TABLE product (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
gtin VARCHAR(14) UNIQUE,
name TEXT NOT NULL,
brand_id UUID REFERENCES brand(id),
manufacturer_id UUID REFERENCES manufacturer(id),
category_id UUID REFERENCES category(id),
gpc_brick_code VARCHAR(8),
net_content_value NUMERIC,
net_content_unit VARCHAR(16),
net_content_canonical NUMERIC,
country_of_origin VARCHAR(64),
shelf_life_days INT,
storage TEXT,
attributes JSONB DEFAULT '{}',
quality_score NUMERIC(4,3) DEFAULT 0,
status VARCHAR(16) DEFAULT 'active', -- active/merged/deprecated
canonical_id UUID REFERENCES product(id), -- 被合并到哪个
search_tsv TSVECTOR,
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
CREATE TABLE food_detail (
product_id UUID PRIMARY KEY REFERENCES product(id) ON DELETE CASCADE,
ingredients_text TEXT,
ingredients JSONB,
allergens TEXT[],
additives TEXT[],
nutriments JSONB,
nutrition_basis VARCHAR(16),
serving_size VARCHAR(32),
nutri_score CHAR(1),
labels TEXT[]
);
CREATE TABLE product_msrp (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
product_id UUID REFERENCES product(id) ON DELETE CASCADE,
amount NUMERIC(12,2) NOT NULL,
currency CHAR(3) NOT NULL,
region VARCHAR(8) DEFAULT 'CN',
source_id UUID REFERENCES source(id),
source_url TEXT,
effective_date DATE,
note TEXT,
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE TABLE product_image (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
product_id UUID REFERENCES product(id) ON DELETE CASCADE,
url TEXT, -- 对象存储 URL
kind VARCHAR(16), -- front/ingredients/nutrition
license TEXT,
source_id UUID REFERENCES source(id)
);
CREATE TABLE product_source ( -- 字段级溯源
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
product_id UUID REFERENCES product(id) ON DELETE CASCADE,
source_id UUID REFERENCES source(id),
url TEXT,
fields TEXT[],
fetched_at TIMESTAMPTZ,
raw JSONB
);
CREATE TABLE merge_log ( -- 合并/回滚
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
kept_id UUID, merged_id UUID,
reason TEXT, by TEXT, created_at TIMESTAMPTZ DEFAULT now()
);
-- 索引
CREATE UNIQUE INDEX idx_product_gtin ON product(gtin) WHERE gtin IS NOT NULL;
CREATE INDEX idx_product_name_trgm ON product USING gin (name gin_trgm_ops);
CREATE INDEX idx_product_attrs ON product USING gin (attributes);
CREATE INDEX idx_food_nutriments ON food_detail USING gin (nutriments);
CREATE INDEX idx_product_tsv ON product USING gin (search_tsv);
CREATE INDEX idx_category_path ON category USING gist (path);
CREATE INDEX idx_product_updated ON product(updated_at);
```
---
## 3. 数据契约文档(`docs/data-contract.md` 概要)
两端共享的"事实约定",避免 Go/Python 理解不一致:
- **枚举固定**`status`(active/merged/deprecated)、`nutrition_basis`(per_100g/per_100ml/per_serving)、`dimension``source.license``image.kind`
- **字段含义与可空性**:逐字段写明(如 `gtin` 可空、唯一;`quality_score` ∈ [0,1])。
- **单位规则**:原始 + canonical 双存;能量双存 kJ/kcal;换算因子来自 `unit` 表。
- **写入责任**:仅 Python(ingestion) 写库;Go 只读。所有写入走 ETL,保证归一化与溯源。
- **版本**:契约本身版本化;schema 变更需同步更新契约 + 迁移 + OpenAPI。
- **示例**:附 1 条完整 product JSON 作为"黄金样例",两端测试都对它断言。
---
## 4. OFFOpen Food Facts)字段映射表(导入直接用)
| OFF 字段 (CSV) | OpenGoods 字段 | 处理 |
|----------------|----------------|------|
| `code` | `product.gtin` | 校验 8/12/13/14 位 + 校验位 |
| `product_name` / `product_name_zh` | `product.name` | 优先中文, 回退英文 |
| `brands` | `brand.name` | 拆分取首个, 规范化, upsert brand |
| `categories` / `categories_tags` | `source_category``category` | 走 OFF→自建→GPC 映射表 |
| `quantity` | `net_content_*` | 解析数值+单位 → 归一化 |
| `countries` | `country_of_origin` | 取销售国/产地 |
| `ingredients_text` | `food_detail.ingredients_text` | 原文保留 |
| `allergens_tags` | `food_detail.allergens` | 标签清洗为中文 |
| `additives_tags` | `food_detail.additives` | E-number 解析 |
| `energy-kj_100g` / `energy-kcal_100g` | `nutriments.energy_kj/kcal` | 缺一个则换算, 标 derived |
| `fat_100g` `saturated-fat_100g` `carbohydrates_100g` `sugars_100g` `proteins_100g` `salt_100g` | `nutriments.*` | 归一 per_100g |
| `nutriscore_grade` | `food_detail.nutri_score` | AE |
| `serving_size` | `food_detail.serving_size` | 原值 |
| `image_url` / `image_front_url` 等 | `product_image.url` | 下载转存对象存储, 记 CC-BY-SA |
| `last_modified_t` | `product_source.fetched_at` | 增量基准 |
| (整行) | `product_source.raw` | 存原始快照 |
> 许可:OFF 数据=ODbL(衍生库需同样开放+署名);图片=CC-BY-SA。映射时全程记 `source_id=OFF`。
---
## 5. 中国合规专项(公益网站落地关键)
> 以下为工程与运营层面的合规要点梳理,**非法律意见**;正式上线前建议咨询专业法务。
### 5.1 网站备案
- 服务器在中国大陆 → 需 **ICP 备案**(公益网站可走非经营性 ICP 备案);部分地区/类目可能涉 **公安联网备案**
- 若用境外/港澳服务器可免 ICP,但访问速度与合规另作权衡。
### 5.2 数据合规(网络安全法 / 数据安全法 / 个人信息保护法)
- 本项目**只处理商品信息、不收集个人信息(PII)**,PIPL 风险低;众包阶段涉及用户账号时再做最小化收集 + 隐私政策。
- 《数据安全法》要求数据收集合法正当;做好数据分级与安全保护义务。
### 5.3 网络爬虫法律边界(重点)
依据中央网信办公开文章与司法实践,判断标准是**客观结果**——是否妨碍目标网站正常运行 / 危害合法权益:
- **守 robots.txt**、礼貌限速、错峰,**不得对目标站造成 DDoS 式压力**(否则可能触及破坏计算机信息系统罪等)。
- **不抓取非公开/需登录/绕过反爬**的数据(可能涉非法获取计算机信息系统数据罪)。
- 只采**客观公开的商品参数**;不抓取受版权保护内容、不抓个人信息。
- 优先用**官方开放数据/API/数据 dump**OFF dump、USDA、GS1 授权)——从源头规避爬虫风险。
### 5.4 食品信息合规
- 展示食品参数时注明"信息仅供参考,以实物标签为准";营养/成分以官方/厂商标签为准。
- 不做医疗/功效宣称;不构成消费建议。
### 5.5 价格与"不导购"
- 价格仅为**官方建议零售价历史快照**,显著标注;**全站无购买/下单/跳转购买链接**,避免被认定为经营性电商导购。
---
## 6. 测试与数据质量保障
### 6.1 代码测试
- **Go**handler 单元测试 + store 层用 `testcontainers`/临时 PG 集成测试 + API 契约测试(对 openapi.yaml 校验响应)。
- **Python**:ETL 纯函数单测(单位归一、分类映射、去重打分)+ adapter 用录制的样例数据测试(不打真实站点)。
- **CI**PR 必跑 lint + test;覆盖率门槛(如 ETL 核心 ≥80%)。
### 6.2 数据质量
- **入库校验**:gtin 校验位、单位可识别、营养数值合理区间、必填字段(按品类模板)。
- **质量评分**:见 v2.0 公式,低分进"待补全"队列。
- **数据回归**:黄金样例集 + 定期跑"数据健康检查"(孤儿记录、单位异常、重复 gtin、营养越界)。
- **可观测**:导入报表(新增/更新/拒绝条数、拒绝原因 top)。
---
## 7. 安全与反滥用
- **API 防刷**IP 限流 + 可选 API Key 分级配额;异常流量识别(突发高频降级/挑战)。
- **缓存挡压**:热点条码走 CDN/Redis,降低数据库压力,也抗刷。
- **输入校验**:所有参数严格校验(gtin 正则、size 上限),防注入(参数化查询,禁拼 SQL)。
- **密钥管理**:DB/对象存储/第三方 key 走环境变量/密钥管理,不入库不入仓。
- **最小权限**:Go 端用**只读** DB 账号;写权限仅 ingestion。
- **采集端被封应对**:合规限速 + 失败退避 + 死信队列 + 切换为官方 dump/API。
- **DDoS**CDN + 速率限制 + 云厂商防护;公益服务以可降级(只读缓存)保命。
- **依赖安全**Go `govulncheck`、Python `pip-audit`CI 中扫描。
---
## 8. 商品图片处理
- **版权**OFF 图片为 CC-BY-SA,须署名 + 同样开放;逐图记 `license` 与来源。
- **存储**:对象存储(MinIO/S3),路径按 `gtin/kind`;原图 + 生成多档缩略图(thumb/medium)。
- **处理管线**:下载 → 校验(类型/大小) → 去重(感知哈希避免重复) → 压缩 → 生成缩略图 → 记录。
- **分发**CDN 加速;API 只返回图片 URL,不内嵌二进制。
- **合规**:不展示含个人信息的图;提供权利方下架通道。
- **降级**:图片缺失返回占位;图片服务故障不影响参数 API。
---
## 9. 可用性与 SLA
| 项 | 目标(建议) |
|----|-----------|
| API 可用性 | 99.5%(公益项目务实目标,先保只读可用) |
| 读延迟 | p95 < 200ms(缓存命中 < 50ms |
| 数据新鲜度 | 增量同步 T+1(每日) |
| 降级策略 | DB 故障 → 只读缓存兜底;图片/搜索故障不影响核心参数查询 |
| 灾备 | 每日备份 + 异地副本;恢复演练季度一次 |
| 维护窗口 | 采集/重建索引放低峰;API 滚动发布不停服 |
> 公益项目优先"省成本 + 稳定只读";写入(采集)可异步、可补偿,读路径要稳。
---
## 10. 竞品 / 同类项目分析
| 项目 | 性质 | 数据 | 借鉴点 | 与我们差异 |
|------|------|------|--------|------------|
| **Open Food Facts** | 公益食品库 | ODbL, 海量, 可贡献, 有 dump/API | 字段模型、众包、Nutri-Score、API 设计 | 我们多语言架构(Go API)、聚焦中文/GPC、收 MSRP |
| **USDA FoodData Central** | 政府营养库 | CC0, 权威营养 | 营养数据补全、公共领域许可 | 偏美国/营养, 无条码生态 |
| **GS1 / Verified by GS1** | 官方条码登记 | 受限, 权威, 2亿+ | 条码→品牌/规格权威源 | 非开放、需授权 |
| **Wikidata** | 通用知识库 | CC0, 有 GTIN 属性(P3962) | 实体链接、结构化、开放 | 非商品专用、参数不规整 |
| **schema.org Product/gtin** | 数据标准 | 标准而非数据 | 用其词汇做对外结构化(SEO/互操作) | 仅规范, 需我们填数据 |
| **brocade.io / 各条码库** | 开放/商业条码库 | 参差 | 条码补全兜底 | 数据量/质量有限或收费 |
**结论与定位**
- 我们不是再造 OFF,而是做**面向中文世界、与 GS1 GPC 对齐、聚焦"商品参数标签"**的公益 API
- **站在巨人肩上**OFF/USDA 做种子与营养,GS1 做条码权威,Wikidata/schema.org 做实体与互操作标准;
- 差异化:中文优先、品类参数模板规整、官方 MSRP、字段级溯源、Go 高并发只读 API。
---
## 11. 小结
v3.0 已把工程落地与公益合规的关键面全部展开。规划层面已相当完整。
你之前说先不写代码,我**继续待命**:可以再深化任何一块,或等你说"开始",从 M0 搭骨架开 PR。
+40
View File
@@ -0,0 +1,40 @@
# 天工·商品标签 (OpenGoods) — 规划文档归档
本目录归档了项目从立项到方案定稿的全部规划文档。
## 项目一句话
公益网站/服务:**采集全网商品信息,对外提供商品参数查询 API**。
核心原则:**只采集 + 只提供信息,绝不涉及任何购买/下单/比价导购。**
## 当前文档(最新,建议优先阅读)
| 文档 | 内容 |
|------|------|
| [00-final-plan.md](./00-final-plan.md) | **最终规划**:锁定的全部决策 + 架构 + 仓库结构 + M0~M5 可执行任务清单 |
| [01-detailed-design-v2.0.md](./01-detailed-design-v2.0.md) | **详细设计**:商品分类体系(GS1 GPC)、单位管理、数据库设计、API 契约、数据治理、采集合规、部署运维、众包、里程碑估算 |
| [02-advanced-topics-v3.0.md](./02-advanced-topics-v3.0.md) | **进阶专题**:完整 OpenAPI、全表 DDL、数据契约、OFF 字段映射、中国合规专项、测试、安全反滥用、图片处理、SLA、竞品分析 |
## 演进历史(History
| 文档 | 阶段 |
|------|------|
| [history/v0.1-initial-plan.md](./history/v0.1-initial-plan.md) | 初版总体规划 |
| [history/v0.2-go-python-foodfmcg.md](./history/v0.2-go-python-foodfmcg.md) | 确定 Go+Python 架构、聚焦食品快消、数据源调研 |
| [history/v1.0-locked-decisions.md](./history/v1.0-locked-decisions.md) | 决策定稿(默认值) |
## 已锁定的关键决策(速览)
| 维度 | 决策 |
|------|------|
| 首批品类 | 食品快消 |
| 价格 | 只收官方标准零售价 (MSRP),静态字段,无购买入口 |
| 技术栈 | Go(对外只读 API + Python(采集/ETL),经 PostgreSQL + Redis/队列解耦 |
| 种子数据 | Open Food Facts 食品 dump |
| 数据许可 | 对外 ODbL + 署名;CC0 来源(USDA)自由混入 |
| 商品分类 | GS1 GPC 四层标准码 + 自建中文品类树映射 |
| 单位管理 | 原始值+归一化双存;营养统一 per_100g/ml;能量双存 kJ+kcal |
| 质量评分 | 0.4 完整度 + 0.3 来源权威 + 0.2 多源一致 + 0.1 新鲜度 |
| 众包 | 一期不做,先纯采集;二期开放 |
| Go 框架 / 迁移 / 部署 | chi + 标准库 / golang-migrate / Docker Compose |
> 注:文档中"中国合规专项"为工程与运营层面梳理,**非法律意见**;正式上线前请咨询专业法务。
+216
View File
@@ -0,0 +1,216 @@
# 商品档案公益 API 系统 — 规划方案 (v0.1)
> 一个公益性质的网站/服务:**采集全网商品信息**,对外提供**商品参数查询 API**。
> 核心原则:**只收集信息、只提供信息,不涉及任何购买、下单、比价导购等交易行为。**
---
## 1. 项目定位与原则
| 维度 | 说明 |
|------|------|
| 定位 | 公益的"商品参数百科 / 商品档案库",类似商品界的 Wikipedia + 开放 API |
| 提供什么 | 商品的客观参数(规格、型号、成分、能效、尺寸、条码等) |
| **不提供什么** | 价格交易、加购物车、下单、跳转购买链接、联盟分佣、比价导购 |
| 服务对象 | 第三方软件 / 开发者,通过 API 查询商品参数 |
| 数据态度 | 客观、中立、可溯源(每条数据标注来源与采集时间) |
> ⚠️ 关于价格:建议**默认不收录价格**。价格属于交易属性,且实时性强、争议大。如果一定要做,只做"历史参考价"且明确标注来源时间,绝不提供购买入口。**这一点需要你确认。**
---
## 2. 系统总体架构
```
┌─────────────────────────────┐
│ 数据来源 (Sources) │
│ 官网/厂商 / 开放数据 / GS1 │
│ 条码库 / 用户贡献 / 监管公开 │
└──────────────┬──────────────┘
┌──────────────────────────▼──────────────────────────┐
│ 采集层 Ingestion (Workers) │
│ 爬虫调度 + 适配器 + 限速 + robots 合规 + 去重 │
└──────────────────────────┬──────────────────────────┘
│ 原始数据 (raw)
┌──────────────────────────▼──────────────────────────┐
│ 清洗/标准化 ETL (Normalize & Dedup) │
│ 字段映射 / 单位归一 / 实体匹配 / 质量评分 │
└──────────────────────────┬──────────────────────────┘
│ 结构化商品档案
┌──────────────────────────▼──────────────────────────┐
│ 存储层 Storage │
│ PostgreSQL(主) + 对象存储(图片) + 搜索引擎(检索) │
└──────────────────────────┬──────────────────────────┘
┌──────────────────────────▼──────────────────────────┐
│ 公开 API 服务 (FastAPI) │
│ REST/JSON + 文档 + 限流 + 缓存 + API Key(可选) │
└──────────────────────────┬──────────────────────────┘
┌──────────────▼──────────────┐
│ 消费者:各种软件/开发者 │
└─────────────────────────────┘
```
分为四个相对独立的子系统:
1. **采集子系统**(爬虫/适配器,离线运行)
2. **数据处理子系统**(清洗、标准化、去重、质量评分)
3. **存储子系统**(关系库 + 搜索 + 对象存储)
4. **API 子系统**(对外只读公开 API + 文档站)
---
## 3. 核心数据模型(商品档案 Schema)
商品的本质是"一个实体 + 一组可扩展的参数"。建议采用 **核心字段 + 灵活属性(KV)** 的混合模型,以适配不同品类(手机、食品、家电、化妆品……参数差异极大)。
### 3.1 核心实体
```jsonc
// Product 商品档案
{
"id": "uuid", // 内部唯一ID
"gtin": "6901234567892", // 全球贸易项目代码(条码), 可空
"name": "示例牌 1.5L 纯净水",
"brand": "示例牌", // -> Brand 实体
"manufacturer": "示例食品有限公司",
"category": "饮料/包装水", // -> Category 树
"model": "型号/SKU标识",
"description": "客观描述, 非营销文案",
"images": ["对象存储URL", ...],
"attributes": [ // 灵活参数(见下)
{"key": "容量", "value": "1.5", "unit": "L"},
{"key": "保质期", "value": "12", "unit": "月"}
],
"identifiers": { // 其他标识
"ean": "...", "upc": "...", "asin": "...", "mpn": "..."
},
"sources": [ // 数据溯源(每个字段可标来源)
{"source_id": "...", "url": "...", "fetched_at": "2026-06-08T...", "field": "容量"}
],
"quality_score": 0.87, // 数据质量/可信度评分
"status": "active|merged|deprecated",
"created_at": "...", "updated_at": "..."
}
```
### 3.2 灵活属性 (EAV / JSONB)
- 不同品类参数差异巨大,核心表存通用字段,品类专属参数存 `attributes`PostgreSQL `JSONB`,可建 GIN 索引)。
- 配合**品类参数模板**Category Schema)约束某品类应有哪些参数,保证质量。
### 3.3 辅助实体
- `Brand`(品牌)、`Manufacturer`(厂商)、`Category`(品类树)、`Source`(数据来源登记)、`AttributeDefinition`(参数字典:标准名/别名/单位)。
- 实体去重/合并需要 `merge` 机制(同一商品多来源 → 合并为一条,保留溯源)。
---
## 4. 数据采集策略(最关键、也最需合规)
### 4.1 来源优先级(从"最合规"到"需谨慎"
1. **官方开放数据 / 标准库**:GS1 条码库、各国监管公开数据(能效标识、食品备案、药品/化妆品备案等)。✅ 最佳
2. **厂商官网 / 官方规格表**:参数最权威。需遵守 robots.txt。
3. **厂商/平台开放 API**:若有官方 API 走 API。
4. **用户/社区贡献**:众包补全与纠错(带审核)。
5. **第三方网页抓取**:⚠️ 合规风险最高,需严格遵守 robots、限速、只取客观参数、标注来源。
### 4.2 采集器设计
- **适配器模式**:每个来源一个 adapter(解析规则独立、可热插拔)。
- **调度**:任务队列(Celery / RQ / arq+ 定时(cron+ 增量更新。
- **合规护栏**:尊重 `robots.txt`、礼貌限速、`User-Agent` 标识身份、错峰、缓存避免重复抓取。
- **去重与匹配**:以 GTIN/条码为主键,无条码时用 (品牌+型号+关键参数) 做模糊匹配。
### 4.3 数据质量
- 每个字段记录来源 + 时间;多来源冲突时按来源可信度加权。
- 质量评分 `quality_score`:字段完整度 + 来源权威度 + 一致性。
---
## 5. 公开 API 设计(只读、RESTful
基础原则:**只读、无副作用、无购买入口、稳定版本化、有文档**。
```
GET /api/v1/products/{id} # 按内部ID查询商品档案
GET /api/v1/products/barcode/{gtin} # 按条码(GTIN/EAN/UPC)查询 ★最常用
GET /api/v1/products/search # 搜索: ?q=&brand=&category=&page=&size=
GET /api/v1/products/{id}/attributes # 仅取参数
GET /api/v1/brands / categories # 品牌/品类树
GET /api/v1/sources/{id} # 数据来源说明(透明溯源)
GET /healthz / /api/v1/openapi.json # 健康检查 / 机读文档
```
设计要点:
- **版本化** `/api/v1/`,破坏性变更升 `v2`
- **分页 + 字段筛选**`fields=` 减少传输)。
- **限流**:匿名按 IP 限流;可选 API Key 提升配额(免费,仅用于防滥用与统计)。
- **缓存**:CDN + 服务端缓存(商品参数变化慢,缓存命中率高)。
- **响应统一**JSON,含 `data` / `meta`(分页) / `sources`(溯源)。
- **开放协议**:数据采用开放许可(如 CC BY / ODbL),鼓励署名引用。
- **自动文档**FastAPI 自带 Swagger UI / ReDoc。
---
## 6. 技术选型建议
| 层 | 选型 | 理由 |
|----|------|------|
| API 框架 | **Python + FastAPI** | 与仓库定位一致、异步性能好、自带 OpenAPI 文档 |
| 主数据库 | **PostgreSQL** (JSONB) | 关系 + 灵活属性兼得,GIN 索引支持检索 |
| 搜索 | **OpenSearch / Elasticsearch / 或 PG 全文** | 商品名/参数全文与分面检索 |
| 缓存 | **Redis** | 热点缓存 + 限流计数 + 任务队列后端 |
| 采集任务 | **arq / Celery / RQ** | 异步调度爬虫与 ETL |
| 爬虫 | **httpx + selectolax/BeautifulSoup**,动态页用 **Playwright** | 轻量为主,必要时浏览器渲染 |
| 对象存储 | **S3 兼容 (MinIO / 云)** | 存商品图片 |
| 部署 | **Docker + Compose**(初期) → K8s(规模化) | 渐进式 |
| 文档站 | FastAPI 文档 + 静态站(MkDocs) | 开发者文档 |
> 如果你更偏好 Node.js / Go 也可以,我按你的偏好调整。仓库描述像是 FastAPI,所以我默认 Python。
---
## 7. 合规与法律(公益项目尤其重要)
- **爬取合规**:遵守 robots.txt、服务条款、合理限速;只采集**客观商品参数**,不抓取受版权保护的营销文案/评测原文(可链接来源)。
- **数据来源透明**:每条数据可溯源,标注来源与时间,尊重原始来源。
- **隐私**:只处理商品信息,不涉及个人数据(无 PII)。
- **商标/品牌**:品牌名仅用于客观标识商品,不做背书或贬损。
- **明确边界**:网站显著声明"仅提供信息、不提供购买、不构成消费建议"。
- **数据开放许可**:选择 CC BY 4.0 或 ODbL,明确他人使用条款。
- **下架机制**:提供来源方/权利方的纠错与下架联系渠道。
> 建议这块后续找法务/合规确认,我可以先把"合规护栏"写进采集器与站点声明。
---
## 8. 建议的实施路线图(分阶段,先跑起来再扩展)
**阶段 0 — 地基(先做)**
- 仓库工程化:FastAPI 项目骨架、PostgreSQL、Docker Compose、CI、代码规范。
- 定义核心数据模型 + 迁移。
**阶段 1 — MVP API**
- 实现只读 API:按条码/ID 查询 + 搜索 + 自动文档。
- 导入一批种子数据(用开放数据/手工录入几百条),让 API 真正可用。
**阶段 2 — 采集管线**
- 1~2 个合规来源的 adapter + 调度 + 清洗/去重/入库。
- 质量评分与溯源。
**阶段 3 — 规模化与开放**
- 搜索引擎、缓存/CDN、限流、API Key、众包贡献与审核后台。
- 开放数据许可与开发者文档站上线。
---
## 9. 需要你拍板的关键问题
1. **是否收录价格?**(建议默认不收录,只做客观参数)
2. **首批聚焦哪些品类?**(如:食品/快消、3C 数码、家电…… 建议先聚焦 1~2 个品类把模型打磨好)
3. **技术栈确认 Python + FastAPI 吗?**(还是有其他偏好)
4. **数据来源**:你手上有没有已知的合规数据源 / 官方 API / 已有数据集?
5. **首要交付**:先搭"可查询的 MVP API + 一批种子数据",还是先做"采集管线"?(建议先 MVP API
---
> 这是 v0.1 规划草案,确认大方向后我会细化成可执行的工程任务,并开始搭建仓库骨架 + MVP。
@@ -0,0 +1,203 @@
# 商品档案公益 API 系统 — 规划方案 (v0.2)
> 公益网站/服务:**采集全网商品信息**,对外提供**商品参数查询 API**。
> 原则:**只收集 + 只提供信息,不涉及任何购买/下单/比价导购**。
> 本版根据你的反馈定稿四件事:① 收录**官方标准零售价(MSRP)** ② 首批聚焦**食品快消** ③ **Go(系统) + Python(采集)** 多语言架构 ④ 附**开放数据源清单**。
---
## 0. 你已确认的决策
| # | 决策 | 说明 |
|---|------|------|
| 1 | **价格 = 官方标准零售价 (MSRP)** | 厂商指导价/官方建议零售价,属**静态属性**,带来源+时间+币种标注;**不收录实时电商售价、不提供购买入口** |
| 2 | **首批品类 = 食品快消 (Food & FMCG)** | 先把食品的数据模型打磨好(成分、营养、过敏原、规格、保质期…) |
| 3 | **技术栈 = Go + Python** | Go 写对外 API/核心服务;Python 写采集/ETL/爬虫;通过 PostgreSQL + 消息队列解耦 |
| 4 | **数据源 = 暂无,后期提供** | 本版先给出可立即接入的开放数据源清单 |
---
## 1. Go + Python 多语言架构(核心)
这是一个很经典且合理的组合。两端**不直接互相调用**,而是通过**共享数据库 + 消息队列**解耦,各自独立部署、独立扩展。
```
┌────────────────────── Python 侧 (采集/数据) ──────────────────────┐
│ │
数据源 ─▶│ 采集 Workers (爬虫/适配器) ─▶ ETL 清洗/标准化/去重 ─▶ 入库 │
│ httpx / Playwright / scrapy pandas / 规则引擎 │
└───────────────────────────┬────────────────────────────────────────┘
│ 写入
┌───────▼────────┐ ┌──────────────┐
│ PostgreSQL │◀──────▶│ 对象存储 S3 │ (商品图)
│ (商品档案主库) │ └──────────────┘
└───────▲────────┘
│ 只读
┌───────────────────────────┴────────────────────────────────────────┐
│ Go 侧 (对外服务) │
│ 公开 API (REST/JSON) + Redis 缓存/限流 + 搜索网关 + OpenAPI │
│ Gin/Echo/Chi/标准库 │
└───────────────────────────┬────────────────────────────────────────┘
各种软件 / 开发者消费
```
### 1.1 职责划分
| 子系统 | 语言 | 职责 |
|--------|------|------|
| **公开 API 服务** | **Go** | 对外只读 API、限流、缓存、鉴权(可选 API Key)、检索网关、高并发承载 |
| **采集 Workers** | **Python** | 每个数据源一个 adapter,抓取/调用 API、遵守 robots、限速、产出原始数据 |
| **ETL / 数据处理** | **Python** | 清洗、字段映射、单位归一、实体去重与合并、质量评分 |
| **调度 / 队列** | Python(worker) + Redis/消息队列 | 定时任务、增量更新、任务分发 |
| **存储** | PostgreSQL + Redis + S3 | 主库 / 缓存+限流 / 图片 |
| **检索** | 初期 PG 全文 → 后期 OpenSearch | 商品名/参数搜索与分面 |
### 1.2 为什么这样分?
- **Go 做 API**:编译型、单二进制部署、并发模型适合高 QPS 的只读公益 API,运维简单。
- **Python 做采集**:爬虫/解析/数据处理生态最强(scrapy、playwright、pandas),迭代快。
- **解耦点 = 数据库**:Go 端**只读**主库(或读副本),Python 端负责写入。两端通过稳定的表结构约定协作,互不阻塞;将来任一端换语言/重写都不影响另一端。
- **契约**:用数据库 schema + 一份内部「数据契约文档」固定字段含义,避免两端理解不一致。
---
## 2. 食品快消数据模型(细化)
食品参数差异大,沿用 **核心字段 + JSONB 灵活属性 + 营养结构化子表**。字段设计大量参考 Open Food Facts(成熟的食品开放库)。
### 2.1 商品主表 `product`
```jsonc
{
"id": "uuid",
"gtin": "6901234567892", // 条码(主键标识), EAN-13/UPC/EAN-8
"name": "示例牌 巧克力榛子酱 400g",
"brand": "示例牌", // -> brand
"manufacturer": "示例食品有限公司", // 生产商
"category": "食品/酱料/巧克力酱", // -> category 树 (可对齐 GS1 GPC / OFF categories)
"net_content": {"value": 400, "unit": "g"}, // 净含量
"country_of_origin": "中国",
"shelf_life": {"value": 12, "unit": "月"}, // 保质期
"storage": "常温避光保存",
"images": ["S3_URL", ...],
"msrp": { ... }, // 官方标准零售价, 见 2.3
"food": { ... }, // 食品专属结构化字段, 见 2.2
"attributes": [ {"key":"","value":"","unit":""} ], // 其余灵活参数(JSONB)
"identifiers": {"ean":"", "upc":"", "off_id":""},
"sources": [ {"source":"", "url":"", "fetched_at":"", "fields":["msrp"]} ],
"quality_score": 0.0,
"status": "active|merged|deprecated",
"created_at": "", "updated_at": ""
}
```
### 2.2 食品专属字段 `food`(结构化)
```jsonc
{
"ingredients_text": "白砂糖, 棕榈油, 榛子(13%), ...", // 配料表原文
"ingredients": [ {"name":"白砂糖","rank":1}, ... ], // 解析后(可选)
"allergens": ["坚果", "大豆", "乳"], // 过敏原
"additives": ["E322 卵磷脂"], // 添加剂
"nutriments": { // 营养成分(每100g/100ml)
"energy_kj": 2252, "energy_kcal": 539,
"fat_g": 30.9, "saturated_fat_g": 10.6,
"carbohydrates_g": 57.5, "sugars_g": 56.3,
"protein_g": 6.3, "salt_g": 0.107
},
"nutrition_basis": "per_100g", // per_100g | per_100ml | per_serving
"serving_size": "15g",
"is_vegetarian": null, "is_vegan": null, // 可空
"nutri_score": "C", // 若引用 OFF
"labels": ["无添加", "清真"] // 认证/标签
}
```
### 2.3 官方标准零售价 `msrp`(重点)
```jsonc
{
"amount": 29.90,
"currency": "CNY",
"type": "msrp", // 仅 msrp/官方指导价; 不存实时电商成交价
"region": "CN", // 适用地区(价格随地区不同)
"source": "厂商官网/官方价目表",
"source_url": "https://...",
"effective_date": "2026-01-01", // 价格生效/采集时间
"note": "官方建议零售价, 实际售价以零售商为准; 本站不提供购买"
}
```
> 设计要点:价格是**带时间戳的历史快照**而非实时报价;明确 `type=msrp`、标注地区与来源;响应里附免责说明。**坚决不出现购买/跳转链接。**
### 2.4 辅助实体
`brand` / `manufacturer` / `category`(品类树) / `source`(数据来源登记) / `attribute_definition`(参数字典: 标准名·别名·单位) / `merge_log`(实体合并记录, 保留溯源)。
---
## 3. 可立即接入的开放数据源清单(食品快消)
按"合规性 / 可用性"排序。这些可作为**种子数据 + 采集 adapter 的首批对象**。
| 数据源 | 内容 | 许可 | 接入方式 | 备注 |
|--------|------|------|----------|------|
| **Open Food Facts** ⭐ | 全球食品(成分/营养/过敏原/Nutri-Score/图片) | **ODbL**(数据)+DbCL+CC-BY-SA(图) | REST API + **每夜全量 dump**(CSV/MongoDB, ~9GB) | 食品首选;可贡献回写;限速 15 req/min/IP(读) |
| **USDA FoodData Central** ⭐ | 美国食品营养成分(含 Branded 品牌库) | **CC0(公共领域)** | REST API(需免费 key) + JSON/CSV 下载 | 营养数据权威;商业可用 |
| **GS1 / Verified by GS1**(中国商品信息服务平台) | 条码→品牌/规格/厂商(官方登记) | 受限(需企业/接口授权) | 网页查询 + API(≤1000 GTIN/次) | **条码→商品**最权威来源;2亿+条;中国数据首选 |
| **brocade.io** | 开放 GTIN/条码产品库 | 开源/开放 | 免费 REST(免鉴权读) | 数据量有限,可作补充 |
| **3023data 等条码接口** | 中国物品编码+UPC+ISBN | 商业(0.005~0.02元/次) | REST API | **付费**,作兜底补全,非首选 |
| 各国**监管公开数据** | 食品备案/标签/能效等 | 多为公开 | 各平台 | 后续按需逐个评估合规 |
**参考用开源项目(架构/数据模型借鉴,非数据源)**
- Open Food Facts Server (Product Opener) — 食品库的完整实现,可学其字段与流程
- UnoPIM / PCMT / brocade.io — 开源 PIM / 商品主数据系统,借鉴建模与去重
> 建议:**先用 Open Food Facts 全量 dump 作种子数据**(直接有海量真实食品),再用 GS1/USDA 做补全与校验。这样 MVP 阶段就有真实可查的数据。
---
## 4. 公开 API 契约(Go 实现,只读)
```
GET /api/v1/products/barcode/{gtin} # ★最常用: 条码查档案
GET /api/v1/products/{id} # 内部ID查
GET /api/v1/products/search # ?q=&brand=&category=&allergen_free=&page=&size=&fields=
GET /api/v1/products/{id}/nutriments # 仅营养
GET /api/v1/products/{id}/msrp # 仅官方零售价(含来源/时间/免责)
GET /api/v1/brands | /categories # 品牌 / 品类树
GET /api/v1/sources/{id} # 数据来源透明说明
GET /healthz | /api/v1/openapi.json # 健康检查 / 机读文档
```
约定:版本化 `/v1/`;统一响应 `{data, meta(分页), sources(溯源)}`;分页 + `fields=` 裁剪;匿名按 IP 限流,可选免费 API Key 提配额;CDN+Redis 缓存(参数变化慢,命中率高);数据采用开放许可(CC BY / ODbL,注意 OFF 的 ODbL 传染性);**无任何购买/交易端点**。
---
## 5. 合规与边界(公益项目重点)
- **数据源许可要分清**:OFF 是 **ODbL**(衍生数据库需同样开放+署名),USDA 是 **CC0**(最宽松)。混用时要按最严格许可对外标注,避免许可冲突。
- 爬取守 robots.txt / 服务条款,礼貌限速,标明 User-Agent 身份。
- 只采**客观参数**;营销文案/评测原文不照搬(链接来源即可)。
- 无个人数据(PII),只处理商品信息。
- 站点显著声明:**仅提供信息、不提供购买、不构成消费建议**;价格为官方指导价历史快照。
- 提供权利方**纠错/下架**联系渠道。
---
## 6. 里程碑(仍不写代码,仅规划,供确认)
| 阶段 | 目标 | 关键产出 |
|------|------|----------|
| **M0 工程地基** | 仓库骨架 | Go API 骨架 + Python 采集骨架 + PostgreSQL + Docker Compose + CI + 数据契约文档 |
| **M1 数据模型** | 食品 schema | 主表/食品字段/MSRP/辅助实体 的迁移与字典 |
| **M2 种子数据** | 有真实数据 | 导入 Open Food Facts dump(食品子集) + USDA 营养补全 |
| **M3 MVP API (Go)** | 可查询 | 条码/ID/搜索/营养/MSRP + OpenAPI 文档 + 限流缓存 |
| **M4 采集管线 (Python)** | 自动更新 | 1~2 个 adapter(OFF API / GS1) + ETL + 去重 + 质量评分 + 调度 |
| **M5 开放与规模化** | 上线 | 搜索引擎 + CDN + API Key + 众包纠错后台 + 开发者文档站 + 开放数据许可 |
---
## 7. 待你确认/补充
1. **价格范围**:确认只收「官方指导价 (MSRP)」、不碰实时电商价?(建议是)
2. **OFF 的 ODbL 许可**:可接受(意味着我们对外的数据库也要用 ODbL 并署名 OFF)?还是更想用 CC0 来源(USDA)为主以保持宽松?
3. **种子数据**:同意先导入 Open Food Facts 食品 dump 作为启动数据吗?
4. **Go Web 框架偏好**Gin / Echo / Chi / 标准库 net/http,有偏好吗?(无偏好我默认 Chi 或标准库,轻量)
5. **地域范围**:首批面向中国市场商品,还是中外都收?(影响优先用 GS1-China 还是 OFF 全球库)
> 你确认后,我把它定为 v1.0 规划,并据此拆成可执行的工程任务清单(仍按你的节奏,需要我动手写代码时再开始)。
@@ -0,0 +1,107 @@
# 商品档案公益 API 系统 — 规划方案 (v1.0 定稿)
> 公益网站/服务:采集全网商品信息,提供商品参数查询 API。**只收集 + 只提供信息,不涉及任何购买行为。**
---
## 1. 已锁定的决策
| # | 决策 | 结论 |
|---|------|------|
| 1 | 品类 | 首批 **食品快消** |
| 2 | 价格 | 只收 **官方标准零售价 (MSRP)**,静态字段,带来源/时间/地区/币种 + 免责说明;**不收实时电商价、不提供购买入口** |
| 3 | 技术栈 | **Go**(对外API/核心服务) + **Python**(采集/ETL/爬虫),经 PostgreSQL + Redis/队列解耦 |
| 4 | 种子数据 | ✅ **先导入 Open Food Facts 食品 dump**,最快拥有真实数据 |
| 5 | 数据许可 | 因采用 OFF → 对外数据库用 **ODbL** 并署名来源;CC0 来源(USDA)可自由混入 |
### 1.1 我先用的默认值(如不同意请指出,否则按此执行)
- **Go Web 框架**`chi` + 标准库 `net/http`(轻量、稳定、易维护)。
- **地域范围**:先用 OFF **全球食品库**起步,后续接 **GS1-China** 补强中国市场数据。
- **数据库迁移工具**Go 侧用 `golang-migrate`(纯 SQL 迁移,两端共享同一套 schema)。
- **部署**:初期 Docker Compose 一键起全套(Postgres/Redis/Go API/Python worker)。
---
## 2. 目标架构(定稿)
```
数据源(OFF dump / OFF API / USDA / GS1)
▼ Python: 采集 adapters → ETL(清洗/归一/去重/质量评分)
┌────▼─────────┐ 图片 ┌──────────┐
│ PostgreSQL │◀───────▶│ S3/MinIO │
│ (商品档案主库)│ └──────────┘
└────▲─────────┘
│ 只读 (+Redis缓存/限流)
▼ Go: 公开 REST API + OpenAPI 文档
各种软件 / 开发者
```
- **解耦契约**:两端通过共享 PostgreSQL schema + 一份《数据契约文档》协作,互不直接调用。
- **Go 端只读主库**(或读副本);**Python 端负责写入**。
---
## 3. 仓库结构(计划,写代码时落地)
```
goods/
├── README.md
├── docker-compose.yml # postgres + redis + minio + api + worker
├── docs/
│ ├── data-contract.md # 两端共享的字段契约
│ └── openapi.yaml # API 契约
├── migrations/ # 共享 SQL 迁移 (golang-migrate)
├── api/ # Go: 对外只读 API
│ ├── cmd/server/main.go
│ ├── internal/{handler,store,model,middleware}/
│ └── go.mod
└── ingestion/ # Python: 采集 + ETL
├── pyproject.toml
├── adapters/{openfoodfacts,usda,gs1}.py
├── etl/{normalize,dedup,quality}.py
└── jobs/{seed_off_dump,scheduler}.py
```
## 4. 数据模型 & API 契约
(沿用 v0.2`product` 主表 + `food` 食品字段 + `msrp` 价格 + 辅助实体;API 以 `GET /products/barcode/{gtin}` 为核心,全只读、无交易端点。详见 v0.2 附件。)
---
## 5. 可执行任务拆分(按里程碑,写代码时逐项落地)
**M0 — 工程地基**
- [ ] 初始化 Go module (`api/`) + Python 项目 (`ingestion/`)
- [ ] `docker-compose.yml`Postgres + Redis + MinIO
- [ ] CIGo: build/vet/testPython: ruff/pytest
- [ ] `docs/data-contract.md` 初版
**M1 — 数据模型**
- [ ] `migrations/`product / food / msrp / brand / manufacturer / category / source / attribute_definition / merge_log
- [ ] JSONB + GIN 索引;gtin 唯一索引
**M2 — 种子数据 (Python)**
- [ ] 下载 OFF 食品 dumpCSV
- [ ] `seed_off_dump`:字段映射 → 入库(含营养/成分/过敏原/图片URL)
- [ ] USDA(CC0) 营养补全(可选)
**M3 — MVP API (Go)**
- [ ] 路由 + handlerbarcode / id / search / nutriments / msrp / brands / categories / sources
- [ ] 统一响应、分页、`fields=` 裁剪、错误处理
- [ ] Redis 缓存 + IP 限流;`/healthz` + OpenAPI 文档
**M4 — 采集管线 (Python)**
- [ ] adapterOFF API(增量更新)+ GS1(条码补全)
- [ ] ETL:清洗/单位归一/去重合并/质量评分/溯源
- [ ] 调度(定时增量更新)
**M5 — 开放与规模化**
- [ ] 搜索引擎(PG 全文 → OpenSearch)、CDN 缓存
- [ ] 免费 API Key(防滥用+统计)、众包纠错后台
- [ ] 开发者文档站 + 开放数据许可声明 + 站点"不提供购买"声明
---
## 6. 下一步
规划已定稿。**你说先不写代码,所以我暂停在这里**。等你说"开始",我就从 **M0 工程地基** 动手,搭好骨架后开 PR 给你看。也可以先只做某个里程碑(比如先 M0+M1 把骨架和数据模型立起来)。
-9
View File
@@ -1,9 +0,0 @@
"""OpenGoods (天工·商品标签) ingestion package.
Collects public product information from open data sources (e.g. Open Food
Facts) and normalizes it into the OpenGoods database. This package only
collects and processes product facts; it performs no purchase or commerce
actions.
"""
__version__ = "0.1.0"
-6
View File
@@ -1,6 +0,0 @@
"""Source adapters.
Each open data source (Open Food Facts, USDA FoodData Central, GS1, ...) gets
its own adapter that fetches raw records and yields them for the ETL layer.
Adapters must respect each source's robots.txt, rate limits and license.
"""
-17
View File
@@ -1,17 +0,0 @@
"""Base adapter protocol shared by all source adapters."""
from __future__ import annotations
from collections.abc import Iterator
from typing import Protocol
class SourceAdapter(Protocol):
"""A source adapter fetches raw product records from one data source."""
#: Stable identifier of the source, e.g. "openfoodfacts".
source_name: str
def fetch(self) -> Iterator[dict]:
"""Yield raw product records as dictionaries."""
...
-124
View File
@@ -1,124 +0,0 @@
"""GS1 barcode supplement adapter.
GS1 (e.g. *Verified by GS1* / GS1 China) is the authoritative registry that maps
a GTIN to its brand owner, product description and GPC category. We use it to
*supplement* — fill gaps in — records gathered from crowd sources like Open Food
Facts, never to overwrite existing values.
Real GS1 access is credentialed and region-specific, so this adapter supports
two modes:
* **offline** (default): look barcodes up in a local JSON mapping file. This is
what tests and air-gapped runs use.
* **online**: GET ``{base_url}/{gtin}`` with an API key header, then normalize
the response. Enabled by passing ``base_url`` + ``client``.
Either way :meth:`fetch_barcode` returns a normalized *supplement* dict (or
``None``); :mod:`opengoods.etl.supplement` applies it to the database.
"""
from __future__ import annotations
import json
from collections.abc import Iterator
from pathlib import Path
import httpx
SOURCE_NAME = "gs1"
GS1_HOMEPAGE = "https://www.gs1.org"
GS1_LICENSE = "proprietary"
# GS1 is the authoritative barcode registry -> high trust.
GS1_TRUST = 0.9
# Keys of a normalized supplement record.
_SUPPLEMENT_KEYS = (
"gtin",
"name",
"brand",
"manufacturer",
"gpc_brick_code",
"country_of_origin",
"net_content_value",
"net_content_unit",
)
def _normalize(code: str, data: dict) -> dict:
"""Project a raw mapping/record onto the supplement schema (non-empty only)."""
rec: dict = {"gtin": code}
for key in _SUPPLEMENT_KEYS:
if key == "gtin":
continue
value = data.get(key)
if value not in (None, "", []):
rec[key] = value
return rec
def _parse_api(code: str, payload: dict) -> dict:
"""Best-effort mapping of a Verified-by-GS1 style payload to our schema."""
item = payload
if isinstance(payload.get("gtinRecords"), list) and payload["gtinRecords"]:
item = payload["gtinRecords"][0]
return _normalize(
code,
{
"name": item.get("productDescription") or item.get("description"),
"brand": item.get("brandName"),
"manufacturer": item.get("companyName") or item.get("licenseeName"),
"gpc_brick_code": item.get("gpcCategoryCode"),
"country_of_origin": item.get("countryOfSaleCode") or item.get("countryCode"),
"net_content_value": item.get("netContent"),
"net_content_unit": item.get("netContentUnit"),
},
)
class GS1Adapter:
"""Look up GTIN supplements from a local mapping or a GS1-style API."""
source_name = SOURCE_NAME
def __init__(
self,
mapping: dict | None = None,
*,
client: httpx.Client | None = None,
base_url: str | None = None,
api_key: str | None = None,
) -> None:
self._mapping = mapping or {}
self._client = client
self._base_url = base_url.rstrip("/") if base_url else None
self._api_key = api_key
@classmethod
def from_file(cls, path: str | Path) -> GS1Adapter:
"""Build an offline adapter from a JSON ``{gtin: {...}}`` mapping file."""
data = json.loads(Path(path).read_text(encoding="utf-8"))
return cls(mapping=data)
def fetch_barcode(self, code: str) -> dict | None:
"""Return a normalized supplement dict for ``code`` (or ``None``)."""
if self._base_url and self._client is not None:
headers = {"apikey": self._api_key} if self._api_key else {}
resp = self._client.get(f"{self._base_url}/{code}", headers=headers)
if resp.status_code == 404:
return None
resp.raise_for_status()
rec = _parse_api(code, resp.json())
else:
data = self._mapping.get(code)
if not data:
return None
rec = _normalize(code, data)
# A record with only the GTIN carries no supplement.
return rec if len(rec) > 1 else None
def fetch(self, barcodes: list[str]) -> Iterator[dict]:
"""Yield supplement records for the given barcodes."""
for code in barcodes:
rec = self.fetch_barcode(code)
if rec is not None:
yield rec
@@ -1,232 +0,0 @@
"""Open Food Facts (OFF) source adapter.
Fetches raw product records either from the OFF read API (one product per
barcode) or from a downloaded JSONL dump file. OFF data is licensed under the
Open Database License (ODbL); product images are CC-BY-SA. We record OFF as the
source for every field we ingest.
The adapter is read-only and rate-limited to stay well within OFF's API limits
(<= ~15 req/min/IP for product reads) and to be a good citizen.
"""
from __future__ import annotations
import json
import time
from collections.abc import Iterator
from pathlib import Path
import httpx
SOURCE_NAME = "openfoodfacts"
OFF_LICENSE = "ODbL"
USER_AGENT = "OpenGoods/0.1 (+https://github.com/baicai2026-baicai/goods) public-good product API"
# Conservative client-side spacing between API calls (seconds).
_DEFAULT_MIN_INTERVAL = 4.0
_API_URL = "https://world.openfoodfacts.org/api/v2/product/{barcode}.json"
_SEARCH_URL = "https://world.openfoodfacts.org/api/v2/search"
# HTTP statuses worth retrying: rate limiting and transient server errors.
_RETRY_STATUS = frozenset({429, 500, 502, 503, 504})
# Fields requested from the search API so a returned product can be transformed
# without an extra per-barcode round trip.
_SEARCH_FIELDS = (
"code,product_name,product_name_en,product_name_zh,brands,quantity,"
"categories,categories_tags,countries,ingredients_text,allergens_tags,"
"additives_tags,nutriments,nutriscore_grade,serving_size,"
"image_front_url,image_url,last_modified_t"
)
class OpenFoodFactsAdapter:
"""Read product records from the OFF API."""
source_name = SOURCE_NAME
def __init__(
self,
client: httpx.Client | None = None,
min_interval: float = _DEFAULT_MIN_INTERVAL,
max_retries: int = 4,
backoff_base: float = 2.0,
) -> None:
self._client = client or httpx.Client(headers={"User-Agent": USER_AGENT}, timeout=30.0)
self._min_interval = min_interval
self._max_retries = max_retries
self._backoff_base = backoff_base
self._last_call = 0.0
def _throttle(self) -> None:
elapsed = time.monotonic() - self._last_call
wait = self._min_interval - elapsed
if wait > 0:
time.sleep(wait)
self._last_call = time.monotonic()
def _get(self, url: str, params: dict | None = None) -> httpx.Response:
"""GET with throttling and retry/backoff on transient errors.
Retries on connection/timeout errors and on retryable HTTP statuses
(429 and 5xx, which OFF returns intermittently when overloaded), using
exponential backoff that honours a ``Retry-After`` header when present.
"""
last_exc: Exception | None = None
for attempt in range(self._max_retries + 1):
self._throttle()
try:
resp = self._client.get(url, params=params)
except httpx.TransportError as exc:
last_exc = exc
else:
if resp.status_code < 400 or resp.status_code not in _RETRY_STATUS:
resp.raise_for_status()
return resp
last_exc = httpx.HTTPStatusError(
f"retryable status {resp.status_code}", request=resp.request, response=resp
)
if attempt < self._max_retries:
retry_after = self._retry_after(last_exc)
time.sleep(retry_after if retry_after is not None else self._backoff_base**attempt)
assert last_exc is not None
raise last_exc
@staticmethod
def _retry_after(exc: Exception | None) -> float | None:
resp = getattr(exc, "response", None)
if resp is None:
return None
value = resp.headers.get("Retry-After")
if not value:
return None
try:
return float(value)
except ValueError:
return None
def fetch_barcode(self, barcode: str) -> dict | None:
"""Fetch a single product by barcode; return the raw `product` dict."""
resp = self._get(_API_URL.format(barcode=barcode))
payload = resp.json()
if payload.get("status") != 1:
return None
return payload["product"]
def fetch(self, barcodes: list[str]) -> Iterator[dict]:
"""Yield raw product records for the given barcodes."""
for code in barcodes:
record = self.fetch_barcode(code)
if record is not None:
yield record
def fetch_modified_since(
self,
since_t: int,
*,
page_size: int = 100,
max_pages: int = 10,
) -> Iterator[dict]:
"""Yield products modified after ``since_t`` (unix ``last_modified_t``).
Uses the OFF search API sorted by ``last_modified_t`` (most recent
first) and paginates until it reaches products at or before the
watermark, an empty/short page, or ``max_pages``. This is the
incremental ingestion path: callers persist the highest
``last_modified_t`` they processed as the next watermark.
"""
for page in range(1, max_pages + 1):
resp = self._get(
_SEARCH_URL,
params={
"fields": _SEARCH_FIELDS,
"sort_by": "last_modified_t",
"page": page,
"page_size": page_size,
},
)
products = resp.json().get("products") or []
if not products:
return
reached_old = False
for prod in products:
if int(prod.get("last_modified_t") or 0) <= since_t:
reached_old = True
break
yield prod
if reached_old or len(products) < page_size:
return
def fetch_by_country(
self,
country: str,
*,
page_size: int = 100,
max_pages: int = 10,
sort_by: str = "unique_scans_n",
) -> Iterator[dict]:
"""Yield products sold in ``country`` (an OFF ``countries_tags_en`` slug).
Used to seed a market-specific catalogue (e.g. ``china``). Results are
sorted by ``sort_by`` (default ``unique_scans_n`` so the most-scanned,
best-known products come first) and de-duplicated across pages, since
OFF's popularity ordering is not stable between page requests.
"""
seen: set[str] = set()
for page in range(1, max_pages + 1):
resp = self._get(
_SEARCH_URL,
params={
"fields": _SEARCH_FIELDS,
"countries_tags_en": country,
"sort_by": sort_by,
"page": page,
"page_size": page_size,
},
)
products = resp.json().get("products") or []
if not products:
return
new_on_page = 0
for prod in products:
code = str(prod.get("code") or "")
if code and code in seen:
continue
if code:
seen.add(code)
new_on_page += 1
yield prod
if len(products) < page_size or new_on_page == 0:
return
def is_cn_gs1(code: str | None) -> bool:
"""Return True for a GS1 China company prefix (barcodes starting 690-699).
These identify products registered with GS1 China, i.e. genuinely domestic
items, as opposed to imported goods merely tagged as sold in China.
"""
if not code:
return False
code = code.strip()
return len(code) >= 3 and code[:2] == "69" and code[2].isdigit()
def read_dump(path: str | Path) -> Iterator[dict]:
"""Yield raw product records from an OFF JSONL dump file.
Each line is one product JSON object (the format of OFF's .jsonl export).
Supports plain or .gz files.
"""
p = Path(path)
if p.suffix == ".gz":
import gzip
opener = lambda: gzip.open(p, "rt", encoding="utf-8") # noqa: E731
else:
opener = lambda: open(p, encoding="utf-8") # noqa: E731
with opener() as fh:
for line in fh:
line = line.strip()
if line:
yield json.loads(line)
-1
View File
@@ -1 +0,0 @@
"""ETL: clean, normalize, dedup and score raw records before loading."""
-139
View File
@@ -1,139 +0,0 @@
"""Duplicate detection and product merging.
Barcodes (GTIN) are already unique at the schema level, so duplicates here are
non-GTIN records that describe the same product (same normalized name + brand +
net content). For each duplicate group we keep the highest-quality product as
canonical and merge the rest into it: child rows (provenance, images, MSRP) are
re-pointed to the canonical product, the merged product is marked ``merged``
with ``canonical_id`` set, and a row is written to ``merge_log``.
"""
from __future__ import annotations
from typing import Any
import psycopg
from opengoods.etl.quality import update_quality
def _norm(text: str | None) -> str:
return " ".join((text or "").lower().split())
def product_signature(name: str | None, brand: str | None, net_canonical: Any | None) -> str | None:
"""Stable signature for non-GTIN dedup, or ``None`` if too sparse to match."""
n = _norm(name)
if not n:
return None
net = "" if net_canonical is None else str(net_canonical)
return f"{n}|{_norm(brand)}|{net}"
def choose_canonical(members: list[dict]) -> dict:
"""Pick the canonical product: best quality, then oldest, then lowest id."""
return min(
members,
key=lambda m: (
-float(m.get("quality_score") or 0.0),
m.get("created_at"),
str(m.get("id")),
),
)
def find_duplicate_groups(conn: psycopg.Connection) -> list[list[dict]]:
"""Return groups (size >= 2) of active products sharing a signature."""
rows = conn.execute(
"""
SELECT p.id, p.name, b.normalized_name, p.net_content_canonical,
p.quality_score, p.created_at
FROM product p
LEFT JOIN brand b ON b.id = p.brand_id
WHERE p.status = 'active'
"""
).fetchall()
groups: dict[str, list[dict]] = {}
for r in rows:
sig = product_signature(r[1], r[2], r[3])
if sig is None:
continue
member = {
"id": r[0],
"name": r[1],
"quality_score": r[4],
"created_at": r[5],
}
groups.setdefault(sig, []).append(member)
return [m for m in groups.values() if len(m) >= 2]
def merge_products(
conn: psycopg.Connection,
kept_id: str,
merged_id: str,
reason: str = "auto-dedup",
actor: str = "ingestion",
) -> None:
"""Merge ``merged_id`` into ``kept_id`` (re-point children, mark merged)."""
if kept_id == merged_id:
return
# Re-point provenance, images and MSRP to the canonical product.
conn.execute(
"UPDATE product_source SET product_id = %s WHERE product_id = %s",
(kept_id, merged_id),
)
conn.execute(
"UPDATE product_image SET product_id = %s WHERE product_id = %s",
(kept_id, merged_id),
)
conn.execute(
"UPDATE product_msrp SET product_id = %s WHERE product_id = %s",
(kept_id, merged_id),
)
# food_detail has product_id as PK, so it can only move if the canonical
# product does not already have one.
kept_has_food = conn.execute(
"SELECT 1 FROM food_detail WHERE product_id = %s", (kept_id,)
).fetchone()
if not kept_has_food:
conn.execute(
"UPDATE food_detail SET product_id = %s WHERE product_id = %s",
(kept_id, merged_id),
)
conn.execute(
"UPDATE product SET status = 'merged', canonical_id = %s WHERE id = %s",
(kept_id, merged_id),
)
conn.execute(
"""
INSERT INTO merge_log (kept_id, merged_id, reason, actor)
VALUES (%s, %s, %s, %s)
""",
(kept_id, merged_id, reason, actor),
)
# The canonical product gained sources, so its quality may have changed.
update_quality(conn, kept_id)
def dedup_all(
conn: psycopg.Connection, actor: str = "ingestion", dry_run: bool = False
) -> dict[str, int]:
"""Merge every duplicate group. Returns counts of groups and merges."""
groups = find_duplicate_groups(conn)
merged = 0
for members in groups:
canonical = choose_canonical(members)
for m in members:
if m["id"] == canonical["id"]:
continue
if not dry_run:
merge_products(conn, canonical["id"], m["id"], actor=actor)
merged += 1
return {"groups": len(groups), "merged": merged}
-235
View File
@@ -1,235 +0,0 @@
"""Load transformed product records into the OpenGoods PostgreSQL database.
Only the ingestion side writes to the database. Every load records OFF as the
source with field-level provenance in `product_source`.
"""
from __future__ import annotations
import json
import logging
import os
from typing import Any
import psycopg
from psycopg.types.json import Jsonb
from opengoods.adapters.openfoodfacts import OFF_LICENSE, SOURCE_NAME
from opengoods.etl.quality import update_quality
OFF_HOMEPAGE = "https://world.openfoodfacts.org"
logger = logging.getLogger(__name__)
def default_dsn() -> str:
return os.environ.get(
"OPENGOODS_DATABASE_URL",
"postgres://opengoods:opengoods@localhost:5432/opengoods?sslmode=disable",
)
def _normalize_brand(name: str) -> str:
return " ".join(name.lower().split())
def ensure_source_named(
conn: psycopg.Connection,
name: str,
homepage: str,
license: str,
trust_weight: float,
) -> str:
"""Upsert a source row by name and return its id."""
row = conn.execute(
"""
INSERT INTO source (name, homepage, license, trust_weight)
VALUES (%s, %s, %s, %s)
ON CONFLICT (name) DO UPDATE SET homepage = EXCLUDED.homepage
RETURNING id
""",
(name, homepage, license, trust_weight),
).fetchone()
return row[0]
def ensure_source(conn: psycopg.Connection) -> str:
"""Upsert the Open Food Facts source row and return its id."""
return ensure_source_named(conn, SOURCE_NAME, OFF_HOMEPAGE, OFF_LICENSE, 0.7)
def _ensure_brand(conn: psycopg.Connection, name: str | None) -> str | None:
if not name:
return None
row = conn.execute(
"""
INSERT INTO brand (name, normalized_name)
VALUES (%s, %s)
ON CONFLICT (normalized_name) DO UPDATE SET name = brand.name
RETURNING id
""",
(name, _normalize_brand(name)),
).fetchone()
return row[0]
def _category_id(conn: psycopg.Connection, path: str | None) -> tuple[str | None, str | None]:
if not path:
return None, None
row = conn.execute(
"SELECT id, gpc_brick_code FROM category WHERE path = %s::ltree", (path,)
).fetchone()
return (row[0], row[1]) if row else (None, None)
def load_record(conn: psycopg.Connection, rec: dict[str, Any], source_id: str, raw: dict) -> str:
"""Upsert one transformed record; return the product id."""
brand_id = _ensure_brand(conn, rec.get("brand"))
category_id, gpc_brick = _category_id(conn, rec.get("category_path"))
fields = ["name", "brand", "net_content", "category", "country_of_origin"]
if rec.get("gtin"):
prod = conn.execute(
"""
INSERT INTO product (gtin, name, brand_id, category_id, gpc_brick_code,
net_content_value, net_content_unit, net_content_canonical,
country_of_origin, attributes)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
ON CONFLICT (gtin) WHERE gtin IS NOT NULL DO UPDATE SET
name = EXCLUDED.name,
brand_id = COALESCE(EXCLUDED.brand_id, product.brand_id),
category_id = COALESCE(EXCLUDED.category_id, product.category_id),
gpc_brick_code = COALESCE(EXCLUDED.gpc_brick_code, product.gpc_brick_code),
net_content_value = EXCLUDED.net_content_value,
net_content_unit = EXCLUDED.net_content_unit,
net_content_canonical = EXCLUDED.net_content_canonical,
country_of_origin = EXCLUDED.country_of_origin
RETURNING id
""",
(
rec["gtin"],
rec["name"],
brand_id,
category_id,
gpc_brick,
rec.get("net_content_value"),
rec.get("net_content_unit"),
rec.get("net_content_canonical"),
rec.get("country_of_origin"),
Jsonb({}),
),
).fetchone()
else:
prod = conn.execute(
"""
INSERT INTO product (name, brand_id, category_id, gpc_brick_code,
net_content_value, net_content_unit, net_content_canonical,
country_of_origin, attributes)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s)
RETURNING id
""",
(
rec["name"],
brand_id,
category_id,
gpc_brick,
rec.get("net_content_value"),
rec.get("net_content_unit"),
rec.get("net_content_canonical"),
rec.get("country_of_origin"),
Jsonb({}),
),
).fetchone()
product_id = prod[0]
food = rec.get("food") or {}
conn.execute(
"""
INSERT INTO food_detail (product_id, ingredients_text, allergens, additives,
nutriments, nutrition_basis, serving_size, nutri_score)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s)
ON CONFLICT (product_id) DO UPDATE SET
ingredients_text = EXCLUDED.ingredients_text,
allergens = EXCLUDED.allergens,
additives = EXCLUDED.additives,
nutriments = EXCLUDED.nutriments,
nutrition_basis = EXCLUDED.nutrition_basis,
serving_size = EXCLUDED.serving_size,
nutri_score = EXCLUDED.nutri_score
""",
(
product_id,
food.get("ingredients_text"),
food.get("allergens") or [],
food.get("additives") or [],
Jsonb(food.get("nutriments") or {}),
food.get("nutrition_basis"),
food.get("serving_size"),
food.get("nutri_score"),
),
)
if rec.get("image_url"):
conn.execute(
"""
INSERT INTO product_image (product_id, url, kind, license, source_id)
VALUES (%s,%s,'front',%s,%s)
""",
(product_id, rec["image_url"], "CC-BY-SA", source_id),
)
fields.append("image")
conn.execute(
"""
INSERT INTO product_source (product_id, source_id, url, fields, fetched_at, raw)
VALUES (%s,%s,%s,%s, now(), %s)
""",
(
product_id,
source_id,
f"{OFF_HOMEPAGE}/product/{rec.get('gtin') or ''}",
fields,
Jsonb(_jsonable(raw)),
),
)
# Recompute the data-quality score now that all facts + provenance exist.
update_quality(conn, product_id)
return product_id
def load_record_safe(
conn: psycopg.Connection, rec: dict[str, Any], source_id: str, raw: dict
) -> bool:
"""Load one record inside a savepoint.
On success the record's writes stay in the surrounding transaction. On any
error, only this record's writes are rolled back (to the savepoint) and the
batch continues, so a single malformed source record cannot abort a large
import. Returns True if loaded, False if skipped due to an error.
"""
try:
with conn.transaction():
load_record(conn, rec, source_id, raw)
return True
except Exception as exc: # noqa: BLE001 - per-record isolation is intentional
logger.warning("skipping 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:
json.dumps(raw)
return raw
except (TypeError, ValueError):
return {k: v for k, v in raw.items() if _is_jsonable(v)}
def _is_jsonable(v: object) -> bool:
try:
json.dumps(v)
return True
except (TypeError, ValueError):
return False
-104
View File
@@ -1,104 +0,0 @@
"""Field-level conflict resolution for multi-source records.
When more than one source describes the same product, each field may have
several candidate values. We pick a winner per field by source trust first,
then recency, ignoring empty values, and keep a provenance trail of which
source won each field.
These are pure functions (no DB / no network) so they are easy to unit-test;
the DB-level record merge lives in :mod:`opengoods.etl.dedup`.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime
@dataclass(frozen=True)
class Candidate:
"""One source's proposed value for a field."""
value: object
source: str
trust: float = 0.5
fetched_at: datetime | None = None
@dataclass
class FieldResolution:
"""The winning value for a field plus the source it came from."""
value: object
source: str | None = None
@dataclass
class MergedRecord:
"""A merged record with per-field provenance (field name -> source)."""
values: dict[str, object] = field(default_factory=dict)
provenance: dict[str, str] = field(default_factory=dict)
def _is_empty(value: object) -> bool:
if value is None:
return True
if isinstance(value, str):
return value.strip() == ""
if isinstance(value, (list, dict, tuple, set)):
return len(value) == 0
return False
def _sort_key(c: Candidate) -> tuple[float, float]:
ts = c.fetched_at.timestamp() if c.fetched_at is not None else float("-inf")
return (c.trust, ts)
def resolve_field(candidates: list[Candidate]) -> FieldResolution | None:
"""Pick the best non-empty candidate for one field.
Ranking: highest source trust, then most recent ``fetched_at``. Returns
``None`` when there is no usable (non-empty) candidate.
"""
usable = [c for c in candidates if not _is_empty(c.value)]
if not usable:
return None
winner = max(usable, key=_sort_key)
return FieldResolution(value=winner.value, source=winner.source)
def merge_records(records: list[dict], *, fields: list[str] | None = None) -> MergedRecord:
"""Merge several ``{field: Candidate|value}`` records into one.
Each input record maps field name -> :class:`Candidate` (preferred) or a
bare value (treated as trust 0.5, no timestamp). The result keeps, for each
field, the winning value and the name of the source that supplied it.
"""
keys: list[str]
if fields is not None:
keys = list(fields)
else:
seen: dict[str, None] = {}
for rec in records:
for k in rec:
seen.setdefault(k, None)
keys = list(seen)
merged = MergedRecord()
for key in keys:
candidates: list[Candidate] = []
for rec in records:
if key not in rec:
continue
cand = rec[key]
if not isinstance(cand, Candidate):
cand = Candidate(value=cand, source="unknown")
candidates.append(cand)
resolution = resolve_field(candidates)
if resolution is not None:
merged.values[key] = resolution.value
if resolution.source is not None:
merged.provenance[key] = resolution.source
return merged
-176
View File
@@ -1,176 +0,0 @@
"""Product data-quality scoring.
The quality score is a 0..1 number combining four signals, per the locked
project decision:
quality = 0.4 * completeness
+ 0.3 * source_trust
+ 0.2 * multi_source_agreement
+ 0.1 * freshness
Each component is itself normalized to 0..1. The pure helpers below are
unit-testable; :func:`compute_quality` / :func:`update_quality` read the signals
for a product out of the database and persist the result on ``product``.
"""
from __future__ import annotations
from datetime import UTC, datetime
import psycopg
W_COMPLETENESS = 0.4
W_SOURCE_TRUST = 0.3
W_AGREEMENT = 0.2
W_FRESHNESS = 0.1
# Fields that count towards completeness (weighted equally).
COMPLETENESS_FIELDS = (
"name",
"gtin",
"brand",
"category",
"net_content",
"country_of_origin",
"nutriments",
"ingredients",
"image",
)
def completeness(present: set[str]) -> float:
"""Fraction of :data:`COMPLETENESS_FIELDS` that are present for a product."""
if not COMPLETENESS_FIELDS:
return 0.0
hits = sum(1 for f in COMPLETENESS_FIELDS if f in present)
return hits / len(COMPLETENESS_FIELDS)
def agreement_from_sources(source_count: int) -> float:
"""Multi-source corroboration proxy from the number of distinct sources.
A single source cannot be corroborated, so it scores a neutral 0.5; more
independent sources that describe the same product raise confidence.
"""
if source_count <= 1:
return 0.5
if source_count == 2:
return 0.8
return 1.0
def freshness_from_age(age_days: float | None) -> float:
"""Recency score from the age (in days) of the most recent source fetch."""
if age_days is None:
return 0.5
if age_days <= 30:
return 1.0
if age_days <= 180:
return 0.8
if age_days <= 365:
return 0.6
if age_days <= 730:
return 0.4
return 0.2
def score(
*,
completeness_score: float,
source_trust: float,
agreement: float,
freshness: float,
) -> float:
"""Combine the four normalized components into a 0..1 quality score."""
raw = (
W_COMPLETENESS * completeness_score
+ W_SOURCE_TRUST * source_trust
+ W_AGREEMENT * agreement
+ W_FRESHNESS * freshness
)
return round(max(0.0, min(1.0, raw)), 3)
def _present_fields(prod: dict, has_image: bool) -> set[str]:
present: set[str] = set()
if prod.get("name"):
present.add("name")
if prod.get("gtin"):
present.add("gtin")
if prod.get("brand_id"):
present.add("brand")
if prod.get("category_id"):
present.add("category")
if prod.get("net_content_canonical") is not None:
present.add("net_content")
if prod.get("country_of_origin"):
present.add("country_of_origin")
if prod.get("nutriments"):
present.add("nutriments")
if prod.get("ingredients_text"):
present.add("ingredients")
if has_image:
present.add("image")
return present
def compute_quality(conn: psycopg.Connection, product_id: str) -> float:
"""Compute (but do not persist) the quality score for one product."""
row = conn.execute(
"""
SELECT p.name, p.gtin, p.brand_id, p.category_id, p.net_content_canonical,
p.country_of_origin, f.nutriments, f.ingredients_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
WHERE p.id = %s
""",
(product_id,),
).fetchone()
if row is None:
return 0.0
prod = {
"name": row[0],
"gtin": row[1],
"brand_id": row[2],
"category_id": row[3],
"net_content_canonical": row[4],
"country_of_origin": row[5],
"nutriments": row[6],
"ingredients_text": row[7],
}
has_image = bool(row[8])
src = conn.execute(
"""
SELECT count(DISTINCT ps.source_id), COALESCE(max(s.trust_weight), 0), max(ps.fetched_at)
FROM product_source ps
LEFT JOIN source s ON s.id = ps.source_id
WHERE ps.product_id = %s
""",
(product_id,),
).fetchone()
source_count = int(src[0] or 0)
source_trust = float(src[1] or 0.0)
last_fetched: datetime | None = src[2]
age_days: float | None = None
if last_fetched is not None:
now = datetime.now(UTC)
if last_fetched.tzinfo is None:
last_fetched = last_fetched.replace(tzinfo=UTC)
age_days = max(0.0, (now - last_fetched).total_seconds() / 86400.0)
return score(
completeness_score=completeness(_present_fields(prod, has_image)),
source_trust=source_trust,
agreement=agreement_from_sources(source_count),
freshness=freshness_from_age(age_days),
)
def update_quality(conn: psycopg.Connection, product_id: str) -> float:
"""Compute the quality score and write it to ``product.quality_score``."""
value = compute_quality(conn, product_id)
conn.execute("UPDATE product SET quality_score = %s WHERE id = %s", (value, product_id))
return value
-45
View File
@@ -1,45 +0,0 @@
"""Persistent ingestion watermark stored in the ``ingest_state`` table.
The incremental updater uses this to remember how far it got for each source
(e.g. Open Food Facts exposes a ``last_modified_t`` unix timestamp on every
product) so repeated runs only fetch what changed.
"""
from __future__ import annotations
from typing import Any
import psycopg
from psycopg.types.json import Jsonb
def get_watermark(conn: psycopg.Connection, source: str) -> int:
"""Return the last processed ``last_modified_t`` for *source* (0 if none)."""
row = conn.execute(
"SELECT last_modified_t FROM ingest_state WHERE source = %s", (source,)
).fetchone()
return int(row[0]) if row else 0
def set_watermark(
conn: psycopg.Connection,
source: str,
last_modified_t: int,
stats: dict[str, Any] | None = None,
) -> None:
"""Upsert the watermark and run metadata for *source*.
The watermark only ever moves forward: a lower ``last_modified_t`` is
ignored so an out-of-order or partial run cannot rewind progress.
"""
conn.execute(
"""
INSERT INTO ingest_state (source, last_modified_t, last_run_at, stats)
VALUES (%s, %s, now(), %s)
ON CONFLICT (source) DO UPDATE SET
last_modified_t = GREATEST(ingest_state.last_modified_t, EXCLUDED.last_modified_t),
last_run_at = now(),
stats = EXCLUDED.stats
""",
(source, int(last_modified_t), Jsonb(stats or {})),
)
-133
View File
@@ -1,133 +0,0 @@
"""Apply GS1 (or other authoritative) supplements to existing products.
A supplement only fills *gaps*: a field is written only when the product does
not already have a value. Each applied supplement records field-level provenance
in ``product_source`` and refreshes the product's quality score.
"""
from __future__ import annotations
from decimal import Decimal, InvalidOperation
from typing import Any
import psycopg
from psycopg.types.json import Jsonb
from opengoods import units
from opengoods.adapters.gs1 import GS1_HOMEPAGE, GS1_LICENSE, GS1_TRUST, SOURCE_NAME
from opengoods.etl.load import _ensure_brand, _normalize_brand, ensure_source_named
from opengoods.etl.quality import update_quality
def ensure_gs1_source(conn: psycopg.Connection) -> str:
"""Upsert the GS1 source row and return its id."""
return ensure_source_named(conn, SOURCE_NAME, GS1_HOMEPAGE, GS1_LICENSE, GS1_TRUST)
def _ensure_manufacturer(conn: psycopg.Connection, name: str | None) -> str | None:
if not name:
return None
row = conn.execute(
"""
INSERT INTO manufacturer (name, normalized_name)
VALUES (%s, %s)
ON CONFLICT (normalized_name) DO UPDATE SET name = manufacturer.name
RETURNING id
""",
(name, _normalize_brand(name)),
).fetchone()
return row[0]
def _net_content(rec: dict) -> tuple[Decimal, str, Decimal | None] | None:
raw_value = rec.get("net_content_value")
unit = rec.get("net_content_unit")
if raw_value is None or not unit:
return None
try:
value = Decimal(str(raw_value))
except (InvalidOperation, ValueError):
return None
try:
canonical = units.normalize(value, unit).canonical_value
except units.UnitError:
canonical = None
return value, unit, canonical
def apply_supplement(conn: psycopg.Connection, rec: dict[str, Any], source_id: str) -> list[str]:
"""Fill missing fields of the GTIN-matched product from ``rec``.
Returns the list of field names actually filled (empty if the product is
unknown or already complete for the supplied fields).
"""
gtin = rec.get("gtin")
if not gtin:
return []
prod = conn.execute(
"""
SELECT id, brand_id, manufacturer_id, gpc_brick_code, country_of_origin,
net_content_value
FROM product
WHERE gtin = %s AND status = 'active'
""",
(gtin,),
).fetchone()
if prod is None:
return []
product_id, brand_id, manufacturer_id, gpc, country, net_value = prod
sets: list[str] = []
params: list[Any] = []
filled: list[str] = []
if brand_id is None and rec.get("brand"):
new_brand_id = _ensure_brand(conn, rec["brand"])
if new_brand_id is not None:
sets.append("brand_id = %s")
params.append(new_brand_id)
filled.append("brand")
if manufacturer_id is None and rec.get("manufacturer"):
new_mfr_id = _ensure_manufacturer(conn, rec["manufacturer"])
if new_mfr_id is not None:
sets.append("manufacturer_id = %s")
params.append(new_mfr_id)
filled.append("manufacturer")
if gpc is None and rec.get("gpc_brick_code"):
sets.append("gpc_brick_code = %s")
params.append(rec["gpc_brick_code"])
filled.append("gpc_brick_code")
if country is None and rec.get("country_of_origin"):
sets.append("country_of_origin = %s")
params.append(rec["country_of_origin"])
filled.append("country_of_origin")
if net_value is None:
net = _net_content(rec)
if net is not None:
value, unit, canonical = net
sets += [
"net_content_value = %s",
"net_content_unit = %s",
"net_content_canonical = %s",
]
params += [value, unit, canonical]
filled.append("net_content")
if not filled:
return []
params.append(product_id)
conn.execute(f"UPDATE product SET {', '.join(sets)} WHERE id = %s", params)
conn.execute(
"""
INSERT INTO product_source (product_id, source_id, url, fields, fetched_at, raw)
VALUES (%s, %s, %s, %s, now(), %s)
""",
(product_id, source_id, GS1_HOMEPAGE, filled, Jsonb(rec)),
)
update_quality(conn, product_id)
return filled
-167
View File
@@ -1,167 +0,0 @@
"""Transform raw Open Food Facts records into the OpenGoods internal shape.
Pure functions (no DB, no network) so they are easy to unit-test against
fixtures. The output dict mirrors the columns the loader writes.
"""
from __future__ import annotations
import re
from decimal import Decimal
from opengoods.units import UnitError, normalize
# OFF nutriment key -> our attribute key. Energy handled separately.
_NUTRIMENT_KEYS = {
"proteins_100g": "proteins",
"fat_100g": "fat",
"saturated-fat_100g": "saturated_fat",
"carbohydrates_100g": "carbohydrates",
"sugars_100g": "sugars",
"salt_100g": "salt",
}
# Very small keyword -> category path map (starter; replaced by a proper
# OFF taxonomy -> GPC mapping table later).
_CATEGORY_KEYWORDS: list[tuple[tuple[str, ...], str]] = [
(("water", "eau", "饮用水", "矿泉水"), "food.beverages.water"),
(("soda", "carbonated", "汽水", "碳酸"), "food.beverages.carbonated"),
(("juice", "jus", "果汁"), "food.beverages.juice"),
(("milk", "lait", "牛奶"), "food.dairy.milk"),
(("yogurt", "yoghurt", "yaourt", "酸奶"), "food.dairy.yogurt"),
(("cheese", "fromage", "奶酪", "干酪"), "food.dairy.cheese"),
(("bread", "pain", "面包"), "food.bakery.bread"),
(("biscuit", "cookie", "饼干"), "food.bakery.biscuits"),
(("chips", "crisps", "薯片", "膨化"), "food.snacks.chips"),
(("chocolate", "chocolat", "巧克力"), "food.snacks.chocolate"),
(("rice", "riz", "大米", "稻米"), "food.staple.rice"),
(("noodle", "pasta", "面条", "挂面"), "food.staple.noodles"),
(("oil", "huile", "食用油", "食油"), "food.staple.cooking_oil"),
(("soy sauce", "酱油"), "food.condiments.soy_sauce"),
(("salt", "sel", "食盐"), "food.condiments.salt"),
]
_QTY_RE = re.compile(r"(?P<value>\d+(?:[.,]\d+)?)\s*(?P<unit>[a-zA-Z\u4e00-\u9fff%]+)")
def is_valid_gtin(code: str) -> bool:
"""Validate a GTIN-8/12/13/14 using the standard check digit."""
if not code.isdigit() or len(code) not in (8, 12, 13, 14):
return False
digits = [int(c) for c in code]
check = digits[-1]
body = digits[:-1][::-1]
total = sum(d * (3 if i % 2 == 0 else 1) for i, d in enumerate(body))
return (10 - total % 10) % 10 == check
def parse_quantity(text: str) -> tuple[Decimal, str] | None:
"""Parse a free-text quantity like '500 g' or '1,5 L' -> (value, unit)."""
if not text:
return None
m = _QTY_RE.search(text)
if not m:
return None
value = Decimal(m.group("value").replace(",", "."))
return value, m.group("unit")
def map_category(raw: dict) -> str | None:
"""Best-effort map OFF categories/name to a self-built category path."""
haystack = " ".join(
str(raw.get(k, ""))
for k in ("categories", "categories_tags", "product_name", "product_name_en")
).lower()
for keywords, path in _CATEGORY_KEYWORDS:
if any(kw.lower() in haystack for kw in keywords):
return path
return None
def _clamp(value: str | None, max_len: int) -> str | None:
"""Trim a string to fit a bounded DB column; external data length varies."""
if value is None:
return None
value = value.strip()
return value[:max_len] or None
def _clean_tags(tags: list[str] | None, prefix: str = "") -> list[str]:
out: list[str] = []
for t in tags or []:
v = t.split(":", 1)[-1] if ":" in t else t
v = v.strip().replace("-", " ")
if v:
out.append(v)
return out
def transform_nutriments(off_nutriments: dict) -> dict:
"""Build a nutriments dict on a per_100g basis with dual energy units."""
out: dict[str, object] = {}
for off_key, our_key in _NUTRIMENT_KEYS.items():
if off_key in off_nutriments and off_nutriments[off_key] is not None:
out[our_key] = float(off_nutriments[off_key])
kj = off_nutriments.get("energy-kj_100g")
kcal = off_nutriments.get("energy-kcal_100g")
if kj is None and kcal is not None:
kj = float(Decimal(str(kcal)) * Decimal("4.184"))
if kcal is None and kj is not None:
kcal = float(Decimal(str(kj)) / Decimal("4.184"))
if kj is not None:
out["energy_kj"] = round(float(kj), 3)
if kcal is not None:
out["energy_kcal"] = round(float(kcal), 3)
return out
def transform(raw: dict) -> dict | None:
"""Transform one raw OFF product record into an internal product dict.
Returns None if the record lacks a usable name.
"""
name = raw.get("product_name_zh") or raw.get("product_name") or raw.get("product_name_en")
if not name:
return None
code = str(raw.get("code", "")).strip()
gtin = code if code and is_valid_gtin(code) else None
brands = raw.get("brands") or ""
brand = brands.split(",")[0].strip() or None
net_value = net_unit = net_canonical = None
parsed = parse_quantity(raw.get("quantity", ""))
if parsed:
value, unit = parsed
try:
norm = normalize(value, unit)
net_value, net_unit, net_canonical = (
norm.value,
norm.unit,
norm.canonical_value,
)
except UnitError:
net_value, net_unit = value, unit
return {
"gtin": gtin,
"name": str(name).strip(),
"brand": brand,
"category_path": map_category(raw),
"net_content_value": net_value,
"net_content_unit": _clamp(net_unit, 16),
"net_content_canonical": net_canonical,
"country_of_origin": _clamp((raw.get("countries") or "").split(",")[0].strip() or None, 64),
"food": {
"ingredients_text": raw.get("ingredients_text") or None,
"allergens": _clean_tags(raw.get("allergens_tags")),
"additives": _clean_tags(raw.get("additives_tags")),
"nutriments": transform_nutriments(raw.get("nutriments") or {}),
"nutrition_basis": "per_100g",
"serving_size": _clamp(raw.get("serving_size") or None, 32),
"nutri_score": (raw.get("nutriscore_grade") or "").upper()[:1] or None,
},
"image_url": raw.get("image_front_url") or raw.get("image_url") or None,
}

Some files were not shown because too many files have changed in this diff Show More