feat(admin): 运营后台(登录/查看/审核编辑/补全)+ 写入API + 审计留痕
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
.git
|
||||
**/node_modules
|
||||
admin-frontend/dist
|
||||
api/server
|
||||
*.test
|
||||
*.out
|
||||
__pycache__
|
||||
.venv
|
||||
.pytest_cache
|
||||
.ruff_cache
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
@@ -5,3 +5,8 @@ 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
|
||||
|
||||
@@ -18,6 +18,13 @@ dist/
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Node / admin frontend
|
||||
node_modules/
|
||||
|
||||
# Keep the embedded SPA placeholder (real build is injected during Docker build)
|
||||
!api/internal/adminweb/dist/
|
||||
!api/internal/adminweb/dist/index.html
|
||||
|
||||
# OS / editors
|
||||
.DS_Store
|
||||
*.swp
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="zh">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>OpenGoods 管理后台</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+2690
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,82 @@
|
||||
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 { LogOut, Package } from "lucide-react";
|
||||
|
||||
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 [view, setView] = useState<View>({ name: "list" });
|
||||
|
||||
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-2 text-lg font-semibold text-gray-800">
|
||||
<Package className="h-5 w-5 text-emerald-600" />
|
||||
OpenGoods 商品档案后台
|
||||
</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">
|
||||
{view.name === "list" ? (
|
||||
<ProductList onOpen={(id) => setView({ name: "detail", id })} />
|
||||
) : (
|
||||
<ProductDetail
|
||||
id={view.id}
|
||||
onBack={() => setView({ name: "list" })}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
// 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}`),
|
||||
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",
|
||||
}),
|
||||
listBrands: () =>
|
||||
request<{ items: import("./types").Brand[] }>("/brands"),
|
||||
listCategories: () =>
|
||||
request<{ items: import("./types").Category[] }>("/categories"),
|
||||
};
|
||||
@@ -0,0 +1,75 @@
|
||||
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">
|
||||
OpenGoods 管理后台
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,642 @@
|
||||
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,
|
||||
} from "lucide-react";
|
||||
|
||||
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: "删除建议零售价",
|
||||
};
|
||||
|
||||
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>
|
||||
|
||||
<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 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { api } from "../api";
|
||||
import { FIELD_LABELS, ProductRow } from "../types";
|
||||
import { Search, AlertCircle } 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] = useState(20);
|
||||
const [rows, setRows] = useState<ProductRow[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
api
|
||||
.listProducts(q, page, size)
|
||||
.then((r) => {
|
||||
setRows(r.items);
|
||||
setTotal(r.total);
|
||||
})
|
||||
.catch((e) => setError(e.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, [q, page, size]);
|
||||
|
||||
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>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-3 rounded bg-red-50 px-3 py-2 text-sm text-red-600">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="overflow-hidden rounded-lg border border-gray-200 bg-white">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50 text-left text-xs uppercase text-gray-500">
|
||||
<tr>
|
||||
<th className="px-4 py-3">名称</th>
|
||||
<th className="px-4 py-3">品牌</th>
|
||||
<th className="px-4 py-3">条码</th>
|
||||
<th className="px-4 py-3">品类</th>
|
||||
<th className="px-4 py-3">状态</th>
|
||||
<th className="px-4 py-3">质量分</th>
|
||||
<th className="px-4 py-3">缺失字段</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-4 py-8 text-center text-gray-400">
|
||||
加载中…
|
||||
</td>
|
||||
</tr>
|
||||
) : rows.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} 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"
|
||||
>
|
||||
<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 items-center justify-end gap-2 text-sm text-gray-600">
|
||||
<button
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage((p) => p - 1)}
|
||||
className="rounded border border-gray-300 px-3 py-1 disabled:opacity-50"
|
||||
>
|
||||
上一页
|
||||
</button>
|
||||
<span>
|
||||
{page} / {pages}
|
||||
</span>
|
||||
<button
|
||||
disabled={page >= pages}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
className="rounded border border-gray-300 px-3 py-1 disabled:opacity-50"
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
@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;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
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>,
|
||||
);
|
||||
@@ -0,0 +1,87 @@
|
||||
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 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;
|
||||
images: ProductImage[];
|
||||
msrp: MSRP[];
|
||||
missing: string[];
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface Brand {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface Category {
|
||||
id: string;
|
||||
name_zh: string;
|
||||
name_en: string | null;
|
||||
path: string;
|
||||
level: number;
|
||||
}
|
||||
|
||||
export interface AuditEntry {
|
||||
id: string;
|
||||
actor: string;
|
||||
action: string;
|
||||
fields: string[];
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export const FIELD_LABELS: Record<string, string> = {
|
||||
name: "名称",
|
||||
gtin: "条码",
|
||||
brand: "品牌",
|
||||
category: "品类",
|
||||
net_content: "净含量",
|
||||
country_of_origin: "产地",
|
||||
nutriments: "营养成分",
|
||||
ingredients: "配料",
|
||||
image: "图片",
|
||||
};
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,6 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ["./index.html", "./src/**/*.{ts,tsx}"],
|
||||
theme: { extend: {} },
|
||||
plugins: [],
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"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" }]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
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 },
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
# 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
|
||||
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"]
|
||||
@@ -0,0 +1,83 @@
|
||||
// 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"
|
||||
)
|
||||
|
||||
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)
|
||||
h := adminhandler.New(adminstore.New(pool), authn, basePath, adminweb.Dist())
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -5,13 +5,13 @@ go 1.23.4
|
||||
require (
|
||||
github.com/go-chi/chi/v5 v5.1.0
|
||||
github.com/jackc/pgx/v5 v5.7.2
|
||||
golang.org/x/crypto v0.31.0
|
||||
)
|
||||
|
||||
require (
|
||||
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
|
||||
golang.org/x/crypto v0.31.0 // indirect
|
||||
golang.org/x/sync v0.10.0 // indirect
|
||||
golang.org/x/text v0.21.0 // indirect
|
||||
)
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
// 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"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
// Handler holds the admin dependencies.
|
||||
type Handler struct {
|
||||
store *adminstore.Store
|
||||
authn *auth.Authenticator
|
||||
basePath string
|
||||
spa fs.FS
|
||||
}
|
||||
|
||||
// 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}
|
||||
}
|
||||
|
||||
// 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/products", h.ListProducts)
|
||||
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.Get("/api/brands", h.ListBrands)
|
||||
r.Get("/api/categories", h.ListCategories)
|
||||
})
|
||||
|
||||
r.Handle("/*", http.HandlerFunc(h.serveSPA))
|
||||
})
|
||||
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)
|
||||
}
|
||||
|
||||
// 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"})
|
||||
}
|
||||
|
||||
// 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})
|
||||
}
|
||||
|
||||
// ---------- helpers ----------
|
||||
|
||||
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
|
||||
}
|
||||
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}})
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
// 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"`
|
||||
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{}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
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, >in, &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
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,415 @@
|
||||
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
|
||||
}
|
||||
|
||||
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.
|
||||
type Brand struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// ListBrands returns all brands ordered by name.
|
||||
func (s *Store) ListBrands(ctx context.Context) ([]Brand, error) {
|
||||
rows, err := s.pool.Query(ctx, "SELECT id, name FROM brand ORDER BY 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); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, b)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// Category is a category option for the edit form.
|
||||
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"`
|
||||
}
|
||||
|
||||
// ListCategories returns the full category tree.
|
||||
func (s *Store) ListCategories(ctx context.Context) ([]Category, error) {
|
||||
rows, err := s.pool.Query(ctx,
|
||||
"SELECT id, name_zh, name_en, path::text, level FROM category ORDER BY path")
|
||||
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); 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"`
|
||||
}
|
||||
|
||||
// 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
@@ -0,0 +1,7 @@
|
||||
<!doctype html>
|
||||
<html lang="zh">
|
||||
<head><meta charset="utf-8" /><title>OpenGoods 管理后台</title></head>
|
||||
<body>
|
||||
<p>管理后台前端尚未构建。Docker 构建会在此处放入真正的前端产物。</p>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,21 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
// 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))
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -56,6 +56,24 @@ services:
|
||||
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:
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
- 所有服务 `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` 仍只读。
|
||||
|
||||
## 步骤
|
||||
|
||||
@@ -23,3 +24,11 @@ curl -s http://127.0.0.1:8120/healthz # {"status":"ok"}
|
||||
```
|
||||
|
||||
nginx 反代(子域 + HTTPS):80 端口 301 跳转到 443,443 `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` 自动应用。
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
DROP INDEX IF EXISTS idx_audit_created;
|
||||
DROP INDEX IF EXISTS idx_audit_entity;
|
||||
DROP TABLE IF EXISTS audit_log;
|
||||
DELETE FROM source WHERE name = 'manual';
|
||||
@@ -0,0 +1,22 @@
|
||||
-- Admin console support: manual edit provenance + audit log.
|
||||
|
||||
-- Manual-entry source used to record field-level provenance for operator edits.
|
||||
INSERT INTO source (name, homepage, license, trust_weight, notes)
|
||||
VALUES ('manual', NULL, 'proprietary', 0.90, '运营人工录入/补全')
|
||||
ON CONFLICT (name) DO NOTHING;
|
||||
|
||||
-- Audit trail: every admin write records actor, action, entity and before/after.
|
||||
CREATE TABLE IF NOT EXISTS audit_log (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
actor TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
entity TEXT NOT NULL,
|
||||
entity_id UUID,
|
||||
fields TEXT[] NOT NULL DEFAULT '{}',
|
||||
before JSONB,
|
||||
after JSONB,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_entity ON audit_log (entity, entity_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_created ON audit_log (created_at DESC);
|
||||
Reference in New Issue
Block a user