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
|
POSTGRES_DB=opengoods
|
||||||
MINIO_ROOT_USER=opengoods
|
MINIO_ROOT_USER=opengoods
|
||||||
MINIO_ROOT_PASSWORD=change-me
|
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.*
|
||||||
!.env.example
|
!.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
|
# OS / editors
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.swp
|
*.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 (
|
require (
|
||||||
github.com/go-chi/chi/v5 v5.1.0
|
github.com/go-chi/chi/v5 v5.1.0
|
||||||
github.com/jackc/pgx/v5 v5.7.2
|
github.com/jackc/pgx/v5 v5.7.2
|
||||||
|
golang.org/x/crypto v0.31.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
github.com/jackc/puddle/v2 v2.2.2 // 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/sync v0.10.0 // indirect
|
||||||
golang.org/x/text v0.21.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:
|
ports:
|
||||||
- "127.0.0.1:8120:8080"
|
- "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:
|
volumes:
|
||||||
pgdata:
|
pgdata:
|
||||||
miniodata:
|
miniodata:
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
- 所有服务 `restart: unless-stopped`。
|
- 所有服务 `restart: unless-stopped`。
|
||||||
- 凭据从 `.env` 注入(见 `.env.example`),不写入仓库。
|
- 凭据从 `.env` 注入(见 `.env.example`),不写入仓库。
|
||||||
- `api` 使用 `api/Dockerfile.prod`:运行镜像用 `scratch`(从构建镜像拷贝 ca-certs),适用于 `gcr.io/distroless` 不可达的环境;Go 模块走 `goproxy.cn`。
|
- `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"` 自动续期。
|
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