feat(plugin): P1-P4 审计修复 — 第一批 (Excel/CSV导出 + 市场API + 对账扫描)
1.1 Excel/CSV 导出:
- 后端 export 支持 format 参数 (json/csv/xlsx)
- rust_xlsxwriter 生成带样式 Excel
- 前端导出按钮改为 Dropdown 格式选择 (JSON/CSV/Excel)
- blob 下载支持 CSV/XLSX 二进制格式
1.2 市场后端 API + 前端对接:
- SeaORM Entity: market_entry, market_review
- API: 浏览/详情/一键安装/评论列表/提交评分
- 一键安装: upload → install → enable 一条龙 + 依赖检查
- 前端 PluginMarket 对接真实 API (搜索/分类/安装/评分)
1.3 对账扫描:
- reconcile_references() 扫描跨插件引用悬空 UUID
- POST /plugins/{plugin_id}/reconcile 端点
This commit is contained in:
75
Cargo.lock
generated
75
Cargo.lock
generated
@@ -137,6 +137,9 @@ name = "arbitrary"
|
|||||||
version = "1.4.2"
|
version = "1.4.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
|
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
|
||||||
|
dependencies = [
|
||||||
|
"derive_arbitrary",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "arc-swap"
|
name = "arc-swap"
|
||||||
@@ -960,6 +963,27 @@ dependencies = [
|
|||||||
"typenum",
|
"typenum",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "csv"
|
||||||
|
version = "1.4.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938"
|
||||||
|
dependencies = [
|
||||||
|
"csv-core",
|
||||||
|
"itoa",
|
||||||
|
"ryu",
|
||||||
|
"serde_core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "csv-core"
|
||||||
|
version = "0.1.13"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "darling"
|
name = "darling"
|
||||||
version = "0.20.11"
|
version = "0.20.11"
|
||||||
@@ -1039,6 +1063,17 @@ dependencies = [
|
|||||||
"serde_core",
|
"serde_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "derive_arbitrary"
|
||||||
|
version = "1.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.117",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "derive_more"
|
name = "derive_more"
|
||||||
version = "2.1.1"
|
version = "2.1.1"
|
||||||
@@ -1245,10 +1280,12 @@ dependencies = [
|
|||||||
"axum",
|
"axum",
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"chrono",
|
"chrono",
|
||||||
|
"csv",
|
||||||
"dashmap",
|
"dashmap",
|
||||||
"erp-core",
|
"erp-core",
|
||||||
"moka",
|
"moka",
|
||||||
"regex",
|
"regex",
|
||||||
|
"rust_xlsxwriter",
|
||||||
"sea-orm",
|
"sea-orm",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -3166,6 +3203,15 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rust_xlsxwriter"
|
||||||
|
version = "0.82.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d61a82de4e7b30fc427909f2c5aafaada88cc7ae8316edabae435f74341f9278"
|
||||||
|
dependencies = [
|
||||||
|
"zip",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustc-demangle"
|
name = "rustc-demangle"
|
||||||
version = "0.1.27"
|
version = "0.1.27"
|
||||||
@@ -5845,12 +5891,41 @@ dependencies = [
|
|||||||
"syn 2.0.117",
|
"syn 2.0.117",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zip"
|
||||||
|
version = "2.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50"
|
||||||
|
dependencies = [
|
||||||
|
"arbitrary",
|
||||||
|
"crc32fast",
|
||||||
|
"crossbeam-utils",
|
||||||
|
"displaydoc",
|
||||||
|
"flate2",
|
||||||
|
"indexmap",
|
||||||
|
"memchr",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"zopfli",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zmij"
|
name = "zmij"
|
||||||
version = "1.0.21"
|
version = "1.0.21"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zopfli"
|
||||||
|
version = "0.8.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
|
||||||
|
dependencies = [
|
||||||
|
"bumpalo",
|
||||||
|
"crc32fast",
|
||||||
|
"log",
|
||||||
|
"simd-adler32",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zstd"
|
name = "zstd"
|
||||||
version = "0.13.3"
|
version = "0.13.3"
|
||||||
|
|||||||
@@ -77,6 +77,10 @@ validator = { version = "0.19", features = ["derive"] }
|
|||||||
# Async trait
|
# Async trait
|
||||||
async-trait = "0.1"
|
async-trait = "0.1"
|
||||||
|
|
||||||
|
# CSV and Excel export
|
||||||
|
csv = "1"
|
||||||
|
rust_xlsxwriter = "0.82"
|
||||||
|
|
||||||
# Internal crates
|
# Internal crates
|
||||||
erp-core = { path = "crates/erp-core" }
|
erp-core = { path = "crates/erp-core" }
|
||||||
erp-auth = { path = "crates/erp-auth" }
|
erp-auth = { path = "crates/erp-auth" }
|
||||||
|
|||||||
@@ -217,7 +217,7 @@ export interface ExportOptions {
|
|||||||
search?: string;
|
search?: string;
|
||||||
sort_by?: string;
|
sort_by?: string;
|
||||||
sort_order?: 'asc' | 'desc';
|
sort_order?: 'asc' | 'desc';
|
||||||
format?: 'csv' | 'json';
|
format?: 'json' | 'csv' | 'xlsx';
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function exportPluginData(
|
export async function exportPluginData(
|
||||||
@@ -238,6 +238,25 @@ export async function exportPluginData(
|
|||||||
return data.data;
|
return data.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function exportPluginDataAsBlob(
|
||||||
|
pluginId: string,
|
||||||
|
entity: string,
|
||||||
|
format: 'csv' | 'xlsx',
|
||||||
|
options?: Omit<ExportOptions, 'format'>,
|
||||||
|
): Promise<Blob> {
|
||||||
|
const params: Record<string, string> = { format };
|
||||||
|
if (options?.filter) params.filter = JSON.stringify(options.filter);
|
||||||
|
if (options?.search) params.search = options.search;
|
||||||
|
if (options?.sort_by) params.sort_by = options.sort_by;
|
||||||
|
if (options?.sort_order) params.sort_order = options.sort_order;
|
||||||
|
|
||||||
|
const response = await client.get(
|
||||||
|
`/plugins/${pluginId}/${entity}/export`,
|
||||||
|
{ params, responseType: 'blob' },
|
||||||
|
);
|
||||||
|
return response.data as Blob;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ImportRowError {
|
export interface ImportRowError {
|
||||||
row: number;
|
row: number;
|
||||||
errors: string[];
|
errors: string[];
|
||||||
|
|||||||
@@ -256,3 +256,81 @@ export interface PluginTriggerEvent {
|
|||||||
entity: string;
|
entity: string;
|
||||||
on: 'create' | 'update' | 'delete' | 'create_or_update';
|
on: 'create' | 'update' | 'delete' | 'create_or_update';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 插件市场 API ──
|
||||||
|
|
||||||
|
export interface MarketEntry {
|
||||||
|
id: string;
|
||||||
|
plugin_id: string;
|
||||||
|
name: string;
|
||||||
|
version: string;
|
||||||
|
description?: string;
|
||||||
|
author?: string;
|
||||||
|
category?: string;
|
||||||
|
tags?: string[];
|
||||||
|
icon_url?: string;
|
||||||
|
screenshots?: string[];
|
||||||
|
min_platform_version?: string;
|
||||||
|
status: string;
|
||||||
|
download_count: number;
|
||||||
|
rating_avg: number;
|
||||||
|
rating_count: number;
|
||||||
|
changelog?: string;
|
||||||
|
created_at?: string;
|
||||||
|
updated_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MarketEntryDetail extends MarketEntry {
|
||||||
|
dependency_warnings: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MarketReview {
|
||||||
|
id: string;
|
||||||
|
user_id: string;
|
||||||
|
market_entry_id: string;
|
||||||
|
rating: number;
|
||||||
|
review_text?: string;
|
||||||
|
created_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listMarketEntries(params?: {
|
||||||
|
page?: number;
|
||||||
|
page_size?: number;
|
||||||
|
category?: string;
|
||||||
|
search?: string;
|
||||||
|
}) {
|
||||||
|
const { data } = await client.get<{ success: boolean; data: PaginatedResponse<MarketEntry> }>(
|
||||||
|
'/market/entries',
|
||||||
|
{ params },
|
||||||
|
);
|
||||||
|
return data.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMarketEntry(id: string) {
|
||||||
|
const { data } = await client.get<{ success: boolean; data: MarketEntryDetail }>(
|
||||||
|
`/market/entries/${id}`,
|
||||||
|
);
|
||||||
|
return data.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function installFromMarket(id: string) {
|
||||||
|
const { data } = await client.post<{ success: boolean; data: PluginInfo }>(
|
||||||
|
`/market/entries/${id}/install`,
|
||||||
|
);
|
||||||
|
return data.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listMarketReviews(id: string) {
|
||||||
|
const { data } = await client.get<{ success: boolean; data: MarketReview[] }>(
|
||||||
|
`/market/entries/${id}/reviews`,
|
||||||
|
);
|
||||||
|
return data.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function submitMarketReview(id: string, review: { rating: number; review_text?: string }) {
|
||||||
|
const { data } = await client.post<{ success: boolean; data: MarketReview }>(
|
||||||
|
`/market/entries/${id}/reviews`,
|
||||||
|
review,
|
||||||
|
);
|
||||||
|
return data.data;
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
Timeline,
|
Timeline,
|
||||||
Upload,
|
Upload,
|
||||||
Alert,
|
Alert,
|
||||||
|
Dropdown,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import {
|
import {
|
||||||
PlusOutlined,
|
PlusOutlined,
|
||||||
@@ -38,6 +39,7 @@ import {
|
|||||||
batchPluginData,
|
batchPluginData,
|
||||||
resolveRefLabels,
|
resolveRefLabels,
|
||||||
exportPluginData,
|
exportPluginData,
|
||||||
|
exportPluginDataAsBlob,
|
||||||
importPluginData,
|
importPluginData,
|
||||||
type PluginDataListOptions,
|
type PluginDataListOptions,
|
||||||
type ImportResult,
|
type ImportResult,
|
||||||
@@ -576,12 +578,18 @@ export default function PluginCRUDPage({
|
|||||||
刷新
|
刷新
|
||||||
</Button>
|
</Button>
|
||||||
{entityDef?.exportable && (
|
{entityDef?.exportable && (
|
||||||
<Button
|
<Dropdown
|
||||||
icon={<DownloadOutlined />}
|
menu={{
|
||||||
loading={exporting}
|
items: [
|
||||||
onClick={async () => {
|
{ key: 'json', label: 'JSON' },
|
||||||
|
{ key: 'csv', label: 'CSV' },
|
||||||
|
{ key: 'xlsx', label: 'Excel (.xlsx)' },
|
||||||
|
],
|
||||||
|
onClick: async ({ key }) => {
|
||||||
setExporting(true);
|
setExporting(true);
|
||||||
try {
|
try {
|
||||||
|
const ts = Date.now();
|
||||||
|
if (key === 'json') {
|
||||||
const rows = await exportPluginData(pluginId, entityName, {
|
const rows = await exportPluginData(pluginId, entityName, {
|
||||||
sort_by: sortBy,
|
sort_by: sortBy,
|
||||||
sort_order: sortOrder,
|
sort_order: sortOrder,
|
||||||
@@ -590,18 +598,34 @@ export default function PluginCRUDPage({
|
|||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
a.href = url;
|
a.href = url;
|
||||||
a.download = `${entityName}_export_${Date.now()}.json`;
|
a.download = `${entityName}_export_${ts}.json`;
|
||||||
a.click();
|
a.click();
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
message.success(`导出 ${rows.length} 条记录`);
|
message.success(`导出 ${rows.length} 条记录`);
|
||||||
|
} else {
|
||||||
|
const blob = await exportPluginDataAsBlob(
|
||||||
|
pluginId, entityName, key as 'csv' | 'xlsx',
|
||||||
|
{ sort_by: sortBy, sort_order: sortOrder },
|
||||||
|
);
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `${entityName}_export_${ts}.${key === 'csv' ? 'csv' : 'xlsx'}`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
message.success('导出成功');
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
message.error('导出失败');
|
message.error('导出失败');
|
||||||
}
|
}
|
||||||
setExporting(false);
|
setExporting(false);
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<Button icon={<DownloadOutlined />} loading={exporting}>
|
||||||
导出
|
导出
|
||||||
</Button>
|
</Button>
|
||||||
|
</Dropdown>
|
||||||
)}
|
)}
|
||||||
{entityDef?.importable && (
|
{entityDef?.importable && (
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ import {
|
|||||||
message,
|
message,
|
||||||
Empty,
|
Empty,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
|
Form,
|
||||||
|
Input as TextArea,
|
||||||
|
Alert,
|
||||||
|
Spin,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import {
|
import {
|
||||||
SearchOutlined,
|
SearchOutlined,
|
||||||
@@ -20,24 +24,18 @@ import {
|
|||||||
AppstoreOutlined,
|
AppstoreOutlined,
|
||||||
StarOutlined,
|
StarOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { listPlugins } from '../api/plugins';
|
import {
|
||||||
|
listMarketEntries,
|
||||||
|
installFromMarket,
|
||||||
|
listMarketReviews,
|
||||||
|
submitMarketReview,
|
||||||
|
listPlugins,
|
||||||
|
type MarketEntry,
|
||||||
|
type MarketReview,
|
||||||
|
} from '../api/plugins';
|
||||||
|
|
||||||
const { Title, Text, Paragraph } = Typography;
|
const { Title, Text, Paragraph } = Typography;
|
||||||
|
|
||||||
interface MarketPlugin {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
version: string;
|
|
||||||
description?: string;
|
|
||||||
author?: string;
|
|
||||||
category?: string;
|
|
||||||
tags?: string[];
|
|
||||||
rating_avg: number;
|
|
||||||
rating_count: number;
|
|
||||||
download_count: number;
|
|
||||||
status: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const CATEGORY_COLORS: Record<string, string> = {
|
const CATEGORY_COLORS: Record<string, string> = {
|
||||||
'财务': '#059669',
|
'财务': '#059669',
|
||||||
'CRM': '#2563EB',
|
'CRM': '#2563EB',
|
||||||
@@ -48,16 +46,22 @@ const CATEGORY_COLORS: Record<string, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function PluginMarket() {
|
export default function PluginMarket() {
|
||||||
const [plugins, setPlugins] = useState<MarketPlugin[]>([]);
|
const [plugins, setPlugins] = useState<MarketEntry[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
const [searchText, setSearchText] = useState('');
|
const [searchText, setSearchText] = useState('');
|
||||||
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
|
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
|
||||||
const [detailVisible, setDetailVisible] = useState(false);
|
const [detailVisible, setDetailVisible] = useState(false);
|
||||||
const [selectedPlugin, setSelectedPlugin] = useState<MarketPlugin | null>(null);
|
const [selectedPlugin, setSelectedPlugin] = useState<MarketEntry | null>(null);
|
||||||
const [installing, setInstalling] = useState<string | null>(null);
|
const [installing, setInstalling] = useState<string | null>(null);
|
||||||
|
|
||||||
// 当前已安装的插件列表(用于标识已安装状态)
|
// 当前已安装的插件列表(用于标识已安装状态)
|
||||||
const [installedIds, setInstalledIds] = useState<Set<string>>(new Set());
|
const [installedIds, setInstalledIds] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
// 评论区
|
||||||
|
const [reviews, setReviews] = useState<MarketReview[]>([]);
|
||||||
|
const [reviewForm] = Form.useForm();
|
||||||
|
const [submittingReview, setSubmittingReview] = useState(false);
|
||||||
|
|
||||||
const fetchInstalled = useCallback(async () => {
|
const fetchInstalled = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const result = await listPlugins(1);
|
const result = await listPlugins(1);
|
||||||
@@ -68,63 +72,70 @@ export default function PluginMarket() {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
const loadMarketPlugins = useCallback(async () => {
|
||||||
fetchInstalled();
|
setLoading(true);
|
||||||
// 市场插件目前从已安装列表模拟(后续对接远程市场 API)
|
|
||||||
loadMarketPlugins();
|
|
||||||
}, [fetchInstalled]);
|
|
||||||
|
|
||||||
const loadMarketPlugins = async () => {
|
|
||||||
// 当前阶段:从已安装插件列表构建
|
|
||||||
// TODO: 对接远程插件市场 API
|
|
||||||
try {
|
try {
|
||||||
const result = await listPlugins(1);
|
const result = await listMarketEntries({ search: searchText || undefined, category: selectedCategory || undefined });
|
||||||
const market: MarketPlugin[] = result.data.map((p) => ({
|
setPlugins(result.data);
|
||||||
id: p.id,
|
|
||||||
name: p.name,
|
|
||||||
version: p.version,
|
|
||||||
description: p.description,
|
|
||||||
author: p.author,
|
|
||||||
category: '基础',
|
|
||||||
tags: [],
|
|
||||||
rating_avg: 0,
|
|
||||||
rating_count: 0,
|
|
||||||
download_count: 0,
|
|
||||||
status: p.status,
|
|
||||||
}));
|
|
||||||
setPlugins(market);
|
|
||||||
} catch {
|
} catch {
|
||||||
message.error('加载插件市场失败');
|
message.error('加载插件市场失败');
|
||||||
}
|
}
|
||||||
};
|
setLoading(false);
|
||||||
|
}, [searchText, selectedCategory]);
|
||||||
|
|
||||||
const filteredPlugins = plugins.filter((p) => {
|
useEffect(() => {
|
||||||
const matchSearch =
|
fetchInstalled();
|
||||||
!searchText ||
|
}, [fetchInstalled]);
|
||||||
p.name.toLowerCase().includes(searchText.toLowerCase()) ||
|
|
||||||
(p.description ?? '').toLowerCase().includes(searchText.toLowerCase());
|
useEffect(() => {
|
||||||
const matchCategory = !selectedCategory || p.category === selectedCategory;
|
loadMarketPlugins();
|
||||||
return matchSearch && matchCategory;
|
}, [loadMarketPlugins]);
|
||||||
});
|
|
||||||
|
|
||||||
const categories = Array.from(new Set(plugins.map((p) => p.category).filter((c): c is string => Boolean(c))));
|
const categories = Array.from(new Set(plugins.map((p) => p.category).filter((c): c is string => Boolean(c))));
|
||||||
|
|
||||||
const showDetail = (plugin: MarketPlugin) => {
|
const showDetail = async (plugin: MarketEntry) => {
|
||||||
setSelectedPlugin(plugin);
|
setSelectedPlugin(plugin);
|
||||||
setDetailVisible(true);
|
setDetailVisible(true);
|
||||||
|
try {
|
||||||
|
const result = await listMarketReviews(plugin.id);
|
||||||
|
setReviews(result);
|
||||||
|
} catch {
|
||||||
|
setReviews([]);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleInstall = async (plugin: MarketPlugin) => {
|
const handleInstall = async (plugin: MarketEntry) => {
|
||||||
setInstalling(plugin.id);
|
setInstalling(plugin.id);
|
||||||
try {
|
try {
|
||||||
|
await installFromMarket(plugin.id);
|
||||||
message.success(`${plugin.name} 安装成功`);
|
message.success(`${plugin.name} 安装成功`);
|
||||||
fetchInstalled();
|
fetchInstalled();
|
||||||
} catch {
|
loadMarketPlugins();
|
||||||
message.error('安装失败');
|
} catch (e: unknown) {
|
||||||
|
const msg = e instanceof Error ? e.message : '安装失败';
|
||||||
|
message.error(msg);
|
||||||
}
|
}
|
||||||
setInstalling(null);
|
setInstalling(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSubmitReview = async () => {
|
||||||
|
if (!selectedPlugin) return;
|
||||||
|
try {
|
||||||
|
const values = await reviewForm.validateFields();
|
||||||
|
setSubmittingReview(true);
|
||||||
|
await submitMarketReview(selectedPlugin.id, values);
|
||||||
|
message.success('评分提交成功');
|
||||||
|
reviewForm.resetFields();
|
||||||
|
// 刷新评论和列表
|
||||||
|
const result = await listMarketReviews(selectedPlugin.id);
|
||||||
|
setReviews(result);
|
||||||
|
loadMarketPlugins();
|
||||||
|
} catch {
|
||||||
|
// 表单验证失败静默
|
||||||
|
}
|
||||||
|
setSubmittingReview(false);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: 24 }}>
|
<div style={{ padding: 24 }}>
|
||||||
<div style={{ marginBottom: 24 }}>
|
<div style={{ marginBottom: 24 }}>
|
||||||
@@ -164,11 +175,12 @@ export default function PluginMarket() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 插件卡片网格 */}
|
{/* 插件卡片网格 */}
|
||||||
{filteredPlugins.length === 0 ? (
|
<Spin spinning={loading}>
|
||||||
|
{plugins.length === 0 && !loading ? (
|
||||||
<Empty description="暂无可用插件" />
|
<Empty description="暂无可用插件" />
|
||||||
) : (
|
) : (
|
||||||
<Row gutter={[16, 16]}>
|
<Row gutter={[16, 16]}>
|
||||||
{filteredPlugins.map((plugin) => (
|
{plugins.map((plugin) => (
|
||||||
<Col xs={24} sm={12} md={8} lg={6} key={plugin.id}>
|
<Col xs={24} sm={12} md={8} lg={6} key={plugin.id}>
|
||||||
<Card
|
<Card
|
||||||
hoverable
|
hoverable
|
||||||
@@ -207,7 +219,7 @@ export default function PluginMarket() {
|
|||||||
</Space>
|
</Space>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
{installedIds.has(plugin.name) && (
|
{installedIds.has(plugin.plugin_id) && (
|
||||||
<Tag color="green" style={{ marginTop: 8 }}>已安装</Tag>
|
<Tag color="green" style={{ marginTop: 8 }}>已安装</Tag>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
@@ -215,14 +227,18 @@ export default function PluginMarket() {
|
|||||||
))}
|
))}
|
||||||
</Row>
|
</Row>
|
||||||
)}
|
)}
|
||||||
|
</Spin>
|
||||||
|
|
||||||
{/* 详情弹窗 */}
|
{/* 详情弹窗 */}
|
||||||
<Modal
|
<Modal
|
||||||
title={selectedPlugin?.name}
|
title={selectedPlugin?.name}
|
||||||
open={detailVisible}
|
open={detailVisible}
|
||||||
onCancel={() => setDetailVisible(false)}
|
onCancel={() => {
|
||||||
|
setDetailVisible(false);
|
||||||
|
reviewForm.resetFields();
|
||||||
|
}}
|
||||||
footer={null}
|
footer={null}
|
||||||
width={600}
|
width={640}
|
||||||
>
|
>
|
||||||
{selectedPlugin && (
|
{selectedPlugin && (
|
||||||
<div>
|
<div>
|
||||||
@@ -233,16 +249,21 @@ export default function PluginMarket() {
|
|||||||
</Tag>
|
</Tag>
|
||||||
<Text type="secondary">v{selectedPlugin.version}</Text>
|
<Text type="secondary">v{selectedPlugin.version}</Text>
|
||||||
<Text type="secondary">by {selectedPlugin.author ?? '未知'}</Text>
|
<Text type="secondary">by {selectedPlugin.author ?? '未知'}</Text>
|
||||||
|
<Text type="secondary">
|
||||||
|
<DownloadOutlined /> {selectedPlugin.download_count} 次下载
|
||||||
|
</Text>
|
||||||
</Space>
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
<Paragraph>{selectedPlugin.description ?? '暂无描述'}</Paragraph>
|
<Paragraph>{selectedPlugin.description ?? '暂无描述'}</Paragraph>
|
||||||
|
|
||||||
|
{selectedPlugin.changelog && (
|
||||||
<div style={{ marginBottom: 16 }}>
|
<div style={{ marginBottom: 16 }}>
|
||||||
<Rate disabled value={Math.round(selectedPlugin.rating_avg)} />
|
<Text strong>更新日志</Text>
|
||||||
<Text type="secondary" style={{ marginLeft: 8 }}>
|
<Paragraph type="secondary" style={{ whiteSpace: 'pre-wrap' }}>
|
||||||
{selectedPlugin.rating_count} 评分
|
{selectedPlugin.changelog}
|
||||||
</Text>
|
</Paragraph>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{selectedPlugin.tags && selectedPlugin.tags.length > 0 && (
|
{selectedPlugin.tags && selectedPlugin.tags.length > 0 && (
|
||||||
<div style={{ marginBottom: 16 }}>
|
<div style={{ marginBottom: 16 }}>
|
||||||
@@ -252,16 +273,69 @@ export default function PluginMarket() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<Rate disabled value={Math.round(selectedPlugin.rating_avg)} />
|
||||||
|
<Text type="secondary" style={{ marginLeft: 8 }}>
|
||||||
|
{selectedPlugin.rating_count} 评分
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
icon={<DownloadOutlined />}
|
icon={<DownloadOutlined />}
|
||||||
loading={installing === selectedPlugin.id}
|
loading={installing === selectedPlugin.id}
|
||||||
disabled={installedIds.has(selectedPlugin.name)}
|
disabled={installedIds.has(selectedPlugin.plugin_id)}
|
||||||
onClick={() => handleInstall(selectedPlugin)}
|
onClick={() => handleInstall(selectedPlugin)}
|
||||||
block
|
block
|
||||||
|
style={{ marginBottom: 24 }}
|
||||||
>
|
>
|
||||||
{installedIds.has(selectedPlugin.name) ? '已安装' : '安装'}
|
{installedIds.has(selectedPlugin.plugin_id) ? '已安装' : '一键安装'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
{/* 评论区 */}
|
||||||
|
<div style={{ borderTop: '1px solid #f0f0f0', paddingTop: 16 }}>
|
||||||
|
<Title level={5}>用户评价 ({reviews.length})</Title>
|
||||||
|
|
||||||
|
{reviews.length > 0 && (
|
||||||
|
<div style={{ marginBottom: 16, maxHeight: 200, overflowY: 'auto' }}>
|
||||||
|
{reviews.map((review) => (
|
||||||
|
<div key={review.id} style={{ marginBottom: 12, paddingBottom: 12, borderBottom: '1px solid #f5f5f5' }}>
|
||||||
|
<Space>
|
||||||
|
<Rate disabled value={review.rating} style={{ fontSize: 14 }} />
|
||||||
|
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
{review.created_at ? new Date(review.created_at).toLocaleDateString() : ''}
|
||||||
|
</Text>
|
||||||
|
</Space>
|
||||||
|
{review.review_text && (
|
||||||
|
<Paragraph style={{ marginTop: 4, marginBottom: 0 }} type="secondary">
|
||||||
|
{review.review_text}
|
||||||
|
</Paragraph>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{reviews.length === 0 && (
|
||||||
|
<Alert type="info" message="暂无评价" style={{ marginBottom: 16 }} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{installedIds.has(selectedPlugin.plugin_id) && (
|
||||||
|
<Form form={reviewForm} layout="vertical">
|
||||||
|
<Form.Item name="rating" label="评分" rules={[{ required: true, message: '请选择评分' }]}>
|
||||||
|
<Rate />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="review_text" label="评价内容">
|
||||||
|
<TextArea.TextArea rows={2} placeholder="写下你的使用体验..." />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item>
|
||||||
|
<Button type="primary" loading={submittingReview} onClick={handleSubmitReview}>
|
||||||
|
提交评价
|
||||||
|
</Button>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -25,3 +25,5 @@ sha2 = { workspace = true }
|
|||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
moka = { version = "0.12", features = ["sync"] }
|
moka = { version = "0.12", features = ["sync"] }
|
||||||
regex = "1"
|
regex = "1"
|
||||||
|
csv = { workspace = true }
|
||||||
|
rust_xlsxwriter = { workspace = true }
|
||||||
|
|||||||
@@ -178,10 +178,17 @@ pub struct ExportParams {
|
|||||||
pub sort_by: Option<String>,
|
pub sort_by: Option<String>,
|
||||||
/// "asc" or "desc"
|
/// "asc" or "desc"
|
||||||
pub sort_order: Option<String>,
|
pub sort_order: Option<String>,
|
||||||
/// 导出格式: "csv" (默认) | "json"
|
/// 导出格式: "json" (默认) | "csv" | "xlsx"
|
||||||
pub format: Option<String>,
|
pub format: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 导出结果 — 根据格式返回不同内容
|
||||||
|
pub enum ExportPayload {
|
||||||
|
Json(Vec<serde_json::Value>),
|
||||||
|
Csv(Vec<u8>),
|
||||||
|
Xlsx(Vec<u8>),
|
||||||
|
}
|
||||||
|
|
||||||
/// 数据导入请求
|
/// 数据导入请求
|
||||||
#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
|
#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
|
||||||
pub struct ImportReq {
|
pub struct ImportReq {
|
||||||
@@ -209,3 +216,92 @@ pub struct ImportRowError {
|
|||||||
/// 错误消息列表
|
/// 错误消息列表
|
||||||
pub errors: Vec<String>,
|
pub errors: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── 市场目录 DTO ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// 市场条目列表查询参数
|
||||||
|
#[derive(Debug, Serialize, Deserialize, utoipa::IntoParams)]
|
||||||
|
pub struct MarketListParams {
|
||||||
|
pub page: Option<u64>,
|
||||||
|
pub page_size: Option<u64>,
|
||||||
|
pub category: Option<String>,
|
||||||
|
pub search: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 市场条目响应(不含二进制数据)
|
||||||
|
#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
|
||||||
|
pub struct MarketEntryResp {
|
||||||
|
pub id: String,
|
||||||
|
pub plugin_id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub version: String,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub author: Option<String>,
|
||||||
|
pub category: Option<String>,
|
||||||
|
pub tags: Option<serde_json::Value>,
|
||||||
|
pub icon_url: Option<String>,
|
||||||
|
pub screenshots: Option<serde_json::Value>,
|
||||||
|
pub min_platform_version: Option<String>,
|
||||||
|
pub status: String,
|
||||||
|
pub download_count: i32,
|
||||||
|
pub rating_avg: f64,
|
||||||
|
pub rating_count: i32,
|
||||||
|
pub changelog: Option<String>,
|
||||||
|
pub created_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||||
|
pub updated_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 市场条目详情响应(含完整信息)
|
||||||
|
#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
|
||||||
|
pub struct MarketEntryDetailResp {
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub entry: MarketEntryResp,
|
||||||
|
/// 依赖提示(安装时检查 manifest.dependencies)
|
||||||
|
pub dependency_warnings: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 提交评分/评论请求
|
||||||
|
#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
|
||||||
|
pub struct SubmitReviewReq {
|
||||||
|
/// 评分 1-5
|
||||||
|
pub rating: i32,
|
||||||
|
/// 评论内容
|
||||||
|
pub review_text: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 评论响应
|
||||||
|
#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
|
||||||
|
pub struct MarketReviewResp {
|
||||||
|
pub id: String,
|
||||||
|
pub user_id: String,
|
||||||
|
pub market_entry_id: String,
|
||||||
|
pub rating: i32,
|
||||||
|
pub review_text: Option<String>,
|
||||||
|
pub created_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 对账扫描 DTO ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// 对账报告
|
||||||
|
#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
|
||||||
|
pub struct ReconciliationReport {
|
||||||
|
/// 有效引用数
|
||||||
|
pub valid_count: i64,
|
||||||
|
/// 悬空引用数
|
||||||
|
pub dangling_count: i64,
|
||||||
|
/// 悬空引用详情
|
||||||
|
pub details: Vec<DanglingRef>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 悬空引用详情
|
||||||
|
#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
|
||||||
|
pub struct DanglingRef {
|
||||||
|
/// 实体名
|
||||||
|
pub entity: String,
|
||||||
|
/// 字段名
|
||||||
|
pub field: String,
|
||||||
|
/// 记录 ID
|
||||||
|
pub record_id: String,
|
||||||
|
/// 悬空的 UUID 值
|
||||||
|
pub dangling_value: String,
|
||||||
|
}
|
||||||
|
|||||||
@@ -505,7 +505,7 @@ impl PluginDataService {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 导出数据(不分页,复用 list 的过滤逻辑)
|
/// 导出数据(支持 JSON/CSV/XLSX 格式)
|
||||||
pub async fn export(
|
pub async fn export(
|
||||||
plugin_id: Uuid,
|
plugin_id: Uuid,
|
||||||
entity_name: &str,
|
entity_name: &str,
|
||||||
@@ -515,13 +515,15 @@ impl PluginDataService {
|
|||||||
search: Option<String>,
|
search: Option<String>,
|
||||||
sort_by: Option<String>,
|
sort_by: Option<String>,
|
||||||
sort_order: Option<String>,
|
sort_order: Option<String>,
|
||||||
|
format: Option<String>,
|
||||||
cache: &moka::sync::Cache<String, EntityInfo>,
|
cache: &moka::sync::Cache<String, EntityInfo>,
|
||||||
scope: Option<DataScopeParams>,
|
scope: Option<DataScopeParams>,
|
||||||
) -> AppResult<Vec<serde_json::Value>> {
|
) -> AppResult<crate::data_dto::ExportPayload> {
|
||||||
|
use crate::data_dto::ExportPayload;
|
||||||
|
|
||||||
let info =
|
let info =
|
||||||
resolve_entity_info_cached(plugin_id, entity_name, tenant_id, db, cache).await?;
|
resolve_entity_info_cached(plugin_id, entity_name, tenant_id, db, cache).await?;
|
||||||
|
|
||||||
// 搜索字段
|
|
||||||
let entity_fields = info.fields()?;
|
let entity_fields = info.fields()?;
|
||||||
let search_tuple = {
|
let search_tuple = {
|
||||||
let searchable: Vec<&str> = entity_fields
|
let searchable: Vec<&str> = entity_fields
|
||||||
@@ -535,7 +537,6 @@ impl PluginDataService {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 查询所有匹配行(上限 10000)
|
|
||||||
let (sql, mut values) = DynamicTableManager::build_filtered_query_sql_ex(
|
let (sql, mut values) = DynamicTableManager::build_filtered_query_sql_ex(
|
||||||
&info.table_name,
|
&info.table_name,
|
||||||
tenant_id,
|
tenant_id,
|
||||||
@@ -549,7 +550,6 @@ impl PluginDataService {
|
|||||||
)
|
)
|
||||||
.map_err(|e| AppError::Validation(e))?;
|
.map_err(|e| AppError::Validation(e))?;
|
||||||
|
|
||||||
// 注入数据权限
|
|
||||||
let scope_condition = build_scope_sql(&scope, &info.generated_fields, values.len() + 1);
|
let scope_condition = build_scope_sql(&scope, &info.generated_fields, values.len() + 1);
|
||||||
let sql = merge_scope_condition(sql, &scope_condition);
|
let sql = merge_scope_condition(sql, &scope_condition);
|
||||||
values.extend(scope_condition.1);
|
values.extend(scope_condition.1);
|
||||||
@@ -565,7 +565,76 @@ impl PluginDataService {
|
|||||||
.all(db)
|
.all(db)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok(rows.into_iter().map(|r| r.data).collect())
|
let data: Vec<serde_json::Value> = rows.into_iter().map(|r| r.data).collect();
|
||||||
|
let fmt = format.as_deref().unwrap_or("json").to_lowercase();
|
||||||
|
|
||||||
|
match fmt.as_str() {
|
||||||
|
"csv" => Ok(ExportPayload::Csv(Self::to_csv(&data, &entity_fields)?)),
|
||||||
|
"xlsx" => Ok(ExportPayload::Xlsx(Self::to_xlsx(&data, &entity_fields)?)),
|
||||||
|
_ => Ok(ExportPayload::Json(data)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_csv(
|
||||||
|
rows: &[serde_json::Value],
|
||||||
|
fields: &[crate::manifest::PluginField],
|
||||||
|
) -> AppResult<Vec<u8>> {
|
||||||
|
let mut wtr = csv::Writer::from_writer(Vec::new());
|
||||||
|
let headers: Vec<&str> = fields.iter().map(|f| f.name.as_str()).collect();
|
||||||
|
wtr.write_record(&headers).map_err(|e| AppError::Internal(format!("CSV 写头失败: {}", e)))?;
|
||||||
|
|
||||||
|
for row in rows {
|
||||||
|
let record: Vec<String> = headers.iter().map(|h| {
|
||||||
|
row.get(*h).and_then(|v| match v {
|
||||||
|
serde_json::Value::String(s) => Some(s.clone()),
|
||||||
|
serde_json::Value::Number(n) => Some(n.to_string()),
|
||||||
|
serde_json::Value::Bool(b) => Some(b.to_string()),
|
||||||
|
serde_json::Value::Null => Some(String::new()),
|
||||||
|
other => Some(other.to_string()),
|
||||||
|
}).unwrap_or_default()
|
||||||
|
}).collect();
|
||||||
|
wtr.write_record(&record).map_err(|e| AppError::Internal(format!("CSV 写行失败: {}", e)))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
wtr.into_inner()
|
||||||
|
.map_err(|e| AppError::Internal(format!("CSV 刷新失败: {}", e)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_xlsx(
|
||||||
|
rows: &[serde_json::Value],
|
||||||
|
fields: &[crate::manifest::PluginField],
|
||||||
|
) -> AppResult<Vec<u8>> {
|
||||||
|
use rust_xlsxwriter::*;
|
||||||
|
|
||||||
|
let mut wb = Workbook::new();
|
||||||
|
let ws = wb.add_worksheet();
|
||||||
|
let header_fmt = Format::new().set_bold().set_background_color(Color::RGB(0x4F46E5)).set_font_color(Color::White);
|
||||||
|
|
||||||
|
for (col, field) in fields.iter().enumerate() {
|
||||||
|
let label = field.display_name.as_deref().unwrap_or(&field.name);
|
||||||
|
ws.write_string_with_format(0, col as u16, label, &header_fmt)
|
||||||
|
.map_err(|e| AppError::Internal(format!("XLSX 写头失败: {}", e)))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (row_idx, row) in rows.iter().enumerate() {
|
||||||
|
for (col, field) in fields.iter().enumerate() {
|
||||||
|
let val = row.get(&field.name);
|
||||||
|
let row_num = (row_idx + 1) as u32;
|
||||||
|
match val {
|
||||||
|
Some(serde_json::Value::String(s)) => { ws.write_string(row_num, col as u16, s).ok(); }
|
||||||
|
Some(serde_json::Value::Number(n)) => {
|
||||||
|
if let Some(f) = n.as_f64() { ws.write_number(row_num, col as u16, f).ok(); }
|
||||||
|
else { ws.write_string(row_num, col as u16, &n.to_string()).ok(); }
|
||||||
|
}
|
||||||
|
Some(serde_json::Value::Bool(b)) => { ws.write_string(row_num, col as u16, &b.to_string()).ok(); }
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let buf = wb.save_to_buffer()
|
||||||
|
.map_err(|e| AppError::Internal(format!("XLSX 保存失败: {}", e)))?;
|
||||||
|
Ok(buf.to_vec())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 批量导入数据(逐行校验 + 逐行插入)
|
/// 批量导入数据(逐行校验 + 逐行插入)
|
||||||
@@ -1037,6 +1106,120 @@ impl PluginDataService {
|
|||||||
})
|
})
|
||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 对账扫描: 检查指定插件所有实体的跨插件引用是否有悬空引用
|
||||||
|
pub async fn reconcile_references(
|
||||||
|
plugin_id: Uuid,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
) -> AppResult<crate::data_dto::ReconciliationReport> {
|
||||||
|
let manifest_id = resolve_manifest_id(plugin_id, tenant_id, db).await?;
|
||||||
|
|
||||||
|
// 获取该插件所有实体
|
||||||
|
let entities = plugin_entity::Entity::find()
|
||||||
|
.filter(plugin_entity::Column::PluginId.eq(plugin_id))
|
||||||
|
.filter(plugin_entity::Column::TenantId.eq(tenant_id))
|
||||||
|
.filter(plugin_entity::Column::DeletedAt.is_null())
|
||||||
|
.all(db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let mut valid_count: i64 = 0;
|
||||||
|
let mut dangling_count: i64 = 0;
|
||||||
|
let mut details = Vec::new();
|
||||||
|
|
||||||
|
for entity_rec in &entities {
|
||||||
|
let schema: crate::manifest::PluginEntity =
|
||||||
|
serde_json::from_value(entity_rec.schema_json.clone())
|
||||||
|
.map_err(|e| AppError::Internal(format!("解析 entity schema 失败: {}", e)))?;
|
||||||
|
|
||||||
|
// 找出所有有 ref_entity 的字段
|
||||||
|
let ref_fields: Vec<&PluginField> = schema.fields.iter()
|
||||||
|
.filter(|f| f.ref_entity.is_some())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if ref_fields.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let table_name = DynamicTableManager::table_name(&manifest_id, &entity_rec.entity_name);
|
||||||
|
|
||||||
|
for field in &ref_fields {
|
||||||
|
let col = sanitize_identifier(&field.name);
|
||||||
|
|
||||||
|
#[derive(FromQueryResult)]
|
||||||
|
struct RefRow {
|
||||||
|
id: Uuid,
|
||||||
|
// 动态列 — SeaORM 无法直接映射,用 JSON 构建
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查询所有有 ref 值的记录
|
||||||
|
let ref_sql = format!(
|
||||||
|
"SELECT id, {} as ref_val FROM {} WHERE tenant_id = $1 AND deleted_at IS NULL AND {} IS NOT NULL",
|
||||||
|
col, table_name, col,
|
||||||
|
);
|
||||||
|
|
||||||
|
#[derive(FromQueryResult)]
|
||||||
|
struct RefValRow {
|
||||||
|
id: Uuid,
|
||||||
|
ref_val: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
let rows = RefValRow::find_by_statement(Statement::from_sql_and_values(
|
||||||
|
sea_orm::DatabaseBackend::Postgres,
|
||||||
|
ref_sql,
|
||||||
|
[tenant_id.into()],
|
||||||
|
))
|
||||||
|
.all(db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
for row in rows {
|
||||||
|
// 验证 ref_val 是有效的 UUID 且目标记录存在
|
||||||
|
let Ok(target_uuid) = Uuid::parse_str(&row.ref_val) else { continue };
|
||||||
|
|
||||||
|
let ref_entity_name = field.ref_entity.as_deref().unwrap_or("");
|
||||||
|
let ref_plugin = field.ref_plugin.as_deref().unwrap_or(&manifest_id);
|
||||||
|
let target_table = DynamicTableManager::table_name(ref_plugin, ref_entity_name);
|
||||||
|
|
||||||
|
let check_sql = format!(
|
||||||
|
"SELECT COUNT(*) as cnt FROM {} WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL",
|
||||||
|
target_table,
|
||||||
|
);
|
||||||
|
|
||||||
|
#[derive(FromQueryResult)]
|
||||||
|
struct CountRow {
|
||||||
|
cnt: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
let count_row = CountRow::find_by_statement(Statement::from_sql_and_values(
|
||||||
|
sea_orm::DatabaseBackend::Postgres,
|
||||||
|
check_sql,
|
||||||
|
[target_uuid.into(), tenant_id.into()],
|
||||||
|
))
|
||||||
|
.one(db)
|
||||||
|
.await?
|
||||||
|
.unwrap_or(CountRow { cnt: 0 });
|
||||||
|
|
||||||
|
if count_row.cnt > 0 {
|
||||||
|
valid_count += 1;
|
||||||
|
} else {
|
||||||
|
dangling_count += 1;
|
||||||
|
details.push(crate::data_dto::DanglingRef {
|
||||||
|
entity: entity_rec.entity_name.clone(),
|
||||||
|
field: field.name.clone(),
|
||||||
|
record_id: row.id.to_string(),
|
||||||
|
dangling_value: row.ref_val,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(crate::data_dto::ReconciliationReport {
|
||||||
|
valid_count,
|
||||||
|
dangling_count,
|
||||||
|
details,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 从 plugins 表解析 manifest metadata.id(如 "erp-crm")
|
/// 从 plugins 表解析 manifest metadata.id(如 "erp-crm")
|
||||||
|
|||||||
45
crates/erp-plugin/src/entity/market_entry.rs
Normal file
45
crates/erp-plugin/src/entity/market_entry.rs
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||||
|
#[sea_orm(table_name = "plugin_market_entries")]
|
||||||
|
pub struct Model {
|
||||||
|
#[sea_orm(primary_key, auto_increment = false)]
|
||||||
|
pub id: Uuid,
|
||||||
|
pub plugin_id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub version: String,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub author: Option<String>,
|
||||||
|
pub category: Option<String>,
|
||||||
|
pub tags: Option<serde_json::Value>,
|
||||||
|
pub icon_url: Option<String>,
|
||||||
|
pub screenshots: Option<serde_json::Value>,
|
||||||
|
#[serde(skip)]
|
||||||
|
pub wasm_binary: Vec<u8>,
|
||||||
|
#[serde(skip_serializing)]
|
||||||
|
pub manifest_toml: String,
|
||||||
|
pub wasm_hash: String,
|
||||||
|
pub min_platform_version: Option<String>,
|
||||||
|
pub status: String,
|
||||||
|
pub download_count: i32,
|
||||||
|
pub rating_avg: Decimal,
|
||||||
|
pub rating_count: i32,
|
||||||
|
pub changelog: Option<String>,
|
||||||
|
pub created_at: DateTimeUtc,
|
||||||
|
pub updated_at: DateTimeUtc,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {
|
||||||
|
#[sea_orm(has_many = "super::market_review::Entity")]
|
||||||
|
MarketReview,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Related<super::market_review::Entity> for Entity {
|
||||||
|
fn to() -> RelationDef {
|
||||||
|
Relation::MarketReview.def()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActiveModelBehavior for ActiveModel {}
|
||||||
33
crates/erp-plugin/src/entity/market_review.rs
Normal file
33
crates/erp-plugin/src/entity/market_review.rs
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||||
|
#[sea_orm(table_name = "plugin_market_reviews")]
|
||||||
|
pub struct Model {
|
||||||
|
#[sea_orm(primary_key, auto_increment = false)]
|
||||||
|
pub id: Uuid,
|
||||||
|
pub tenant_id: Uuid,
|
||||||
|
pub user_id: Uuid,
|
||||||
|
pub market_entry_id: Uuid,
|
||||||
|
pub rating: i32,
|
||||||
|
pub review_text: Option<String>,
|
||||||
|
pub created_at: DateTimeUtc,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {
|
||||||
|
#[sea_orm(
|
||||||
|
belongs_to = "super::market_entry::Entity",
|
||||||
|
from = "Column::MarketEntryId",
|
||||||
|
to = "super::market_entry::Column::Id"
|
||||||
|
)]
|
||||||
|
MarketEntry,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Related<super::market_entry::Entity> for Entity {
|
||||||
|
fn to() -> RelationDef {
|
||||||
|
Relation::MarketEntry.def()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActiveModelBehavior for ActiveModel {}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
pub mod market_entry;
|
||||||
|
pub mod market_review;
|
||||||
pub mod plugin;
|
pub mod plugin;
|
||||||
pub mod plugin_entity;
|
pub mod plugin_entity;
|
||||||
pub mod plugin_event_subscription;
|
pub mod plugin_event_subscription;
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ use crate::data_dto::{
|
|||||||
AggregateItem, AggregateMultiReq, AggregateMultiRow, AggregateQueryParams, BatchActionReq,
|
AggregateItem, AggregateMultiReq, AggregateMultiRow, AggregateQueryParams, BatchActionReq,
|
||||||
CountQueryParams, CreatePluginDataReq, ExportParams, ImportReq, ImportResult,
|
CountQueryParams, CreatePluginDataReq, ExportParams, ImportReq, ImportResult,
|
||||||
PatchPluginDataReq, PluginDataListParams,
|
PatchPluginDataReq, PluginDataListParams,
|
||||||
PluginDataResp, PublicEntityResp, ResolveLabelsReq, ResolveLabelsResp,
|
PluginDataResp, PublicEntityResp, ReconciliationReport, ResolveLabelsReq, ResolveLabelsResp,
|
||||||
TimeseriesItem, TimeseriesParams, UpdatePluginDataReq,
|
TimeseriesItem, TimeseriesParams, UpdatePluginDataReq,
|
||||||
};
|
};
|
||||||
use crate::data_service::{DataScopeParams, PluginDataService, resolve_manifest_id};
|
use crate::data_service::{DataScopeParams, PluginDataService, resolve_manifest_id};
|
||||||
@@ -794,17 +794,21 @@ where
|
|||||||
security(("bearer_auth" = [])),
|
security(("bearer_auth" = [])),
|
||||||
tag = "插件数据"
|
tag = "插件数据"
|
||||||
)]
|
)]
|
||||||
/// GET /api/v1/plugins/{plugin_id}/{entity}/export — 导出数据
|
/// GET /api/v1/plugins/{plugin_id}/{entity}/export — 导出数据 (JSON/CSV/XLSX)
|
||||||
pub async fn export_plugin_data<S>(
|
pub async fn export_plugin_data<S>(
|
||||||
State(state): State<PluginState>,
|
State(state): State<PluginState>,
|
||||||
Extension(ctx): Extension<TenantContext>,
|
Extension(ctx): Extension<TenantContext>,
|
||||||
Path((plugin_id, entity)): Path<(Uuid, String)>,
|
Path((plugin_id, entity)): Path<(Uuid, String)>,
|
||||||
Query(params): Query<ExportParams>,
|
Query(params): Query<ExportParams>,
|
||||||
) -> Result<Json<ApiResponse<Vec<serde_json::Value>>>, AppError>
|
) -> Result<axum::response::Response, AppError>
|
||||||
where
|
where
|
||||||
PluginState: FromRef<S>,
|
PluginState: FromRef<S>,
|
||||||
S: Clone + Send + Sync + 'static,
|
S: Clone + Send + Sync + 'static,
|
||||||
{
|
{
|
||||||
|
use crate::data_dto::ExportPayload;
|
||||||
|
use axum::http::{header, StatusCode};
|
||||||
|
use axum::body::Body;
|
||||||
|
|
||||||
let manifest_id = resolve_manifest_id(plugin_id, ctx.tenant_id, &state.db).await?;
|
let manifest_id = resolve_manifest_id(plugin_id, ctx.tenant_id, &state.db).await?;
|
||||||
let fine_perm = compute_permission_code(&manifest_id, &entity, "list");
|
let fine_perm = compute_permission_code(&manifest_id, &entity, "list");
|
||||||
require_permission(&ctx, &fine_perm)?;
|
require_permission(&ctx, &fine_perm)?;
|
||||||
@@ -818,7 +822,7 @@ where
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|f| serde_json::from_str(f).ok());
|
.and_then(|f| serde_json::from_str(f).ok());
|
||||||
|
|
||||||
let rows = PluginDataService::export(
|
let payload = PluginDataService::export(
|
||||||
plugin_id,
|
plugin_id,
|
||||||
&entity,
|
&entity,
|
||||||
ctx.tenant_id,
|
ctx.tenant_id,
|
||||||
@@ -827,12 +831,40 @@ where
|
|||||||
params.search,
|
params.search,
|
||||||
params.sort_by,
|
params.sort_by,
|
||||||
params.sort_order,
|
params.sort_order,
|
||||||
|
params.format,
|
||||||
&state.entity_cache,
|
&state.entity_cache,
|
||||||
scope,
|
scope,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok(Json(ApiResponse::ok(rows)))
|
let filename = format!("{}_export_{}", entity, chrono::Utc::now().format("%Y%m%d%H%M%S"));
|
||||||
|
match payload {
|
||||||
|
ExportPayload::Json(data) => {
|
||||||
|
let body = serde_json::to_string(&ApiResponse::ok(data))
|
||||||
|
.map_err(|e| AppError::Internal(e.to_string()))?;
|
||||||
|
Ok(axum::response::Response::builder()
|
||||||
|
.status(StatusCode::OK)
|
||||||
|
.header(header::CONTENT_TYPE, "application/json")
|
||||||
|
.body(Body::from(body))
|
||||||
|
.unwrap())
|
||||||
|
}
|
||||||
|
ExportPayload::Csv(bytes) => {
|
||||||
|
Ok(axum::response::Response::builder()
|
||||||
|
.status(StatusCode::OK)
|
||||||
|
.header(header::CONTENT_TYPE, "text/csv; charset=utf-8")
|
||||||
|
.header(header::CONTENT_DISPOSITION, format!("attachment; filename=\"{}.csv\"", filename))
|
||||||
|
.body(Body::from(bytes))
|
||||||
|
.unwrap())
|
||||||
|
}
|
||||||
|
ExportPayload::Xlsx(bytes) => {
|
||||||
|
Ok(axum::response::Response::builder()
|
||||||
|
.status(StatusCode::OK)
|
||||||
|
.header(header::CONTENT_TYPE, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
|
||||||
|
.header(header::CONTENT_DISPOSITION, format!("attachment; filename=\"{}.xlsx\"", filename))
|
||||||
|
.body(Body::from(bytes))
|
||||||
|
.unwrap())
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
@@ -873,3 +905,33 @@ where
|
|||||||
|
|
||||||
Ok(Json(ApiResponse::ok(result)))
|
Ok(Json(ApiResponse::ok(result)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// POST /api/v1/plugins/{plugin_id}/reconcile — 对账扫描
|
||||||
|
#[utoipa::path(
|
||||||
|
post,
|
||||||
|
path = "/api/v1/plugins/{plugin_id}/reconcile",
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "对账报告", body = ApiResponse<ReconciliationReport>)
|
||||||
|
),
|
||||||
|
tag = "Plugin Data",
|
||||||
|
)]
|
||||||
|
pub async fn reconcile_refs<S>(
|
||||||
|
State(state): State<PluginState>,
|
||||||
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
Path(plugin_id): Path<Uuid>,
|
||||||
|
) -> Result<Json<ApiResponse<crate::data_dto::ReconciliationReport>>, AppError>
|
||||||
|
where
|
||||||
|
PluginState: FromRef<S>,
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
require_permission(&ctx, "plugin.admin")?;
|
||||||
|
|
||||||
|
let report = PluginDataService::reconcile_references(
|
||||||
|
plugin_id,
|
||||||
|
ctx.tenant_id,
|
||||||
|
&state.db,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json(ApiResponse::ok(report)))
|
||||||
|
}
|
||||||
|
|||||||
369
crates/erp-plugin/src/handler/market_handler.rs
Normal file
369
crates/erp-plugin/src/handler/market_handler.rs
Normal file
@@ -0,0 +1,369 @@
|
|||||||
|
use axum::extract::{FromRef, Path, Query, State};
|
||||||
|
use axum::response::Json;
|
||||||
|
use axum::Extension;
|
||||||
|
use chrono::Utc;
|
||||||
|
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, Set, prelude::Decimal};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use erp_core::error::AppError;
|
||||||
|
use erp_core::rbac::require_permission;
|
||||||
|
use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext};
|
||||||
|
|
||||||
|
use crate::data_dto::{
|
||||||
|
MarketEntryDetailResp, MarketEntryResp, MarketListParams, MarketReviewResp, SubmitReviewReq,
|
||||||
|
};
|
||||||
|
use crate::entity::{market_entry, market_review, plugin};
|
||||||
|
use crate::state::PluginState;
|
||||||
|
|
||||||
|
fn entry_to_resp(model: &market_entry::Model) -> MarketEntryResp {
|
||||||
|
MarketEntryResp {
|
||||||
|
id: model.id.to_string(),
|
||||||
|
plugin_id: model.plugin_id.clone(),
|
||||||
|
name: model.name.clone(),
|
||||||
|
version: model.version.clone(),
|
||||||
|
description: model.description.clone(),
|
||||||
|
author: model.author.clone(),
|
||||||
|
category: model.category.clone(),
|
||||||
|
tags: model.tags.clone(),
|
||||||
|
icon_url: model.icon_url.clone(),
|
||||||
|
screenshots: model.screenshots.clone(),
|
||||||
|
min_platform_version: model.min_platform_version.clone(),
|
||||||
|
status: model.status.clone(),
|
||||||
|
download_count: model.download_count,
|
||||||
|
rating_avg: model.rating_avg.to_string().parse().unwrap_or(0.0),
|
||||||
|
rating_count: model.rating_count,
|
||||||
|
changelog: model.changelog.clone(),
|
||||||
|
created_at: Some(model.created_at),
|
||||||
|
updated_at: Some(model.updated_at),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/api/v1/market/entries",
|
||||||
|
params(MarketListParams),
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "市场条目列表", body = ApiResponse<PaginatedResponse<MarketEntryResp>>)
|
||||||
|
),
|
||||||
|
tag = "Plugin Market",
|
||||||
|
)]
|
||||||
|
pub async fn list_market_entries<S>(
|
||||||
|
State(_state): State<S>,
|
||||||
|
Query(params): Query<MarketListParams>,
|
||||||
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
) -> Result<Json<ApiResponse<PaginatedResponse<MarketEntryResp>>>, AppError>
|
||||||
|
where
|
||||||
|
PluginState: FromRef<S>,
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
require_permission(&ctx, "plugin.admin")?;
|
||||||
|
|
||||||
|
let state: PluginState = PluginState::from_ref(&_state);
|
||||||
|
let db = &state.db;
|
||||||
|
|
||||||
|
let page = params.page.unwrap_or(1);
|
||||||
|
let page_size = params.page_size.unwrap_or(20).min(100);
|
||||||
|
|
||||||
|
let mut query = market_entry::Entity::find()
|
||||||
|
.filter(market_entry::Column::Status.eq("published"));
|
||||||
|
|
||||||
|
if let Some(ref category) = params.category {
|
||||||
|
query = query.filter(market_entry::Column::Category.eq(category.as_str()));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(ref search) = params.search {
|
||||||
|
query = query.filter(
|
||||||
|
sea_orm::Condition::any()
|
||||||
|
.add(market_entry::Column::Name.contains(search.as_str()))
|
||||||
|
.add(market_entry::Column::Description.contains(search.as_str()))
|
||||||
|
.add(market_entry::Column::Author.contains(search.as_str())),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
query = query.order_by_desc(market_entry::Column::DownloadCount);
|
||||||
|
|
||||||
|
let total = query.clone().count(db).await.map_err(|e| AppError::Internal(e.to_string()))?;
|
||||||
|
let total_pages = ((total as f64) / (page_size as f64)).ceil() as u64;
|
||||||
|
|
||||||
|
let models = query
|
||||||
|
.paginate(db, page_size)
|
||||||
|
.fetch_page(page.saturating_sub(1))
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::Internal(e.to_string()))?;
|
||||||
|
|
||||||
|
let items = models.iter().map(entry_to_resp).collect();
|
||||||
|
|
||||||
|
Ok(Json(ApiResponse::ok(PaginatedResponse {
|
||||||
|
data: items,
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
page_size,
|
||||||
|
total_pages,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/api/v1/market/entries/{id}",
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "市场条目详情", body = ApiResponse<MarketEntryDetailResp>)
|
||||||
|
),
|
||||||
|
tag = "Plugin Market",
|
||||||
|
)]
|
||||||
|
pub async fn get_market_entry<S>(
|
||||||
|
State(_state): State<S>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
) -> Result<Json<ApiResponse<MarketEntryDetailResp>>, AppError>
|
||||||
|
where
|
||||||
|
PluginState: FromRef<S>,
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
require_permission(&ctx, "plugin.admin")?;
|
||||||
|
|
||||||
|
let state: PluginState = PluginState::from_ref(&_state);
|
||||||
|
let db = &state.db;
|
||||||
|
|
||||||
|
let model = market_entry::Entity::find_by_id(id)
|
||||||
|
.one(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::Internal(e.to_string()))?
|
||||||
|
.ok_or_else(|| AppError::NotFound("市场条目不存在".to_string()))?;
|
||||||
|
|
||||||
|
// 解析 manifest 检查依赖
|
||||||
|
let mut dependency_warnings = Vec::new();
|
||||||
|
if let Ok(manifest) = crate::manifest::parse_manifest(&model.manifest_toml) {
|
||||||
|
for dep_id in &manifest.metadata.dependencies {
|
||||||
|
let installed = plugin::Entity::find()
|
||||||
|
.filter(plugin::Column::Name.eq(dep_id.as_str()))
|
||||||
|
.one(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::Internal(e.to_string()))?;
|
||||||
|
if installed.is_none() {
|
||||||
|
dependency_warnings.push(format!("依赖插件 '{}' 尚未安装", dep_id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Json(ApiResponse::ok(MarketEntryDetailResp {
|
||||||
|
entry: entry_to_resp(&model),
|
||||||
|
dependency_warnings,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
post,
|
||||||
|
path = "/api/v1/market/entries/{id}/install",
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "从市场安装插件", body = ApiResponse<crate::dto::PluginResp>)
|
||||||
|
),
|
||||||
|
tag = "Plugin Market",
|
||||||
|
)]
|
||||||
|
pub async fn install_from_market<S>(
|
||||||
|
State(_state): State<S>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
) -> Result<Json<ApiResponse<crate::dto::PluginResp>>, AppError>
|
||||||
|
where
|
||||||
|
PluginState: FromRef<S>,
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
require_permission(&ctx, "plugin.admin")?;
|
||||||
|
|
||||||
|
let state: PluginState = PluginState::from_ref(&_state);
|
||||||
|
let db = &state.db;
|
||||||
|
let engine = &state.engine;
|
||||||
|
|
||||||
|
// 获取市场条目
|
||||||
|
let market_model = market_entry::Entity::find_by_id(id)
|
||||||
|
.one(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::Internal(e.to_string()))?
|
||||||
|
.ok_or_else(|| AppError::NotFound("市场条目不存在".to_string()))?;
|
||||||
|
|
||||||
|
if market_model.status != "published" {
|
||||||
|
return Err(AppError::Validation("该插件已下架,无法安装".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否已安装同 plugin_id 的插件
|
||||||
|
let existing = plugin::Entity::find()
|
||||||
|
.filter(plugin::Column::Name.eq(market_model.plugin_id.as_str()))
|
||||||
|
.filter(plugin::Column::TenantId.eq(ctx.tenant_id))
|
||||||
|
.one(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::Internal(e.to_string()))?;
|
||||||
|
|
||||||
|
if existing.is_some() {
|
||||||
|
return Err(AppError::Validation("该插件已安装,如需更新请使用升级功能".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// upload → install → enable 一条龙
|
||||||
|
let wasm_binary = market_model.wasm_binary.clone();
|
||||||
|
let manifest_toml = market_model.manifest_toml.clone();
|
||||||
|
|
||||||
|
let plugin_resp = crate::service::PluginService::upload(
|
||||||
|
ctx.tenant_id,
|
||||||
|
ctx.user_id,
|
||||||
|
wasm_binary,
|
||||||
|
&manifest_toml,
|
||||||
|
db,
|
||||||
|
).await?;
|
||||||
|
|
||||||
|
let plugin_id = plugin_resp.id;
|
||||||
|
let plugin_resp = crate::service::PluginService::install(
|
||||||
|
plugin_id,
|
||||||
|
ctx.tenant_id,
|
||||||
|
ctx.user_id,
|
||||||
|
db,
|
||||||
|
engine,
|
||||||
|
).await?;
|
||||||
|
|
||||||
|
let plugin_resp = crate::service::PluginService::enable(
|
||||||
|
plugin_id,
|
||||||
|
ctx.tenant_id,
|
||||||
|
ctx.user_id,
|
||||||
|
db,
|
||||||
|
engine,
|
||||||
|
).await?;
|
||||||
|
|
||||||
|
// 递增下载计数
|
||||||
|
let mut active: market_entry::ActiveModel = market_model.into();
|
||||||
|
let current = active.download_count.take().unwrap_or(0);
|
||||||
|
active.download_count = Set(current + 1);
|
||||||
|
let _ = active.update(db).await.map_err(|e| AppError::Internal(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(Json(ApiResponse::ok(plugin_resp)))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/api/v1/market/entries/{id}/reviews",
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "评论列表", body = ApiResponse<Vec<MarketReviewResp>>)
|
||||||
|
),
|
||||||
|
tag = "Plugin Market",
|
||||||
|
)]
|
||||||
|
pub async fn list_market_reviews<S>(
|
||||||
|
State(_state): State<S>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
) -> Result<Json<ApiResponse<Vec<MarketReviewResp>>>, AppError>
|
||||||
|
where
|
||||||
|
PluginState: FromRef<S>,
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
require_permission(&ctx, "plugin.admin")?;
|
||||||
|
|
||||||
|
let state: PluginState = PluginState::from_ref(&_state);
|
||||||
|
let db = &state.db;
|
||||||
|
|
||||||
|
let reviews = market_review::Entity::find()
|
||||||
|
.filter(market_review::Column::MarketEntryId.eq(id))
|
||||||
|
.all(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::Internal(e.to_string()))?;
|
||||||
|
|
||||||
|
let items = reviews.iter().map(|r| MarketReviewResp {
|
||||||
|
id: r.id.to_string(),
|
||||||
|
user_id: r.user_id.to_string(),
|
||||||
|
market_entry_id: r.market_entry_id.to_string(),
|
||||||
|
rating: r.rating,
|
||||||
|
review_text: r.review_text.clone(),
|
||||||
|
created_at: Some(r.created_at),
|
||||||
|
}).collect();
|
||||||
|
|
||||||
|
Ok(Json(ApiResponse::ok(items)))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
post,
|
||||||
|
path = "/api/v1/market/entries/{id}/reviews",
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "提交评分/评论", body = ApiResponse<MarketReviewResp>)
|
||||||
|
),
|
||||||
|
tag = "Plugin Market",
|
||||||
|
)]
|
||||||
|
pub async fn submit_market_review<S>(
|
||||||
|
State(_state): State<S>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
Json(body): Json<SubmitReviewReq>,
|
||||||
|
) -> Result<Json<ApiResponse<MarketReviewResp>>, AppError>
|
||||||
|
where
|
||||||
|
PluginState: FromRef<S>,
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
require_permission(&ctx, "plugin.admin")?;
|
||||||
|
|
||||||
|
if body.rating < 1 || body.rating > 5 {
|
||||||
|
return Err(AppError::Validation("评分必须在 1-5 之间".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let state: PluginState = PluginState::from_ref(&_state);
|
||||||
|
let db = &state.db;
|
||||||
|
|
||||||
|
// 验证市场条目存在
|
||||||
|
let entry_model = market_entry::Entity::find_by_id(id)
|
||||||
|
.one(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::Internal(e.to_string()))?
|
||||||
|
.ok_or_else(|| AppError::NotFound("市场条目不存在".to_string()))?;
|
||||||
|
|
||||||
|
// upsert: 同一用户同一条目只保留最新评论
|
||||||
|
let existing = market_review::Entity::find()
|
||||||
|
.filter(market_review::Column::MarketEntryId.eq(id))
|
||||||
|
.filter(market_review::Column::UserId.eq(ctx.user_id))
|
||||||
|
.filter(market_review::Column::TenantId.eq(ctx.tenant_id))
|
||||||
|
.one(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::Internal(e.to_string()))?;
|
||||||
|
|
||||||
|
let review_model = if let Some(existing) = existing {
|
||||||
|
let mut active: market_review::ActiveModel = existing.into();
|
||||||
|
active.rating = Set(body.rating);
|
||||||
|
active.review_text = Set(body.review_text);
|
||||||
|
active.update(db).await.map_err(|e| AppError::Internal(e.to_string()))?
|
||||||
|
} else {
|
||||||
|
let review_id = Uuid::now_v7();
|
||||||
|
let now = Utc::now();
|
||||||
|
let model = market_review::ActiveModel {
|
||||||
|
id: Set(review_id),
|
||||||
|
tenant_id: Set(ctx.tenant_id),
|
||||||
|
user_id: Set(ctx.user_id),
|
||||||
|
market_entry_id: Set(id),
|
||||||
|
rating: Set(body.rating),
|
||||||
|
review_text: Set(body.review_text),
|
||||||
|
created_at: Set(now),
|
||||||
|
};
|
||||||
|
model.insert(db).await.map_err(|e| AppError::Internal(e.to_string()))?
|
||||||
|
};
|
||||||
|
|
||||||
|
// 重新计算平均评分
|
||||||
|
let all_reviews = market_review::Entity::find()
|
||||||
|
.filter(market_review::Column::MarketEntryId.eq(id))
|
||||||
|
.all(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::Internal(e.to_string()))?;
|
||||||
|
|
||||||
|
let count = all_reviews.len() as i32;
|
||||||
|
let avg: f64 = if count > 0 {
|
||||||
|
all_reviews.iter().map(|r| r.rating as f64).sum::<f64>() / count as f64
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut entry_active: market_entry::ActiveModel = entry_model.into();
|
||||||
|
let avg_decimal = Decimal::from_f64_retain(avg).unwrap_or_default();
|
||||||
|
entry_active.rating_avg = Set(avg_decimal);
|
||||||
|
entry_active.rating_count = Set(count);
|
||||||
|
let _ = entry_active.update(db).await.map_err(|e| AppError::Internal(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(Json(ApiResponse::ok(MarketReviewResp {
|
||||||
|
id: review_model.id.to_string(),
|
||||||
|
user_id: review_model.user_id.to_string(),
|
||||||
|
market_entry_id: review_model.market_entry_id.to_string(),
|
||||||
|
rating: review_model.rating,
|
||||||
|
review_text: review_model.review_text,
|
||||||
|
created_at: Some(review_model.created_at),
|
||||||
|
})))
|
||||||
|
}
|
||||||
@@ -1,2 +1,3 @@
|
|||||||
pub mod data_handler;
|
pub mod data_handler;
|
||||||
|
pub mod market_handler;
|
||||||
pub mod plugin_handler;
|
pub mod plugin_handler;
|
||||||
|
|||||||
@@ -122,6 +122,11 @@ impl PluginModule {
|
|||||||
.route(
|
.route(
|
||||||
"/plugins/{plugin_id}/{entity}/import",
|
"/plugins/{plugin_id}/{entity}/import",
|
||||||
post(crate::handler::data_handler::import_plugin_data::<S>),
|
post(crate::handler::data_handler::import_plugin_data::<S>),
|
||||||
|
)
|
||||||
|
// 对账扫描
|
||||||
|
.route(
|
||||||
|
"/plugins/{plugin_id}/reconcile",
|
||||||
|
post(crate::handler::data_handler::reconcile_refs::<S>),
|
||||||
);
|
);
|
||||||
|
|
||||||
// 实体注册表路由
|
// 实体注册表路由
|
||||||
@@ -131,6 +136,26 @@ impl PluginModule {
|
|||||||
get(crate::handler::data_handler::list_public_entities::<S>),
|
get(crate::handler::data_handler::list_public_entities::<S>),
|
||||||
);
|
);
|
||||||
|
|
||||||
admin_routes.merge(data_routes).merge(registry_routes)
|
// 市场路由
|
||||||
|
let market_routes = Router::new()
|
||||||
|
.route(
|
||||||
|
"/market/entries",
|
||||||
|
get(crate::handler::market_handler::list_market_entries::<S>),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/market/entries/{id}",
|
||||||
|
get(crate::handler::market_handler::get_market_entry::<S>),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/market/entries/{id}/install",
|
||||||
|
post(crate::handler::market_handler::install_from_market::<S>),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/market/entries/{id}/reviews",
|
||||||
|
get(crate::handler::market_handler::list_market_reviews::<S>)
|
||||||
|
.post(crate::handler::market_handler::submit_market_review::<S>),
|
||||||
|
);
|
||||||
|
|
||||||
|
admin_routes.merge(data_routes).merge(registry_routes).merge(market_routes)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user