初始化
This commit is contained in:
+21
@@ -0,0 +1,21 @@
|
|||||||
|
# 环境变量和本地敏感配置
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
# 前端依赖
|
||||||
|
node_modules/
|
||||||
|
# 前端构建产物
|
||||||
|
ADMIN/dist/
|
||||||
|
WEB/dist/
|
||||||
|
# 后端编译产物和运行时模型
|
||||||
|
/API/bin/
|
||||||
|
/API/models/
|
||||||
|
# 本地工具二进制
|
||||||
|
/API/tools/ffmpeg/bin/
|
||||||
|
*.exe
|
||||||
|
# 测试、覆盖率和日志产物
|
||||||
|
coverage/
|
||||||
|
coverage.out
|
||||||
|
*.log
|
||||||
|
# 不应上传的文本配置、说明和本地文档
|
||||||
|
*.txt
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/prettierrc",
|
||||||
|
"printWidth": 120,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"useTabs": false,
|
||||||
|
"semi": true,
|
||||||
|
"singleQuote": false,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"bracketSameLine": false,
|
||||||
|
"singleAttributePerLine": true
|
||||||
|
}
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<link rel="icon" type="image/png" href="/assets/logo.png?v=1" />
|
||||||
|
<title>剧游AI-后台管理</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+2912
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"name": "juhe-factory-admin",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vue-tsc --noEmit && vite build",
|
||||||
|
"format": "prettier --write src",
|
||||||
|
"format:check": "prettier --check src",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"lint": "eslint . --ext .vue,.ts,.tsx --fix"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@element-plus/icons-vue": "^2.3.1",
|
||||||
|
"axios": "^1.18.1",
|
||||||
|
"element-plus": "^2.5.6",
|
||||||
|
"pinia": "^2.1.7",
|
||||||
|
"vue": "^3.4.21",
|
||||||
|
"vue-router": "^4.3.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^5.0.4",
|
||||||
|
"@vue/tsconfig": "^0.5.1",
|
||||||
|
"prettier": "3.9.6",
|
||||||
|
"sass": "^1.71.1",
|
||||||
|
"sass-embedded": "^1.100.0",
|
||||||
|
"typescript": "^5.9.3",
|
||||||
|
"vite": "^5.1.5",
|
||||||
|
"vue-tsc": "^2.2.12"
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 97 KiB |
@@ -0,0 +1,9 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { RouterView } from "vue-router";
|
||||||
|
import ToastContainer from "@/components/ToastContainer.vue";
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<RouterView />
|
||||||
|
<ToastContainer />
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import axios, { AxiosError, type InternalAxiosRequestConfig } from "axios";
|
||||||
|
|
||||||
|
const http = axios.create({ baseURL: "/api/admin", timeout: 20000 });
|
||||||
|
let refreshPromise: Promise<string> | null = null;
|
||||||
|
|
||||||
|
http.interceptors.request.use((config) => {
|
||||||
|
const token = localStorage.getItem("admin_access_token");
|
||||||
|
if (token) config.headers.Authorization = `Bearer ${token}`;
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
|
||||||
|
http.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
async (error: AxiosError) => {
|
||||||
|
const request = error.config as (InternalAxiosRequestConfig & { _retried?: boolean }) | undefined;
|
||||||
|
const canRefresh = !request?.url?.includes("/auth/");
|
||||||
|
if (error.response?.status === 401 && request && !request._retried && canRefresh) {
|
||||||
|
request._retried = true;
|
||||||
|
try {
|
||||||
|
const token = await refreshAccessToken();
|
||||||
|
request.headers.Authorization = `Bearer ${token}`;
|
||||||
|
return http(request);
|
||||||
|
} catch {
|
||||||
|
clearAuth();
|
||||||
|
if (!location.pathname.endsWith("/login")) location.href = "/admin/login";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Promise.reject(error);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
async function refreshAccessToken() {
|
||||||
|
if (!refreshPromise) {
|
||||||
|
refreshPromise = axios
|
||||||
|
.post("/api/admin/auth/refresh", { refresh_token: localStorage.getItem("admin_refresh_token") })
|
||||||
|
.then(({ data }) => {
|
||||||
|
saveTokens(data.data);
|
||||||
|
return data.data.access_token as string;
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
refreshPromise = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return refreshPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveTokens(data: { access_token: string; refresh_token: string; admin?: unknown }) {
|
||||||
|
localStorage.setItem("admin_access_token", data.access_token);
|
||||||
|
localStorage.setItem("admin_refresh_token", data.refresh_token);
|
||||||
|
if (data.admin) localStorage.setItem("admin_profile", JSON.stringify(data.admin));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearAuth() {
|
||||||
|
localStorage.removeItem("admin_access_token");
|
||||||
|
localStorage.removeItem("admin_refresh_token");
|
||||||
|
localStorage.removeItem("admin_profile");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function apiError(error: unknown) {
|
||||||
|
const axiosError = error as AxiosError<{ code?: string; message?: string; trace_id?: string }>;
|
||||||
|
const body = axiosError.response?.data;
|
||||||
|
const message = body?.message || axiosError.message || "请求失败";
|
||||||
|
const copyText = `错误码:${body?.code || "request_failed"}\n错误描述:${message}\n请求追踪 ID:${body?.trace_id || axiosError.response?.headers?.["x-request-id"] || "无"}`;
|
||||||
|
return { message, copyText };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default http;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import logoUrl from "../../../WEB/src/assets/logo.png";
|
||||||
|
|
||||||
|
export default logoUrl;
|
||||||
@@ -0,0 +1,837 @@
|
|||||||
|
@use "./variables.scss";
|
||||||
|
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
html {
|
||||||
|
font-size: 16px;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-width: 320px;
|
||||||
|
font-family: Inter, "PingFang SC", "Microsoft YaHei", Arial, sans-serif;
|
||||||
|
color: var(--color-text);
|
||||||
|
background: var(--color-bg);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
button,
|
||||||
|
input,
|
||||||
|
textarea,
|
||||||
|
select {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
touch-action: manipulation;
|
||||||
|
}
|
||||||
|
#app {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 100dvh;
|
||||||
|
}
|
||||||
|
a {
|
||||||
|
color: var(--color-primary);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
h4,
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-shell {
|
||||||
|
min-height: 100dvh;
|
||||||
|
padding-left: var(--sidebar-width);
|
||||||
|
}
|
||||||
|
.sidebar {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0 auto 0 0;
|
||||||
|
z-index: 40;
|
||||||
|
width: var(--sidebar-width);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 12px 10px;
|
||||||
|
background: #111827;
|
||||||
|
color: var(--color-sidebar-text);
|
||||||
|
border-right: 1px solid #202c3e;
|
||||||
|
box-shadow: 4px 0 16px rgba(15, 23, 42, 0.08);
|
||||||
|
transition: width 0.2s ease;
|
||||||
|
}
|
||||||
|
.brand {
|
||||||
|
height: 54px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 0 8px 10px;
|
||||||
|
color: #f8fafc;
|
||||||
|
font-size: 17px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.brand strong {
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
.brand-mark {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
flex: 0 0 34px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid #314058;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: #182235;
|
||||||
|
}
|
||||||
|
.brand-mark img {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
.menu {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
min-height: 0;
|
||||||
|
margin-top: 8px;
|
||||||
|
overflow-y: auto;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: #344258 transparent;
|
||||||
|
}
|
||||||
|
.menu-item {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 38px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 7px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 0 10px;
|
||||||
|
background: transparent;
|
||||||
|
color: #9cabc1;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
transition:
|
||||||
|
background-color 0.15s ease,
|
||||||
|
color 0.15s ease,
|
||||||
|
box-shadow 0.15s ease;
|
||||||
|
}
|
||||||
|
.menu-item .el-icon {
|
||||||
|
color: #8495ad;
|
||||||
|
transition: color 0.15s ease;
|
||||||
|
}
|
||||||
|
.menu-item:hover {
|
||||||
|
background: #182335;
|
||||||
|
color: #dce5f2;
|
||||||
|
}
|
||||||
|
.menu-item:hover .el-icon {
|
||||||
|
color: #b7c6da;
|
||||||
|
}
|
||||||
|
.menu-item.active {
|
||||||
|
background: #24344d;
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: inset 2px 0 #60a5fa;
|
||||||
|
}
|
||||||
|
.menu-item.active .el-icon {
|
||||||
|
color: #e5edf8;
|
||||||
|
}
|
||||||
|
.menu-item.logout {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
margin-top: 10px;
|
||||||
|
background: transparent;
|
||||||
|
color: #8493aa;
|
||||||
|
}
|
||||||
|
.menu-item.logout:hover {
|
||||||
|
background: #182335;
|
||||||
|
color: #dce5f2;
|
||||||
|
}
|
||||||
|
.is-collapsed {
|
||||||
|
padding-left: var(--sidebar-collapsed-width);
|
||||||
|
}
|
||||||
|
.is-collapsed .sidebar {
|
||||||
|
width: var(--sidebar-collapsed-width);
|
||||||
|
padding-inline: 12px;
|
||||||
|
}
|
||||||
|
.is-collapsed .brand {
|
||||||
|
justify-content: center;
|
||||||
|
padding-inline: 0;
|
||||||
|
}
|
||||||
|
.is-collapsed .menu-item {
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.shell-main {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.topbar {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 30;
|
||||||
|
height: var(--topbar-height);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 0 28px;
|
||||||
|
border-bottom: 1px solid var(--color-border-light);
|
||||||
|
background: rgba(255, 255, 255, 0.96);
|
||||||
|
}
|
||||||
|
.topbar h1 {
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
.topbar p {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
.topbar-menu {
|
||||||
|
flex: 0 0 44px;
|
||||||
|
}
|
||||||
|
.admin-profile {
|
||||||
|
margin-left: auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.page-content {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 24px 28px 40px;
|
||||||
|
}
|
||||||
|
.page-stack {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
.management-page {
|
||||||
|
height: max(814px, calc(100dvh - 64px));
|
||||||
|
min-height: 814px;
|
||||||
|
}
|
||||||
|
.management-page > .table-panel {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.management-page > .table-panel > .el-pagination {
|
||||||
|
margin-top: auto;
|
||||||
|
padding-top: 18px;
|
||||||
|
}
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
.page-header h2 {
|
||||||
|
font-size: var(--font-size-xl);
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
.page-header p {
|
||||||
|
margin-top: 5px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.page-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.panel {
|
||||||
|
min-width: 0;
|
||||||
|
padding: 20px;
|
||||||
|
border: 1px solid var(--color-border-light);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
background: var(--color-surface);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
.table-panel {
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
.filter-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.filter-bar > .el-input {
|
||||||
|
width: min(280px, 100%);
|
||||||
|
}
|
||||||
|
.filter-bar > .el-select {
|
||||||
|
width: 180px;
|
||||||
|
}
|
||||||
|
.filter-bar > .el-date-editor {
|
||||||
|
max-width: 420px;
|
||||||
|
}
|
||||||
|
.batch-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border: 1px solid #bfdbfe;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--color-primary-soft);
|
||||||
|
}
|
||||||
|
.batch-toolbar strong {
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
.section-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
.section-heading h3 {
|
||||||
|
font-size: 17px;
|
||||||
|
}
|
||||||
|
.section-heading span {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.channel-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.balance-card {
|
||||||
|
padding: 18px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.balance-card.warning {
|
||||||
|
border-color: #f59e0b;
|
||||||
|
background: #fffbeb;
|
||||||
|
}
|
||||||
|
.balance-card__header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.balance-card__header strong,
|
||||||
|
.balance-card__header span {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.balance-card__header span,
|
||||||
|
.balance-card p {
|
||||||
|
margin-top: 3px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.balance-value {
|
||||||
|
margin: 22px 0 14px;
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 650;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.balance-value small {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
.line-chart {
|
||||||
|
width: 100%;
|
||||||
|
height: 360px;
|
||||||
|
}
|
||||||
|
.icon-button {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 6px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.icon-button:hover {
|
||||||
|
background: rgba(15, 23, 42, 0.08);
|
||||||
|
}
|
||||||
|
.el-button .el-icon {
|
||||||
|
margin-right: 6px;
|
||||||
|
}
|
||||||
|
.field-full {
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
.field-helper {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 6px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
.form-grid,
|
||||||
|
.dialog-form-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 0 18px;
|
||||||
|
}
|
||||||
|
.dialog-field--full {
|
||||||
|
grid-column: 1/-1;
|
||||||
|
}
|
||||||
|
.style-thumb {
|
||||||
|
width: 64px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 6px;
|
||||||
|
object-fit: cover;
|
||||||
|
background: var(--color-bg);
|
||||||
|
}
|
||||||
|
.upload-preview {
|
||||||
|
display: block;
|
||||||
|
width: 120px;
|
||||||
|
height: 84px;
|
||||||
|
margin-top: 10px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
.code-output {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
.el-pagination {
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 18px;
|
||||||
|
}
|
||||||
|
.el-table {
|
||||||
|
--el-table-header-bg-color: #f8fafc;
|
||||||
|
--el-table-row-hover-bg-color: #f8fafc;
|
||||||
|
}
|
||||||
|
.el-table th.el-table__cell {
|
||||||
|
color: #39465a;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.mobile-card-list {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.data-card {
|
||||||
|
padding: 16px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
.data-card dl {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 110px minmax(0, 1fr);
|
||||||
|
gap: 8px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.data-card dt {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
.data-card dd {
|
||||||
|
margin: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.card-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
.el-overlay {
|
||||||
|
background: rgba(15, 23, 42, 0.58);
|
||||||
|
}
|
||||||
|
.el-overlay-dialog {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
.el-dialog {
|
||||||
|
margin: 0;
|
||||||
|
border: 1px solid #d3dbe7;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 24px 70px rgba(15, 23, 42, 0.24);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.el-dialog__header {
|
||||||
|
margin: 0;
|
||||||
|
padding: 19px 24px 16px;
|
||||||
|
border-bottom: 1px solid var(--color-border-light);
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
.el-dialog__title {
|
||||||
|
color: var(--color-text);
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 650;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
}
|
||||||
|
.el-dialog__headerbtn {
|
||||||
|
top: 12px;
|
||||||
|
right: 14px;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.el-dialog__headerbtn:hover {
|
||||||
|
background: #e9eef5;
|
||||||
|
}
|
||||||
|
.el-dialog__headerbtn .el-dialog__close {
|
||||||
|
color: #718096;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
.el-dialog__body {
|
||||||
|
max-height: calc(100dvh - 180px);
|
||||||
|
padding: 22px 24px 6px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.el-dialog.managed-scroll-dialog {
|
||||||
|
margin: 0;
|
||||||
|
height: min(760px, calc(100dvh - 40px));
|
||||||
|
max-height: calc(100dvh - 40px);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.el-dialog.managed-scroll-dialog .el-dialog__header,
|
||||||
|
.el-dialog.managed-scroll-dialog .el-dialog__footer {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
.el-dialog.managed-scroll-dialog .el-dialog__body {
|
||||||
|
min-height: 0;
|
||||||
|
max-height: none;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
overflow-y: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
scrollbar-gutter: stable;
|
||||||
|
}
|
||||||
|
.managed-scroll-overlay .el-overlay-dialog {
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
overscroll-behavior: none;
|
||||||
|
}
|
||||||
|
.el-dialog .el-dialog__footer {
|
||||||
|
padding: 14px 24px 16px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
border-top: 0;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
.el-dialog__footer .el-button {
|
||||||
|
min-width: 82px;
|
||||||
|
height: 38px;
|
||||||
|
margin-left: 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.el-dialog .el-form-item {
|
||||||
|
margin-bottom: 19px;
|
||||||
|
}
|
||||||
|
.el-dialog .el-form-item__label {
|
||||||
|
padding-bottom: 7px;
|
||||||
|
color: #39465a;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
.el-dialog .el-input__wrapper,
|
||||||
|
.el-dialog .el-select__wrapper {
|
||||||
|
min-height: 40px;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 0 0 1px #d5dce6 inset;
|
||||||
|
}
|
||||||
|
.el-dialog .el-input__wrapper:hover,
|
||||||
|
.el-dialog .el-select__wrapper:hover {
|
||||||
|
box-shadow: 0 0 0 1px #9bb8e7 inset;
|
||||||
|
}
|
||||||
|
.el-dialog .el-input__wrapper.is-focus,
|
||||||
|
.el-dialog .el-select__wrapper.is-focused {
|
||||||
|
box-shadow: 0 0 0 1px var(--color-primary) inset;
|
||||||
|
}
|
||||||
|
.el-dialog .el-textarea__inner {
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 0 0 1px #d5dce6 inset;
|
||||||
|
line-height: 1.65;
|
||||||
|
}
|
||||||
|
.el-dialog .el-input-number {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.el-dialog .el-upload .el-button {
|
||||||
|
height: 38px;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.el-dialog .el-switch {
|
||||||
|
--el-switch-on-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
.toast-region {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 3000;
|
||||||
|
top: 18px;
|
||||||
|
left: 50%;
|
||||||
|
width: min(440px, calc(100vw - 32px));
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.app-toast {
|
||||||
|
position: relative;
|
||||||
|
min-height: 68px;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 14px 12px 16px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-left: 4px solid var(--color-info);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
overflow: hidden;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
.app-toast--success {
|
||||||
|
border-left-color: var(--color-success);
|
||||||
|
}
|
||||||
|
.app-toast--warning {
|
||||||
|
border-left-color: var(--color-warning);
|
||||||
|
}
|
||||||
|
.app-toast--error {
|
||||||
|
border-left-color: var(--color-danger);
|
||||||
|
}
|
||||||
|
.app-toast__icon {
|
||||||
|
margin-top: 3px;
|
||||||
|
}
|
||||||
|
.app-toast__content {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.app-toast__content span {
|
||||||
|
margin-top: 2px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.app-toast .icon-button {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
flex: 0 0 32px;
|
||||||
|
}
|
||||||
|
.app-toast__progress {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
bottom: 0;
|
||||||
|
height: 3px;
|
||||||
|
background: var(--color-primary);
|
||||||
|
transition: width 0.05s linear;
|
||||||
|
}
|
||||||
|
.toast-fade-enter-active,
|
||||||
|
.toast-fade-leave-active {
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
.toast-fade-enter-from,
|
||||||
|
.toast-fade-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
.login-page {
|
||||||
|
min-height: 100dvh;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 24px;
|
||||||
|
background: #eef2f7;
|
||||||
|
}
|
||||||
|
.login-panel {
|
||||||
|
width: min(440px, 100%);
|
||||||
|
padding: 34px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
}
|
||||||
|
.login-brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
.login-heading {
|
||||||
|
margin: 32px 0 24px;
|
||||||
|
}
|
||||||
|
.login-heading h1 {
|
||||||
|
font-size: 26px;
|
||||||
|
}
|
||||||
|
.login-heading p {
|
||||||
|
margin-top: 6px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
.login-submit {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.admin-shell {
|
||||||
|
padding-left: 0;
|
||||||
|
}
|
||||||
|
.page-content {
|
||||||
|
padding: 18px 14px 32px;
|
||||||
|
}
|
||||||
|
.topbar {
|
||||||
|
height: 64px;
|
||||||
|
padding: 0 14px;
|
||||||
|
}
|
||||||
|
.admin-profile span {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.management-page {
|
||||||
|
height: auto;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
.page-header {
|
||||||
|
align-items: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.page-actions {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
.page-actions .el-button {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
.panel {
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
.filter-bar {
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
.filter-bar > * {
|
||||||
|
width: 100% !important;
|
||||||
|
max-width: none !important;
|
||||||
|
}
|
||||||
|
.form-grid,
|
||||||
|
.dialog-form-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.dialog-field--full {
|
||||||
|
grid-column: auto;
|
||||||
|
}
|
||||||
|
.desktop-table {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.mobile-card-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.line-chart {
|
||||||
|
height: 300px;
|
||||||
|
}
|
||||||
|
.channel-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.login-panel {
|
||||||
|
padding: 26px 20px;
|
||||||
|
}
|
||||||
|
.el-overlay-dialog {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
.el-dialog {
|
||||||
|
margin: 0 !important;
|
||||||
|
}
|
||||||
|
.el-dialog__header {
|
||||||
|
padding: 17px 18px 14px;
|
||||||
|
}
|
||||||
|
.el-dialog__body {
|
||||||
|
max-height: calc(100dvh - 150px);
|
||||||
|
padding: 18px 18px 4px;
|
||||||
|
}
|
||||||
|
.el-dialog .el-dialog__footer {
|
||||||
|
padding: 18px 18px 12px;
|
||||||
|
}
|
||||||
|
.el-pagination {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.menu {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.mobile-drawer .el-drawer__body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 16px 12px;
|
||||||
|
background: var(--color-sidebar);
|
||||||
|
color: var(--color-sidebar-text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.desktop-user-table {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.el-dialog .el-dialog__header {
|
||||||
|
padding: 8px 24px 2px;
|
||||||
|
border-bottom: 0;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
.el-dialog .el-dialog__headerbtn {
|
||||||
|
top: 7px;
|
||||||
|
right: 11px;
|
||||||
|
}
|
||||||
|
.el-dialog__body {
|
||||||
|
padding-top: 16px;
|
||||||
|
}
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.el-dialog .el-dialog__header {
|
||||||
|
padding: 6px 18px 2px;
|
||||||
|
}
|
||||||
|
.el-dialog__body {
|
||||||
|
padding-top: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.el-dialog[style*="720px"] .el-form {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 0 18px;
|
||||||
|
}
|
||||||
|
.el-dialog[style*="720px"] .el-form > .form-grid {
|
||||||
|
grid-column: 1/-1;
|
||||||
|
}
|
||||||
|
.el-dialog[style*="720px"] .el-form > .el-form-item:has(.el-textarea__inner),
|
||||||
|
.el-dialog[style*="720px"] .el-form > .el-form-item:has(.el-upload),
|
||||||
|
.el-dialog[style*="720px"] .el-form > .el-form-item:has(.el-input__password) {
|
||||||
|
grid-column: 1/-1;
|
||||||
|
}
|
||||||
|
.el-dialog[style*="720px"] .el-form > .el-form-item {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.el-dialog[style*="720px"] .el-form {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.el-dialog .el-dialog__headerbtn {
|
||||||
|
top: 4px;
|
||||||
|
right: 10px;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
.el-dialog .el-dialog__headerbtn .el-dialog__close {
|
||||||
|
color: #718096;
|
||||||
|
font-size: 15px;
|
||||||
|
transition: color 0.16s ease;
|
||||||
|
}
|
||||||
|
.el-dialog .el-dialog__headerbtn:hover {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
.el-dialog .el-dialog__headerbtn:hover .el-dialog__close {
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
:root {
|
||||||
|
--color-primary: #4f46e5;
|
||||||
|
--color-primary-hover: #4338ca;
|
||||||
|
--color-primary-soft: #eef2ff;
|
||||||
|
--color-success: #15803d;
|
||||||
|
--color-warning: #b45309;
|
||||||
|
--color-danger: #b91c1c;
|
||||||
|
--color-info: #64748b;
|
||||||
|
--color-text: #172033;
|
||||||
|
--color-text-secondary: #64748b;
|
||||||
|
--color-text-placeholder: #94a3b8;
|
||||||
|
--color-border: #e2e8f0;
|
||||||
|
--color-border-light: #edf1f6;
|
||||||
|
--color-bg: #f4f6fb;
|
||||||
|
--color-surface: #ffffff;
|
||||||
|
--color-sidebar: #111827;
|
||||||
|
--color-sidebar-text: #cbd5e1;
|
||||||
|
--space-xs: 4px;
|
||||||
|
--space-sm: 8px;
|
||||||
|
--space-md: 16px;
|
||||||
|
--space-lg: 24px;
|
||||||
|
--space-xl: 32px;
|
||||||
|
--font-size-xs: 12px;
|
||||||
|
--font-size-sm: 14px;
|
||||||
|
--font-size-md: 16px;
|
||||||
|
--font-size-lg: 20px;
|
||||||
|
--font-size-xl: 26px;
|
||||||
|
--radius-sm: 5px;
|
||||||
|
--radius-md: 8px;
|
||||||
|
--radius-lg: 12px;
|
||||||
|
--shadow-sm: 0 1px 3px rgba(15, 23, 42, 0.08);
|
||||||
|
--shadow-md: 0 10px 30px rgba(15, 23, 42, 0.12);
|
||||||
|
--sidebar-width: 224px;
|
||||||
|
--sidebar-collapsed-width: 72px;
|
||||||
|
--topbar-height: 72px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<!-- 管理后台左侧菜单组件,统一维护菜单入口、图标和展示顺序。 -->
|
||||||
|
<script setup lang="ts">
|
||||||
|
import AppIcon from "@/components/AppIcon.vue";
|
||||||
|
import { adminMenuItems } from "@/router/adminNavigation";
|
||||||
|
|
||||||
|
defineProps<{ activePath: string; collapsed?: boolean }>();
|
||||||
|
const emit = defineEmits<{ navigate: [path: string] }>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<nav
|
||||||
|
class="menu admin-menu"
|
||||||
|
:class="{ 'admin-menu--collapsed': collapsed }"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
v-for="item in adminMenuItems"
|
||||||
|
:key="item.path"
|
||||||
|
class="menu-item"
|
||||||
|
:class="{ active: activePath === item.path }"
|
||||||
|
type="button"
|
||||||
|
:title="collapsed ? item.label : undefined"
|
||||||
|
@click="emit('navigate', item.path)"
|
||||||
|
>
|
||||||
|
<AppIcon
|
||||||
|
:name="item.icon"
|
||||||
|
:size="20"
|
||||||
|
/>
|
||||||
|
<span v-if="!collapsed">{{ item.label }}</span>
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.admin-menu {
|
||||||
|
gap: 5px;
|
||||||
|
padding: 12px 1px 8px;
|
||||||
|
}
|
||||||
|
.admin-menu--collapsed {
|
||||||
|
padding-inline: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch } from "vue";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
modelValue?: number | string | null;
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
precision?: 0 | 2;
|
||||||
|
disabled?: boolean;
|
||||||
|
readonly?: boolean;
|
||||||
|
placeholder?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
modelValue: null,
|
||||||
|
precision: 2,
|
||||||
|
disabled: false,
|
||||||
|
readonly: false,
|
||||||
|
placeholder: "",
|
||||||
|
});
|
||||||
|
const emit = defineEmits<{ "update:modelValue": [value: number | null] }>();
|
||||||
|
const text = ref(formatValue(props.modelValue));
|
||||||
|
const focused = ref(false);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
(value) => {
|
||||||
|
if (!focused.value) text.value = formatValue(value);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
function formatValue(value: number | string | null | undefined) {
|
||||||
|
if (value === null || value === undefined || value === "") return "";
|
||||||
|
const numeric = Number(value);
|
||||||
|
if (!Number.isFinite(numeric)) return "";
|
||||||
|
const factor = 10 ** props.precision;
|
||||||
|
return String(Math.round(numeric * factor) / factor);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitize(value: string) {
|
||||||
|
const normalized = value.replace(/,/g, ".").replace(/[^\d.-]/g, "");
|
||||||
|
const negative = normalized.startsWith("-");
|
||||||
|
const unsigned = normalized.replace(/-/g, "");
|
||||||
|
const [integer = "", ...decimalParts] = unsigned.split(".");
|
||||||
|
const sign = negative ? "-" : "";
|
||||||
|
if (props.precision === 0) return sign + integer;
|
||||||
|
const hasDecimalPoint = unsigned.includes(".");
|
||||||
|
const decimal = decimalParts.join("").slice(0, props.precision);
|
||||||
|
return sign + integer + (hasDecimalPoint ? `.${decimal}` : "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function update(value: string) {
|
||||||
|
text.value = sanitize(value);
|
||||||
|
if (text.value === "" || text.value === "-" || text.value === "." || text.value === "-.") {
|
||||||
|
emit("update:modelValue", null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const numeric = Number(text.value);
|
||||||
|
if (Number.isFinite(numeric)) emit("update:modelValue", numeric);
|
||||||
|
}
|
||||||
|
|
||||||
|
function commit() {
|
||||||
|
focused.value = false;
|
||||||
|
if (text.value === "" || text.value === "-" || text.value === "." || text.value === "-.") {
|
||||||
|
text.value = "";
|
||||||
|
emit("update:modelValue", null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let value = Number(text.value);
|
||||||
|
if (!Number.isFinite(value)) {
|
||||||
|
text.value = formatValue(props.modelValue);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (props.min !== undefined) value = Math.max(props.min, value);
|
||||||
|
if (props.max !== undefined) value = Math.min(props.max, value);
|
||||||
|
const factor = 10 ** props.precision;
|
||||||
|
value = Math.round(value * factor) / factor;
|
||||||
|
text.value = String(value);
|
||||||
|
emit("update:modelValue", value);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<el-input
|
||||||
|
class="admin-number-input"
|
||||||
|
:class="{ 'is-readonly': readonly }"
|
||||||
|
:model-value="text"
|
||||||
|
type="text"
|
||||||
|
:inputmode="precision === 0 ? 'numeric' : 'decimal'"
|
||||||
|
:disabled="disabled"
|
||||||
|
:readonly="readonly"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
@focus="focused = true"
|
||||||
|
@input="update"
|
||||||
|
@blur="commit"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.admin-number-input {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-number-input :deep(.el-input__wrapper) {
|
||||||
|
min-height: 40px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
box-shadow: 0 0 0 1px var(--color-border) inset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-number-input :deep(.el-input__wrapper.is-focus) {
|
||||||
|
box-shadow: 0 0 0 1px var(--color-primary) inset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-number-input :deep(.el-input__inner) {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-number-input.is-readonly :deep(.el-input__wrapper) {
|
||||||
|
background: var(--color-bg);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<!-- 剧游AI管理后台图标组件,统一封装第三方图标库的使用入口 -->
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from "vue";
|
||||||
|
import * as Icons from "@element-plus/icons-vue";
|
||||||
|
|
||||||
|
type IconName = keyof typeof Icons;
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
name: IconName;
|
||||||
|
size?: number | string;
|
||||||
|
color?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
size: 16,
|
||||||
|
});
|
||||||
|
|
||||||
|
const iconComponent = computed(() => Icons[props.name]);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<el-icon
|
||||||
|
:size="size"
|
||||||
|
:color="color"
|
||||||
|
>
|
||||||
|
<component :is="iconComponent" />
|
||||||
|
</el-icon>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,326 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from "vue";
|
||||||
|
import AdminNumberInput from "@/components/AdminNumberInput.vue";
|
||||||
|
import AppIcon from "@/components/AppIcon.vue";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
form: Record<string, any>;
|
||||||
|
editing: boolean;
|
||||||
|
}>();
|
||||||
|
const replacingAPIKey = ref(false);
|
||||||
|
|
||||||
|
const normalizedRate = computed(() => {
|
||||||
|
const cny = Number(props.form.exchange_cny_amount);
|
||||||
|
const points = Number(props.form.exchange_point_amount);
|
||||||
|
if (!Number.isFinite(cny) || cny <= 0 || !Number.isFinite(points) || points <= 0) return "--";
|
||||||
|
return (points / cny).toFixed(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
function cancelAPIKeyReplacement() {
|
||||||
|
props.form.api_key = "";
|
||||||
|
replacingAPIKey.value = false;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="channel-editor">
|
||||||
|
<section class="channel-section">
|
||||||
|
<header class="channel-section__header">
|
||||||
|
<span class="channel-section__icon"><AppIcon name="Connection" /></span>
|
||||||
|
<div>
|
||||||
|
<h3>基础连接</h3>
|
||||||
|
<p>配置渠道标识、接口地址和访问凭证。</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div class="channel-grid">
|
||||||
|
<el-form-item
|
||||||
|
label="渠道名"
|
||||||
|
prop="name"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<el-input
|
||||||
|
v-model.trim="form.name"
|
||||||
|
placeholder="输入渠道名称"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item
|
||||||
|
label="渠道 URL"
|
||||||
|
prop="base_url"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<el-input
|
||||||
|
v-model.trim="form.base_url"
|
||||||
|
placeholder="https://api.example.com"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item
|
||||||
|
class="channel-field--full"
|
||||||
|
label="API Key"
|
||||||
|
prop="api_key"
|
||||||
|
:required="!editing"
|
||||||
|
>
|
||||||
|
<template v-if="editing && !replacingAPIKey">
|
||||||
|
<div class="masked-key-row">
|
||||||
|
<el-input
|
||||||
|
:model-value="form.api_key_masked || '未配置'"
|
||||||
|
readonly
|
||||||
|
/>
|
||||||
|
<el-button @click="replacingAPIKey = true">更换 API Key</el-button>
|
||||||
|
</div>
|
||||||
|
<div class="field-helper">仅显示前后各 5 位,完整密钥不会返回管理端</div>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<el-input
|
||||||
|
v-model="form.api_key"
|
||||||
|
type="password"
|
||||||
|
show-password
|
||||||
|
placeholder="输入新 API Key"
|
||||||
|
/>
|
||||||
|
<div class="api-key-helper-row">
|
||||||
|
<div class="field-helper">凭证将加密保存</div>
|
||||||
|
<el-button
|
||||||
|
v-if="editing"
|
||||||
|
link
|
||||||
|
@click="cancelAPIKeyReplacement"
|
||||||
|
>取消更换</el-button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-form-item>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="channel-section">
|
||||||
|
<header class="channel-section__header">
|
||||||
|
<span class="channel-section__icon"><AppIcon name="Coin" /></span>
|
||||||
|
<div>
|
||||||
|
<h3>渠道汇率</h3>
|
||||||
|
<p>设置人民币与渠道积分的兑换关系,用于模型价格换算。</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div class="exchange-rate-row">
|
||||||
|
<el-form-item
|
||||||
|
label="人民币金额"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<AdminNumberInput
|
||||||
|
v-model="form.exchange_cny_amount"
|
||||||
|
:min="0.01"
|
||||||
|
:precision="2"
|
||||||
|
placeholder="1.00"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<span class="exchange-rate-row__equals">=</span>
|
||||||
|
<el-form-item
|
||||||
|
label="渠道积分"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<AdminNumberInput
|
||||||
|
v-model="form.exchange_point_amount"
|
||||||
|
:min="0.01"
|
||||||
|
:precision="2"
|
||||||
|
placeholder="1.00"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</div>
|
||||||
|
<div class="exchange-rate-summary">
|
||||||
|
<span>归一化汇率</span>
|
||||||
|
<strong>1.00 元 = {{ normalizedRate }} 渠道积分</strong>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="channel-section">
|
||||||
|
<header class="channel-section__header">
|
||||||
|
<span class="channel-section__icon"><AppIcon name="Setting" /></span>
|
||||||
|
<div>
|
||||||
|
<h3>运行限制</h3>
|
||||||
|
<p>配置余额预警和任务并发上限。</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div class="channel-grid channel-grid--three">
|
||||||
|
<el-form-item
|
||||||
|
label="余额预警阈值"
|
||||||
|
prop="warning_threshold"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<AdminNumberInput
|
||||||
|
v-model="form.warning_threshold"
|
||||||
|
:min="0"
|
||||||
|
:precision="2"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item
|
||||||
|
label="最大并发数"
|
||||||
|
prop="max_concurrency"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<AdminNumberInput
|
||||||
|
v-model="form.max_concurrency"
|
||||||
|
:min="1"
|
||||||
|
:max="5000"
|
||||||
|
:precision="0"
|
||||||
|
/>
|
||||||
|
<div class="field-helper">已提交且未进入终态的总任务数</div>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item
|
||||||
|
label="单用户最高并发数"
|
||||||
|
prop="max_user_concurrency"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<AdminNumberInput
|
||||||
|
v-model="form.max_user_concurrency"
|
||||||
|
:min="1"
|
||||||
|
:max="500"
|
||||||
|
:precision="0"
|
||||||
|
/>
|
||||||
|
<div class="field-helper">超出后继续排队,不计入轮询请求</div>
|
||||||
|
</el-form-item>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.channel-editor {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-section {
|
||||||
|
padding: 18px;
|
||||||
|
border: 1px solid var(--color-border-light);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--color-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-section__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-section__icon {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
flex: 0 0 34px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--color-primary-soft);
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-section__header h3 {
|
||||||
|
color: var(--color-text);
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-section__header p {
|
||||||
|
margin-top: 3px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 0 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-grid--three {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-field--full {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.masked-key-row {
|
||||||
|
width: 100%;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.masked-key-row :deep(.el-input__inner) {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.masked-key-row > .el-button {
|
||||||
|
min-height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-key-helper-row {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exchange-rate-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 32px minmax(0, 1fr);
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exchange-rate-row__equals {
|
||||||
|
margin-top: 2px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 18px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exchange-rate-summary {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 11px 13px;
|
||||||
|
border: 1px solid var(--color-border-light);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--color-bg);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exchange-rate-summary strong {
|
||||||
|
color: var(--color-text);
|
||||||
|
font-size: 13px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.channel-section {
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-grid,
|
||||||
|
.channel-grid--three {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exchange-rate-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exchange-rate-row__equals {
|
||||||
|
margin: -4px 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exchange-rate-summary {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.masked-key-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{ title: string; description: string; emptyText: string; hasSelection: boolean }>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="page-stack management-page">
|
||||||
|
<header class="page-header master-detail-heading">
|
||||||
|
<div>
|
||||||
|
<h2>{{ title }}</h2>
|
||||||
|
</div>
|
||||||
|
<div class="page-actions"><slot name="header-actions" /></div>
|
||||||
|
</header>
|
||||||
|
<section class="master-detail-panel">
|
||||||
|
<aside class="master-list">
|
||||||
|
<div class="master-list-toolbar"><slot name="list-toolbar" /></div>
|
||||||
|
<div class="master-list-items"><slot name="list" /></div>
|
||||||
|
</aside>
|
||||||
|
<section class="master-editor">
|
||||||
|
<slot
|
||||||
|
v-if="hasSelection"
|
||||||
|
name="detail"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="master-empty"
|
||||||
|
>
|
||||||
|
{{ emptyText }}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.master-detail-heading {
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.master-detail-panel {
|
||||||
|
display: grid;
|
||||||
|
flex: none;
|
||||||
|
height: max(760px, calc(100dvh - 118px));
|
||||||
|
min-height: 760px;
|
||||||
|
grid-template-columns: 300px minmax(0, 1fr);
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--color-border-light);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
.master-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
border-right: 1px solid var(--color-border-light);
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
.master-list-toolbar {
|
||||||
|
padding: 16px;
|
||||||
|
border-bottom: 1px solid var(--color-border-light);
|
||||||
|
}
|
||||||
|
.master-list-items {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
.master-editor {
|
||||||
|
min-width: 0;
|
||||||
|
padding: 26px 30px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.master-empty {
|
||||||
|
height: 100%;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.master-detail-heading :deep(.page-actions) {
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
@media (max-width: 800px) {
|
||||||
|
.master-detail-panel {
|
||||||
|
display: grid;
|
||||||
|
flex: none;
|
||||||
|
height: auto;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
.master-list {
|
||||||
|
max-height: 280px;
|
||||||
|
border-right: 0;
|
||||||
|
border-bottom: 1px solid var(--color-border-light);
|
||||||
|
}
|
||||||
|
.master-editor {
|
||||||
|
padding: 20px 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{ title: string; description?: string }>();
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<header class="page-header">
|
||||||
|
<div>
|
||||||
|
<h2>{{ title }}</h2>
|
||||||
|
</div>
|
||||||
|
<div class="page-actions"><slot /></div>
|
||||||
|
</header>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onBeforeUnmount, reactive, watch } from "vue";
|
||||||
|
import { Check, Close, CopyDocument, InfoFilled, WarningFilled } from "@element-plus/icons-vue";
|
||||||
|
import { useToastStore, type ToastItem } from "@/stores/toast";
|
||||||
|
|
||||||
|
const store = useToastStore();
|
||||||
|
const elapsed = reactive<Record<number, number>>({});
|
||||||
|
const paused = reactive<Record<number, boolean>>({});
|
||||||
|
let lastTick = performance.now();
|
||||||
|
const timer = window.setInterval(() => {
|
||||||
|
const now = performance.now();
|
||||||
|
const delta = now - lastTick;
|
||||||
|
lastTick = now;
|
||||||
|
for (const item of store.visible) {
|
||||||
|
if (!paused[item.id]) elapsed[item.id] = (elapsed[item.id] || 0) + delta;
|
||||||
|
if (elapsed[item.id] >= item.duration) store.remove(item.id);
|
||||||
|
}
|
||||||
|
}, 50);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => store.visible.map((item) => item.id),
|
||||||
|
(ids) => {
|
||||||
|
ids.forEach((id) => {
|
||||||
|
if (elapsed[id] === undefined) elapsed[id] = 0;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
onBeforeUnmount(() => window.clearInterval(timer));
|
||||||
|
|
||||||
|
const icons = { success: Check, warning: WarningFilled, error: WarningFilled, info: InfoFilled };
|
||||||
|
const progress = (item: ToastItem) =>
|
||||||
|
computed(() => Math.max(0, 100 - ((elapsed[item.id] || 0) / item.duration) * 100)).value;
|
||||||
|
async function copy(item: ToastItem) {
|
||||||
|
await navigator.clipboard.writeText(item.copyText || item.message);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="toast-region">
|
||||||
|
<TransitionGroup name="toast-fade">
|
||||||
|
<section
|
||||||
|
v-for="item in store.visible"
|
||||||
|
:key="item.id"
|
||||||
|
class="app-toast"
|
||||||
|
:class="`app-toast--${item.type}`"
|
||||||
|
@mouseenter="paused[item.id] = true"
|
||||||
|
@mouseleave="paused[item.id] = false"
|
||||||
|
>
|
||||||
|
<el-icon class="app-toast__icon"><component :is="icons[item.type]" /></el-icon>
|
||||||
|
<div class="app-toast__content">
|
||||||
|
<strong>{{ item.title }}</strong>
|
||||||
|
<span>{{ item.message }}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
v-if="item.type === 'error'"
|
||||||
|
class="icon-button"
|
||||||
|
type="button"
|
||||||
|
title="复制完整错误"
|
||||||
|
@click="copy(item)"
|
||||||
|
>
|
||||||
|
<el-icon><CopyDocument /></el-icon>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="icon-button"
|
||||||
|
type="button"
|
||||||
|
title="关闭提示"
|
||||||
|
@click="store.remove(item.id)"
|
||||||
|
>
|
||||||
|
<el-icon><Close /></el-icon>
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
class="app-toast__progress"
|
||||||
|
:style="{ width: `${progress(item)}%` }"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
</TransitionGroup>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
|
||||||
|
import { ElMessageBox } from "element-plus";
|
||||||
|
import { RouterView, useRoute, useRouter } from "vue-router";
|
||||||
|
import AdminMenu from "@/components/AdminMenu.vue";
|
||||||
|
import AppIcon from "@/components/AppIcon.vue";
|
||||||
|
import logoUrl from "@/assets/logo";
|
||||||
|
import { useAuthStore } from "@/stores/auth";
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
const auth = useAuthStore();
|
||||||
|
const width = ref(window.innerWidth);
|
||||||
|
const drawerOpen = ref(false);
|
||||||
|
const loggingOut = ref(false);
|
||||||
|
const collapsed = computed(() => width.value < 1200);
|
||||||
|
const mobile = computed(() => width.value < 768);
|
||||||
|
const updateWidth = () => {
|
||||||
|
width.value = window.innerWidth;
|
||||||
|
if (!mobile.value) drawerOpen.value = false;
|
||||||
|
};
|
||||||
|
onMounted(() => window.addEventListener("resize", updateWidth, { passive: true }));
|
||||||
|
onBeforeUnmount(() => window.removeEventListener("resize", updateWidth));
|
||||||
|
|
||||||
|
/** 确认管理员主动退出后注销当前会话,桌面侧栏与移动抽屉共用该流程。 */
|
||||||
|
async function logout() {
|
||||||
|
if (loggingOut.value) return;
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm("退出后需要重新登录,确定退出管理后台吗?", "退出登录", {
|
||||||
|
type: "warning",
|
||||||
|
confirmButtonText: "退出登录",
|
||||||
|
cancelButtonText: "取消",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error === "cancel" || error === "close") return;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
loggingOut.value = true;
|
||||||
|
try {
|
||||||
|
await auth.logout();
|
||||||
|
await router.replace("/login");
|
||||||
|
} finally {
|
||||||
|
loggingOut.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function navigate(path: string) {
|
||||||
|
router.push(path);
|
||||||
|
drawerOpen.value = false;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="admin-shell"
|
||||||
|
:class="{ 'is-collapsed': collapsed && !mobile }"
|
||||||
|
>
|
||||||
|
<aside
|
||||||
|
v-if="!mobile"
|
||||||
|
class="sidebar"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="brand"
|
||||||
|
:title="collapsed ? '剧游AI' : undefined"
|
||||||
|
>
|
||||||
|
<span class="brand-mark"
|
||||||
|
><img
|
||||||
|
:src="logoUrl"
|
||||||
|
alt="" /></span
|
||||||
|
><strong v-if="!collapsed">剧游AI</strong>
|
||||||
|
</div>
|
||||||
|
<AdminMenu
|
||||||
|
:active-path="route.path"
|
||||||
|
:collapsed="collapsed"
|
||||||
|
@navigate="navigate"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
class="menu-item logout"
|
||||||
|
type="button"
|
||||||
|
:title="collapsed ? '退出登录' : undefined"
|
||||||
|
:disabled="loggingOut"
|
||||||
|
@click="logout"
|
||||||
|
>
|
||||||
|
<AppIcon
|
||||||
|
name="SwitchButton"
|
||||||
|
:size="20"
|
||||||
|
/><span v-if="!collapsed">退出登录</span>
|
||||||
|
</button>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<el-drawer
|
||||||
|
v-model="drawerOpen"
|
||||||
|
direction="ltr"
|
||||||
|
size="240px"
|
||||||
|
:with-header="false"
|
||||||
|
class="mobile-drawer"
|
||||||
|
>
|
||||||
|
<div class="brand">
|
||||||
|
<span class="brand-mark"
|
||||||
|
><img
|
||||||
|
:src="logoUrl"
|
||||||
|
alt="" /></span
|
||||||
|
><strong>剧游AI</strong>
|
||||||
|
</div>
|
||||||
|
<AdminMenu
|
||||||
|
:active-path="route.path"
|
||||||
|
@navigate="navigate"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
class="menu-item logout"
|
||||||
|
type="button"
|
||||||
|
:disabled="loggingOut"
|
||||||
|
@click="logout"
|
||||||
|
>
|
||||||
|
<AppIcon
|
||||||
|
name="SwitchButton"
|
||||||
|
:size="20"
|
||||||
|
/><span>退出登录</span>
|
||||||
|
</button>
|
||||||
|
</el-drawer>
|
||||||
|
|
||||||
|
<div class="shell-main">
|
||||||
|
<main class="page-content">
|
||||||
|
<button
|
||||||
|
v-if="mobile"
|
||||||
|
class="icon-button mobile-menu-trigger"
|
||||||
|
type="button"
|
||||||
|
title="打开菜单"
|
||||||
|
@click="drawerOpen = true"
|
||||||
|
>
|
||||||
|
<AppIcon
|
||||||
|
name="Menu"
|
||||||
|
:size="22"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<RouterView />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.mobile-menu-trigger {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
// 剧游AI管理后台入口,负责挂载 Vue 应用、注册路由与状态管理
|
||||||
|
|
||||||
|
import { createApp } from "vue";
|
||||||
|
import { createPinia } from "pinia";
|
||||||
|
import ElementPlus from "element-plus";
|
||||||
|
import zhCn from "element-plus/es/locale/lang/zh-cn";
|
||||||
|
|
||||||
|
import App from "./App.vue";
|
||||||
|
import router from "./router";
|
||||||
|
|
||||||
|
import "./assets/styles/main.scss";
|
||||||
|
import "element-plus/dist/index.css";
|
||||||
|
|
||||||
|
const app = createApp(App);
|
||||||
|
|
||||||
|
app.use(createPinia());
|
||||||
|
app.use(router);
|
||||||
|
app.use(ElementPlus, { locale: zhCn });
|
||||||
|
|
||||||
|
app.mount("#app");
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
// 管理后台主导航配置,统一维护菜单顺序和默认入口。
|
||||||
|
|
||||||
|
/** 管理后台左侧菜单项,顺序同时决定默认打开的页面。 */
|
||||||
|
export const adminMenuItems = [
|
||||||
|
{ path: "/users", label: "用户管理", icon: "User" },
|
||||||
|
{ path: "/redemption-codes", label: "兑换码管理", icon: "Key" },
|
||||||
|
{ path: "/styles", label: "风格管理", icon: "Picture" },
|
||||||
|
{ path: "/prompts", label: "提示词管理", icon: "Document" },
|
||||||
|
{ path: "/channels", label: "渠道管理", icon: "Connection" },
|
||||||
|
{ path: "/models", label: "模型管理", icon: "Cpu" },
|
||||||
|
{ path: "/audit-logs", label: "审计日志", icon: "Tickets" },
|
||||||
|
{ path: "/security", label: "账号安全管理", icon: "Lock" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/** 管理后台未指定目标页面时打开的第一个菜单路径。 */
|
||||||
|
export const DEFAULT_ADMIN_PATH = adminMenuItems[0].path;
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
// 管理后台路由配置,负责页面注册、默认入口和登录访问控制。
|
||||||
|
import { createRouter, createWebHistory } from "vue-router";
|
||||||
|
import { DEFAULT_ADMIN_PATH } from "@/router/adminNavigation";
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHistory(import.meta.env.BASE_URL),
|
||||||
|
routes: [
|
||||||
|
{ path: "/login", name: "login", component: () => import("@/views/LoginView.vue"), meta: { public: true } },
|
||||||
|
{
|
||||||
|
path: "/",
|
||||||
|
component: () => import("@/layouts/AdminLayout.vue"),
|
||||||
|
children: [
|
||||||
|
{ path: "", redirect: DEFAULT_ADMIN_PATH },
|
||||||
|
{
|
||||||
|
path: "redemption-codes",
|
||||||
|
name: "redemption-codes",
|
||||||
|
component: () => import("@/views/RedemptionsView.vue"),
|
||||||
|
meta: { title: "兑换码管理" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "styles",
|
||||||
|
name: "styles",
|
||||||
|
component: () => import("@/views/StylesView.vue"),
|
||||||
|
meta: { title: "风格管理" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "prompts",
|
||||||
|
name: "prompts",
|
||||||
|
component: () => import("@/views/PromptsView.vue"),
|
||||||
|
meta: { title: "提示词管理" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "channels",
|
||||||
|
name: "channels",
|
||||||
|
component: () => import("@/views/ChannelsView.vue"),
|
||||||
|
meta: { title: "渠道管理" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "models",
|
||||||
|
name: "models",
|
||||||
|
component: () => import("@/views/ModelsView.vue"),
|
||||||
|
meta: { title: "模型管理" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "users",
|
||||||
|
name: "users",
|
||||||
|
component: () => import("@/views/UsersView.vue"),
|
||||||
|
meta: { title: "用户管理" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "audit-logs",
|
||||||
|
name: "audit-logs",
|
||||||
|
component: () => import("@/views/AuditLogsView.vue"),
|
||||||
|
meta: { title: "审计日志" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "security",
|
||||||
|
name: "security",
|
||||||
|
component: () => import("@/views/SecurityView.vue"),
|
||||||
|
meta: { title: "账号安全" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ path: "/:pathMatch(.*)*", redirect: DEFAULT_ADMIN_PATH },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
router.beforeEach((to) => {
|
||||||
|
const authenticated = Boolean(localStorage.getItem("admin_access_token"));
|
||||||
|
if (!to.meta.public && !authenticated) return { name: "login", query: { redirect: to.fullPath } };
|
||||||
|
if (to.name === "login" && authenticated) return { path: DEFAULT_ADMIN_PATH };
|
||||||
|
document.title = "剧游AI-后台管理";
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { defineStore } from "pinia";
|
||||||
|
import http, { clearAuth, saveTokens } from "@/api/http";
|
||||||
|
|
||||||
|
export const useAuthStore = defineStore("auth", {
|
||||||
|
state: () => ({
|
||||||
|
profile: JSON.parse(localStorage.getItem("admin_profile") || "null") as { id: string; username: string } | null,
|
||||||
|
loading: false,
|
||||||
|
}),
|
||||||
|
getters: { authenticated: () => Boolean(localStorage.getItem("admin_access_token")) },
|
||||||
|
actions: {
|
||||||
|
async login(username: string, password: string) {
|
||||||
|
this.loading = true;
|
||||||
|
try {
|
||||||
|
const { data } = await http.post("/auth/login", { username, password });
|
||||||
|
saveTokens(data.data);
|
||||||
|
this.profile = data.data.admin;
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async logout() {
|
||||||
|
const refreshToken = localStorage.getItem("admin_refresh_token");
|
||||||
|
try {
|
||||||
|
await http.post("/auth/logout", { refresh_token: refreshToken });
|
||||||
|
} finally {
|
||||||
|
clearAuth();
|
||||||
|
this.profile = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
// 剧游AI管理后台计数器示例 Store,演示 Pinia 状态管理用法
|
||||||
|
|
||||||
|
import { ref, computed } from "vue";
|
||||||
|
import { defineStore } from "pinia";
|
||||||
|
|
||||||
|
export const useCounterStore = defineStore("counter", () => {
|
||||||
|
const count = ref(0);
|
||||||
|
const doubleCount = computed(() => count.value * 2);
|
||||||
|
|
||||||
|
function increment() {
|
||||||
|
count.value++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { count, doubleCount, increment };
|
||||||
|
});
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { defineStore } from "pinia";
|
||||||
|
|
||||||
|
export type ToastType = "success" | "warning" | "error" | "info";
|
||||||
|
export interface ToastItem {
|
||||||
|
id: number;
|
||||||
|
type: ToastType;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
copyText?: string;
|
||||||
|
duration: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
let nextId = 1;
|
||||||
|
|
||||||
|
export const useToastStore = defineStore("toast", {
|
||||||
|
state: () => ({ visible: [] as ToastItem[], queued: [] as ToastItem[] }),
|
||||||
|
actions: {
|
||||||
|
show(type: ToastType, message: string, options?: { title?: string; copyText?: string }) {
|
||||||
|
const item: ToastItem = {
|
||||||
|
id: nextId++,
|
||||||
|
type,
|
||||||
|
title: options?.title || typeTitle(type),
|
||||||
|
message,
|
||||||
|
copyText: options?.copyText,
|
||||||
|
duration: 5000,
|
||||||
|
};
|
||||||
|
if (this.visible.length < 3) this.visible.push(item);
|
||||||
|
else this.queued.push(item);
|
||||||
|
return item.id;
|
||||||
|
},
|
||||||
|
success(message: string) {
|
||||||
|
return this.show("success", message);
|
||||||
|
},
|
||||||
|
warning(message: string) {
|
||||||
|
return this.show("warning", message);
|
||||||
|
},
|
||||||
|
error(message: string, copyText?: string) {
|
||||||
|
return this.show("error", message, { copyText });
|
||||||
|
},
|
||||||
|
remove(id: number) {
|
||||||
|
this.visible = this.visible.filter((item) => item.id !== id);
|
||||||
|
const next = this.queued.shift();
|
||||||
|
if (next) this.visible.push(next);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function typeTitle(type: ToastType) {
|
||||||
|
return { success: "操作成功", warning: "请注意", error: "操作失败", info: "提示" }[type];
|
||||||
|
}
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
<!-- 管理后台审计日志页,负责只读查询和展示关键管理操作。 -->
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, reactive, ref } from "vue";
|
||||||
|
import http, { apiError } from "@/api/http";
|
||||||
|
import AppIcon from "@/components/AppIcon.vue";
|
||||||
|
import PageHeader from "@/components/PageHeader.vue";
|
||||||
|
import { useToastStore } from "@/stores/toast";
|
||||||
|
|
||||||
|
const toast = useToastStore();
|
||||||
|
const loading = ref(false);
|
||||||
|
const items = ref<Record<string, any>[]>([]);
|
||||||
|
const total = ref(0);
|
||||||
|
const query = reactive({ keyword: "", page: 1, page_size: 15 });
|
||||||
|
|
||||||
|
const actionLabels: Record<string, string> = {
|
||||||
|
create: "新建",
|
||||||
|
batch_create: "批量新建",
|
||||||
|
update: "修改",
|
||||||
|
batch_update: "批量修改",
|
||||||
|
toggle: "状态变更",
|
||||||
|
delete: "删除",
|
||||||
|
batch_delete: "批量删除",
|
||||||
|
reorder: "调整顺序",
|
||||||
|
generate: "生成",
|
||||||
|
save: "保存",
|
||||||
|
save_draft: "保存草稿",
|
||||||
|
grant_points: "增加积分",
|
||||||
|
password_changed: "修改密码",
|
||||||
|
password_change_failed: "修改密码失败",
|
||||||
|
};
|
||||||
|
const resourceLabels: Record<string, string> = {
|
||||||
|
users: "用户",
|
||||||
|
"redemption-codes": "兑换码",
|
||||||
|
styles: "风格",
|
||||||
|
prompts: "提示词",
|
||||||
|
channels: "渠道",
|
||||||
|
models: "模型",
|
||||||
|
"admin-auth": "管理员账号",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 按当前筛选条件加载审计日志。 */
|
||||||
|
async function load() {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const { data } = await http.get("/audit-logs", { params: query });
|
||||||
|
items.value = data.data.items;
|
||||||
|
total.value = data.data.total;
|
||||||
|
} catch (error) {
|
||||||
|
const detail = apiError(error);
|
||||||
|
toast.error(detail.message, detail.copyText);
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 清空关键字并返回第一页。 */
|
||||||
|
function resetQuery() {
|
||||||
|
query.keyword = "";
|
||||||
|
query.page = 1;
|
||||||
|
void load();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 将审计动作代码转换为中文业务名称。 */
|
||||||
|
function actionText(value: string) {
|
||||||
|
return actionLabels[value] || value || "未知操作";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 将审计资源代码转换为中文业务名称。 */
|
||||||
|
function resourceText(value: string) {
|
||||||
|
return resourceLabels[value] || value || "未知资源";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 使用中国时区格式化审计发生时间。 */
|
||||||
|
function formatTime(value: unknown) {
|
||||||
|
if (!value) return "--";
|
||||||
|
return new Date(String(value)).toLocaleString("zh-CN", { timeZone: "Asia/Shanghai", hour12: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="page-stack management-page">
|
||||||
|
<PageHeader title="审计日志" />
|
||||||
|
<section class="panel filter-bar">
|
||||||
|
<el-input
|
||||||
|
v-model.trim="query.keyword"
|
||||||
|
clearable
|
||||||
|
placeholder="管理员、操作、资源、原因、对象 ID 或追踪 ID"
|
||||||
|
@keyup.enter="
|
||||||
|
query.page = 1;
|
||||||
|
load();
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
@click="
|
||||||
|
query.page = 1;
|
||||||
|
load();
|
||||||
|
"
|
||||||
|
>查询</el-button
|
||||||
|
>
|
||||||
|
<el-button @click="resetQuery">重置</el-button>
|
||||||
|
</section>
|
||||||
|
<section
|
||||||
|
class="panel table-panel"
|
||||||
|
v-loading="loading"
|
||||||
|
>
|
||||||
|
<el-table
|
||||||
|
class="desktop-table"
|
||||||
|
:data="items"
|
||||||
|
>
|
||||||
|
<el-table-column
|
||||||
|
prop="admin_username"
|
||||||
|
label="管理员"
|
||||||
|
min-width="120"
|
||||||
|
show-overflow-tooltip
|
||||||
|
/>
|
||||||
|
<el-table-column
|
||||||
|
label="操作"
|
||||||
|
min-width="120"
|
||||||
|
>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag effect="plain">{{ actionText(row.action) }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column
|
||||||
|
label="资源"
|
||||||
|
min-width="120"
|
||||||
|
>
|
||||||
|
<template #default="{ row }">{{ resourceText(row.resource_type) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column
|
||||||
|
prop="resource_id"
|
||||||
|
min-width="180"
|
||||||
|
show-overflow-tooltip
|
||||||
|
>
|
||||||
|
<template #header>
|
||||||
|
<span class="column-label">
|
||||||
|
对象 ID
|
||||||
|
<el-tooltip
|
||||||
|
content="被操作数据的唯一标识;即使对象改名或删除,仍可用它准确定位记录。"
|
||||||
|
placement="top"
|
||||||
|
>
|
||||||
|
<span class="column-help"><AppIcon name="QuestionFilled" /></span>
|
||||||
|
</el-tooltip>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column
|
||||||
|
prop="reason"
|
||||||
|
label="操作原因"
|
||||||
|
min-width="180"
|
||||||
|
show-overflow-tooltip
|
||||||
|
>
|
||||||
|
<template #default="{ row }">{{ row.reason || "--" }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column
|
||||||
|
prop="trace_id"
|
||||||
|
min-width="180"
|
||||||
|
show-overflow-tooltip
|
||||||
|
>
|
||||||
|
<template #header>
|
||||||
|
<span class="column-label">
|
||||||
|
请求追踪 ID
|
||||||
|
<el-tooltip
|
||||||
|
content="本次请求的唯一追踪编号,用于在服务器日志中查找并关联同一次请求。"
|
||||||
|
placement="top"
|
||||||
|
>
|
||||||
|
<span class="column-help"><AppIcon name="QuestionFilled" /></span>
|
||||||
|
</el-tooltip>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column
|
||||||
|
label="操作时间"
|
||||||
|
min-width="180"
|
||||||
|
>
|
||||||
|
<template #default="{ row }">{{ formatTime(row.created_at) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="items.length"
|
||||||
|
class="mobile-card-list"
|
||||||
|
>
|
||||||
|
<article
|
||||||
|
v-for="row in items"
|
||||||
|
:key="row.id"
|
||||||
|
class="data-card"
|
||||||
|
>
|
||||||
|
<dl>
|
||||||
|
<dt>管理员</dt>
|
||||||
|
<dd>{{ row.admin_username }}</dd>
|
||||||
|
<dt>操作</dt>
|
||||||
|
<dd>{{ actionText(row.action) }}</dd>
|
||||||
|
<dt>资源</dt>
|
||||||
|
<dd>{{ resourceText(row.resource_type) }}</dd>
|
||||||
|
<dt>对象 ID</dt>
|
||||||
|
<dd>{{ row.resource_id || "--" }}</dd>
|
||||||
|
<dt>操作原因</dt>
|
||||||
|
<dd>{{ row.reason || "--" }}</dd>
|
||||||
|
<dt>请求追踪 ID</dt>
|
||||||
|
<dd>{{ row.trace_id || "--" }}</dd>
|
||||||
|
<dt>操作时间</dt>
|
||||||
|
<dd>{{ formatTime(row.created_at) }}</dd>
|
||||||
|
</dl>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-empty
|
||||||
|
v-if="!loading && !items.length"
|
||||||
|
description="暂无审计日志"
|
||||||
|
/>
|
||||||
|
<el-pagination
|
||||||
|
v-if="total > query.page_size"
|
||||||
|
v-model:current-page="query.page"
|
||||||
|
background
|
||||||
|
layout="total,prev,pager,next"
|
||||||
|
:total="total"
|
||||||
|
:page-size="query.page_size"
|
||||||
|
@current-change="load"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.column-label,
|
||||||
|
.column-help {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-label {
|
||||||
|
gap: var(--space-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-help {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
cursor: help;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,345 @@
|
|||||||
|
<!-- 渠道管理页,负责渠道配置、余额状态和启停操作。 -->
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, reactive, ref } from "vue";
|
||||||
|
import { ElMessageBox, type FormInstance } from "element-plus";
|
||||||
|
import AppIcon from "@/components/AppIcon.vue";
|
||||||
|
import ChannelFormFields from "@/components/ChannelFormFields.vue";
|
||||||
|
import PageHeader from "@/components/PageHeader.vue";
|
||||||
|
import http, { apiError } from "@/api/http";
|
||||||
|
import { useToastStore } from "@/stores/toast";
|
||||||
|
|
||||||
|
const toast = useToastStore();
|
||||||
|
const loading = ref(false);
|
||||||
|
const saving = ref(false);
|
||||||
|
const dialogOpen = ref(false);
|
||||||
|
const editingId = ref("");
|
||||||
|
const formRef = ref<FormInstance>();
|
||||||
|
const items = ref<Record<string, any>[]>([]);
|
||||||
|
const total = ref(0);
|
||||||
|
const query = reactive({ keyword: "", enabled: "", page: 1, page_size: 15 });
|
||||||
|
const form = reactive<Record<string, any>>({});
|
||||||
|
const rules = {
|
||||||
|
name: [{ required: true, message: "请填写渠道名", trigger: "blur" }],
|
||||||
|
base_url: [{ required: true, message: "请填写渠道 URL", trigger: "blur" }],
|
||||||
|
api_key: [
|
||||||
|
{
|
||||||
|
validator: (_rule: unknown, value: string, callback: (error?: Error) => void) => {
|
||||||
|
if (!editingId.value && !String(value || "").trim()) callback(new Error("请填写 API Key"));
|
||||||
|
else callback();
|
||||||
|
},
|
||||||
|
trigger: "blur",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const { data } = await http.get("/resources/channels", { params: query });
|
||||||
|
items.value = data.data.items || [];
|
||||||
|
total.value = Number(data.data.total || 0);
|
||||||
|
} catch (error) {
|
||||||
|
showError(error);
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function reset(row?: Record<string, any>) {
|
||||||
|
Object.keys(form).forEach((key) => delete form[key]);
|
||||||
|
Object.assign(form, {
|
||||||
|
name: row?.name || "",
|
||||||
|
channel_type: row?.channel_type || "relay",
|
||||||
|
base_url: row?.base_url || "",
|
||||||
|
api_key: "",
|
||||||
|
api_key_masked: row?.api_key_masked || "",
|
||||||
|
warning_threshold: Number(row?.warning_threshold ?? 500),
|
||||||
|
exchange_cny_amount: 1,
|
||||||
|
exchange_point_amount: Number(row?.channel_points_per_cny ?? 1),
|
||||||
|
max_concurrency: Number(row?.max_concurrency ?? 500),
|
||||||
|
max_user_concurrency: Number(row?.max_user_concurrency ?? 10),
|
||||||
|
enabled: row?.enabled ?? true,
|
||||||
|
});
|
||||||
|
editingId.value = row?.id || "";
|
||||||
|
}
|
||||||
|
function openCreate() {
|
||||||
|
reset();
|
||||||
|
dialogOpen.value = true;
|
||||||
|
}
|
||||||
|
function openEdit(row: Record<string, any>) {
|
||||||
|
reset(row);
|
||||||
|
dialogOpen.value = true;
|
||||||
|
}
|
||||||
|
async function save() {
|
||||||
|
if (!(await formRef.value?.validate().catch(() => false))) return;
|
||||||
|
const cnyAmount = Number(form.exchange_cny_amount);
|
||||||
|
const pointAmount = Number(form.exchange_point_amount);
|
||||||
|
if (!Number.isFinite(cnyAmount) || cnyAmount <= 0 || !Number.isFinite(pointAmount) || pointAmount <= 0) {
|
||||||
|
toast.warning("请填写大于 0 的人民币金额和渠道积分");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (editingId.value && String(form.api_key || "").trim()) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm("此次修改将覆盖已加密保存的 API Key,是否继续?", "确认覆盖敏感配置", {
|
||||||
|
type: "warning",
|
||||||
|
confirmButtonText: "继续覆盖",
|
||||||
|
cancelButtonText: "取消",
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
saving.value = true;
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
...form,
|
||||||
|
channel_points_per_cny: Number((pointAmount / cnyAmount).toFixed(6)),
|
||||||
|
};
|
||||||
|
delete payload.exchange_cny_amount;
|
||||||
|
delete payload.exchange_point_amount;
|
||||||
|
delete payload.api_key_masked;
|
||||||
|
if (!payload.api_key) delete payload.api_key;
|
||||||
|
if (editingId.value) await http.put(`/resources/channels/${editingId.value}`, payload);
|
||||||
|
else await http.post("/resources/channels", payload);
|
||||||
|
dialogOpen.value = false;
|
||||||
|
toast.success(editingId.value ? "渠道已更新" : "渠道已创建");
|
||||||
|
await load();
|
||||||
|
} catch (error) {
|
||||||
|
showError(error);
|
||||||
|
} finally {
|
||||||
|
saving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function toggle(row: Record<string, any>, enabled: boolean) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`${enabled ? "启用" : "停用"}后将立即生效,是否继续?`, "确认状态变更", {
|
||||||
|
type: "warning",
|
||||||
|
confirmButtonText: "确认",
|
||||||
|
cancelButtonText: "取消",
|
||||||
|
});
|
||||||
|
await http.patch(`/resources/channels/${row.id}/enabled`, { enabled });
|
||||||
|
row.enabled = enabled;
|
||||||
|
toast.success("渠道状态已更新");
|
||||||
|
} catch (error) {
|
||||||
|
row.enabled = !enabled;
|
||||||
|
if (error !== "cancel" && error !== "close") showError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function remove(row: Record<string, any>) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`确定删除渠道“${row.name}”吗?`, "删除渠道", {
|
||||||
|
type: "warning",
|
||||||
|
confirmButtonText: "删除",
|
||||||
|
cancelButtonText: "取消",
|
||||||
|
});
|
||||||
|
await http.delete(`/resources/channels/${row.id}`);
|
||||||
|
toast.success("渠道已删除");
|
||||||
|
await load();
|
||||||
|
} catch (error) {
|
||||||
|
if (error !== "cancel" && error !== "close") showError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function search() {
|
||||||
|
query.page = 1;
|
||||||
|
void load();
|
||||||
|
}
|
||||||
|
function resetQuery() {
|
||||||
|
query.keyword = "";
|
||||||
|
query.enabled = "";
|
||||||
|
search();
|
||||||
|
}
|
||||||
|
function balance(row: Record<string, any>) {
|
||||||
|
return Number(row.balance) < 0 ? "无限" : `${Number(row.balance || 0).toFixed(2)} ${row.currency || ""}`;
|
||||||
|
}
|
||||||
|
function showError(error: unknown) {
|
||||||
|
const detail = apiError(error);
|
||||||
|
toast.error(detail.message, detail.copyText);
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="page-stack management-page">
|
||||||
|
<PageHeader
|
||||||
|
title="渠道管理"
|
||||||
|
description="维护 API 地址、密钥、汇率、余额和并发配置"
|
||||||
|
>
|
||||||
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
@click="openCreate"
|
||||||
|
><AppIcon name="Plus" />新建渠道</el-button
|
||||||
|
>
|
||||||
|
</PageHeader>
|
||||||
|
<section class="panel filter-bar">
|
||||||
|
<el-input
|
||||||
|
v-model="query.keyword"
|
||||||
|
clearable
|
||||||
|
placeholder="输入渠道名或地址"
|
||||||
|
><template #prefix><AppIcon name="Search" /></template
|
||||||
|
></el-input>
|
||||||
|
<el-select
|
||||||
|
v-model="query.enabled"
|
||||||
|
clearable
|
||||||
|
placeholder="启用状态"
|
||||||
|
><el-option
|
||||||
|
label="已启用"
|
||||||
|
value="true" /><el-option
|
||||||
|
label="已停用"
|
||||||
|
value="false"
|
||||||
|
/></el-select>
|
||||||
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
@click="search"
|
||||||
|
>查询</el-button
|
||||||
|
>
|
||||||
|
<el-button @click="resetQuery">重置</el-button>
|
||||||
|
</section>
|
||||||
|
<section
|
||||||
|
v-loading="loading"
|
||||||
|
class="panel table-panel"
|
||||||
|
>
|
||||||
|
<el-table
|
||||||
|
v-if="items.length"
|
||||||
|
:data="items"
|
||||||
|
class="desktop-table"
|
||||||
|
>
|
||||||
|
<el-table-column
|
||||||
|
prop="name"
|
||||||
|
label="渠道名"
|
||||||
|
min-width="130"
|
||||||
|
/>
|
||||||
|
<el-table-column
|
||||||
|
prop="base_url"
|
||||||
|
label="渠道 URL"
|
||||||
|
min-width="240"
|
||||||
|
show-overflow-tooltip
|
||||||
|
/>
|
||||||
|
<el-table-column
|
||||||
|
prop="api_key_masked"
|
||||||
|
label="API Key"
|
||||||
|
min-width="190"
|
||||||
|
/>
|
||||||
|
<el-table-column
|
||||||
|
label="渠道汇率"
|
||||||
|
min-width="180"
|
||||||
|
><template #default="{ row }"
|
||||||
|
>1.00 元 = {{ Number(row.channel_points_per_cny || 0).toFixed(2) }} 积分</template
|
||||||
|
></el-table-column
|
||||||
|
>
|
||||||
|
<el-table-column
|
||||||
|
prop="max_concurrency"
|
||||||
|
label="最大并发"
|
||||||
|
min-width="100"
|
||||||
|
/>
|
||||||
|
<el-table-column
|
||||||
|
prop="max_user_concurrency"
|
||||||
|
label="单用户并发"
|
||||||
|
min-width="120"
|
||||||
|
/>
|
||||||
|
<el-table-column
|
||||||
|
label="当前余额"
|
||||||
|
min-width="130"
|
||||||
|
><template #default="{ row }">{{ balance(row) }}</template></el-table-column
|
||||||
|
>
|
||||||
|
<el-table-column
|
||||||
|
label="启用"
|
||||||
|
min-width="90"
|
||||||
|
><template #default="{ row }"
|
||||||
|
><el-switch
|
||||||
|
:model-value="row.enabled"
|
||||||
|
@change="toggle(row, Boolean($event))" /></template
|
||||||
|
></el-table-column>
|
||||||
|
<el-table-column
|
||||||
|
label="操作"
|
||||||
|
width="140"
|
||||||
|
fixed="right"
|
||||||
|
><template #default="{ row }"
|
||||||
|
><el-button
|
||||||
|
link
|
||||||
|
type="primary"
|
||||||
|
@click="openEdit(row)"
|
||||||
|
>编辑</el-button
|
||||||
|
><el-button
|
||||||
|
link
|
||||||
|
type="danger"
|
||||||
|
@click="remove(row)"
|
||||||
|
>删除</el-button
|
||||||
|
></template
|
||||||
|
></el-table-column
|
||||||
|
>
|
||||||
|
</el-table>
|
||||||
|
<div
|
||||||
|
v-if="items.length"
|
||||||
|
class="mobile-card-list"
|
||||||
|
>
|
||||||
|
<article
|
||||||
|
v-for="row in items"
|
||||||
|
:key="row.id"
|
||||||
|
class="data-card"
|
||||||
|
>
|
||||||
|
<dl>
|
||||||
|
<dt>渠道名</dt>
|
||||||
|
<dd>{{ row.name }}</dd>
|
||||||
|
<dt>地址</dt>
|
||||||
|
<dd>{{ row.base_url }}</dd>
|
||||||
|
<dt>渠道汇率</dt>
|
||||||
|
<dd>{{ Number(row.channel_points_per_cny || 0).toFixed(2) }}</dd>
|
||||||
|
<dt>当前余额</dt>
|
||||||
|
<dd>{{ balance(row) }}</dd>
|
||||||
|
<dt>最大并发</dt>
|
||||||
|
<dd>{{ row.max_concurrency }} / 用户 {{ row.max_user_concurrency }}</dd>
|
||||||
|
</dl>
|
||||||
|
<div class="card-actions">
|
||||||
|
<el-button @click="openEdit(row)">编辑</el-button
|
||||||
|
><el-button
|
||||||
|
type="danger"
|
||||||
|
plain
|
||||||
|
@click="remove(row)"
|
||||||
|
>删除</el-button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
<el-empty
|
||||||
|
v-if="!items.length"
|
||||||
|
description="暂无渠道"
|
||||||
|
/>
|
||||||
|
<el-pagination
|
||||||
|
v-if="total > query.page_size"
|
||||||
|
v-model:current-page="query.page"
|
||||||
|
:page-size="query.page_size"
|
||||||
|
:total="total"
|
||||||
|
layout="total, prev, pager, next"
|
||||||
|
@current-change="load"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<el-dialog
|
||||||
|
v-model="dialogOpen"
|
||||||
|
:title="editingId ? '编辑渠道' : '新建渠道'"
|
||||||
|
width="min(820px, calc(100vw - 32px))"
|
||||||
|
class="managed-scroll-dialog"
|
||||||
|
align-center
|
||||||
|
destroy-on-close
|
||||||
|
>
|
||||||
|
<el-form
|
||||||
|
ref="formRef"
|
||||||
|
:model="form"
|
||||||
|
:rules="rules"
|
||||||
|
label-position="top"
|
||||||
|
><ChannelFormFields
|
||||||
|
:form="form"
|
||||||
|
:editing="Boolean(editingId)"
|
||||||
|
/></el-form>
|
||||||
|
<template #footer
|
||||||
|
><el-button @click="dialogOpen = false">取消</el-button
|
||||||
|
><el-button
|
||||||
|
type="primary"
|
||||||
|
:loading="saving"
|
||||||
|
@click="save"
|
||||||
|
>保存</el-button
|
||||||
|
></template
|
||||||
|
>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
<!-- 管理后台登录页,负责管理员凭据校验和登录后页面跳转。 -->
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { reactive, ref } from "vue";
|
||||||
|
import { useRoute, useRouter } from "vue-router";
|
||||||
|
import type { FormInstance, FormRules } from "element-plus";
|
||||||
|
import AppIcon from "@/components/AppIcon.vue";
|
||||||
|
import logoUrl from "@/assets/logo";
|
||||||
|
import { apiError } from "@/api/http";
|
||||||
|
import { useAuthStore } from "@/stores/auth";
|
||||||
|
import { useToastStore } from "@/stores/toast";
|
||||||
|
import { DEFAULT_ADMIN_PATH } from "@/router/adminNavigation";
|
||||||
|
|
||||||
|
const auth = useAuthStore();
|
||||||
|
const toast = useToastStore();
|
||||||
|
const router = useRouter();
|
||||||
|
const route = useRoute();
|
||||||
|
const form = reactive({ username: "", password: "" });
|
||||||
|
const formRef = ref<FormInstance>();
|
||||||
|
const rules: FormRules = {
|
||||||
|
username: [{ required: true, message: "请输入管理员账号", trigger: "blur" }],
|
||||||
|
password: [{ required: true, message: "请输入密码", trigger: "blur" }],
|
||||||
|
};
|
||||||
|
async function submit() {
|
||||||
|
if (auth.loading) return;
|
||||||
|
if (!(await formRef.value?.validate().catch(() => false))) return;
|
||||||
|
try {
|
||||||
|
await auth.login(form.username, form.password);
|
||||||
|
router.replace(String(route.query.redirect || DEFAULT_ADMIN_PATH));
|
||||||
|
} catch (error) {
|
||||||
|
const detail = apiError(error);
|
||||||
|
toast.error(detail.message, detail.copyText);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<main class="login-page">
|
||||||
|
<section class="login-panel">
|
||||||
|
<div class="login-brand">
|
||||||
|
<span class="brand-mark"
|
||||||
|
><img
|
||||||
|
:src="logoUrl"
|
||||||
|
alt="" /></span
|
||||||
|
><span>剧游AI</span>
|
||||||
|
</div>
|
||||||
|
<div class="login-heading"><h1 id="login-title">管理后台登录</h1></div>
|
||||||
|
<el-form
|
||||||
|
ref="formRef"
|
||||||
|
:model="form"
|
||||||
|
:rules="rules"
|
||||||
|
label-position="top"
|
||||||
|
size="large"
|
||||||
|
@submit.prevent="submit"
|
||||||
|
>
|
||||||
|
<el-form-item
|
||||||
|
label="管理员账号"
|
||||||
|
prop="username"
|
||||||
|
><el-input
|
||||||
|
v-model.trim="form.username"
|
||||||
|
autocomplete="username"
|
||||||
|
placeholder="请输入管理员账号"
|
||||||
|
><template #prefix><AppIcon name="User" /></template></el-input
|
||||||
|
></el-form-item>
|
||||||
|
<el-form-item
|
||||||
|
label="密码"
|
||||||
|
prop="password"
|
||||||
|
><el-input
|
||||||
|
v-model="form.password"
|
||||||
|
type="password"
|
||||||
|
autocomplete="current-password"
|
||||||
|
show-password
|
||||||
|
placeholder="请输入密码"
|
||||||
|
><template #prefix><AppIcon name="Lock" /></template></el-input
|
||||||
|
></el-form-item>
|
||||||
|
<el-button
|
||||||
|
class="login-submit"
|
||||||
|
type="primary"
|
||||||
|
native-type="submit"
|
||||||
|
:loading="auth.loading"
|
||||||
|
>登录</el-button
|
||||||
|
>
|
||||||
|
</el-form>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,548 @@
|
|||||||
|
<!-- 模型管理页,负责模型能力、渠道归属和计费配置。 -->
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, reactive, ref } from "vue";
|
||||||
|
import { ElMessageBox } from "element-plus";
|
||||||
|
import http, { apiError } from "@/api/http";
|
||||||
|
import PageHeader from "@/components/PageHeader.vue";
|
||||||
|
import AppIcon from "@/components/AppIcon.vue";
|
||||||
|
import AdminNumberInput from "@/components/AdminNumberInput.vue";
|
||||||
|
import { useToastStore } from "@/stores/toast";
|
||||||
|
|
||||||
|
type PriceField = [key: string, unit: string, label: string];
|
||||||
|
|
||||||
|
const toast = useToastStore();
|
||||||
|
const loading = ref(false);
|
||||||
|
const dialog = ref(false);
|
||||||
|
const items = ref<Record<string, any>[]>([]);
|
||||||
|
const channels = ref<Record<string, any>[]>([]);
|
||||||
|
const editing = ref("");
|
||||||
|
const query = reactive({ keyword: "", model_type: "", channel_id: "" });
|
||||||
|
const form = reactive<Record<string, any>>({});
|
||||||
|
|
||||||
|
const selectedChannel = computed(() => channels.value.find((channel) => channel.id === form.channel_id));
|
||||||
|
|
||||||
|
const priceFields = computed<PriceField[]>(() => {
|
||||||
|
if (form.model_type === "text") {
|
||||||
|
if (form.text_billing_mode === "per_token") {
|
||||||
|
return [
|
||||||
|
["input", "M Token", "输入 Token"],
|
||||||
|
["output", "M Token", "输出 Token"],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return [["default", "次", "每次价格"]];
|
||||||
|
}
|
||||||
|
if (form.model_type === "image")
|
||||||
|
return [
|
||||||
|
["1K", "次", "1K"],
|
||||||
|
["2K", "次", "2K"],
|
||||||
|
["4K", "次", "4K"],
|
||||||
|
];
|
||||||
|
return [
|
||||||
|
["480p", "秒", "480p"],
|
||||||
|
["720p", "秒", "720p"],
|
||||||
|
["1080p", "秒", "1080p"],
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
function parsePrices(value: unknown): Record<string, any>[] {
|
||||||
|
if (Array.isArray(value)) return value;
|
||||||
|
if (typeof value !== "string") return [];
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(value);
|
||||||
|
return Array.isArray(parsed) ? parsed : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasTokenPrices(value: unknown) {
|
||||||
|
return parsePrices(value).some((price) => ["input", "output"].includes(String(price.price_key).toLowerCase()));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTokenBillingRow(row: Record<string, any>) {
|
||||||
|
return row.model_type === "text" && (row.text_billing_mode === "per_token" || hasTokenPrices(row.prices));
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayPrices(row: Record<string, any>) {
|
||||||
|
const prices = parsePrices(row.prices);
|
||||||
|
if (row.model_type !== "text") return prices;
|
||||||
|
const keys = isTokenBillingRow(row) ? ["input", "output"] : ["default"];
|
||||||
|
return prices.filter((price) => keys.includes(String(price.price_key).toLowerCase()));
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset(row?: Record<string, any>) {
|
||||||
|
const rowPrices = parsePrices(row?.prices);
|
||||||
|
const prices: Record<string, number> = {};
|
||||||
|
for (const price of rowPrices) prices[price.price_key] = Number(price.price);
|
||||||
|
const billingMode = hasTokenPrices(rowPrices) ? "per_token" : row?.text_billing_mode || "per_request";
|
||||||
|
Object.assign(form, {
|
||||||
|
channel_id: row?.channel_id || "",
|
||||||
|
name: row?.name || "",
|
||||||
|
model_type: row?.model_type || "text",
|
||||||
|
multimodal: row?.multimodal || false,
|
||||||
|
text_billing_mode: billingMode,
|
||||||
|
enabled: row?.enabled ?? true,
|
||||||
|
price_values: prices,
|
||||||
|
});
|
||||||
|
editing.value = row?.id || "";
|
||||||
|
dialog.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchAllModels() {
|
||||||
|
const result: Record<string, any>[] = [];
|
||||||
|
let page = 1;
|
||||||
|
while (true) {
|
||||||
|
const { data } = await http.get("/models", { params: { ...query, page, page_size: 100 } });
|
||||||
|
const current = data.data.items || [];
|
||||||
|
result.push(...current);
|
||||||
|
if (!current.length || result.length >= Number(data.data.total || 0)) return result;
|
||||||
|
page += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const [models, { data: channelData }] = await Promise.all([
|
||||||
|
fetchAllModels(),
|
||||||
|
http.get("/resources/channels", { params: { page_size: 100 } }),
|
||||||
|
]);
|
||||||
|
items.value = models.map((row: Record<string, any>) => ({ ...row, prices: parsePrices(row.prices) }));
|
||||||
|
channels.value = channelData.data.items;
|
||||||
|
} catch (error) {
|
||||||
|
show(error);
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
if (!form.channel_id || !form.name) {
|
||||||
|
toast.warning("请填写模型名并选择渠道");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const prices = priceFields.value.map(([priceKey, unit]) => ({
|
||||||
|
price_key: priceKey,
|
||||||
|
unit,
|
||||||
|
price: Number(Number(form.price_values[priceKey] || 0).toFixed(2)),
|
||||||
|
}));
|
||||||
|
try {
|
||||||
|
const payload = { ...form, prices };
|
||||||
|
delete payload.price_values;
|
||||||
|
if (editing.value) await http.put(`/models/${editing.value}`, payload);
|
||||||
|
else await http.post("/models", payload);
|
||||||
|
toast.success(editing.value ? "模型已更新" : "模型已创建");
|
||||||
|
dialog.value = false;
|
||||||
|
load();
|
||||||
|
} catch (error) {
|
||||||
|
show(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggle(row: Record<string, any>, enabled: boolean) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`确认${enabled ? "启用" : "停用"}模型 ${row.name}?`, "状态变更", { type: "warning" });
|
||||||
|
await http.patch(`/resources/models/${row.id}/enabled`, { enabled });
|
||||||
|
row.enabled = enabled;
|
||||||
|
toast.success("模型状态已更新");
|
||||||
|
} catch (error) {
|
||||||
|
row.enabled = !enabled;
|
||||||
|
if (error !== "cancel" && error !== "close") show(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(row: Record<string, any>) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm("模型将被软删除;已有使用记录不会删除。", "确认删除", { type: "warning" });
|
||||||
|
await http.delete(`/resources/models/${row.id}`);
|
||||||
|
toast.success("模型已删除");
|
||||||
|
load();
|
||||||
|
} catch (error) {
|
||||||
|
if (error !== "cancel" && error !== "close") show(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function show(error: unknown) {
|
||||||
|
const detail = apiError(error);
|
||||||
|
toast.error(detail.message, detail.copyText);
|
||||||
|
}
|
||||||
|
|
||||||
|
const typeText = (value: string) => ({ text: "文本", image: "图片", video: "视频" })[value] || value;
|
||||||
|
const priceLabel = (value: string) => ({ default: "基础", input: "输入", output: "输出" })[value] || value;
|
||||||
|
onMounted(load);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="page-stack management-page">
|
||||||
|
<PageHeader
|
||||||
|
title="模型管理"
|
||||||
|
description="模型实际可用状态由模型和所属渠道的启用状态共同决定"
|
||||||
|
>
|
||||||
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
@click="reset()"
|
||||||
|
><AppIcon name="Plus" />新建模型</el-button
|
||||||
|
>
|
||||||
|
</PageHeader>
|
||||||
|
<section class="panel filter-bar">
|
||||||
|
<el-input
|
||||||
|
v-model="query.keyword"
|
||||||
|
clearable
|
||||||
|
placeholder="模型名"
|
||||||
|
/>
|
||||||
|
<el-select
|
||||||
|
v-model="query.model_type"
|
||||||
|
clearable
|
||||||
|
placeholder="模型类型"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
label="文本"
|
||||||
|
value="text"
|
||||||
|
/><el-option
|
||||||
|
label="图片"
|
||||||
|
value="image"
|
||||||
|
/><el-option
|
||||||
|
label="视频"
|
||||||
|
value="video"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
<el-select
|
||||||
|
v-model="query.channel_id"
|
||||||
|
clearable
|
||||||
|
placeholder="所属渠道"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="channel in channels"
|
||||||
|
:key="channel.id"
|
||||||
|
:label="channel.name"
|
||||||
|
:value="channel.id"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
@click="load"
|
||||||
|
>查询</el-button
|
||||||
|
>
|
||||||
|
</section>
|
||||||
|
<section
|
||||||
|
class="panel table-panel"
|
||||||
|
v-loading="loading"
|
||||||
|
>
|
||||||
|
<el-table :data="items">
|
||||||
|
<el-table-column
|
||||||
|
prop="name"
|
||||||
|
label="模型名"
|
||||||
|
min-width="150"
|
||||||
|
/>
|
||||||
|
<el-table-column
|
||||||
|
label="类型"
|
||||||
|
width="90"
|
||||||
|
><template #default="{ row }"
|
||||||
|
><el-tag
|
||||||
|
size="small"
|
||||||
|
effect="plain"
|
||||||
|
>{{ typeText(row.model_type) }}</el-tag
|
||||||
|
></template
|
||||||
|
></el-table-column
|
||||||
|
>
|
||||||
|
<el-table-column
|
||||||
|
label="所属渠道"
|
||||||
|
min-width="130"
|
||||||
|
><template #default="{ row }"
|
||||||
|
><el-tag
|
||||||
|
size="small"
|
||||||
|
type="info"
|
||||||
|
effect="plain"
|
||||||
|
>{{ row.channel_name }}</el-tag
|
||||||
|
></template
|
||||||
|
></el-table-column
|
||||||
|
>
|
||||||
|
<el-table-column
|
||||||
|
label="能力"
|
||||||
|
min-width="180"
|
||||||
|
>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span v-if="row.model_type === 'text'">{{ row.multimodal ? "支持多模态" : "仅文本" }}</span>
|
||||||
|
<span v-else-if="row.model_type === 'video'">视频生成</span>
|
||||||
|
<span v-else>图片生成</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column
|
||||||
|
label="模型价格"
|
||||||
|
min-width="240"
|
||||||
|
>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div
|
||||||
|
v-if="displayPrices(row).length"
|
||||||
|
class="price-list"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
v-for="price in displayPrices(row)"
|
||||||
|
:key="price.price_key"
|
||||||
|
>{{ priceLabel(price.price_key) }}:{{ Number(price.price).toFixed(2) }} 积分/{{ price.unit }}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<span v-else>--</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column
|
||||||
|
label="启用"
|
||||||
|
width="130"
|
||||||
|
><template #default="{ row }"
|
||||||
|
><el-tag
|
||||||
|
v-if="!row.channel_enabled"
|
||||||
|
size="small"
|
||||||
|
type="warning"
|
||||||
|
effect="plain"
|
||||||
|
>渠道已禁用</el-tag
|
||||||
|
><el-switch
|
||||||
|
v-else
|
||||||
|
:model-value="row.enabled"
|
||||||
|
@change="toggle(row, Boolean($event))" /></template
|
||||||
|
></el-table-column>
|
||||||
|
<el-table-column
|
||||||
|
label="操作"
|
||||||
|
width="140"
|
||||||
|
fixed="right"
|
||||||
|
><template #default="{ row }"
|
||||||
|
><el-button
|
||||||
|
link
|
||||||
|
type="primary"
|
||||||
|
@click="reset(row)"
|
||||||
|
>编辑</el-button
|
||||||
|
><el-button
|
||||||
|
link
|
||||||
|
type="danger"
|
||||||
|
@click="remove(row)"
|
||||||
|
>删除</el-button
|
||||||
|
></template
|
||||||
|
></el-table-column
|
||||||
|
>
|
||||||
|
</el-table>
|
||||||
|
<el-empty
|
||||||
|
v-if="!loading && !items.length"
|
||||||
|
description="暂无模型"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<el-dialog
|
||||||
|
v-model="dialog"
|
||||||
|
:title="editing ? '编辑模型' : '新建模型'"
|
||||||
|
width="min(820px, calc(100vw - 32px))"
|
||||||
|
class="managed-scroll-dialog model-dialog"
|
||||||
|
modal-class="managed-scroll-overlay"
|
||||||
|
:lock-scroll="true"
|
||||||
|
>
|
||||||
|
<el-form
|
||||||
|
:model="form"
|
||||||
|
label-position="top"
|
||||||
|
>
|
||||||
|
<div class="form-grid model-identity-grid">
|
||||||
|
<el-form-item
|
||||||
|
label="模型名"
|
||||||
|
required
|
||||||
|
><el-input v-model.trim="form.name"
|
||||||
|
/></el-form-item>
|
||||||
|
<el-form-item
|
||||||
|
label="所属渠道"
|
||||||
|
required
|
||||||
|
><el-select
|
||||||
|
v-model="form.channel_id"
|
||||||
|
class="field-full"
|
||||||
|
><el-option
|
||||||
|
v-for="channel in channels"
|
||||||
|
:key="channel.id"
|
||||||
|
:label="channel.name"
|
||||||
|
:value="channel.id" /></el-select
|
||||||
|
></el-form-item>
|
||||||
|
<el-form-item
|
||||||
|
label="模型类型"
|
||||||
|
required
|
||||||
|
><el-select
|
||||||
|
v-model="form.model_type"
|
||||||
|
class="field-full"
|
||||||
|
><el-option
|
||||||
|
label="文本"
|
||||||
|
value="text" /><el-option
|
||||||
|
label="图片"
|
||||||
|
value="image" /><el-option
|
||||||
|
label="视频"
|
||||||
|
value="video" /></el-select
|
||||||
|
></el-form-item>
|
||||||
|
</div>
|
||||||
|
<el-form-item
|
||||||
|
v-if="form.model_type === 'text'"
|
||||||
|
label="支持多模态"
|
||||||
|
><el-switch v-model="form.multimodal"
|
||||||
|
/></el-form-item>
|
||||||
|
<div
|
||||||
|
v-if="form.model_type === 'text'"
|
||||||
|
class="text-billing-settings"
|
||||||
|
>
|
||||||
|
<el-form-item
|
||||||
|
label="计费方式"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<el-radio-group v-model="form.text_billing_mode">
|
||||||
|
<el-radio-button label="per_request">按次计费</el-radio-button>
|
||||||
|
<el-radio-button label="per_token">按 Token 计费</el-radio-button>
|
||||||
|
</el-radio-group>
|
||||||
|
</el-form-item>
|
||||||
|
</div>
|
||||||
|
<section class="model-price-section">
|
||||||
|
<header class="model-price-section__header">
|
||||||
|
<div>
|
||||||
|
<h3>模型价格</h3>
|
||||||
|
<p>输入用户执行对应任务时实际扣除的积分。</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div class="model-price-grid">
|
||||||
|
<div
|
||||||
|
v-for="[key, unit, label] in priceFields"
|
||||||
|
:key="key"
|
||||||
|
class="model-price-card"
|
||||||
|
>
|
||||||
|
<el-form-item :label="`${label}计费(积分/${unit})`">
|
||||||
|
<AdminNumberInput
|
||||||
|
v-model="form.price_values[key]"
|
||||||
|
class="price-input"
|
||||||
|
:min="0"
|
||||||
|
:precision="2"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</el-form>
|
||||||
|
<template #footer
|
||||||
|
><el-button @click="dialog = false">取消</el-button
|
||||||
|
><el-button
|
||||||
|
type="primary"
|
||||||
|
@click="save"
|
||||||
|
>保存</el-button
|
||||||
|
></template
|
||||||
|
>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
:global(.el-dialog.managed-scroll-dialog.model-dialog) {
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
:global(.el-dialog.managed-scroll-dialog.model-dialog .el-dialog__body) {
|
||||||
|
max-height: calc(100dvh - 180px);
|
||||||
|
flex: 0 1 auto;
|
||||||
|
}
|
||||||
|
.model-identity-grid {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
.price-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
.model-price-section {
|
||||||
|
margin-top: 2px;
|
||||||
|
padding: 18px;
|
||||||
|
border: 1px solid var(--color-border-light);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--color-surface);
|
||||||
|
}
|
||||||
|
.model-price-section__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 20px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.model-price-section h3 {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.model-price-section__header p {
|
||||||
|
margin-top: 4px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.model-price-rates {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.model-price-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.model-price-card {
|
||||||
|
min-width: 0;
|
||||||
|
padding: 14px;
|
||||||
|
border: 1px solid var(--color-border-light);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--color-bg);
|
||||||
|
}
|
||||||
|
.model-price-card :deep(.el-form-item) {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.price-conversion {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.price-conversion > div {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.price-conversion dt {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.price-conversion dd {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-text);
|
||||||
|
font-size: 12px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.text-billing-settings {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(180px, 0.7fr);
|
||||||
|
gap: 16px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
.field-hint {
|
||||||
|
margin-top: 6px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.model-identity-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.model-price-section {
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
.model-price-section__header {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.model-price-rates {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.model-price-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.text-billing-settings {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,475 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, reactive, ref } from "vue";
|
||||||
|
import { ElMessageBox } from "element-plus";
|
||||||
|
import http, { apiError } from "@/api/http";
|
||||||
|
import AppIcon from "@/components/AppIcon.vue";
|
||||||
|
import MasterDetailLayout from "@/components/MasterDetailLayout.vue";
|
||||||
|
import { useToastStore } from "@/stores/toast";
|
||||||
|
|
||||||
|
type Prompt = Record<string, any>;
|
||||||
|
interface PromptBackupItem {
|
||||||
|
title: string;
|
||||||
|
type: string;
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
const promptTypes = [
|
||||||
|
"剧本反推",
|
||||||
|
"剧本分析",
|
||||||
|
"角色生成",
|
||||||
|
"场景生成",
|
||||||
|
"道具生成",
|
||||||
|
"分镜图生成",
|
||||||
|
"首尾帧生成",
|
||||||
|
"视频生成",
|
||||||
|
];
|
||||||
|
const toast = useToastStore();
|
||||||
|
const loading = ref(false);
|
||||||
|
const saving = ref(false);
|
||||||
|
const exporting = ref(false);
|
||||||
|
const restoring = ref(false);
|
||||||
|
const restoreInput = ref<HTMLInputElement>();
|
||||||
|
const items = ref<Prompt[]>([]);
|
||||||
|
const selectedId = ref("");
|
||||||
|
const creating = ref(false);
|
||||||
|
const query = reactive({ keyword: "", page: 1, page_size: 100 });
|
||||||
|
const form = reactive({ name: "", type: "", content: "" });
|
||||||
|
const selected = computed(() => items.value.find((item) => item.id === selectedId.value));
|
||||||
|
|
||||||
|
function reset(row?: Prompt) {
|
||||||
|
Object.assign(form, {
|
||||||
|
name: row?.name || "",
|
||||||
|
type: row?.type || row?.category || "",
|
||||||
|
content: row?.content || row?.version_content || "",
|
||||||
|
});
|
||||||
|
selectedId.value = row?.id || "";
|
||||||
|
creating.value = !row;
|
||||||
|
}
|
||||||
|
async function load() {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const { data } = await http.get("/prompts", { params: query });
|
||||||
|
items.value = data.data?.items || [];
|
||||||
|
if (selectedId.value) {
|
||||||
|
const current = items.value.find((item) => item.id === selectedId.value);
|
||||||
|
if (current) reset(current);
|
||||||
|
else reset();
|
||||||
|
} else if (items.value.length) reset(items.value[0]);
|
||||||
|
} catch (error) {
|
||||||
|
showError(error);
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function create() {
|
||||||
|
reset();
|
||||||
|
}
|
||||||
|
function validate() {
|
||||||
|
if (!form.name.trim()) return "请输入提示词名称";
|
||||||
|
if (!form.type) return "请选择适用类型";
|
||||||
|
if (!form.content.trim()) return "请输入提示词内容";
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
async function save() {
|
||||||
|
const message = validate();
|
||||||
|
if (message) {
|
||||||
|
toast.warning(message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
saving.value = true;
|
||||||
|
try {
|
||||||
|
const payload = { name: form.name.trim(), type: form.type, content: form.content };
|
||||||
|
if (selectedId.value) await http.put(`/prompts/${selectedId.value}`, payload);
|
||||||
|
else {
|
||||||
|
const { data } = await http.post("/prompts", payload);
|
||||||
|
selectedId.value = data.data.id;
|
||||||
|
}
|
||||||
|
creating.value = false;
|
||||||
|
toast.success("提示词已保存");
|
||||||
|
await load();
|
||||||
|
} catch (error) {
|
||||||
|
showError(error);
|
||||||
|
} finally {
|
||||||
|
saving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function remove() {
|
||||||
|
if (!selected.value) return;
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
`确定要删除提示词“${selected.value.name || "未命名提示词"}”吗?此操作不可恢复。`,
|
||||||
|
"确认删除",
|
||||||
|
{ type: "warning" },
|
||||||
|
);
|
||||||
|
await http.delete(`/prompts/${selectedId.value}`);
|
||||||
|
toast.success("提示词已删除");
|
||||||
|
selectedId.value = "";
|
||||||
|
reset();
|
||||||
|
await load();
|
||||||
|
} catch (error) {
|
||||||
|
if (error !== "cancel" && error !== "close") showError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function fetchAllPrompts() {
|
||||||
|
const result: Prompt[] = [];
|
||||||
|
let page = 1;
|
||||||
|
while (true) {
|
||||||
|
const { data } = await http.get("/prompts", { params: { page, page_size: 100 } });
|
||||||
|
const current = data.data?.items || [];
|
||||||
|
result.push(...current);
|
||||||
|
if (result.length >= Number(data.data?.total || 0) || !current.length) return result;
|
||||||
|
page += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function backupFilename() {
|
||||||
|
const value = new Date()
|
||||||
|
.toLocaleString("sv-SE", { timeZone: "Asia/Shanghai", hour12: false })
|
||||||
|
.replace(/[-:]/g, "")
|
||||||
|
.replace(" ", "-");
|
||||||
|
return `prompts-backup-${value}.json`;
|
||||||
|
}
|
||||||
|
async function exportPrompts() {
|
||||||
|
exporting.value = true;
|
||||||
|
try {
|
||||||
|
const prompts = await fetchAllPrompts();
|
||||||
|
const backup = {
|
||||||
|
format: "juhe-factory-prompts",
|
||||||
|
version: 1,
|
||||||
|
exported_at: new Date().toISOString(),
|
||||||
|
prompts: prompts.map((item) => ({
|
||||||
|
title: String(item.name || ""),
|
||||||
|
type: String(item.type || ""),
|
||||||
|
content: String(item.content || ""),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
const url = URL.createObjectURL(
|
||||||
|
new Blob([JSON.stringify(backup, null, 2)], { type: "application/json;charset=utf-8" }),
|
||||||
|
);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = url;
|
||||||
|
link.download = backupFilename();
|
||||||
|
link.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
toast.success(`已导出 ${prompts.length} 条提示词`);
|
||||||
|
} catch (error) {
|
||||||
|
showError(error);
|
||||||
|
} finally {
|
||||||
|
exporting.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function parseBackup(value: string): PromptBackupItem[] {
|
||||||
|
const parsed = JSON.parse(value) as { prompts?: unknown } | unknown[];
|
||||||
|
const source = Array.isArray(parsed) ? parsed : parsed?.prompts;
|
||||||
|
if (!Array.isArray(source) || !source.length) throw new Error("JSON 中没有可恢复的提示词");
|
||||||
|
const titles = new Set<string>();
|
||||||
|
return source.map((raw, index) => {
|
||||||
|
if (!raw || typeof raw !== "object") throw new Error(`第 ${index + 1} 条提示词格式无效`);
|
||||||
|
const item = raw as Record<string, unknown>;
|
||||||
|
const title = String(item.title || item.name || "").trim();
|
||||||
|
const type = String(item.type || "").trim();
|
||||||
|
const content = String(item.content || "").trim();
|
||||||
|
if (!title || !type || !content) throw new Error(`第 ${index + 1} 条提示词缺少标题、类型或内容`);
|
||||||
|
if (titles.has(title)) throw new Error(`JSON 中存在重复标题:${title}`);
|
||||||
|
titles.add(title);
|
||||||
|
return { title, type, content };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function chooseRestore() {
|
||||||
|
restoreInput.value?.click();
|
||||||
|
}
|
||||||
|
async function restorePrompts(event: Event) {
|
||||||
|
const input = event.target as HTMLInputElement;
|
||||||
|
const file = input.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
restoring.value = true;
|
||||||
|
try {
|
||||||
|
const backup = parseBackup(await file.text());
|
||||||
|
const existing = await fetchAllPrompts();
|
||||||
|
const byTitle = new Map<string, Prompt>();
|
||||||
|
for (const item of existing)
|
||||||
|
if (!byTitle.has(String(item.name || "").trim())) byTitle.set(String(item.name || "").trim(), item);
|
||||||
|
const overwriteCount = backup.filter((item) => byTitle.has(item.title)).length;
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
`文件包含 ${backup.length} 条提示词,将覆盖 ${overwriteCount} 条、新增 ${backup.length - overwriteCount} 条。是否继续恢复?`,
|
||||||
|
"确认恢复提示词",
|
||||||
|
{ type: "warning", confirmButtonText: "确认恢复", cancelButtonText: "取消" },
|
||||||
|
);
|
||||||
|
for (const item of backup) {
|
||||||
|
const payload = { name: item.title, type: item.type, content: item.content };
|
||||||
|
const current = byTitle.get(item.title);
|
||||||
|
if (current) await http.put(`/prompts/${current.id}`, payload);
|
||||||
|
else await http.post("/prompts", payload);
|
||||||
|
}
|
||||||
|
toast.success(`恢复完成:覆盖 ${overwriteCount} 条,新增 ${backup.length - overwriteCount} 条`);
|
||||||
|
await load();
|
||||||
|
} catch (error) {
|
||||||
|
if (error !== "cancel" && error !== "close") {
|
||||||
|
if (error instanceof SyntaxError) toast.error("JSON 文件格式错误");
|
||||||
|
else if (error instanceof Error && !("response" in error)) toast.error(error.message);
|
||||||
|
else showError(error);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
restoring.value = false;
|
||||||
|
input.value = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function showError(error: unknown) {
|
||||||
|
const detail = apiError(error);
|
||||||
|
toast.error(detail.message, detail.copyText);
|
||||||
|
}
|
||||||
|
onMounted(load);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<MasterDetailLayout
|
||||||
|
title="提示词管理"
|
||||||
|
description="管理系统提示词,保存后立即生效"
|
||||||
|
empty-text="请从左侧选择提示词,或点击新增提示词"
|
||||||
|
:has-selection="Boolean(selectedId) || creating"
|
||||||
|
>
|
||||||
|
<template #header-actions>
|
||||||
|
<div class="header-actions">
|
||||||
|
<input
|
||||||
|
ref="restoreInput"
|
||||||
|
class="restore-input"
|
||||||
|
type="file"
|
||||||
|
accept="application/json,.json"
|
||||||
|
@change="restorePrompts"
|
||||||
|
/>
|
||||||
|
<el-button
|
||||||
|
:loading="exporting"
|
||||||
|
:disabled="restoring"
|
||||||
|
@click="exportPrompts"
|
||||||
|
><AppIcon name="Upload" />导出</el-button
|
||||||
|
>
|
||||||
|
<el-button
|
||||||
|
:loading="restoring"
|
||||||
|
:disabled="exporting"
|
||||||
|
@click="chooseRestore"
|
||||||
|
><AppIcon name="Download" />恢复</el-button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #list-toolbar
|
||||||
|
><el-input
|
||||||
|
v-model="query.keyword"
|
||||||
|
clearable
|
||||||
|
placeholder="搜索提示词名称"
|
||||||
|
><template #prefix><AppIcon name="Search" /></template></el-input
|
||||||
|
></template>
|
||||||
|
<template #list>
|
||||||
|
<el-button
|
||||||
|
class="create-prompt-button"
|
||||||
|
type="primary"
|
||||||
|
@click="create"
|
||||||
|
><AppIcon name="Plus" />新建提示词</el-button
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
v-for="item in items"
|
||||||
|
:key="item.id"
|
||||||
|
class="master-item"
|
||||||
|
:class="{ active: item.id === selectedId }"
|
||||||
|
type="button"
|
||||||
|
@click="reset(item)"
|
||||||
|
>
|
||||||
|
<strong>{{ item.name || "未命名提示词" }}</strong
|
||||||
|
><span>{{ item.type || item.category || "未分类" }}</span>
|
||||||
|
</button>
|
||||||
|
<el-empty
|
||||||
|
v-if="!loading && !items.length"
|
||||||
|
description="暂无提示词"
|
||||||
|
:image-size="70"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template #detail>
|
||||||
|
<header class="editor-heading">
|
||||||
|
<div>
|
||||||
|
<span>{{ selectedId ? "编辑提示词" : "新增提示词" }}</span>
|
||||||
|
<h3>{{ form.name || "未命名提示词" }}</h3>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<el-form
|
||||||
|
class="editor-form"
|
||||||
|
:model="form"
|
||||||
|
label-position="top"
|
||||||
|
>
|
||||||
|
<div class="form-grid">
|
||||||
|
<el-form-item
|
||||||
|
label="提示词名称"
|
||||||
|
required
|
||||||
|
><el-input
|
||||||
|
v-model.trim="form.name"
|
||||||
|
placeholder="请输入提示词名称"
|
||||||
|
/></el-form-item>
|
||||||
|
<el-form-item
|
||||||
|
label="适用类型"
|
||||||
|
required
|
||||||
|
><el-select
|
||||||
|
v-model="form.type"
|
||||||
|
placeholder="请选择适用类型"
|
||||||
|
class="type-select"
|
||||||
|
filterable
|
||||||
|
><el-option
|
||||||
|
v-for="type in promptTypes"
|
||||||
|
:key="type"
|
||||||
|
:label="type"
|
||||||
|
:value="type" /></el-select
|
||||||
|
></el-form-item>
|
||||||
|
</div>
|
||||||
|
<el-form-item
|
||||||
|
class="prompt-content-field"
|
||||||
|
label="提示词内容"
|
||||||
|
required
|
||||||
|
><el-input
|
||||||
|
v-model="form.content"
|
||||||
|
type="textarea"
|
||||||
|
:rows="16"
|
||||||
|
resize="none"
|
||||||
|
placeholder="请输入提示词内容"
|
||||||
|
/></el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<footer class="editor-actions">
|
||||||
|
<el-button
|
||||||
|
v-if="selectedId"
|
||||||
|
type="danger"
|
||||||
|
plain
|
||||||
|
@click="remove"
|
||||||
|
><AppIcon name="Delete" />删除</el-button
|
||||||
|
><el-button
|
||||||
|
type="primary"
|
||||||
|
:loading="saving"
|
||||||
|
@click="save"
|
||||||
|
>保存</el-button
|
||||||
|
>
|
||||||
|
</footer>
|
||||||
|
</template>
|
||||||
|
</MasterDetailLayout>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.header-actions :deep(.el-button) {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
.restore-input {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.master-item {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px 13px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 5px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-text);
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
background 0.16s,
|
||||||
|
border-color 0.16s;
|
||||||
|
}
|
||||||
|
.master-item:hover {
|
||||||
|
background: #eef4ff;
|
||||||
|
}
|
||||||
|
.master-item.active {
|
||||||
|
border-color: #b8cff5;
|
||||||
|
background: #eaf2ff;
|
||||||
|
}
|
||||||
|
.master-item strong {
|
||||||
|
font-size: 14px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.master-item span {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.create-prompt-button {
|
||||||
|
width: 100%;
|
||||||
|
margin: 0 0 8px;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.editor-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
padding-bottom: 18px;
|
||||||
|
border-bottom: 1px solid var(--color-border-light);
|
||||||
|
}
|
||||||
|
.editor-heading span {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.editor-heading h3 {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
.editor-form {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
padding-top: 22px;
|
||||||
|
}
|
||||||
|
.form-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.type-select {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.prompt-content-field {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.prompt-content-field :deep(.el-form-item__content) {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
.prompt-content-field :deep(.el-textarea) {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.prompt-content-field :deep(.el-textarea__inner) {
|
||||||
|
height: 100%;
|
||||||
|
min-height: 420px !important;
|
||||||
|
}
|
||||||
|
.editor-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
padding-top: 18px;
|
||||||
|
border-top: 1px solid var(--color-border-light);
|
||||||
|
}
|
||||||
|
:deep(.master-editor) {
|
||||||
|
display: flex;
|
||||||
|
min-height: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.header-actions {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
.form-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.prompt-content-field :deep(.el-textarea__inner) {
|
||||||
|
min-height: 360px !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, reactive, ref } from "vue";
|
||||||
|
import http, { apiError } from "@/api/http";
|
||||||
|
import PageHeader from "@/components/PageHeader.vue";
|
||||||
|
import AppIcon from "@/components/AppIcon.vue";
|
||||||
|
import AdminNumberInput from "@/components/AdminNumberInput.vue";
|
||||||
|
import { useToastStore } from "@/stores/toast";
|
||||||
|
const toast = useToastStore(),
|
||||||
|
loading = ref(false),
|
||||||
|
generating = ref(false),
|
||||||
|
dialog = ref(false),
|
||||||
|
resultDialog = ref(false),
|
||||||
|
items = ref<Record<string, any>[]>([]),
|
||||||
|
codes = ref<string[]>([]),
|
||||||
|
total = ref(0);
|
||||||
|
async function copyCode(code: string) {
|
||||||
|
await navigator.clipboard.writeText(code);
|
||||||
|
toast.success("兑换码已复制");
|
||||||
|
}
|
||||||
|
const query = reactive({ keyword: "", status: "", page: 1, page_size: 15 });
|
||||||
|
const form = reactive({ name: "", points: 10, quantity: 1, expires_at: "" });
|
||||||
|
async function load() {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const { data } = await http.get("/redemption-codes", { params: query });
|
||||||
|
items.value = data.data.items;
|
||||||
|
total.value = data.data.total;
|
||||||
|
} catch (e) {
|
||||||
|
show(e);
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function open() {
|
||||||
|
Object.assign(form, { name: "", points: 10, quantity: 1, expires_at: "" });
|
||||||
|
dialog.value = true;
|
||||||
|
}
|
||||||
|
async function generate() {
|
||||||
|
const points = Number(form.points);
|
||||||
|
const quantity = Number(form.quantity);
|
||||||
|
if (!Number.isFinite(points) || points < 1 || points > 100) {
|
||||||
|
toast.warning("单码积分数量必须为 1 至 100");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!Number.isInteger(quantity) || quantity < 1 || quantity > 10) {
|
||||||
|
toast.warning("生成数量必须为 1 至 10");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
generating.value = true;
|
||||||
|
try {
|
||||||
|
const { data } = await http.post("/redemption-batches", form);
|
||||||
|
codes.value = data.data.codes;
|
||||||
|
dialog.value = false;
|
||||||
|
resultDialog.value = true;
|
||||||
|
toast.success("兑换码批次生成成功");
|
||||||
|
load();
|
||||||
|
} catch (e) {
|
||||||
|
show(e);
|
||||||
|
} finally {
|
||||||
|
generating.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function copyAll() {
|
||||||
|
await navigator.clipboard.writeText(codes.value.join("\n"));
|
||||||
|
toast.success("完整兑换码已复制");
|
||||||
|
}
|
||||||
|
function exportCodes() {
|
||||||
|
const blob = new Blob([codes.value.join("\r\n")], { type: "text/plain;charset=utf-8" });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = `redemption-codes-${Date.now()}.txt`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
function closeResult() {
|
||||||
|
codes.value = [];
|
||||||
|
resultDialog.value = false;
|
||||||
|
}
|
||||||
|
function show(e: unknown) {
|
||||||
|
const d = apiError(e);
|
||||||
|
toast.error(d.message, d.copyText);
|
||||||
|
}
|
||||||
|
const statusText = (v: string) => ({ unused: "未使用", used: "已使用", expired: "已过期" })[v] || v;
|
||||||
|
const fmt = (v: any) =>
|
||||||
|
v ? new Date(v).toLocaleString("zh-CN", { timeZone: "Asia/Shanghai", hour12: false }) : "长期有效";
|
||||||
|
onMounted(load);
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<div class="page-stack management-page">
|
||||||
|
<PageHeader
|
||||||
|
title="兑换码管理"
|
||||||
|
description="完整兑换码可在列表中直接查看并复制"
|
||||||
|
><el-button
|
||||||
|
type="primary"
|
||||||
|
@click="open"
|
||||||
|
>
|
||||||
|
<AppIcon name="Plus" />生成兑换码
|
||||||
|
</el-button></PageHeader
|
||||||
|
>
|
||||||
|
<section class="panel filter-bar">
|
||||||
|
<el-input
|
||||||
|
v-model="query.keyword"
|
||||||
|
clearable
|
||||||
|
placeholder="批次、兑换码或用户 UID"
|
||||||
|
/><el-select
|
||||||
|
v-model="query.status"
|
||||||
|
clearable
|
||||||
|
placeholder="状态"
|
||||||
|
><el-option
|
||||||
|
label="未使用"
|
||||||
|
value="unused" /><el-option
|
||||||
|
label="已使用"
|
||||||
|
value="used" /><el-option
|
||||||
|
label="已过期"
|
||||||
|
value="expired" /></el-select
|
||||||
|
><el-button
|
||||||
|
type="primary"
|
||||||
|
@click="
|
||||||
|
query.page = 1;
|
||||||
|
load();
|
||||||
|
"
|
||||||
|
>查询</el-button
|
||||||
|
>
|
||||||
|
</section>
|
||||||
|
<section
|
||||||
|
class="panel table-panel"
|
||||||
|
v-loading="loading"
|
||||||
|
>
|
||||||
|
<el-table :data="items"
|
||||||
|
><el-table-column
|
||||||
|
prop="code"
|
||||||
|
label="兑换码"
|
||||||
|
min-width="230"
|
||||||
|
show-overflow-tooltip
|
||||||
|
><template #default="{ row }"
|
||||||
|
><span class="code-cell">{{ row.code || "--" }}</span></template
|
||||||
|
></el-table-column
|
||||||
|
><el-table-column
|
||||||
|
label="操作"
|
||||||
|
width="90"
|
||||||
|
fixed="right"
|
||||||
|
><template #default="{ row }"
|
||||||
|
><el-button
|
||||||
|
v-if="row.code"
|
||||||
|
link
|
||||||
|
type="primary"
|
||||||
|
@click="copyCode(row.code)"
|
||||||
|
><AppIcon name="CopyDocument" />复制</el-button
|
||||||
|
></template
|
||||||
|
></el-table-column
|
||||||
|
><el-table-column
|
||||||
|
prop="batch_name"
|
||||||
|
label="所属批次"
|
||||||
|
min-width="140" /><el-table-column
|
||||||
|
label="积分数量"
|
||||||
|
width="110"
|
||||||
|
><template #default="{ row }">{{ Number(row.points).toFixed(2) }}</template></el-table-column
|
||||||
|
><el-table-column
|
||||||
|
label="状态"
|
||||||
|
width="100"
|
||||||
|
><template #default="{ row }"
|
||||||
|
><el-tag effect="plain">{{ statusText(row.status) }}</el-tag></template
|
||||||
|
></el-table-column
|
||||||
|
><el-table-column
|
||||||
|
prop="redeemed_uid"
|
||||||
|
label="兑换用户 UID"
|
||||||
|
width="130" /><el-table-column
|
||||||
|
label="兑换时间"
|
||||||
|
min-width="180"
|
||||||
|
><template #default="{ row }">{{ row.redeemed_at ? fmt(row.redeemed_at) : "--" }}</template></el-table-column
|
||||||
|
><el-table-column
|
||||||
|
label="失效时间"
|
||||||
|
min-width="180"
|
||||||
|
><template #default="{ row }">{{
|
||||||
|
row.status === "used" ? "--" : fmt(row.expires_at)
|
||||||
|
}}</template></el-table-column
|
||||||
|
><el-table-column
|
||||||
|
prop="created_by"
|
||||||
|
label="创建管理员"
|
||||||
|
width="130" /></el-table
|
||||||
|
><el-empty
|
||||||
|
v-if="!loading && !items.length"
|
||||||
|
description="暂无兑换码"
|
||||||
|
/><el-pagination
|
||||||
|
v-if="total > query.page_size"
|
||||||
|
v-model:current-page="query.page"
|
||||||
|
background
|
||||||
|
layout="total,prev,pager,next"
|
||||||
|
:total="total"
|
||||||
|
@current-change="load"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
<el-dialog
|
||||||
|
v-model="dialog"
|
||||||
|
title="按批次生成兑换码"
|
||||||
|
width="min(560px, calc(100vw - 32px))"
|
||||||
|
><el-form
|
||||||
|
:model="form"
|
||||||
|
label-position="top"
|
||||||
|
><el-form-item
|
||||||
|
label="批次名称"
|
||||||
|
required
|
||||||
|
><el-input v-model.trim="form.name"
|
||||||
|
/></el-form-item>
|
||||||
|
<div class="form-grid">
|
||||||
|
<el-form-item
|
||||||
|
label="单码积分数量"
|
||||||
|
required
|
||||||
|
><AdminNumberInput
|
||||||
|
v-model="form.points"
|
||||||
|
:min="1"
|
||||||
|
:max="100"
|
||||||
|
:precision="2" /></el-form-item
|
||||||
|
><el-form-item
|
||||||
|
label="生成数量"
|
||||||
|
required
|
||||||
|
><AdminNumberInput
|
||||||
|
v-model="form.quantity"
|
||||||
|
:min="1"
|
||||||
|
:max="10"
|
||||||
|
:precision="0"
|
||||||
|
/></el-form-item>
|
||||||
|
</div>
|
||||||
|
<el-form-item label="有效期"
|
||||||
|
><el-date-picker
|
||||||
|
v-model="form.expires_at"
|
||||||
|
type="datetime"
|
||||||
|
value-format="YYYY-MM-DDTHH:mm:ssZ"
|
||||||
|
class="field-full"
|
||||||
|
/></el-form-item> </el-form
|
||||||
|
><template #footer
|
||||||
|
><el-button @click="dialog = false">取消</el-button
|
||||||
|
><el-button
|
||||||
|
type="primary"
|
||||||
|
:loading="generating"
|
||||||
|
:disabled="generating"
|
||||||
|
@click="generate"
|
||||||
|
>生成</el-button
|
||||||
|
></template
|
||||||
|
></el-dialog
|
||||||
|
>
|
||||||
|
<el-dialog
|
||||||
|
:model-value="resultDialog"
|
||||||
|
title="完整兑换码(仅展示一次)"
|
||||||
|
width="min(720px, calc(100vw - 32px))"
|
||||||
|
:close-on-click-modal="false"
|
||||||
|
:close-on-press-escape="false"
|
||||||
|
:show-close="false"
|
||||||
|
><el-alert
|
||||||
|
type="warning"
|
||||||
|
:closable="false"
|
||||||
|
title="关闭后系统无法再次查看完整兑换码,请立即复制或导出。"
|
||||||
|
/><el-input
|
||||||
|
class="code-output"
|
||||||
|
:model-value="codes.join('\n')"
|
||||||
|
type="textarea"
|
||||||
|
:rows="12"
|
||||||
|
readonly
|
||||||
|
/><template #footer
|
||||||
|
><el-button @click="copyAll"> <AppIcon name="CopyDocument" />复制全部 </el-button
|
||||||
|
><el-button @click="exportCodes"> <AppIcon name="Download" />导出文件 </el-button
|
||||||
|
><el-button
|
||||||
|
type="primary"
|
||||||
|
@click="closeResult"
|
||||||
|
>我已保存,关闭</el-button
|
||||||
|
></template
|
||||||
|
></el-dialog
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { reactive, ref } from "vue";
|
||||||
|
import { useRouter } from "vue-router";
|
||||||
|
import type { FormInstance, FormRules } from "element-plus";
|
||||||
|
import http, { apiError, clearAuth } from "@/api/http";
|
||||||
|
import AppIcon from "@/components/AppIcon.vue";
|
||||||
|
import PageHeader from "@/components/PageHeader.vue";
|
||||||
|
import { useAuthStore } from "@/stores/auth";
|
||||||
|
import { useToastStore } from "@/stores/toast";
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const auth = useAuthStore();
|
||||||
|
const toast = useToastStore();
|
||||||
|
const formRef = ref<FormInstance>();
|
||||||
|
const saving = ref(false);
|
||||||
|
const form = reactive({ currentPassword: "", newPassword: "", confirmPassword: "" });
|
||||||
|
|
||||||
|
const rules: FormRules = {
|
||||||
|
currentPassword: [{ required: true, message: "请输入当前密码", trigger: "blur" }],
|
||||||
|
newPassword: [
|
||||||
|
{ required: true, message: "请输入新密码", trigger: "blur" },
|
||||||
|
{ min: 8, max: 20, message: "密码长度必须为 8~20 位", trigger: "blur" },
|
||||||
|
],
|
||||||
|
confirmPassword: [
|
||||||
|
{ required: true, message: "请再次输入新密码", trigger: "blur" },
|
||||||
|
{
|
||||||
|
validator: (_rule, value, callback) =>
|
||||||
|
value === form.newPassword ? callback() : callback(new Error("两次输入的新密码不一致")),
|
||||||
|
trigger: "blur",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
if (!(await formRef.value?.validate().catch(() => false))) return;
|
||||||
|
saving.value = true;
|
||||||
|
try {
|
||||||
|
await http.put("/auth/password", {
|
||||||
|
current_password: form.currentPassword,
|
||||||
|
new_password: form.newPassword,
|
||||||
|
confirm_password: form.confirmPassword,
|
||||||
|
});
|
||||||
|
clearAuth();
|
||||||
|
auth.profile = null;
|
||||||
|
toast.success("密码已修改,请使用新密码重新登录");
|
||||||
|
await router.replace("/login");
|
||||||
|
} catch (error) {
|
||||||
|
const detail = apiError(error);
|
||||||
|
toast.error(detail.message, detail.copyText);
|
||||||
|
} finally {
|
||||||
|
saving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="page-stack security-page">
|
||||||
|
<PageHeader title="账号安全" />
|
||||||
|
|
||||||
|
<section class="panel security-panel">
|
||||||
|
<div class="security-intro">
|
||||||
|
<span class="security-icon"
|
||||||
|
><AppIcon
|
||||||
|
name="Lock"
|
||||||
|
:size="22"
|
||||||
|
/></span>
|
||||||
|
<div>
|
||||||
|
<h3>修改管理员密码</h3>
|
||||||
|
<p>需验证当前密码。修改成功后将退出当前账号,请使用新密码重新登录。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-form
|
||||||
|
ref="formRef"
|
||||||
|
:model="form"
|
||||||
|
:rules="rules"
|
||||||
|
label-position="top"
|
||||||
|
class="password-form"
|
||||||
|
>
|
||||||
|
<el-form-item
|
||||||
|
label="当前密码"
|
||||||
|
prop="currentPassword"
|
||||||
|
>
|
||||||
|
<el-input
|
||||||
|
v-model="form.currentPassword"
|
||||||
|
type="password"
|
||||||
|
autocomplete="current-password"
|
||||||
|
show-password
|
||||||
|
placeholder="请输入当前密码"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item
|
||||||
|
label="新密码"
|
||||||
|
prop="newPassword"
|
||||||
|
>
|
||||||
|
<el-input
|
||||||
|
v-model="form.newPassword"
|
||||||
|
type="password"
|
||||||
|
autocomplete="new-password"
|
||||||
|
show-password
|
||||||
|
placeholder="请输入 8~20 位新密码"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item
|
||||||
|
label="确认新密码"
|
||||||
|
prop="confirmPassword"
|
||||||
|
>
|
||||||
|
<el-input
|
||||||
|
v-model="form.confirmPassword"
|
||||||
|
type="password"
|
||||||
|
autocomplete="new-password"
|
||||||
|
show-password
|
||||||
|
placeholder="请再次输入新密码"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<div class="form-actions">
|
||||||
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
:loading="saving"
|
||||||
|
@click="submit"
|
||||||
|
>确认修改</el-button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</el-form>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.security-page {
|
||||||
|
max-width: 760px;
|
||||||
|
}
|
||||||
|
.security-panel {
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
.security-intro {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 14px;
|
||||||
|
padding-bottom: 20px;
|
||||||
|
border-bottom: 1px solid var(--color-border-light);
|
||||||
|
}
|
||||||
|
.security-icon {
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
flex: 0 0 42px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
color: var(--color-primary);
|
||||||
|
background: var(--color-primary-soft);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
.security-intro h3 {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
.security-intro p {
|
||||||
|
margin-top: 5px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.password-form {
|
||||||
|
max-width: 480px;
|
||||||
|
margin-top: 22px;
|
||||||
|
}
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding-top: 4px;
|
||||||
|
}
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.security-panel {
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
.password-form {
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
.form-actions .el-button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,437 @@
|
|||||||
|
<!-- 视觉风格管理页,负责风格图片、名称和排序维护。 -->
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, reactive, ref } from "vue";
|
||||||
|
import { ElMessageBox, type FormInstance, type UploadRequestOptions } from "element-plus";
|
||||||
|
import AppIcon from "@/components/AppIcon.vue";
|
||||||
|
import PageHeader from "@/components/PageHeader.vue";
|
||||||
|
import http, { apiError } from "@/api/http";
|
||||||
|
import { useToastStore } from "@/stores/toast";
|
||||||
|
|
||||||
|
type UploadError = Error & { status: number; method: string; url: string };
|
||||||
|
const toast = useToastStore();
|
||||||
|
const loading = ref(false);
|
||||||
|
const saving = ref(false);
|
||||||
|
const dialogOpen = ref(false);
|
||||||
|
const editingId = ref("");
|
||||||
|
const items = ref<Record<string, any>[]>([]);
|
||||||
|
const formRef = ref<FormInstance>();
|
||||||
|
const form = reactive<Record<string, any>>({ name: "", image: "", image_key: "" });
|
||||||
|
const draggedId = ref("");
|
||||||
|
const originalOrder = ref<string[]>([]);
|
||||||
|
const reordering = ref(false);
|
||||||
|
const canReorder = computed(() => items.value.length > 1 && !reordering.value);
|
||||||
|
const rules = { name: [{ required: true, message: "请填写风格名", trigger: "blur" }] };
|
||||||
|
|
||||||
|
// 加载风格列表,并保留接口返回的排序结果。
|
||||||
|
async function load() {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const { data } = await http.get("/resources/styles", { params: { page: 1, page_size: 100 } });
|
||||||
|
items.value = data.data.items || [];
|
||||||
|
} catch (error) {
|
||||||
|
showError(error);
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 重置弹窗表单;编辑时回填当前风格及其图片信息。
|
||||||
|
function reset(row?: Record<string, any>) {
|
||||||
|
editingId.value = row?.id || "";
|
||||||
|
Object.assign(form, {
|
||||||
|
name: row?.name || "",
|
||||||
|
image: row?.image_url || "",
|
||||||
|
image_key: row?.image_key || "",
|
||||||
|
image_url: row?.image_url || "",
|
||||||
|
image_mime: row?.image_mime || "",
|
||||||
|
image_size: row?.image_size || "",
|
||||||
|
image_width: row?.image_width || "",
|
||||||
|
image_height: row?.image_height || "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// 打开新建风格弹窗。
|
||||||
|
function openCreate() {
|
||||||
|
reset();
|
||||||
|
dialogOpen.value = true;
|
||||||
|
}
|
||||||
|
// 打开编辑风格弹窗并回填目标风格。
|
||||||
|
function openEdit(row: Record<string, any>) {
|
||||||
|
reset(row);
|
||||||
|
dialogOpen.value = true;
|
||||||
|
}
|
||||||
|
// 上传风格图片,并将服务端返回的图片元数据写入表单。
|
||||||
|
async function upload(options: UploadRequestOptions) {
|
||||||
|
const data = new FormData();
|
||||||
|
data.append("file", options.file);
|
||||||
|
try {
|
||||||
|
const response = await http.post("/uploads/style-images", data, {
|
||||||
|
headers: { "Content-Type": "multipart/form-data" },
|
||||||
|
});
|
||||||
|
Object.assign(form, response.data.data);
|
||||||
|
form.image = response.data.data.image_url || response.data.data.image_key;
|
||||||
|
options.onSuccess(response.data);
|
||||||
|
} catch (error) {
|
||||||
|
const uploadError = Object.assign(error instanceof Error ? error : new Error(String(error)), {
|
||||||
|
status: 0,
|
||||||
|
method: "POST",
|
||||||
|
url: "/uploads/style-images",
|
||||||
|
}) as UploadError;
|
||||||
|
options.onError(uploadError);
|
||||||
|
showError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 校验并保存风格;图片上传成功后才允许提交。
|
||||||
|
async function save() {
|
||||||
|
if (!(await formRef.value?.validate().catch(() => false))) return;
|
||||||
|
if (!form.image_key) {
|
||||||
|
toast.warning("请先上传风格图片");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
saving.value = true;
|
||||||
|
try {
|
||||||
|
const payload = { ...form };
|
||||||
|
delete payload.image;
|
||||||
|
if (editingId.value) await http.put(`/resources/styles/${editingId.value}`, payload);
|
||||||
|
else await http.post("/resources/styles", payload);
|
||||||
|
dialogOpen.value = false;
|
||||||
|
toast.success(editingId.value ? "风格已更新" : "风格已创建");
|
||||||
|
await load();
|
||||||
|
} catch (error) {
|
||||||
|
showError(error);
|
||||||
|
} finally {
|
||||||
|
saving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 删除指定风格,并在成功后刷新列表。
|
||||||
|
async function remove(row: Record<string, any>) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`确定删除风格“${row.name}”吗?`, "删除风格", {
|
||||||
|
type: "warning",
|
||||||
|
confirmButtonText: "删除",
|
||||||
|
cancelButtonText: "取消",
|
||||||
|
});
|
||||||
|
await http.delete(`/resources/styles/${row.id}`);
|
||||||
|
toast.success("风格已删除");
|
||||||
|
await load();
|
||||||
|
} catch (error) {
|
||||||
|
if (error !== "cancel" && error !== "close") showError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 记录拖拽起点和原始顺序,用于提交排序或失败回滚。
|
||||||
|
function startDrag(row: Record<string, any>, event: DragEvent) {
|
||||||
|
if (!canReorder.value) return;
|
||||||
|
draggedId.value = row.id;
|
||||||
|
originalOrder.value = items.value.map((item) => item.id);
|
||||||
|
event.dataTransfer?.setData("text/plain", row.id);
|
||||||
|
}
|
||||||
|
// 根据当前悬停位置实时调整风格卡片顺序。
|
||||||
|
function moveOver(targetId: string) {
|
||||||
|
const sourceIndex = items.value.findIndex((item) => item.id === draggedId.value);
|
||||||
|
const targetIndex = items.value.findIndex((item) => item.id === targetId);
|
||||||
|
if (sourceIndex < 0 || targetIndex < 0 || sourceIndex === targetIndex) return;
|
||||||
|
const [moved] = items.value.splice(sourceIndex, 1);
|
||||||
|
items.value.splice(targetIndex, 0, moved);
|
||||||
|
}
|
||||||
|
// 提交拖拽后的顺序;失败时恢复原始顺序。
|
||||||
|
async function finishDrag() {
|
||||||
|
if (!draggedId.value) return;
|
||||||
|
const nextOrder = items.value.map((item) => item.id);
|
||||||
|
draggedId.value = "";
|
||||||
|
if (nextOrder.every((id, index) => id === originalOrder.value[index])) return;
|
||||||
|
reordering.value = true;
|
||||||
|
try {
|
||||||
|
await http.put("/resources/styles/reorder", { ids: nextOrder });
|
||||||
|
toast.success("风格排序已保存");
|
||||||
|
} catch (error) {
|
||||||
|
const positions = new Map(originalOrder.value.map((id, index) => [id, index]));
|
||||||
|
items.value.sort((a, b) => (positions.get(a.id) ?? 0) - (positions.get(b.id) ?? 0));
|
||||||
|
showError(error);
|
||||||
|
} finally {
|
||||||
|
reordering.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 将接口异常转换为管理端统一提示。
|
||||||
|
function showError(error: unknown) {
|
||||||
|
const detail = apiError(error);
|
||||||
|
toast.error(detail.message, detail.copyText);
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="page-stack management-page">
|
||||||
|
<PageHeader
|
||||||
|
title="风格管理"
|
||||||
|
description="管理项目风格、展示图片与排序"
|
||||||
|
>
|
||||||
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
@click="openCreate"
|
||||||
|
><AppIcon name="Plus" />新建风格</el-button
|
||||||
|
>
|
||||||
|
</PageHeader>
|
||||||
|
<section
|
||||||
|
v-loading="loading"
|
||||||
|
class="panel style-grid-panel"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="items.length"
|
||||||
|
class="style-card-grid"
|
||||||
|
>
|
||||||
|
<article
|
||||||
|
v-for="row in items"
|
||||||
|
:key="row.id"
|
||||||
|
class="style-card"
|
||||||
|
:class="{ 'is-dragging': draggedId === row.id }"
|
||||||
|
@dragover.prevent="moveOver(row.id)"
|
||||||
|
@drop.prevent="finishDrag"
|
||||||
|
>
|
||||||
|
<div class="style-card__visual">
|
||||||
|
<img
|
||||||
|
:src="row.image_url"
|
||||||
|
alt=""
|
||||||
|
draggable="false"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
class="style-card__drag"
|
||||||
|
:draggable="canReorder"
|
||||||
|
title="拖动排序"
|
||||||
|
@dragstart="startDrag(row, $event)"
|
||||||
|
@dragend="finishDrag"
|
||||||
|
>
|
||||||
|
<AppIcon name="Rank" />
|
||||||
|
</span>
|
||||||
|
<div class="style-card__actions">
|
||||||
|
<el-button
|
||||||
|
circle
|
||||||
|
title="编辑风格"
|
||||||
|
@click="openEdit(row)"
|
||||||
|
><AppIcon name="EditPen"
|
||||||
|
/></el-button>
|
||||||
|
<el-button
|
||||||
|
circle
|
||||||
|
type="danger"
|
||||||
|
plain
|
||||||
|
title="删除风格"
|
||||||
|
@click="remove(row)"
|
||||||
|
><AppIcon name="Delete"
|
||||||
|
/></el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<strong>{{ row.name }}</strong>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
<el-empty
|
||||||
|
v-else
|
||||||
|
description="暂无风格"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<el-dialog
|
||||||
|
v-model="dialogOpen"
|
||||||
|
:title="editingId ? '编辑风格' : '新建风格'"
|
||||||
|
class="style-dialog"
|
||||||
|
width="min(720px, calc(100vw - 32px))"
|
||||||
|
align-center
|
||||||
|
>
|
||||||
|
<el-form
|
||||||
|
ref="formRef"
|
||||||
|
:model="form"
|
||||||
|
:rules="rules"
|
||||||
|
label-position="top"
|
||||||
|
class="style-form"
|
||||||
|
>
|
||||||
|
<div class="style-form__preview-column">
|
||||||
|
<span class="style-form__label">图片预览</span>
|
||||||
|
<div class="upload-preview-frame">
|
||||||
|
<img
|
||||||
|
v-if="form.image"
|
||||||
|
class="upload-preview"
|
||||||
|
:src="form.image"
|
||||||
|
alt=""
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="upload-preview-empty"
|
||||||
|
>
|
||||||
|
<AppIcon name="Picture" />
|
||||||
|
<span>9:16 图片预览</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="style-form__fields">
|
||||||
|
<el-form-item
|
||||||
|
label="风格名"
|
||||||
|
prop="name"
|
||||||
|
><el-input v-model.trim="form.name"
|
||||||
|
/></el-form-item>
|
||||||
|
<el-form-item
|
||||||
|
label="风格图片"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<el-upload
|
||||||
|
:show-file-list="false"
|
||||||
|
:http-request="upload"
|
||||||
|
accept="image/jpeg,image/png,image/gif"
|
||||||
|
>
|
||||||
|
<el-button><AppIcon name="Upload" />{{ form.image ? "重新上传" : "上传图片" }}</el-button>
|
||||||
|
</el-upload>
|
||||||
|
<p class="field-helper">推荐 9:16,支持 JPEG、PNG、GIF,最大 10MB</p>
|
||||||
|
</el-form-item>
|
||||||
|
</div>
|
||||||
|
</el-form>
|
||||||
|
<template #footer
|
||||||
|
><el-button @click="dialogOpen = false">取消</el-button
|
||||||
|
><el-button
|
||||||
|
type="primary"
|
||||||
|
:loading="saving"
|
||||||
|
@click="save"
|
||||||
|
>保存</el-button
|
||||||
|
></template
|
||||||
|
>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.style-grid-panel {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
.style-card-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.style-card {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
transition:
|
||||||
|
border-color 0.18s,
|
||||||
|
transform 0.18s;
|
||||||
|
}
|
||||||
|
.style-card:hover {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
.style-card.is-dragging {
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
.style-card__visual {
|
||||||
|
height: 230px;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--color-bg);
|
||||||
|
}
|
||||||
|
.style-card__visual img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
.style-card__drag {
|
||||||
|
position: absolute;
|
||||||
|
top: 10px;
|
||||||
|
left: 10px;
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
color: #fff;
|
||||||
|
background: #111827;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
.style-card__actions {
|
||||||
|
position: absolute;
|
||||||
|
top: 10px;
|
||||||
|
right: 10px;
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.style-card > strong {
|
||||||
|
padding: 13px 14px;
|
||||||
|
display: block;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.style-form {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 180px minmax(0, 1fr);
|
||||||
|
gap: var(--space-lg);
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
.style-form__preview-column,
|
||||||
|
.style-form__fields {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.style-form__label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: var(--space-sm);
|
||||||
|
color: var(--color-text);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
line-height: 22px;
|
||||||
|
}
|
||||||
|
.style-form__label::before {
|
||||||
|
margin-right: var(--space-xs);
|
||||||
|
color: var(--color-danger);
|
||||||
|
content: "*";
|
||||||
|
}
|
||||||
|
.upload-preview-frame {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 9 / 16;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--color-bg);
|
||||||
|
}
|
||||||
|
.upload-preview {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
.upload-preview-empty {
|
||||||
|
display: flex;
|
||||||
|
height: 100%;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
}
|
||||||
|
.upload-preview-empty :deep(.el-icon) {
|
||||||
|
font-size: 28px;
|
||||||
|
}
|
||||||
|
.style-form__fields :deep(.el-form-item:last-child) {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
.field-helper {
|
||||||
|
width: 100%;
|
||||||
|
margin: var(--space-sm) 0 0;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.style-card-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
.style-card__visual {
|
||||||
|
height: 190px;
|
||||||
|
}
|
||||||
|
.style-form {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: var(--space-md);
|
||||||
|
}
|
||||||
|
.upload-preview-frame {
|
||||||
|
width: min(180px, 100%);
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,730 @@
|
|||||||
|
<!-- 管理后台用户管理页,负责用户创建、状态维护、每日限额和积分发放。 -->
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onBeforeUnmount, onMounted, reactive, ref } from "vue";
|
||||||
|
import { ElMessageBox, type FormInstance } from "element-plus";
|
||||||
|
import http, { apiError } from "@/api/http";
|
||||||
|
import AppIcon from "@/components/AppIcon.vue";
|
||||||
|
import AdminNumberInput from "@/components/AdminNumberInput.vue";
|
||||||
|
import PageHeader from "@/components/PageHeader.vue";
|
||||||
|
import { useToastStore } from "@/stores/toast";
|
||||||
|
|
||||||
|
const toast = useToastStore(),
|
||||||
|
loading = ref(false),
|
||||||
|
items = ref<Record<string, any>[]>([]),
|
||||||
|
total = ref(0),
|
||||||
|
selected = ref<Record<string, any>[]>([]),
|
||||||
|
dialog = ref<"single" | "batch" | "">(""),
|
||||||
|
pointGrantOpen = ref(false),
|
||||||
|
pointGrantTarget = ref<Record<string, any>>(),
|
||||||
|
grantingPoints = ref(false),
|
||||||
|
editingLimitId = ref(""),
|
||||||
|
editingLimitValue = ref<number | null>(null),
|
||||||
|
savingLimitId = ref("");
|
||||||
|
const query = reactive({ keyword: "", enabled: "", page: 1, page_size: 15 });
|
||||||
|
const form = reactive({
|
||||||
|
account: "",
|
||||||
|
prefix: "",
|
||||||
|
start_sequence: "001",
|
||||||
|
end_sequence: "001",
|
||||||
|
password: "",
|
||||||
|
daily_limit: 9999,
|
||||||
|
});
|
||||||
|
const pointGrantForm = reactive({ points: null as number | null, reason: "", request_id: "" });
|
||||||
|
const formRef = ref<FormInstance>();
|
||||||
|
const dialogOpen = computed({
|
||||||
|
get: () => dialog.value !== "",
|
||||||
|
set: (value: boolean) => {
|
||||||
|
if (!value) dialog.value = "";
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const canGrantPoints = computed(
|
||||||
|
() =>
|
||||||
|
Number.isFinite(Number(pointGrantForm.points)) &&
|
||||||
|
Number(pointGrantForm.points) > 0 &&
|
||||||
|
Boolean(pointGrantForm.reason.trim()),
|
||||||
|
);
|
||||||
|
const batchPreview = computed(() => {
|
||||||
|
const prefix = form.prefix.trim();
|
||||||
|
const startRaw = form.start_sequence.trim();
|
||||||
|
const endRaw = form.end_sequence.trim();
|
||||||
|
if (!prefix) return { error: "请输入账号前缀", count: 0, first: "", last: "" };
|
||||||
|
if (!/^\d+$/.test(startRaw) || !/^\d+$/.test(endRaw)) {
|
||||||
|
return { error: "起始序号和终止序号必须为数字", count: 0, first: "", last: "" };
|
||||||
|
}
|
||||||
|
const start = Number(startRaw);
|
||||||
|
const end = Number(endRaw);
|
||||||
|
if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end)) {
|
||||||
|
return { error: "序号超出有效范围", count: 0, first: "", last: "" };
|
||||||
|
}
|
||||||
|
if (start > end) return { error: "起始序号不能大于终止序号", count: 0, first: "", last: "" };
|
||||||
|
const count = end - start + 1;
|
||||||
|
if (count > 50) return { error: "每次最多批量创建 50 个用户", count, first: "", last: "" };
|
||||||
|
const width = Math.max(startRaw.length, endRaw.length);
|
||||||
|
return {
|
||||||
|
error: "",
|
||||||
|
count,
|
||||||
|
first: `${prefix}-${String(start).padStart(width, "0")}`,
|
||||||
|
last: `${prefix}-${String(end).padStart(width, "0")}`,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
async function load(silent = false) {
|
||||||
|
if (silent && editingLimitId.value) return;
|
||||||
|
if (!silent) loading.value = true;
|
||||||
|
try {
|
||||||
|
const { data } = await http.get("/users", { params: query });
|
||||||
|
items.value = data.data.items;
|
||||||
|
total.value = data.data.total;
|
||||||
|
} catch (e) {
|
||||||
|
showError(e);
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function editDailyLimit(row: Record<string, any>) {
|
||||||
|
editingLimitId.value = row.id;
|
||||||
|
editingLimitValue.value = Number(row.daily_limit);
|
||||||
|
}
|
||||||
|
function cancelDailyLimitEdit() {
|
||||||
|
editingLimitId.value = "";
|
||||||
|
editingLimitValue.value = null;
|
||||||
|
}
|
||||||
|
function blurLimitInput(event: KeyboardEvent) {
|
||||||
|
(event.target as HTMLInputElement).blur();
|
||||||
|
}
|
||||||
|
async function saveDailyLimit(row: Record<string, any>) {
|
||||||
|
if (editingLimitId.value !== row.id) return;
|
||||||
|
const value = Number(editingLimitValue.value);
|
||||||
|
if (!Number.isFinite(value) || value < 0) {
|
||||||
|
toast.warning("每日限额不能小于 0");
|
||||||
|
cancelDailyLimitEdit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (value === Number(row.daily_limit)) {
|
||||||
|
cancelDailyLimitEdit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
savingLimitId.value = row.id;
|
||||||
|
try {
|
||||||
|
await http.patch("/users/batch", {
|
||||||
|
ids: [row.id],
|
||||||
|
daily_limit: value.toFixed(2),
|
||||||
|
reason: "管理员原地修改每日限额",
|
||||||
|
});
|
||||||
|
row.daily_limit = value;
|
||||||
|
toast.success("每日限额已保存");
|
||||||
|
} catch (e) {
|
||||||
|
showError(e);
|
||||||
|
} finally {
|
||||||
|
savingLimitId.value = "";
|
||||||
|
cancelDailyLimitEdit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function open(type: "single" | "batch") {
|
||||||
|
Object.assign(form, {
|
||||||
|
account: "",
|
||||||
|
prefix: "",
|
||||||
|
start_sequence: "001",
|
||||||
|
end_sequence: "001",
|
||||||
|
password: "",
|
||||||
|
daily_limit: 9999,
|
||||||
|
});
|
||||||
|
dialog.value = type;
|
||||||
|
}
|
||||||
|
async function create() {
|
||||||
|
if (!(await formRef.value?.validate().catch(() => false))) return;
|
||||||
|
if (dialog.value === "batch" && batchPreview.value.error) {
|
||||||
|
toast.warning(batchPreview.value.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
if (dialog.value === "single")
|
||||||
|
await http.post("/users", {
|
||||||
|
account: form.account,
|
||||||
|
password: form.password,
|
||||||
|
daily_limit: form.daily_limit,
|
||||||
|
});
|
||||||
|
else
|
||||||
|
await http.post("/users/batch", {
|
||||||
|
prefix: form.prefix,
|
||||||
|
start_sequence: form.start_sequence,
|
||||||
|
end_sequence: form.end_sequence,
|
||||||
|
password: form.password,
|
||||||
|
daily_limit: form.daily_limit,
|
||||||
|
});
|
||||||
|
toast.success("用户创建成功");
|
||||||
|
dialog.value = "";
|
||||||
|
load();
|
||||||
|
} catch (e) {
|
||||||
|
showError(e);
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function toggle(row: Record<string, any>, enabled: boolean) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
`${enabled ? "启用" : "停用"}用户 ${row.account}(${row.username})?停用后会撤销其有效会话。`,
|
||||||
|
"确认状态变更",
|
||||||
|
{ type: "warning" },
|
||||||
|
);
|
||||||
|
await http.patch("/users/batch", {
|
||||||
|
ids: [row.id],
|
||||||
|
enabled,
|
||||||
|
reason: `管理员${enabled ? "启用" : "停用"}用户`,
|
||||||
|
});
|
||||||
|
row.enabled = enabled;
|
||||||
|
toast.success("用户状态已更新");
|
||||||
|
} catch (e) {
|
||||||
|
row.enabled = !enabled;
|
||||||
|
if (e !== "cancel" && e !== "close") showError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function adjustLimit() {
|
||||||
|
try {
|
||||||
|
const { value } = await ElMessageBox.prompt(
|
||||||
|
"请输入新的每日限额(0 表示禁止消耗,9999 表示不限制)",
|
||||||
|
"批量调整每日限额",
|
||||||
|
{
|
||||||
|
inputType: "text",
|
||||||
|
inputValue: "9999",
|
||||||
|
inputValidator: (v) => Number(v) >= 0 || "限额不能小于 0",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const reason = await ElMessageBox.prompt("请填写调整原因", "填写审计原因", {
|
||||||
|
inputValidator: (v) => Boolean(v.trim()) || "调整原因不能为空",
|
||||||
|
});
|
||||||
|
await http.patch("/users/batch", {
|
||||||
|
ids: selected.value.map((i) => i.id),
|
||||||
|
daily_limit: Number(value).toFixed(2),
|
||||||
|
reason: reason.value,
|
||||||
|
});
|
||||||
|
toast.success("每日限额已调整");
|
||||||
|
selected.value = [];
|
||||||
|
load();
|
||||||
|
} catch (e) {
|
||||||
|
if (e !== "cancel" && e !== "close") showError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/** 打开单用户积分发放弹窗,并为本次操作生成幂等请求号。 */
|
||||||
|
function openPointGrant(row: Record<string, any>) {
|
||||||
|
pointGrantTarget.value = row;
|
||||||
|
Object.assign(pointGrantForm, { points: null, reason: "", request_id: crypto.randomUUID() });
|
||||||
|
pointGrantOpen.value = true;
|
||||||
|
}
|
||||||
|
/** 校验正数积分并通过账务接口完成入账,成功后同步列表余额。 */
|
||||||
|
async function grantPoints() {
|
||||||
|
if (!pointGrantTarget.value || !canGrantPoints.value) return;
|
||||||
|
grantingPoints.value = true;
|
||||||
|
try {
|
||||||
|
const points = Number(pointGrantForm.points).toFixed(2);
|
||||||
|
const { data } = await http.post(`/users/${pointGrantTarget.value.id}/points`, {
|
||||||
|
request_id: pointGrantForm.request_id,
|
||||||
|
points,
|
||||||
|
reason: pointGrantForm.reason.trim(),
|
||||||
|
});
|
||||||
|
pointGrantTarget.value.point_balance = data.data.balance;
|
||||||
|
toast.success(`已增加 ${points} 积分`);
|
||||||
|
pointGrantOpen.value = false;
|
||||||
|
} catch (e) {
|
||||||
|
showError(e);
|
||||||
|
} finally {
|
||||||
|
grantingPoints.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function remove(ids: string[]) {
|
||||||
|
try {
|
||||||
|
const reason = await ElMessageBox.prompt(
|
||||||
|
`确认软删除 ${ids.length} 个用户?其订单、流水和使用记录将保留。`,
|
||||||
|
"确认删除",
|
||||||
|
{
|
||||||
|
type: "warning",
|
||||||
|
inputPlaceholder: "请输入删除原因",
|
||||||
|
inputValidator: (v) => Boolean(v.trim()) || "删除原因不能为空",
|
||||||
|
confirmButtonText: "确认删除",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await http.delete("/users/batch", { data: { ids, reason: reason.value } });
|
||||||
|
toast.success("用户已删除");
|
||||||
|
selected.value = [];
|
||||||
|
load();
|
||||||
|
} catch (e) {
|
||||||
|
if (e !== "cancel" && e !== "close") showError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const fmt = (v: any) =>
|
||||||
|
v
|
||||||
|
? new Date(v).toLocaleString("zh-CN", {
|
||||||
|
timeZone: "Asia/Shanghai",
|
||||||
|
hour12: false,
|
||||||
|
})
|
||||||
|
: "从未在线";
|
||||||
|
function showError(e: unknown) {
|
||||||
|
const d = apiError(e);
|
||||||
|
toast.error(d.message, d.copyText);
|
||||||
|
}
|
||||||
|
const timer = window.setInterval(() => load(true), 300000);
|
||||||
|
onMounted(load);
|
||||||
|
onBeforeUnmount(() => clearInterval(timer));
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<div class="page-stack management-page">
|
||||||
|
<PageHeader
|
||||||
|
title="用户管理"
|
||||||
|
description="管理 WEB 用户账号、每日限额和启用状态"
|
||||||
|
><el-button @click="open('batch')"> <AppIcon name="UserFilled" />批量新建 </el-button
|
||||||
|
><el-button
|
||||||
|
type="primary"
|
||||||
|
@click="open('single')"
|
||||||
|
>
|
||||||
|
<AppIcon name="Plus" />新建用户
|
||||||
|
</el-button></PageHeader
|
||||||
|
>
|
||||||
|
<section class="panel filter-bar">
|
||||||
|
<el-input
|
||||||
|
v-model="query.keyword"
|
||||||
|
clearable
|
||||||
|
placeholder="账号、用户名或 UID"
|
||||||
|
/><el-select
|
||||||
|
v-model="query.enabled"
|
||||||
|
clearable
|
||||||
|
placeholder="启用状态"
|
||||||
|
><el-option
|
||||||
|
label="已启用"
|
||||||
|
value="true" /><el-option
|
||||||
|
label="已停用"
|
||||||
|
value="false" /></el-select
|
||||||
|
><el-button
|
||||||
|
type="primary"
|
||||||
|
@click="
|
||||||
|
query.page = 1;
|
||||||
|
load();
|
||||||
|
"
|
||||||
|
>查询</el-button
|
||||||
|
><el-button
|
||||||
|
@click="
|
||||||
|
query.keyword = '';
|
||||||
|
query.enabled = '';
|
||||||
|
query.page = 1;
|
||||||
|
load();
|
||||||
|
"
|
||||||
|
>重置</el-button
|
||||||
|
>
|
||||||
|
</section>
|
||||||
|
<section
|
||||||
|
v-if="selected.length"
|
||||||
|
class="batch-toolbar"
|
||||||
|
>
|
||||||
|
<strong>已选择 {{ selected.length }} 项</strong><el-button @click="selected = []">取消选择</el-button
|
||||||
|
><el-button @click="adjustLimit">调整每日限额</el-button
|
||||||
|
><el-button
|
||||||
|
type="danger"
|
||||||
|
@click="remove(selected.map((i) => i.id))"
|
||||||
|
>批量删除</el-button
|
||||||
|
>
|
||||||
|
</section>
|
||||||
|
<section
|
||||||
|
class="panel table-panel"
|
||||||
|
v-loading="loading"
|
||||||
|
>
|
||||||
|
<el-table
|
||||||
|
class="desktop-user-table"
|
||||||
|
:data="items"
|
||||||
|
@selection-change="selected = $event"
|
||||||
|
><el-table-column
|
||||||
|
type="selection"
|
||||||
|
width="46"
|
||||||
|
/><el-table-column
|
||||||
|
prop="account"
|
||||||
|
label="账号"
|
||||||
|
min-width="150"
|
||||||
|
show-overflow-tooltip
|
||||||
|
/><el-table-column
|
||||||
|
prop="username"
|
||||||
|
label="用户名"
|
||||||
|
min-width="170"
|
||||||
|
/><el-table-column
|
||||||
|
prop="uid"
|
||||||
|
label="UID"
|
||||||
|
min-width="125"
|
||||||
|
/><el-table-column
|
||||||
|
label="积分余额"
|
||||||
|
min-width="120"
|
||||||
|
><template #default="{ row }">{{ Number(row.point_balance).toFixed(2) }}</template></el-table-column
|
||||||
|
><el-table-column
|
||||||
|
label="今日消耗"
|
||||||
|
min-width="120"
|
||||||
|
><template #default="{ row }">{{ Number(row.today_consumption).toFixed(2) }}</template></el-table-column
|
||||||
|
><el-table-column
|
||||||
|
label="每日限额"
|
||||||
|
min-width="170"
|
||||||
|
><template #default="{ row }">
|
||||||
|
<div class="limit-cell">
|
||||||
|
<AdminNumberInput
|
||||||
|
v-if="editingLimitId === row.id"
|
||||||
|
v-model="editingLimitValue"
|
||||||
|
class="limit-input"
|
||||||
|
:min="0"
|
||||||
|
:precision="2"
|
||||||
|
:disabled="savingLimitId === row.id"
|
||||||
|
autofocus
|
||||||
|
@blur="saveDailyLimit(row)"
|
||||||
|
/><template v-else
|
||||||
|
><span>{{ Number(row.daily_limit) === 9999 ? "不限制" : Number(row.daily_limit).toFixed(2) }}</span
|
||||||
|
><el-button
|
||||||
|
class="limit-edit-button"
|
||||||
|
text
|
||||||
|
circle
|
||||||
|
type="primary"
|
||||||
|
title="编辑每日限额"
|
||||||
|
@click="editDailyLimit(row)"
|
||||||
|
>
|
||||||
|
<AppIcon
|
||||||
|
name="Edit"
|
||||||
|
:size="15"
|
||||||
|
/> </el-button
|
||||||
|
></template>
|
||||||
|
</div> </template></el-table-column
|
||||||
|
><el-table-column
|
||||||
|
label="启用状态"
|
||||||
|
width="96"
|
||||||
|
><template #default="{ row }"
|
||||||
|
><el-switch
|
||||||
|
:model-value="row.enabled"
|
||||||
|
@change="toggle(row, Boolean($event))" /></template></el-table-column
|
||||||
|
><el-table-column
|
||||||
|
label="最后在线时间"
|
||||||
|
min-width="190"
|
||||||
|
><template #default="{ row }">{{ fmt(row.last_online_at) }}</template></el-table-column
|
||||||
|
><el-table-column
|
||||||
|
label="操作"
|
||||||
|
width="150"
|
||||||
|
><template #default="{ row }"
|
||||||
|
><el-button
|
||||||
|
link
|
||||||
|
type="primary"
|
||||||
|
:disabled="!row.enabled"
|
||||||
|
:title="row.enabled ? '增加积分' : '停用用户不可增加积分'"
|
||||||
|
@click="openPointGrant(row)"
|
||||||
|
>增加积分</el-button
|
||||||
|
><el-button
|
||||||
|
link
|
||||||
|
type="danger"
|
||||||
|
@click="remove([row.id])"
|
||||||
|
>删除</el-button
|
||||||
|
></template
|
||||||
|
></el-table-column
|
||||||
|
></el-table
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="items.length"
|
||||||
|
class="mobile-card-list"
|
||||||
|
>
|
||||||
|
<article
|
||||||
|
v-for="row in items"
|
||||||
|
:key="row.id"
|
||||||
|
class="data-card"
|
||||||
|
>
|
||||||
|
<dl>
|
||||||
|
<dt>账号</dt>
|
||||||
|
<dd>{{ row.account }}</dd>
|
||||||
|
<dt>用户名</dt>
|
||||||
|
<dd>{{ row.username }}</dd>
|
||||||
|
<dt>UID</dt>
|
||||||
|
<dd>{{ row.uid }}</dd>
|
||||||
|
<dt>积分余额</dt>
|
||||||
|
<dd>{{ Number(row.point_balance).toFixed(2) }}</dd>
|
||||||
|
<dt>今日消耗</dt>
|
||||||
|
<dd>{{ Number(row.today_consumption).toFixed(2) }}</dd>
|
||||||
|
<dt>每日限额</dt>
|
||||||
|
<dd>
|
||||||
|
<div class="limit-cell">
|
||||||
|
<AdminNumberInput
|
||||||
|
v-if="editingLimitId === row.id"
|
||||||
|
v-model="editingLimitValue"
|
||||||
|
class="limit-input"
|
||||||
|
:min="0"
|
||||||
|
:precision="2"
|
||||||
|
:disabled="savingLimitId === row.id"
|
||||||
|
autofocus
|
||||||
|
@blur="saveDailyLimit(row)"
|
||||||
|
/><template v-else
|
||||||
|
><span>{{ Number(row.daily_limit) === 9999 ? "不限制" : Number(row.daily_limit).toFixed(2) }}</span
|
||||||
|
><el-button
|
||||||
|
class="limit-edit-button"
|
||||||
|
text
|
||||||
|
circle
|
||||||
|
type="primary"
|
||||||
|
title="编辑每日限额"
|
||||||
|
@click="editDailyLimit(row)"
|
||||||
|
>
|
||||||
|
<AppIcon
|
||||||
|
name="Edit"
|
||||||
|
:size="15"
|
||||||
|
/> </el-button
|
||||||
|
></template>
|
||||||
|
</div>
|
||||||
|
</dd>
|
||||||
|
<dt>最后在线</dt>
|
||||||
|
<dd>{{ fmt(row.last_online_at) }}</dd>
|
||||||
|
</dl>
|
||||||
|
<div class="card-actions">
|
||||||
|
<el-switch
|
||||||
|
:model-value="row.enabled"
|
||||||
|
@change="toggle(row, Boolean($event))"
|
||||||
|
/>
|
||||||
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
plain
|
||||||
|
:disabled="!row.enabled"
|
||||||
|
@click="openPointGrant(row)"
|
||||||
|
>增加积分</el-button
|
||||||
|
>
|
||||||
|
<el-button
|
||||||
|
type="danger"
|
||||||
|
plain
|
||||||
|
@click="remove([row.id])"
|
||||||
|
>删除</el-button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
<el-empty
|
||||||
|
v-if="!loading && !items.length"
|
||||||
|
description="暂无用户"
|
||||||
|
/><el-pagination
|
||||||
|
v-if="total > query.page_size"
|
||||||
|
v-model:current-page="query.page"
|
||||||
|
background
|
||||||
|
layout="total,prev,pager,next"
|
||||||
|
:total="total"
|
||||||
|
@current-change="load"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
<el-dialog
|
||||||
|
v-model="pointGrantOpen"
|
||||||
|
title="增加用户积分"
|
||||||
|
width="min(480px, calc(100vw - 32px))"
|
||||||
|
:close-on-click-modal="!grantingPoints"
|
||||||
|
:show-close="!grantingPoints"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="pointGrantTarget"
|
||||||
|
class="point-grant-summary"
|
||||||
|
>
|
||||||
|
<strong>{{ pointGrantTarget.account }}({{ pointGrantTarget.username }})</strong>
|
||||||
|
<span>当前余额 {{ Number(pointGrantTarget.point_balance).toFixed(2) }} 积分</span>
|
||||||
|
</div>
|
||||||
|
<el-form label-position="top">
|
||||||
|
<el-form-item label="增加积分">
|
||||||
|
<AdminNumberInput
|
||||||
|
v-model="pointGrantForm.points"
|
||||||
|
:min="0.01"
|
||||||
|
:precision="2"
|
||||||
|
:disabled="grantingPoints"
|
||||||
|
placeholder="请输入大于 0 的积分数量"
|
||||||
|
/>
|
||||||
|
<div class="field-helper">仅支持增加积分,到账后会写入用户积分变动记录</div>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="操作原因">
|
||||||
|
<el-input
|
||||||
|
v-model.trim="pointGrantForm.reason"
|
||||||
|
maxlength="200"
|
||||||
|
show-word-limit
|
||||||
|
:disabled="grantingPoints"
|
||||||
|
placeholder="用于管理后台审计,不会向用户展示"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button
|
||||||
|
:disabled="grantingPoints"
|
||||||
|
@click="pointGrantOpen = false"
|
||||||
|
>取消</el-button
|
||||||
|
>
|
||||||
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
:loading="grantingPoints"
|
||||||
|
:disabled="!canGrantPoints"
|
||||||
|
@click="grantPoints"
|
||||||
|
>确认增加</el-button
|
||||||
|
>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
<el-dialog
|
||||||
|
v-model="dialogOpen"
|
||||||
|
:title="dialog === 'single' ? '新建用户' : '批量新建用户'"
|
||||||
|
width="min(560px, calc(100vw - 32px))"
|
||||||
|
><el-form
|
||||||
|
ref="formRef"
|
||||||
|
:model="form"
|
||||||
|
label-position="top"
|
||||||
|
><el-form-item
|
||||||
|
v-if="dialog === 'single'"
|
||||||
|
label="账号"
|
||||||
|
prop="account"
|
||||||
|
:rules="[{ required: true, message: '请输入账号' }]"
|
||||||
|
>
|
||||||
|
<el-input
|
||||||
|
v-model.trim="form.account"
|
||||||
|
placeholder="用于用户登录,创建后不可修改"
|
||||||
|
/>
|
||||||
|
<div class="field-helper">系统将自动生成随机用户名,账号创建后不可修改</div>
|
||||||
|
</el-form-item>
|
||||||
|
<template v-else>
|
||||||
|
<div class="dialog-form-grid batch-account-grid">
|
||||||
|
<el-form-item
|
||||||
|
class="dialog-field--full"
|
||||||
|
label="账号前缀"
|
||||||
|
prop="prefix"
|
||||||
|
:rules="[{ required: true, message: '请输入账号前缀' }]"
|
||||||
|
>
|
||||||
|
<el-input
|
||||||
|
v-model.trim="form.prefix"
|
||||||
|
placeholder="例如 RR"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item
|
||||||
|
label="起始序号"
|
||||||
|
prop="start_sequence"
|
||||||
|
:rules="[
|
||||||
|
{ required: true, message: '请输入起始序号' },
|
||||||
|
{ pattern: /^\d+$/, message: '起始序号必须为数字' },
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<el-input
|
||||||
|
v-model.trim="form.start_sequence"
|
||||||
|
placeholder="例如 001"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item
|
||||||
|
label="终止序号"
|
||||||
|
prop="end_sequence"
|
||||||
|
:rules="[
|
||||||
|
{ required: true, message: '请输入终止序号' },
|
||||||
|
{ pattern: /^\d+$/, message: '终止序号必须为数字' },
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<el-input
|
||||||
|
v-model.trim="form.end_sequence"
|
||||||
|
placeholder="例如 050"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</div>
|
||||||
|
<el-alert
|
||||||
|
class="batch-preview"
|
||||||
|
:closable="false"
|
||||||
|
:type="batchPreview.error ? 'error' : 'info'"
|
||||||
|
:title="
|
||||||
|
batchPreview.error
|
||||||
|
? batchPreview.error
|
||||||
|
: `将创建 ${batchPreview.count} 个账号:${batchPreview.first}${batchPreview.count > 1 ? ` 至 ${batchPreview.last}` : ''}`
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<div class="field-helper batch-helper">
|
||||||
|
账号之间使用短横线分隔;序号会保留输入位数,每次最多创建 50 个用户
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<el-form-item
|
||||||
|
label="密码"
|
||||||
|
prop="password"
|
||||||
|
:rules="[{ required: true, min: 8, message: '密码至少 8 个字符' }]"
|
||||||
|
><el-input
|
||||||
|
v-model="form.password"
|
||||||
|
type="password"
|
||||||
|
show-password
|
||||||
|
autocomplete="new-password" /></el-form-item
|
||||||
|
><el-form-item label="每日限额"
|
||||||
|
><AdminNumberInput
|
||||||
|
v-model="form.daily_limit"
|
||||||
|
:min="0"
|
||||||
|
:precision="2"
|
||||||
|
/>
|
||||||
|
<div class="field-helper">0 表示当日禁止消耗,9999 表示不限制</div>
|
||||||
|
</el-form-item></el-form
|
||||||
|
><template #footer
|
||||||
|
><el-button @click="dialog = ''">取消</el-button
|
||||||
|
><el-button
|
||||||
|
type="primary"
|
||||||
|
:loading="loading"
|
||||||
|
:disabled="dialog === 'batch' && Boolean(batchPreview.error)"
|
||||||
|
@click="create"
|
||||||
|
>创建</el-button
|
||||||
|
></template
|
||||||
|
></el-dialog
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.desktop-user-table {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-preview {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-helper {
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.limit-cell {
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.limit-cell > span {
|
||||||
|
min-width: 58px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.limit-cell .el-button {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.limit-edit-button {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.limit-input {
|
||||||
|
width: 118px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.limit-input :deep(input) {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.point-grant-summary {
|
||||||
|
margin-bottom: var(--space-md);
|
||||||
|
padding: var(--space-sm) var(--space-md);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
color: var(--color-text);
|
||||||
|
background: var(--color-bg);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.point-grant-summary span {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.limit-cell {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.limit-input {
|
||||||
|
width: min(128px, 100%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||||
|
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
|
||||||
|
"exclude": ["src/**/__tests__/*"],
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.node.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.app.json"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"include": ["vite.config.ts"],
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"target": "ESNext",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"types": ["node"]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
// 剧游AI管理后台 Vite 构建配置,配置 Vue 插件、路径别名、代理与输出目录
|
||||||
|
|
||||||
|
import { fileURLToPath, URL } from 'node:url'
|
||||||
|
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import { findAvailablePort } from '../vite-port'
|
||||||
|
|
||||||
|
const devHost = '127.0.0.1'
|
||||||
|
const devPort = await findAvailablePort(devHost, 5500)
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
base: '/admin/',
|
||||||
|
plugins: [vue()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
css: {
|
||||||
|
preprocessorOptions: {
|
||||||
|
scss: {
|
||||||
|
api: 'modern-compiler',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: '../API/static/admin',
|
||||||
|
emptyOutDir: true,
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
host: devHost,
|
||||||
|
port: devPort,
|
||||||
|
open: false,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:8123',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
# AGENTS.md
|
||||||
|
|
||||||
|
<INSTRUCTIONS>
|
||||||
|
|
||||||
|
## 一、基本原则
|
||||||
|
|
||||||
|
1. 回答问题时避免过分夸赞。用户和 AI 的判断都可能不准确,应结合代码、日志、测试结果和现有实现反复核对,优先保证准确性。
|
||||||
|
|
||||||
|
2. 回答应保持结构化、条理清晰,明确区分:
|
||||||
|
- 已确认的事实
|
||||||
|
- 基于现有信息的推断
|
||||||
|
- 尚未确认的问题
|
||||||
|
- 实际修改和验证结果
|
||||||
|
|
||||||
|
3. 如果缺少的信息会显著影响实现方向、安全性或兼容性,应主动向用户索要补充信息或证据。对于不影响整体方向的小问题,可以结合项目现状作出合理假设,但必须说明假设内容。
|
||||||
|
|
||||||
|
4. 开始修改前,必须先阅读相关代码、项目结构、配置和调用链,确认问题根因及现有实现。禁止在未了解上下文的情况下直接创建一套平行实现。
|
||||||
|
|
||||||
|
5. 遵循最小改动原则:
|
||||||
|
- 只修改完成当前需求或解决根因所必需的代码。
|
||||||
|
- 禁止顺带重构无关代码。
|
||||||
|
- 禁止擅自升级依赖。
|
||||||
|
- 禁止调整无关代码格式。
|
||||||
|
- 禁止改变无关功能和既有行为。
|
||||||
|
- 禁止扩大用户未授权的修改范围。
|
||||||
|
|
||||||
|
6. 优先复用项目已有代码、组件、工具函数、服务和基础设施。相同逻辑、算法、功能、页面或样式应抽取为公共实现,禁止在多个位置重复实现,除非存在明确的独立实现理由。
|
||||||
|
|
||||||
|
7. 修改代码时,应保持现有接口、参数、返回结构、错误语义和业务行为兼容。确实需要破坏兼容性时,必须提前说明原因和影响范围,用户确认后才能实施。
|
||||||
|
|
||||||
|
8. 默认仅提供分析、解释、诊断、方案或修改建议。除非用户明确要求实施、修改、修复、创建、删除或重构,否则禁止主动更改任何代码、配置、数据库、依赖、文件或外部系统状态。
|
||||||
|
- “分析一下”“检查一下”“看看问题”“给出方案”“怎么实现”等表述,仅授权只读检查和提供建议,不代表授权实施修改。
|
||||||
|
- “帮我修改”“直接修复”“实现这个功能”“按方案执行”“开干”等明确表述,才视为授权实施。
|
||||||
|
- 获得实施授权后,只能修改当前需求明确涉及的范围,并遵循最小改动原则。
|
||||||
|
- 如果用户的表述无法判断是否授权修改,应先询问用户,不得自行实施。
|
||||||
|
- 读取文件、搜索代码、查看日志、运行不改变数据和外部状态的诊断命令,不视为代码修改。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、文件与代码组织
|
||||||
|
|
||||||
|
1. 每个文件应具有明确职责,避免将路由、参数处理、业务逻辑、数据库操作和第三方服务调用全部堆放在同一个文件中。
|
||||||
|
|
||||||
|
2. 代码文件接近或超过 800 行时,应检查是否需要按照功能、业务领域、页面或组件职责进行拆分。
|
||||||
|
|
||||||
|
3. 800 行不是强制凑齐的目标:
|
||||||
|
- 800 行是一个参考值,根据项目需求和代码复杂度,可以适当调整。
|
||||||
|
- 超出 800 行的文件,应检查是否需要按照功能、业务领域、页面或组件职责进行拆分,不得盲目拆分。
|
||||||
|
- 禁止为了减少行数而将多行代码压缩成一行。
|
||||||
|
- 禁止为了形式上的拆分创建大量缺乏独立职责的小文件。
|
||||||
|
- 文件是否拆分应以职责边界、可维护性和复用价值为依据。
|
||||||
|
|
||||||
|
4. 新增公共能力前,必须先搜索项目中是否已经存在相同或相近实现。
|
||||||
|
|
||||||
|
5. 禁止使用 PowerShell、Python、重定向符或其他临时脚本拼接、生成或覆盖源代码文件。修改已有文件时,在原文件编码受支持且修改范围适合的情况下,应优先使用精确补丁,避免为少量改动重新写入整个文件;如果补丁方式不适用,应采用能够保持原文件编码、BOM 状态和换行格式的安全编辑方式。
|
||||||
|
|
||||||
|
6. 文件、模块、函数及数据库定义必须使用中文注释说明其用途:
|
||||||
|
- 每个支持注释的代码文件顶部,必须使用中文注释简要说明该文件的作用和主要职责。
|
||||||
|
- 每个模块、类、接口、组件和函数顶部,必须使用中文注释简要说明其用途;存在重要参数、返回值、副作用、异常或使用限制时,应一并说明。
|
||||||
|
- 数据库定义文件中,每个模型、表和字段都必须使用中文注释说明其业务含义;对于枚举值、关联关系、默认值、索引和约束,也应说明其用途。
|
||||||
|
- 修改代码功能、参数或字段含义时,必须同步更新对应注释,禁止保留与实际实现不一致的注释。
|
||||||
|
- 注释应说明职责、业务含义或设计原因,禁止仅将代码名称直译成没有实际信息的注释。
|
||||||
|
- JSON 等语法本身不支持注释的文件,不得为了添加注释而使用非标准语法或破坏文件有效性。
|
||||||
|
- 第三方依赖、自动生成文件、构建产物及无法安全添加注释的文件不受此规则约束,除非用户明确要求修改。
|
||||||
|
|
||||||
|
7. 必须保护文件编码和换行格式,禁止因读写方式不一致造成中文乱码:
|
||||||
|
- 修改文件前,应识别并保持原文件的字符编码、BOM 状态和换行格式。
|
||||||
|
- 修改已有文件时,禁止擅自将 UTF-8、UTF-8 with BOM、GBK、UTF-16 等编码相互转换。
|
||||||
|
- 新建文本文件默认使用 UTF-8 编码;如果项目已有明确编码规范,则遵循项目现有规范。
|
||||||
|
- 禁止通过读取全文后重新写入的方式完成少量修改。
|
||||||
|
- 修改后必须检查本次变更中的中文内容是否正常,重点检查是否出现 `�`、异常问号或类似“涓枃”的乱码。
|
||||||
|
- 如果无法确定文件原始编码,应停止写入并先说明情况,不得猜测编码后直接覆盖。
|
||||||
|
- 禁止为了统一编码而批量转换无关文件;确需转换时,必须获得用户明确授权。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、后端模块化与解耦
|
||||||
|
|
||||||
|
1. 后端应按照业务领域和职责进行模块化设计,实现关注点分离和低耦合。
|
||||||
|
|
||||||
|
2. 上传、删除、查询、转换、通知等不同业务能力,应根据实际职责划分为独立模块。禁止仅通过复制文件或移动代码实现表面上的模块化。
|
||||||
|
|
||||||
|
3. 每个后端业务模块必须使用独立目录组织。即使该业务模块当前只有一个功能或一个实现文件,也必须为其创建单独目录,禁止将不同业务模块的实现文件直接平铺在公共目录中。
|
||||||
|
|
||||||
|
- 本规则所称“业务模块”,是指具有独立业务职责、领域边界或外部服务集成边界的功能,例如用户、订单、支付、文件管理、AI 中转站和对象存储。
|
||||||
|
- 普通工具函数、常量、类型定义、内部辅助类和仅服务于某个模块的实现,不视为独立业务模块,不要求分别创建目录。
|
||||||
|
- 模块相关的业务逻辑、数据访问、类型、配置和外部服务适配代码,应集中放置在该模块目录中。
|
||||||
|
- 模块只有一个简单功能时,可以只包含一个实现文件;不得为了填充目录而创建没有实际职责的文件。
|
||||||
|
- 模块包含多个职责明确的子功能时,应按照子功能分别创建文件;子功能不需要建立子目录。
|
||||||
|
- 基础 CRUD 操作通常是同一业务实体的数据访问方法,不得仅按照新增、查询、修改和删除机械拆分为四个模块。
|
||||||
|
- 不强制为每个模块创建 `index`、`types`、`service` 等固定文件,只有存在实际职责时才创建。
|
||||||
|
- 新增模块以及本次需求实际修改的模块必须遵守本规则;禁止为了统一目录形式而擅自重构与当前需求无关的既有模块。
|
||||||
|
- 目录和文件命名应遵循项目现有命名规范。
|
||||||
|
|
||||||
|
4. 模块之间应通过明确的公开接口进行调用,包括:
|
||||||
|
- 函数参数
|
||||||
|
- 返回值
|
||||||
|
- 类型或接口
|
||||||
|
- 服务抽象
|
||||||
|
- 依赖注入
|
||||||
|
- 事件机制(仅在确有异步解耦需求时使用)
|
||||||
|
|
||||||
|
5. 禁止一个模块直接访问另一个模块的内部变量、私有实现、内部数据库细节或非公开文件。
|
||||||
|
|
||||||
|
6. 模块应遵循单一职责原则。根据项目技术栈和现有结构,可以按以下职责组织:
|
||||||
|
- Router:定义路由和中间件
|
||||||
|
- Controller:接收请求、校验输入、调用服务、组织响应
|
||||||
|
- Service:实现业务逻辑
|
||||||
|
- Repository:封装数据库或持久化操作
|
||||||
|
- Adapter:封装外部存储、第三方 API 等基础设施
|
||||||
|
- Types/Interfaces:声明模块对外契约
|
||||||
|
|
||||||
|
7. 上述分层是职责参考,不是强制要求。对于简单功能,可以合并没有独立价值的层级,绝对禁止为了套用架构而过度设计。
|
||||||
|
|
||||||
|
8. 其他代码调用某个模块时,应只使用该模块明确对外提供的函数、参数和返回值,禁止依赖该模块的内部变量、内部文件或具体实现方式。模块内部实现被替换或扩展时,应尽量避免要求其他无关代码同步修改。
|
||||||
|
|
||||||
|
9. 模块内部实现可以变化,但对外接口应尽量保持稳定。
|
||||||
|
|
||||||
|
10. 禁止循环依赖。发现循环依赖时,应优先检查:
|
||||||
|
- 职责是否划分错误
|
||||||
|
- 是否存在应抽取的公共能力
|
||||||
|
- 是否需要使用抽象接口
|
||||||
|
- 是否错误地让业务模块相互了解内部实现
|
||||||
|
|
||||||
|
11. 公共逻辑只能保留一套权威实现。禁止在上传、删除或其他模块中分别复制相同的:
|
||||||
|
- 权限校验
|
||||||
|
- 路径处理
|
||||||
|
- 文件校验
|
||||||
|
- 错误转换
|
||||||
|
- 数据查询
|
||||||
|
- 状态判断
|
||||||
|
|
||||||
|
12. 模块化改造应保持现有接口路径、请求参数、响应结构和错误行为兼容,除非用户明确要求修改。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、前端开发
|
||||||
|
|
||||||
|
1. 前端禁止使用多渐变色,仅能使用同色系渐变,且不能太艳丽。
|
||||||
|
|
||||||
|
2. 前端禁止使用 Emoji 作为图标。图标必须统一使用第三方图标库;如果项目已经选定图标库,禁止再引入另一套图标库。
|
||||||
|
|
||||||
|
3. 前端禁止主动增加无障碍设置,包括但不限于额外的 ARIA 属性、无障碍模式和无障碍专用交互。
|
||||||
|
|
||||||
|
4. 前端禁止增加键盘快捷键、按键监听或依赖键盘完成的交互,除非用户明确要求。
|
||||||
|
|
||||||
|
5. 前端必须采用响应式布局,应适配项目现有支持的桌面端、平板端和移动端尺寸。
|
||||||
|
|
||||||
|
6. 前端必须使用统一的样式系统:
|
||||||
|
- 使用统一的颜色、间距、字号、圆角、阴影和层级变量。
|
||||||
|
- 使用语义化变量名,禁止在业务组件中散落大量无语义的硬编码样式值。
|
||||||
|
- 样式应放入项目现有的统一样式文件、主题文件或设计令牌系统。
|
||||||
|
- 优先复用现有组件和样式类。
|
||||||
|
- 禁止同一种视觉效果在多个页面分别实现。
|
||||||
|
|
||||||
|
7. 除非用户明确要求,否则前端禁止使用英文副标题。界面文案应与项目现有语言和表达方式保持一致。
|
||||||
|
|
||||||
|
8. 页面和组件应根据职责合理拆分。公共交互、公共布局和公共业务展示应抽取为公共组件,禁止在多个页面复制相同实现。
|
||||||
|
|
||||||
|
9. 修复前端问题时,仅修改解决根因所需的组件、样式和逻辑,禁止顺带重做页面设计。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、数据库修改
|
||||||
|
|
||||||
|
1. 涉及数据库结构修改时,必须同时完成:
|
||||||
|
- 更新数据库定义文件或 Schema。
|
||||||
|
- 创建对应的数据库迁移文件。
|
||||||
|
- 实际执行数据库迁移。
|
||||||
|
- 检查迁移执行结果。
|
||||||
|
- 验证应用代码与新结构兼容。
|
||||||
|
|
||||||
|
2. 禁止只修改或新增迁移文件而不更新数据库定义文件。
|
||||||
|
|
||||||
|
3. 禁止只更新数据库定义文件而不创建和执行迁移。
|
||||||
|
|
||||||
|
4. 修改字段、索引、约束、默认值或数据类型前,应检查现有数据兼容性和迁移风险。
|
||||||
|
|
||||||
|
5. 涉及删除字段、修改字段类型、增加非空约束等可能造成数据丢失或迁移失败的操作时,必须先说明风险,并制定数据迁移或兼容方案。
|
||||||
|
|
||||||
|
6. 数据库迁移完成后,应验证:
|
||||||
|
- 当前数据库版本
|
||||||
|
- 新旧数据兼容性
|
||||||
|
- 相关查询和写入逻辑
|
||||||
|
- 回滚或恢复风险
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、验证要求
|
||||||
|
|
||||||
|
1. 修改完成后,应优先运行与本次修改直接相关的:
|
||||||
|
- 单元测试
|
||||||
|
- 集成测试
|
||||||
|
- 类型检查
|
||||||
|
- 编译或构建检查
|
||||||
|
- Lint 检查
|
||||||
|
- 数据库迁移检查
|
||||||
|
|
||||||
|
2. 除非用户明确要求,否则必须禁止主动使用浏览器进行验证,也禁止主动调用浏览器自动化工具。
|
||||||
|
|
||||||
|
3. 如果前端修改无法通过现有自动化测试充分验证,应说明未验证的具体部分,不得将未验证内容描述为已经确认正常。
|
||||||
|
|
||||||
|
4. 测试失败时,应区分:
|
||||||
|
- 本次修改导致的失败
|
||||||
|
- 项目原本存在的失败
|
||||||
|
- 环境或依赖导致的失败
|
||||||
|
|
||||||
|
5. 禁止为了让测试通过而删除测试、降低断言强度、屏蔽错误或跳过必要检查。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、任务完成后的输出
|
||||||
|
|
||||||
|
完成任务后,应简要说明:
|
||||||
|
|
||||||
|
1. 问题根因或实现依据。
|
||||||
|
2. 实际修改的文件和主要内容。
|
||||||
|
3. 模块之间的调用关系。
|
||||||
|
4. 是否涉及接口或兼容性变化。
|
||||||
|
5. 是否涉及数据库迁移及执行结果。
|
||||||
|
6. 已运行的验证命令及结果。
|
||||||
|
7. 尚未验证的内容、已知限制和潜在风险。
|
||||||
|
|
||||||
|
禁止声称未实际执行的测试、迁移或验证已经通过。
|
||||||
|
|
||||||
|
</INSTRUCTIONS>
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
# 剧游 AI 后端环境变量示例。
|
||||||
|
# 使用方式:复制为 API/.env,再填写实际配置;API/.env 不应提交到 Git。
|
||||||
|
# 标记为“必填”的变量不能继续使用下方占位符,否则服务可能无法正常启动。
|
||||||
|
|
||||||
|
# 应用基础配置
|
||||||
|
APP_NAME=剧游AI API
|
||||||
|
APP_ENV=development
|
||||||
|
DEBUG=true
|
||||||
|
SERVER_ADDRESS=:8900
|
||||||
|
|
||||||
|
# PostgreSQL
|
||||||
|
# 若使用根目录 docker-compose.yml,请将这三项填写为 postgres 服务的实际配置。
|
||||||
|
DATABASE_HOST=localhost
|
||||||
|
DATABASE_PORT=25432
|
||||||
|
# 本地开发示例值,不要填写生产数据库凭据。
|
||||||
|
DATABASE_NAME=juyou_ai_prod
|
||||||
|
DATABASE_USER=juyou_ai_app
|
||||||
|
DATABASE_PASSWORD=replace-with-local-postgres-password
|
||||||
|
# 填写后将优先使用该连接串;留空时由上面的 PostgreSQL 参数自动拼接。
|
||||||
|
DATABASE_URL=
|
||||||
|
|
||||||
|
# Redis
|
||||||
|
REDIS_HOST=localhost
|
||||||
|
REDIS_PORT=26379
|
||||||
|
REDIS_PASSWORD=
|
||||||
|
REDIS_DB=0
|
||||||
|
|
||||||
|
# Asynq 使用独立 Redis DB
|
||||||
|
ASYNQ_REDIS_DB=1
|
||||||
|
|
||||||
|
# 前端构建产物
|
||||||
|
STATIC_DIRECTORY=static
|
||||||
|
|
||||||
|
# 可选:外部模型服务需要代理时配置
|
||||||
|
HTTP_PROXY=
|
||||||
|
HTTPS_PROXY=
|
||||||
|
NO_PROXY=localhost,127.0.0.1,::1,.myqcloud.com,cos.youjuhui.xyz
|
||||||
|
|
||||||
|
# JWT(必填:请替换为随机长字符串)
|
||||||
|
JWT_SECRET_KEY=replace-with-a-random-secret-at-least-32-characters
|
||||||
|
JWT_ALGORITHM=HS256
|
||||||
|
JWT_ACCESS_TOKEN_EXPIRE_MINUTES=60
|
||||||
|
JWT_REFRESH_TOKEN_EXPIRE_HOURS=168
|
||||||
|
|
||||||
|
# 首次启动时仅在管理员表为空的情况下创建初始管理员;密码只用于初始化。
|
||||||
|
ADMIN_BOOTSTRAP_USERNAME=admin
|
||||||
|
ADMIN_BOOTSTRAP_PASSWORD=replace-with-a-strong-initial-password
|
||||||
|
|
||||||
|
# Argon2 密码哈希
|
||||||
|
ARGON2_TIME_COST=2
|
||||||
|
ARGON2_MEMORY_COST=19456
|
||||||
|
ARGON2_PARALLELISM=1
|
||||||
|
ARGON2_HASH_LENGTH=32
|
||||||
|
ARGON2_SALT_LENGTH=16
|
||||||
|
|
||||||
|
# 敏感配置加密(必填)。CONFIG_ENCRYPTION_KEY 必须是 32 字节随机值的标准 Base64 编码。
|
||||||
|
CONFIG_ENCRYPTION_KEY_VERSION=v1
|
||||||
|
CONFIG_ENCRYPTION_KEY=replace-with-base64-encoded-32-byte-key
|
||||||
|
|
||||||
|
# 腾讯云 COS(使用对象存储时必填)。密钥只配置在 API 服务端,不得暴露给前端。
|
||||||
|
COS_SECRET_ID=replace-with-tencent-secret-id
|
||||||
|
COS_SECRET_KEY=replace-with-tencent-secret-key
|
||||||
|
COS_BUCKET=replace-with-cos-bucket-name
|
||||||
|
COS_REGION=ap-chengdu
|
||||||
|
COS_ENDPOINT=https://cos.ap-chengdu.myqcloud.com
|
||||||
|
# 必填。视频反推与生成模型需要通过该域名读取参考图和抽帧。
|
||||||
|
COS_PUBLIC_BASE_URL=https://media.example.com
|
||||||
|
COS_MAX_IMAGE_SIZE_MB=20
|
||||||
|
COS_MAX_VIDEO_SIZE_MB=100
|
||||||
|
COS_MAX_AUDIO_SIZE_MB=30
|
||||||
|
|
||||||
|
# 持久任务与本地媒体处理。
|
||||||
|
AI_WORKER_CONCURRENCY=100
|
||||||
|
AI_POLL_INTERVAL_SECONDS=10
|
||||||
|
AI_HTTP_MAX_CONNECTIONS=200
|
||||||
|
FFMPEG_PATH=ffmpeg
|
||||||
|
FFPROBE_PATH=ffprobe
|
||||||
|
PYTHON_PATH=python3
|
||||||
|
ASR_SCRIPT_PATH=workers/transcribe.py
|
||||||
|
# 可填写模型目录;留空时由转写脚本使用 FASTER_WHISPER_MODEL_SIZE。
|
||||||
|
FASTER_WHISPER_MODEL_PATH=
|
||||||
|
FASTER_WHISPER_MODEL_SIZE=small
|
||||||
|
FASTER_WHISPER_DEVICE=cpu
|
||||||
|
FASTER_WHISPER_COMPUTE_TYPE=int8
|
||||||
|
|
||||||
|
# CORS(多个域名用逗号分隔)
|
||||||
|
CORS_ORIGINS=http://localhost:5500,http://localhost:5501
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# acme.sh 本地目录
|
||||||
|
|
||||||
|
这里保存部署使用的 acme.sh 单文件版本,不从 GitHub clone。
|
||||||
|
|
||||||
|
更新方式:在 Linux 服务器或 WSL 中执行 `bash ../deploy/update_acme.sh`。
|
||||||
+9226
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,138 @@
|
|||||||
|
// API 进程入口,负责装配依赖、启动 HTTP 服务和异步任务处理器并执行平滑关闭。
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"juhe-factory/api/internal/cache"
|
||||||
|
"juhe-factory/api/internal/config"
|
||||||
|
"juhe-factory/api/internal/database"
|
||||||
|
"juhe-factory/api/internal/datetime"
|
||||||
|
"juhe-factory/api/internal/handler"
|
||||||
|
adminmodule "juhe-factory/api/internal/modules/admin"
|
||||||
|
"juhe-factory/api/internal/modules/productimage"
|
||||||
|
"juhe-factory/api/internal/modules/prompt"
|
||||||
|
"juhe-factory/api/internal/queue"
|
||||||
|
"juhe-factory/api/internal/security"
|
||||||
|
"juhe-factory/api/internal/server"
|
||||||
|
"juhe-factory/api/internal/service"
|
||||||
|
"juhe-factory/api/internal/storage"
|
||||||
|
"juhe-factory/api/internal/worker"
|
||||||
|
|
||||||
|
"github.com/hibiken/asynq"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
time.Local = datetime.Location
|
||||||
|
cfg, err := config.Load()
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("加载配置失败", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
gormDB, sqlDB, err := database.Open(cfg)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("连接 PostgreSQL 失败", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
defer sqlDB.Close()
|
||||||
|
|
||||||
|
redisClient, err := cache.Open(cfg)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("连接 Redis 失败", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
defer redisClient.Close()
|
||||||
|
|
||||||
|
queueClient := queue.NewClient(cfg)
|
||||||
|
defer queueClient.Close()
|
||||||
|
|
||||||
|
healthHandler := handler.NewHealth(sqlDB, redisClient)
|
||||||
|
passwords := security.PasswordHasher{Time: cfg.Argon2Time, Memory: cfg.Argon2Memory, Parallelism: cfg.Argon2Parallelism, HashLength: cfg.Argon2HashLength, SaltLength: cfg.Argon2SaltLength}
|
||||||
|
tokens := security.TokenService{Secret: []byte(cfg.JWTSecretKey), AccessTTL: time.Duration(cfg.JWTAccessMinutes) * time.Minute, RefreshTTL: time.Duration(cfg.JWTRefreshHours) * time.Hour}
|
||||||
|
encryptor, err := security.NewEncryptor(cfg.EncryptionKey)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("加载敏感配置加密密钥失败", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
authService := service.NewAuth(gormDB, passwords, tokens)
|
||||||
|
if err := authService.Bootstrap(cfg.BootstrapAdminUsername, cfg.BootstrapAdminPassword); err != nil {
|
||||||
|
slog.Error("初始化管理员失败", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
cos, err := storage.NewCOS(context.Background(), cfg)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("初始化 COS 失败", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
adminDataService := &service.AdminData{DB: gormDB, Passwords: passwords, Encryptor: encryptor, HTTPClient: http.DefaultClient, EncryptionKeyVersion: cfg.EncryptionKeyVersion}
|
||||||
|
webService := &service.Web{DB: gormDB, Passwords: passwords, Tokens: security.WebTokenService{Secret: []byte(cfg.JWTSecretKey), AccessTTL: time.Duration(cfg.JWTAccessMinutes) * time.Minute}, RefreshTTL: time.Duration(cfg.JWTRefreshHours) * time.Hour}
|
||||||
|
creativeService := &service.Creative{DB: gormDB, Queue: queueClient, Encryptor: encryptor, HTTPClient: http.DefaultClient}
|
||||||
|
adminService := adminmodule.NewService(gormDB)
|
||||||
|
promptService := prompt.NewService(gormDB)
|
||||||
|
productImageService := productimage.NewService(gormDB, queueClient)
|
||||||
|
creativeHandler := handler.NewCreative(creativeService, promptService, cos)
|
||||||
|
httpServer := server.New(cfg, healthHandler, handler.NewAdminAuth(authService), handler.NewAdminData(adminDataService, adminService, promptService, cos), handler.NewWeb(webService, cos), creativeHandler, handler.NewProductImage(productImageService, cos))
|
||||||
|
|
||||||
|
aiHTTP := worker.NewHTTPClient(cfg.AIHTTPMaxConnections)
|
||||||
|
generationWorker := worker.NewGeneration(gormDB, queueClient, cos, encryptor, aiHTTP, time.Duration(cfg.AIPollIntervalSeconds)*time.Second)
|
||||||
|
mediaWorker := worker.NewMedia(gormDB, cos, encryptor, aiHTTP, cfg.FFmpegPath, cfg.FFprobePath, cfg.PythonPath, cfg.ASRScriptPath)
|
||||||
|
dramaParseWorker := worker.NewDramaParse(gormDB, queueClient, encryptor, aiHTTP)
|
||||||
|
scriptAnalysisWorker := worker.NewScriptAnalysis(gormDB, queueClient, encryptor, aiHTTP)
|
||||||
|
workerMux := asynq.NewServeMux()
|
||||||
|
generationWorker.Register(workerMux)
|
||||||
|
mediaWorker.Register(workerMux)
|
||||||
|
dramaParseWorker.Register(workerMux)
|
||||||
|
scriptAnalysisWorker.Register(workerMux)
|
||||||
|
queueServer := queue.NewServer(cfg)
|
||||||
|
workerErr := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
slog.Info("AI 任务 Worker 已启动", "concurrency", cfg.AIWorkerConcurrency)
|
||||||
|
workerErr <- queueServer.Run(workerMux)
|
||||||
|
}()
|
||||||
|
if err := generationWorker.Recover(context.Background()); err != nil {
|
||||||
|
slog.Error("恢复 AI 任务失败", "error", err)
|
||||||
|
}
|
||||||
|
if err := dramaParseWorker.Recover(context.Background()); err != nil {
|
||||||
|
slog.Error("恢复短剧创作解析任务失败", "error", err)
|
||||||
|
}
|
||||||
|
if err := scriptAnalysisWorker.Recover(context.Background()); err != nil {
|
||||||
|
slog.Error("恢复剧本分析任务失败", "error", err)
|
||||||
|
}
|
||||||
|
serverErr := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
slog.Info("API 服务已启动", "address", cfg.ServerAddress)
|
||||||
|
serverErr <- httpServer.ListenAndServe()
|
||||||
|
}()
|
||||||
|
|
||||||
|
stop := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case sig := <-stop:
|
||||||
|
slog.Info("收到停止信号", "signal", sig.String())
|
||||||
|
case err = <-serverErr:
|
||||||
|
if !errors.Is(err, http.ErrServerClosed) {
|
||||||
|
slog.Error("API 服务异常退出", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
case err = <-workerErr:
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("AI 任务 Worker 异常退出", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
queueServer.Shutdown()
|
||||||
|
if err := httpServer.Shutdown(ctx); err != nil {
|
||||||
|
slog.Error("API 服务关闭失败", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# 剧核工厂部署脚本
|
||||||
|
|
||||||
|
## 一键推送
|
||||||
|
|
||||||
|
在 Windows 项目根目录执行:
|
||||||
|
|
||||||
|
```bat
|
||||||
|
API\deploy\push.bat root@服务器IP
|
||||||
|
```
|
||||||
|
|
||||||
|
脚本会构建 WEB、ADMIN,交叉编译 Linux amd64 API,并上传二进制、静态文件、迁移、部署脚本以及 `workers`。默认增量推送不会上传本地 `API/.env`,服务器继续使用 `/etc/juyou_ai/juyou_ai.env`,避免 Windows 开发环境配置覆盖 Linux 生产配置。
|
||||||
|
|
||||||
|
仅上传、不执行服务器部署时也可以显式使用:
|
||||||
|
|
||||||
|
```bat
|
||||||
|
API\deploy\push.bat root@服务器IP stage-only
|
||||||
|
```
|
||||||
|
|
||||||
|
只有首次部署或明确需要整体替换服务器环境配置时,才使用:
|
||||||
|
|
||||||
|
```bat
|
||||||
|
API\deploy\push.bat root@服务器IP with-env
|
||||||
|
```
|
||||||
|
|
||||||
|
`with-env` 会把本地 `API/.env` 暂存为服务器 `/tmp/juyou_ai.env`。使用前必须确认其中的 FFmpeg、Python 和脚本路径适用于 Linux;普通增量部署不要使用该选项。
|
||||||
|
|
||||||
|
## 一键部署
|
||||||
|
|
||||||
|
上传完成后,在服务器执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo bash /opt/juyou_ai/deploy/one_click_deployment.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
普通增量部署会继续使用 `/etc/juyou_ai/juyou_ai.env`。使用 `with-env` 推送时,部署脚本才会用 `/tmp/juyou_ai.env` 更新生产环境配置。
|
||||||
|
|
||||||
|
部署脚本会安装 `python3-pip`,并在缺失时安装 `faster-whisper 1.2.1`;同时会将 FFmpeg、Python 和音频识别脚本路径规范为 Linux 运行路径。`push.bat` 会在本机下载默认 `small` 模型,并在服务器缺少模型时上传到 `/opt/juyou_ai/models/faster-whisper-small`。服务器部署和运行阶段不会访问 Hugging Face;后续增量部署会保留并复用已经上传的模型。
|
||||||
|
|
||||||
|
项目目录固定为 `/opt/juyou_ai`,systemd 服务名固定为 `juyou_ai`,API 内部端口固定为 `8123`。脚本仅保存域名和证书邮箱;再次执行会自动复用配置。多个域名会全部作为 SAN 追加到同一张证书。脚本只复用输入域名对应的现有证书,不会扫描其他项目的证书;没有匹配证书但填写邮箱时使用本地 `API/acme/acme.sh` 申请 HTTPS,域名填 `_` 或未填写邮箱时使用 HTTP。systemd 从 `/etc/juyou_ai/juyou_ai.env` 加载环境变量,项目目录不会保留 `.env`。
|
||||||
|
|
||||||
|
API、AI Worker 和 HTTP 请求日志统一写入 `/var/log/juyou_ai/juyou_ai.log`。日志每天轮转,最多保留 14 份,单个文件超过 50MB 时会提前轮转;可使用 `sudo tail -f /var/log/juyou_ai/juyou_ai.log` 实时查看。
|
||||||
|
|
||||||
|
完整操作说明参见 [部署文档.md](部署文档.md)。
|
||||||
|
|
||||||
|
第三方接口、网址和密钥字段说明参见 [第三方说明及密钥.md](第三方说明及密钥.md)。
|
||||||
|
|
||||||
|
## 更新 acme.sh
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo bash /opt/juyou_ai/deploy/update_acme.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
更新脚本通过 jsDelivr CDN 下载 acmesh-official/acme.sh 的最新 master 单文件版本,不执行 GitHub clone。
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# 剧核工厂 Linux 一键部署脚本。
|
||||||
|
# 负责安装 Nginx/PostgreSQL/Redis/FFmpeg、执行迁移、创建 systemd 服务,
|
||||||
|
# 并按需使用本地 API/acme/acme.sh 申请或安装 HTTPS 证书。
|
||||||
|
|
||||||
|
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; RESET='\033[0m'
|
||||||
|
info(){ echo -e "${CYAN} -> $*${RESET}"; }
|
||||||
|
ok(){ echo -e "${GREEN} OK $*${RESET}"; }
|
||||||
|
warn(){ echo -e "${YELLOW} !! $*${RESET}"; }
|
||||||
|
fail(){ echo -e "${RED} ERR $*${RESET}" >&2; exit 1; }
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
API_ROOT="$(realpath "${1:-$SCRIPT_DIR/..}")"
|
||||||
|
PROJECT_ROOT="$(realpath "$API_ROOT/..")"
|
||||||
|
CONFIG_FILE="$SCRIPT_DIR/.deploy.conf"
|
||||||
|
SYSTEM_ENV_FILE="/etc/juyou_ai/juyou_ai.env"
|
||||||
|
[ -f "$SCRIPT_DIR/prepare_server.sh" ] || fail "缺少服务器初始化脚本。"
|
||||||
|
chmod +x "$API_ROOT/bin/jcf-api" 2>/dev/null || true
|
||||||
|
[ -x "$API_ROOT/bin/jcf-api" ] || fail "缺少 Linux API 二进制 $API_ROOT/bin/jcf-api,请先执行 push.bat。"
|
||||||
|
[ -f "$API_ROOT/static/web/index.html" ] || fail "缺少 WEB 构建产物。"
|
||||||
|
[ -f "$API_ROOT/static/admin/index.html" ] || fail "缺少 ADMIN 构建产物。"
|
||||||
|
|
||||||
|
if [ -s "$CONFIG_FILE" ]; then
|
||||||
|
# shellcheck disable=SC1090
|
||||||
|
source "$CONFIG_FILE"
|
||||||
|
fi
|
||||||
|
SERVICE_NAME="juyou_ai"
|
||||||
|
APP_PORT="8123"
|
||||||
|
DOMAIN_INPUT="${SAVED_DOMAIN_INPUT:-_}"
|
||||||
|
USER_EMAIL="${SAVED_USER_EMAIL:-}"
|
||||||
|
DOMAINS=()
|
||||||
|
if [ "$DOMAIN_INPUT" != "_" ]; then
|
||||||
|
for domain in $DOMAIN_INPUT; do
|
||||||
|
[[ "$domain" =~ ^([A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)+[A-Za-z]{2,63}$ ]] || fail "域名格式无效:$domain"
|
||||||
|
DOMAINS+=("$domain")
|
||||||
|
done
|
||||||
|
[ "${#DOMAINS[@]}" -gt 0 ] || fail "至少需要一个有效域名。"
|
||||||
|
DOMAIN_INPUT="${DOMAINS[*]}"
|
||||||
|
FIRST_DOMAIN="${DOMAINS[0]}"
|
||||||
|
fi
|
||||||
|
FIRST_DOMAIN="${FIRST_DOMAIN:-_}"
|
||||||
|
command -v sudo >/dev/null 2>&1 || fail "系统缺少 sudo。"
|
||||||
|
sudo env SAVED_DOMAIN_INPUT="$DOMAIN_INPUT" JCF_ENV_SOURCE="${JCF_ENV_SOURCE:-}" bash "$SCRIPT_DIR/prepare_server.sh" "$API_ROOT"
|
||||||
|
[ -f "$SYSTEM_ENV_FILE" ] || fail "服务器初始化后仍缺少 $SYSTEM_ENV_FILE。"
|
||||||
|
SOCKET_PATH="/run/$SERVICE_NAME/app.sock"
|
||||||
|
CERT_DIR="/etc/nginx/ssl"
|
||||||
|
NGINX_FILE="/etc/nginx/conf.d/$SERVICE_NAME.conf"
|
||||||
|
SERVICE_FILE="/etc/systemd/system/$SERVICE_NAME.service"
|
||||||
|
LOG_DIR="/var/log/$SERVICE_NAME"
|
||||||
|
LOG_FILE="$LOG_DIR/$SERVICE_NAME.log"
|
||||||
|
LOGROTATE_FILE="/etc/logrotate.d/$SERVICE_NAME"
|
||||||
|
|
||||||
|
# 只读取迁移所需的数据库变量,不直接 source .env。
|
||||||
|
# APP_NAME 等值可能包含空格,直接 source 会被 Bash 误解析。
|
||||||
|
env_value() {
|
||||||
|
local key="$1" value
|
||||||
|
value="$(sudo sed -n -E "s/^${key}=//p" "$SYSTEM_ENV_FILE" | head -n 1)"
|
||||||
|
value="${value#\"}"; value="${value%\"}"
|
||||||
|
printf '%s' "$value"
|
||||||
|
}
|
||||||
|
DATABASE_URL="$(env_value DATABASE_URL)"
|
||||||
|
DATABASE_NAME="$(env_value DATABASE_NAME)"
|
||||||
|
DATABASE_HOST="$(env_value DATABASE_HOST)"
|
||||||
|
DATABASE_PORT="$(env_value DATABASE_PORT)"
|
||||||
|
DATABASE_USER="$(env_value DATABASE_USER)"
|
||||||
|
DATABASE_PASSWORD="$(env_value DATABASE_PASSWORD)"
|
||||||
|
PSQL_ARGS=()
|
||||||
|
if [ -n "${DATABASE_URL:-}" ]; then
|
||||||
|
PSQL_TARGET="$DATABASE_URL"
|
||||||
|
else
|
||||||
|
PSQL_TARGET="dbname=${DATABASE_NAME:-juchuang_factory} host=${DATABASE_HOST:-127.0.0.1} port=${DATABASE_PORT:-5432} user=${DATABASE_USER:-postgres}"
|
||||||
|
export PGPASSWORD="${DATABASE_PASSWORD:-}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 使用独立记录表避免重复执行同一个 SQL 迁移。
|
||||||
|
psql "$PSQL_TARGET" -v ON_ERROR_STOP=1 -c 'CREATE TABLE IF NOT EXISTS jcf_schema_migrations (version varchar(128) PRIMARY KEY, applied_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP);' >/dev/null
|
||||||
|
for migration in "$API_ROOT"/migrations/*.up.sql; do
|
||||||
|
[ -f "$migration" ] || continue
|
||||||
|
version="$(basename "$migration")"
|
||||||
|
already="$(psql "$PSQL_TARGET" -At -v ON_ERROR_STOP=1 -c "SELECT 1 FROM jcf_schema_migrations WHERE version='${version//\'/\'\'}' LIMIT 1")"
|
||||||
|
if [ "$already" = "1" ]; then
|
||||||
|
info "跳过已执行迁移:$version"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
info "执行数据库迁移:$version"
|
||||||
|
psql "$PSQL_TARGET" -v ON_ERROR_STOP=1 -f "$migration" >/dev/null
|
||||||
|
psql "$PSQL_TARGET" -v ON_ERROR_STOP=1 -c "INSERT INTO jcf_schema_migrations(version) VALUES ('${version//\'/\'\'}')" >/dev/null
|
||||||
|
done
|
||||||
|
unset PGPASSWORD
|
||||||
|
|
||||||
|
sudo chown -R www-data:www-data "$API_ROOT"
|
||||||
|
sudo rm -f "$API_ROOT/.env"
|
||||||
|
sudo mkdir -p "$(dirname "$SOCKET_PATH")" "$CERT_DIR" /var/www/jcf-acme
|
||||||
|
sudo install -d -m 0750 -o www-data -g www-data "$LOG_DIR"
|
||||||
|
sudo touch "$LOG_FILE"
|
||||||
|
sudo chown www-data:www-data "$LOG_FILE"
|
||||||
|
sudo chmod 0640 "$LOG_FILE"
|
||||||
|
|
||||||
|
# 每天轮转 API 与 Worker 的统一日志,并在单个文件过大时提前轮转。
|
||||||
|
sudo tee "$LOGROTATE_FILE" >/dev/null <<EOF
|
||||||
|
$LOG_FILE {
|
||||||
|
daily
|
||||||
|
rotate 14
|
||||||
|
maxsize 50M
|
||||||
|
missingok
|
||||||
|
notifempty
|
||||||
|
compress
|
||||||
|
delaycompress
|
||||||
|
copytruncate
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# 创建 API 与队列 Worker 同进程 systemd 服务。
|
||||||
|
sudo tee "$SERVICE_FILE" >/dev/null <<EOF
|
||||||
|
[Unit]
|
||||||
|
Description=JuheFactory Go API and AI Worker
|
||||||
|
After=network.target postgresql.service redis-server.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
User=www-data
|
||||||
|
Group=www-data
|
||||||
|
WorkingDirectory=$API_ROOT
|
||||||
|
EnvironmentFile=$SYSTEM_ENV_FILE
|
||||||
|
Environment=HOME=$API_ROOT
|
||||||
|
CacheDirectory=juyou_ai
|
||||||
|
ExecStart=$API_ROOT/bin/jcf-api
|
||||||
|
StandardOutput=append:$LOG_FILE
|
||||||
|
StandardError=append:$LOG_FILE
|
||||||
|
Restart=always
|
||||||
|
RestartSec=3
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOF
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable "$SERVICE_NAME" --now
|
||||||
|
|
||||||
|
mkdir -p "$API_ROOT/static/web" "$API_ROOT/static/admin"
|
||||||
|
EXISTING_CERT_BUNDLE="$CERT_DIR/${FIRST_DOMAIN}_bundle.crt"
|
||||||
|
EXISTING_CERT_KEY="$CERT_DIR/${FIRST_DOMAIN}.key"
|
||||||
|
if [ "$FIRST_DOMAIN" != "_" ] && [ -f "$EXISTING_CERT_BUNDLE" ] && [ -f "$EXISTING_CERT_KEY" ]; then
|
||||||
|
info "检测到现有 SSL 证书,跳过 ACME 申请并复用证书"
|
||||||
|
USE_SSL=true
|
||||||
|
elif [ "$FIRST_DOMAIN" != "_" ] && [ -n "$USER_EMAIL" ]; then
|
||||||
|
# 先用 HTTP 临时站点提供 ACME HTTP-01 验证目录。
|
||||||
|
sudo tee "$NGINX_FILE" >/dev/null <<EOF
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name $DOMAIN_INPUT;
|
||||||
|
location ^~ /.well-known/acme-challenge/ { root /var/www/jcf-acme; }
|
||||||
|
location / { root $API_ROOT/static/web; try_files \$uri \$uri/ /index.html; }
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
sudo nginx -t && sudo systemctl reload nginx
|
||||||
|
ACME_BIN="$API_ROOT/acme/acme.sh"
|
||||||
|
[ -f "$ACME_BIN" ] || fail "缺少本地 ACME 脚本,请先执行 API/deploy/update_acme.sh。"
|
||||||
|
chmod +x "$ACME_BIN"
|
||||||
|
export LE_CONFIG_HOME="/root/.acme.sh"
|
||||||
|
bash "$ACME_BIN" --set-default-ca --server letsencrypt
|
||||||
|
ACME_DOMAIN_ARGS=()
|
||||||
|
for domain in "${DOMAINS[@]}"; do ACME_DOMAIN_ARGS+=( -d "$domain" ); done
|
||||||
|
bash "$ACME_BIN" --issue --webroot /var/www/jcf-acme "${ACME_DOMAIN_ARGS[@]}" --accountemail "$USER_EMAIL"
|
||||||
|
bash "$ACME_BIN" --install-cert -d "$FIRST_DOMAIN" \
|
||||||
|
--key-file "$CERT_DIR/${FIRST_DOMAIN}.key" \
|
||||||
|
--fullchain-file "$CERT_DIR/${FIRST_DOMAIN}_bundle.crt" \
|
||||||
|
--reloadcmd "systemctl reload nginx"
|
||||||
|
USE_SSL=true
|
||||||
|
else
|
||||||
|
USE_SSL=false
|
||||||
|
[ "$FIRST_DOMAIN" = "_" ] || warn "未填写证书邮箱,部署为 HTTP。"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$USE_SSL" = true ]; then
|
||||||
|
PROTOCOL=https
|
||||||
|
sudo tee "$NGINX_FILE" >/dev/null <<EOF
|
||||||
|
server { listen 80; server_name $DOMAIN_INPUT; return 301 https://\$host\$request_uri; }
|
||||||
|
server {
|
||||||
|
listen 443 ssl http2;
|
||||||
|
server_name $DOMAIN_INPUT;
|
||||||
|
ssl_certificate $CERT_DIR/${FIRST_DOMAIN}_bundle.crt;
|
||||||
|
ssl_certificate_key $CERT_DIR/${FIRST_DOMAIN}.key;
|
||||||
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
client_max_body_size 2g;
|
||||||
|
location /api/ { proxy_pass http://127.0.0.1:$APP_PORT; proxy_set_header Host \$host; proxy_set_header X-Real-IP \$remote_addr; proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for; proxy_read_timeout 300; proxy_buffering off; }
|
||||||
|
location ~ ^/(health|docs|redoc|openapi.json)(/.*)?\$ { proxy_pass http://127.0.0.1:$APP_PORT; proxy_set_header Host \$host; }
|
||||||
|
location /admin/ { alias $API_ROOT/static/admin/; try_files \$uri \$uri/ /admin/index.html; }
|
||||||
|
location / { root $API_ROOT/static/web; try_files \$uri \$uri/ /index.html; }
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
else
|
||||||
|
PROTOCOL=http
|
||||||
|
sudo tee "$NGINX_FILE" >/dev/null <<EOF
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name $DOMAIN_INPUT;
|
||||||
|
client_max_body_size 2g;
|
||||||
|
location /api/ { proxy_pass http://127.0.0.1:$APP_PORT; proxy_set_header Host \$host; proxy_set_header X-Real-IP \$remote_addr; proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for; proxy_read_timeout 300; proxy_buffering off; }
|
||||||
|
location ~ ^/(health|docs|redoc|openapi.json)(/.*)?\$ { proxy_pass http://127.0.0.1:$APP_PORT; proxy_set_header Host \$host; }
|
||||||
|
location /admin/ { alias $API_ROOT/static/admin/; try_files \$uri \$uri/ /admin/index.html; }
|
||||||
|
location / { root $API_ROOT/static/web; try_files \$uri \$uri/ /index.html; }
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
fi
|
||||||
|
sudo nginx -t
|
||||||
|
sudo systemctl reload nginx || sudo systemctl restart nginx
|
||||||
|
sudo systemctl restart "$SERVICE_NAME"
|
||||||
|
if [ -n "${JCF_ENV_SOURCE:-}" ]; then
|
||||||
|
rm -f -- "$JCF_ENV_SOURCE"
|
||||||
|
fi
|
||||||
|
ok "部署完成:${PROTOCOL}://${FIRST_DOMAIN},服务名:$SERVICE_NAME"
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from faster_whisper.utils import download_model
|
||||||
|
|
||||||
|
|
||||||
|
MODEL_SIZE = "small"
|
||||||
|
MODEL_DIR = Path(__file__).resolve().parent.parent / "models" / f"faster-whisper-{MODEL_SIZE}"
|
||||||
|
REQUIRED_FILES = ("config.json", "model.bin", "tokenizer.json", "vocabulary.txt")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
missing = [name for name in REQUIRED_FILES if not (MODEL_DIR / name).is_file()]
|
||||||
|
if not missing:
|
||||||
|
print(f"Whisper model already exists: {MODEL_DIR}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
MODEL_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
print(f"Downloading faster-whisper-{MODEL_SIZE} to {MODEL_DIR} ...")
|
||||||
|
download_model(MODEL_SIZE, output_dir=str(MODEL_DIR))
|
||||||
|
missing = [name for name in REQUIRED_FILES if not (MODEL_DIR / name).is_file()]
|
||||||
|
if missing:
|
||||||
|
raise RuntimeError(f"Whisper model download is incomplete; missing: {', '.join(missing)}")
|
||||||
|
print(f"Whisper model is ready: {MODEL_DIR}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# 剧核工厂一键部署入口。
|
||||||
|
# 第一次执行会询问部署参数并保存到 API/deploy/.deploy.conf;以后执行会自动复用。
|
||||||
|
# 完整部署流程在同目录的 deploy_with_ssl.sh 中,便于后续单独重复执行。
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
API_ROOT="$(realpath "$SCRIPT_DIR/..")"
|
||||||
|
PROJECT_ROOT="$(realpath "$API_ROOT/..")"
|
||||||
|
CONFIG_FILE="$SCRIPT_DIR/.deploy.conf"
|
||||||
|
|
||||||
|
if [ -s "$CONFIG_FILE" ]; then
|
||||||
|
# shellcheck disable=SC1090
|
||||||
|
source "$CONFIG_FILE"
|
||||||
|
else
|
||||||
|
SAVED_DOMAIN_INPUT=""
|
||||||
|
SAVED_USER_EMAIL=""
|
||||||
|
EXISTING_NGINX_CONFIG="/etc/nginx/conf.d/juyou_ai.conf"
|
||||||
|
if [ -f "$EXISTING_NGINX_CONFIG" ]; then
|
||||||
|
SAVED_DOMAIN_INPUT="$(sed -n -E 's/^[[:space:]]*server_name[[:space:]]+([^;]+);.*$/\1/p' "$EXISTING_NGINX_CONFIG" | head -n 1)"
|
||||||
|
SAVED_DOMAIN_INPUT="${SAVED_DOMAIN_INPUT//_/}"
|
||||||
|
SAVED_DOMAIN_INPUT="$(printf '%s' "$SAVED_DOMAIN_INPUT" | xargs)"
|
||||||
|
fi
|
||||||
|
if [ -z "$SAVED_DOMAIN_INPUT" ]; then
|
||||||
|
read -r -p "域名(多个域名空格分隔,将全部追加到证书;不申请 SSL 填 _)[_]: " SAVED_DOMAIN_INPUT
|
||||||
|
SAVED_DOMAIN_INPUT="${SAVED_DOMAIN_INPUT:-_}"
|
||||||
|
fi
|
||||||
|
FIRST_SAVED_DOMAIN="${SAVED_DOMAIN_INPUT%% *}"
|
||||||
|
EXISTING_CERT_READY=false
|
||||||
|
if [ -n "$FIRST_SAVED_DOMAIN" ] && [ -f "/etc/nginx/ssl/${FIRST_SAVED_DOMAIN}_bundle.crt" ] && [ -f "/etc/nginx/ssl/${FIRST_SAVED_DOMAIN}.key" ]; then
|
||||||
|
EXISTING_CERT_READY=true
|
||||||
|
fi
|
||||||
|
if [ "$SAVED_DOMAIN_INPUT" != "_" ] && [ "$EXISTING_CERT_READY" = true ]; then
|
||||||
|
echo "检测到当前域名的现有证书,复用域名和证书:$SAVED_DOMAIN_INPUT"
|
||||||
|
elif [ "$SAVED_DOMAIN_INPUT" != "_" ]; then
|
||||||
|
read -r -p "SSL 证书邮箱(申请新证书;不申请 SSL 直接回车): " SAVED_USER_EMAIL
|
||||||
|
fi
|
||||||
|
cat >"$CONFIG_FILE" <<EOF
|
||||||
|
# 部署脚本自动保存的配置,可直接编辑后重新运行。
|
||||||
|
SAVED_DOMAIN_INPUT=$(printf '%q' "$SAVED_DOMAIN_INPUT")
|
||||||
|
SAVED_USER_EMAIL=$(printf '%q' "$SAVED_USER_EMAIL")
|
||||||
|
EOF
|
||||||
|
fi
|
||||||
|
|
||||||
|
SAVED_DOMAIN_INPUT="${SAVED_DOMAIN_INPUT//$'\r'/}"
|
||||||
|
SAVED_USER_EMAIL="${SAVED_USER_EMAIL//$'\r'/}"
|
||||||
|
export SAVED_DOMAIN_INPUT SAVED_USER_EMAIL
|
||||||
|
if [ -z "${JCF_ENV_SOURCE:-}" ] && [ -f /tmp/juyou_ai.env ]; then
|
||||||
|
export JCF_ENV_SOURCE=/tmp/juyou_ai.env
|
||||||
|
fi
|
||||||
|
exec bash "$SCRIPT_DIR/deploy_with_ssl.sh" "$API_ROOT"
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Prepare the Linux host and install the uploaded environment file outside the app directory.
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
API_ROOT="$(realpath "${1:-$SCRIPT_DIR/..}")"
|
||||||
|
SOURCE_ENV="${JCF_ENV_SOURCE:-}"
|
||||||
|
SYSTEM_ENV_DIR="/etc/juyou_ai"
|
||||||
|
SYSTEM_ENV_FILE="$SYSTEM_ENV_DIR/juyou_ai.env"
|
||||||
|
ASR_CACHE_ROOT="/var/cache/juyou_ai"
|
||||||
|
ASR_HF_HOME="$ASR_CACHE_ROOT/huggingface"
|
||||||
|
ASR_MODEL_PATH="$API_ROOT/models/faster-whisper-small"
|
||||||
|
|
||||||
|
info(){ printf ' -> %s\n' "$*"; }
|
||||||
|
fail(){ printf ' ERR %s\n' "$*" >&2; exit 1; }
|
||||||
|
|
||||||
|
[ "$(id -u)" -eq 0 ] || fail "请使用 sudo 或 root 执行。"
|
||||||
|
|
||||||
|
info "安装 PostgreSQL、Redis、Nginx、FFmpeg 与基础工具..."
|
||||||
|
if command -v apt-get >/dev/null 2>&1; then
|
||||||
|
apt-get update -qq
|
||||||
|
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq sudo nginx postgresql postgresql-contrib redis-server ffmpeg curl socat ca-certificates openssl logrotate python3 python3-pip
|
||||||
|
elif command -v dnf >/dev/null 2>&1; then
|
||||||
|
dnf install -y sudo nginx postgresql-server postgresql-contrib redis ffmpeg curl socat ca-certificates openssl logrotate python3 python3-pip
|
||||||
|
elif command -v yum >/dev/null 2>&1; then
|
||||||
|
yum install -y sudo nginx postgresql-server postgresql-contrib redis ffmpeg curl socat ca-certificates openssl logrotate python3 python3-pip
|
||||||
|
else
|
||||||
|
fail "不支持当前 Linux 发行版:未找到 apt-get、dnf 或 yum。"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! python3 -c 'import faster_whisper' >/dev/null 2>&1; then
|
||||||
|
PIP_ARGS=(install --disable-pip-version-check)
|
||||||
|
if python3 -m pip install --help 2>/dev/null | grep -q -- '--break-system-packages'; then
|
||||||
|
PIP_ARGS+=(--break-system-packages)
|
||||||
|
fi
|
||||||
|
python3 -m pip "${PIP_ARGS[@]}" 'faster-whisper==1.2.1'
|
||||||
|
fi
|
||||||
|
python3 -c 'import faster_whisper' >/dev/null 2>&1 || fail "faster-whisper installation failed."
|
||||||
|
|
||||||
|
if command -v postgresql-setup >/dev/null 2>&1 && [ ! -f /var/lib/pgsql/data/PG_VERSION ]; then
|
||||||
|
info "初始化 PostgreSQL 数据目录..."
|
||||||
|
postgresql-setup --initdb
|
||||||
|
fi
|
||||||
|
systemctl enable --now postgresql >/dev/null 2>&1 || fail "PostgreSQL 启动失败。"
|
||||||
|
if systemctl list-unit-files redis-server.service >/dev/null 2>&1; then
|
||||||
|
systemctl enable --now redis-server >/dev/null
|
||||||
|
else
|
||||||
|
systemctl enable --now redis >/dev/null || fail "Redis 启动失败。"
|
||||||
|
fi
|
||||||
|
systemctl enable --now nginx >/dev/null
|
||||||
|
sudo -u postgres pg_isready >/dev/null || fail "PostgreSQL 未就绪。"
|
||||||
|
redis-cli ping | grep -q '^PONG$' || fail "Redis 未就绪。"
|
||||||
|
|
||||||
|
install -d -m 700 -o root -g root "$SYSTEM_ENV_DIR"
|
||||||
|
if [ -n "$SOURCE_ENV" ] && [ -f "$SOURCE_ENV" ]; then
|
||||||
|
install -m 600 -o root -g root "$SOURCE_ENV" "$SYSTEM_ENV_FILE"
|
||||||
|
info "已将上传的环境配置安装到 $SYSTEM_ENV_FILE。"
|
||||||
|
elif [ -f "$SYSTEM_ENV_FILE" ]; then
|
||||||
|
info "未上传新配置,继续使用 $SYSTEM_ENV_FILE。"
|
||||||
|
else
|
||||||
|
fail "缺少上传的 API/.env,且服务器不存在 $SYSTEM_ENV_FILE。"
|
||||||
|
fi
|
||||||
|
# The application directory must not retain deployment secrets.
|
||||||
|
rm -f "$API_ROOT/.env"
|
||||||
|
|
||||||
|
env_value() {
|
||||||
|
local key="$1"
|
||||||
|
sed -n -E "s/^${key}=//p" "$SYSTEM_ENV_FILE" | head -n 1 | tr -d '\r' | sed -E 's/^"//;s/"$//'
|
||||||
|
}
|
||||||
|
set_env() {
|
||||||
|
local key="$1" value="$2" temporary
|
||||||
|
temporary="$(mktemp "$SYSTEM_ENV_DIR/env.XXXXXX")"
|
||||||
|
awk -v key="$key" -v value="$value" '
|
||||||
|
BEGIN { found=0 }
|
||||||
|
index($0, key "=")==1 { print key "=" value; found=1; next }
|
||||||
|
{ print }
|
||||||
|
END { if (!found) print key "=" value }
|
||||||
|
' "$SYSTEM_ENV_FILE" >"$temporary"
|
||||||
|
install -m 600 -o root -g root "$temporary" "$SYSTEM_ENV_FILE"
|
||||||
|
rm -f "$temporary"
|
||||||
|
}
|
||||||
|
require_env() {
|
||||||
|
local key="$1" value
|
||||||
|
value="$(env_value "$key")"
|
||||||
|
[ -n "$value" ] || fail "$key 未配置。"
|
||||||
|
[[ "$value" != *replace-with* && "$value" != *example.com* && "$value" != *change-me* && "$value" != "juchuang_dev" ]] || fail "$key 仍是示例或开发环境值。"
|
||||||
|
}
|
||||||
|
|
||||||
|
APP_PORT="8123"
|
||||||
|
DOMAIN_INPUT="${SAVED_DOMAIN_INPUT:-_}"
|
||||||
|
if ! [[ "$APP_PORT" =~ ^[0-9]+$ ]] || [ "$APP_PORT" -lt 1 ] || [ "$APP_PORT" -gt 65535 ]; then
|
||||||
|
fail "API 端口无效:$APP_PORT"
|
||||||
|
fi
|
||||||
|
CORS_ORIGINS=""
|
||||||
|
if [ "$DOMAIN_INPUT" != "_" ]; then
|
||||||
|
for domain in $DOMAIN_INPUT; do
|
||||||
|
[[ "$domain" =~ ^([A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)+[A-Za-z]{2,63}$ ]] || fail "域名格式无效:$domain"
|
||||||
|
[ -n "$CORS_ORIGINS" ] && CORS_ORIGINS+=","
|
||||||
|
CORS_ORIGINS+="https://$domain,http://$domain"
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Infrastructure values are normalized for native Linux services; application secrets stay unchanged.
|
||||||
|
set_env APP_ENV production
|
||||||
|
set_env DEBUG false
|
||||||
|
set_env SERVER_ADDRESS "127.0.0.1:$APP_PORT"
|
||||||
|
set_env DATABASE_HOST 127.0.0.1
|
||||||
|
set_env DATABASE_PORT 5432
|
||||||
|
set_env DATABASE_URL ""
|
||||||
|
set_env REDIS_HOST 127.0.0.1
|
||||||
|
set_env REDIS_PORT 6379
|
||||||
|
set_env HTTP_PROXY ""
|
||||||
|
set_env HTTPS_PROXY ""
|
||||||
|
set_env ALL_PROXY ""
|
||||||
|
set_env http_proxy ""
|
||||||
|
set_env https_proxy ""
|
||||||
|
set_env all_proxy ""
|
||||||
|
set_env CORS_ORIGINS "$CORS_ORIGINS"
|
||||||
|
set_env FFMPEG_PATH ffmpeg
|
||||||
|
set_env FFPROBE_PATH ffprobe
|
||||||
|
set_env PYTHON_PATH python3
|
||||||
|
set_env ASR_SCRIPT_PATH "$API_ROOT/workers/transcribe.py"
|
||||||
|
set_env JCF_ASR_CACHE_DIR "$ASR_CACHE_ROOT"
|
||||||
|
set_env HF_HOME "$ASR_HF_HOME"
|
||||||
|
set_env FASTER_WHISPER_MODEL_PATH "$ASR_MODEL_PATH"
|
||||||
|
[ -f "$API_ROOT/workers/transcribe.py" ] || fail "Missing ASR script: $API_ROOT/workers/transcribe.py"
|
||||||
|
for model_file in config.json model.bin tokenizer.json vocabulary.txt; do
|
||||||
|
[ -f "$ASR_MODEL_PATH/$model_file" ] || fail "离线 Whisper 模型不完整,缺少:$ASR_MODEL_PATH/$model_file。请重新执行 push.bat 上传模型。"
|
||||||
|
done
|
||||||
|
|
||||||
|
install -d -m 750 -o www-data -g www-data "$ASR_CACHE_ROOT" "$ASR_HF_HOME"
|
||||||
|
chown -R www-data:www-data "$ASR_MODEL_PATH"
|
||||||
|
info "使用离线 faster-whisper 模型:$ASR_MODEL_PATH。"
|
||||||
|
|
||||||
|
for key in DATABASE_NAME DATABASE_USER DATABASE_PASSWORD JWT_SECRET_KEY ADMIN_BOOTSTRAP_PASSWORD CONFIG_ENCRYPTION_KEY COS_SECRET_ID COS_SECRET_KEY COS_BUCKET COS_REGION COS_ENDPOINT COS_PUBLIC_BASE_URL; do
|
||||||
|
require_env "$key"
|
||||||
|
done
|
||||||
|
|
||||||
|
DB_NAME="$(env_value DATABASE_NAME)"
|
||||||
|
DB_USER="$(env_value DATABASE_USER)"
|
||||||
|
DB_PASSWORD="$(env_value DATABASE_PASSWORD)"
|
||||||
|
[[ "$DB_NAME" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || fail "DATABASE_NAME 只允许字母、数字和下划线。"
|
||||||
|
[[ "$DB_USER" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || fail "DATABASE_USER 只允许字母、数字和下划线。"
|
||||||
|
|
||||||
|
if ! sudo -u postgres psql -tAc "SELECT 1 FROM pg_roles WHERE rolname='$DB_USER'" | grep -q 1; then
|
||||||
|
sudo -u postgres psql -v ON_ERROR_STOP=1 --set=password="$DB_PASSWORD" >/dev/null <<SQL
|
||||||
|
SET password_encryption = 'scram-sha-256';
|
||||||
|
CREATE ROLE "$DB_USER" LOGIN PASSWORD :'password';
|
||||||
|
SQL
|
||||||
|
else
|
||||||
|
sudo -u postgres psql -v ON_ERROR_STOP=1 --set=password="$DB_PASSWORD" >/dev/null <<SQL
|
||||||
|
SET password_encryption = 'scram-sha-256';
|
||||||
|
ALTER ROLE "$DB_USER" WITH LOGIN PASSWORD :'password';
|
||||||
|
SQL
|
||||||
|
fi
|
||||||
|
if ! sudo -u postgres psql -tAc "SELECT 1 FROM pg_database WHERE datname='$DB_NAME'" | grep -q 1; then
|
||||||
|
sudo -u postgres createdb -O "$DB_USER" "$DB_NAME"
|
||||||
|
fi
|
||||||
|
sudo -u postgres psql -v ON_ERROR_STOP=1 -d "$DB_NAME" -c "ALTER DATABASE \"$DB_NAME\" SET timezone TO 'Asia/Shanghai';" >/dev/null
|
||||||
|
HBA_FILE="$(sudo -u postgres psql -Atc 'SHOW hba_file')"
|
||||||
|
if ! grep -q '^# JCF local application access$' "$HBA_FILE"; then
|
||||||
|
sed -i "1i# JCF local application access\nhost all all 127.0.0.1/32 scram-sha-256\nhost all all ::1/128 scram-sha-256" "$HBA_FILE"
|
||||||
|
systemctl reload postgresql
|
||||||
|
fi
|
||||||
|
info "PostgreSQL 数据库与用户已初始化,Redis 已启动。"
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
@echo off
|
||||||
|
REM JCF deployment: build locally, upload over SSH, and deploy on Linux.
|
||||||
|
REM Usage: push.bat user@host [stage-only|with-env]
|
||||||
|
|
||||||
|
setlocal DisableDelayedExpansion
|
||||||
|
cd /d "%~dp0..\.."
|
||||||
|
if "%~1"=="" (
|
||||||
|
echo Usage: push.bat user@host [stage-only^|with-env]
|
||||||
|
echo Example: push.bat root@1.2.3.4
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
set "REMOTE=%~1"
|
||||||
|
set "REMOTE_ROOT=/opt/juyou_ai"
|
||||||
|
set "PACKAGE_ENV="
|
||||||
|
if "%~2"=="with-env" set "PACKAGE_ENV=.env"
|
||||||
|
if not "%~2"=="" if not "%~2"=="stage-only" if not "%~2"=="with-env" (
|
||||||
|
echo The second argument can only be stage-only or with-env.
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
if "%~2"=="with-env" if not exist "API\.env" (
|
||||||
|
echo Missing API\.env. Deployment environment cannot be uploaded.
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
if not "%~3"=="" (
|
||||||
|
echo Too many arguments.
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
set "ARCHIVE=%TEMP%\juyou-deploy-%RANDOM%-%RANDOM%.tar.gz"
|
||||||
|
set "REMOTE_ARCHIVE=/tmp/juyou-deploy.tar.gz"
|
||||||
|
set "REMOTE_ENV=/tmp/juyou_ai.env"
|
||||||
|
set "PACKAGE_MODEL="
|
||||||
|
|
||||||
|
echo [0/5] Checking offline Whisper model...
|
||||||
|
ssh "%REMOTE%" "test -f %REMOTE_ROOT%/models/faster-whisper-small/config.json && test -f %REMOTE_ROOT%/models/faster-whisper-small/model.bin && test -f %REMOTE_ROOT%/models/faster-whisper-small/tokenizer.json && test -f %REMOTE_ROOT%/models/faster-whisper-small/vocabulary.txt" >nul 2>nul
|
||||||
|
if errorlevel 1 (
|
||||||
|
python "API\deploy\download_whisper_model.py"
|
||||||
|
if errorlevel 1 exit /b 1
|
||||||
|
set "PACKAGE_MODEL=models"
|
||||||
|
) else (
|
||||||
|
echo Offline Whisper model already exists on the server.
|
||||||
|
)
|
||||||
|
|
||||||
|
echo [1/5] Building WEB and ADMIN...
|
||||||
|
call npm --prefix WEB run build
|
||||||
|
if errorlevel 1 exit /b 1
|
||||||
|
call npm --prefix ADMIN run build
|
||||||
|
if errorlevel 1 exit /b 1
|
||||||
|
|
||||||
|
echo [2/5] Building Linux amd64 API...
|
||||||
|
pushd API
|
||||||
|
set "GOOS=linux"
|
||||||
|
set "GOARCH=amd64"
|
||||||
|
go build -trimpath -ldflags "-s -w" -o bin/jcf-api ./cmd/api
|
||||||
|
if errorlevel 1 (popd & exit /b 1)
|
||||||
|
popd
|
||||||
|
|
||||||
|
echo [3/5] Packaging deployment files...
|
||||||
|
tar --exclude=.git --exclude=tools/ffmpeg --exclude=deploy/.deploy.conf -C API -czf "%ARCHIVE%" bin static deploy migrations acme workers %PACKAGE_MODEL% %PACKAGE_ENV%
|
||||||
|
if errorlevel 1 exit /b 1
|
||||||
|
|
||||||
|
echo [4/5] Uploading and staging deployment files...
|
||||||
|
scp "%ARCHIVE%" "%REMOTE%:%REMOTE_ARCHIVE%"
|
||||||
|
if errorlevel 1 exit /b 1
|
||||||
|
ssh "%REMOTE%" "bash -lc 'set -e; WORK=/tmp/juyou-deploy-work; ENV_UPLOAD=%REMOTE_ENV%; cleanup(){ rm -rf $WORK %REMOTE_ARCHIVE%; }; trap cleanup EXIT; rm -rf $WORK; mkdir -p $WORK; tar -xzf %REMOTE_ARCHIVE% -C $WORK; if [ -f $WORK/.env ]; then install -m 600 $WORK/.env $ENV_UPLOAD; else rm -f $ENV_UPLOAD; fi; rm -f $WORK/.env; mkdir -p %REMOTE_ROOT%; if command -v rsync >/dev/null 2>&1; then rsync -a --delete --exclude=/.env --exclude=/media/ --exclude=/backups/ --exclude=/models/ $WORK/ %REMOTE_ROOT%/; if [ -d $WORK/models ]; then mkdir -p %REMOTE_ROOT%/models; rsync -a $WORK/models/ %REMOTE_ROOT%/models/; fi; else cp -a $WORK/. %REMOTE_ROOT%/; fi; echo upload complete; echo run manually: bash %REMOTE_ROOT%/deploy/one_click_deployment.sh'"
|
||||||
|
if errorlevel 1 exit /b 1
|
||||||
|
|
||||||
|
echo [5/5] Cleaning local temporary archive...
|
||||||
|
del "%ARCHIVE%" >nul 2>nul
|
||||||
|
echo Deployment push complete.
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# 一键更新本地 acme.sh。
|
||||||
|
# 使用 jsDelivr CDN 获取 acmesh-official/acme.sh 的 master 最新版本,
|
||||||
|
# 不执行 GitHub clone,也不依赖 GitHub 下载地址;脚本保存到 API/acme/。
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
API_ROOT="$(realpath "$SCRIPT_DIR/..")"
|
||||||
|
ACME_DIR="$API_ROOT/acme"
|
||||||
|
ACME_FILE="$ACME_DIR/acme.sh"
|
||||||
|
SOURCE_URL="https://cdn.jsdelivr.net/gh/acmesh-official/acme.sh@master/acme.sh"
|
||||||
|
mkdir -p "$ACME_DIR"
|
||||||
|
TEMP_FILE="$(mktemp "$ACME_DIR/acme.sh.XXXXXX")"
|
||||||
|
trap 'rm -f "$TEMP_FILE"' EXIT
|
||||||
|
|
||||||
|
command -v curl >/dev/null 2>&1 || { echo "缺少 curl,请先安装 curl。" >&2; exit 1; }
|
||||||
|
echo "正在从非 GitHub CDN 下载 acme.sh 最新版本..."
|
||||||
|
curl -fsSL --retry 3 --connect-timeout 15 "$SOURCE_URL" -o "$TEMP_FILE"
|
||||||
|
grep -q '^VER=' "$TEMP_FILE" || { echo "下载内容不是有效的 acme.sh 文件。" >&2; exit 1; }
|
||||||
|
chmod 755 "$TEMP_FILE"
|
||||||
|
mv -f "$TEMP_FILE" "$ACME_FILE"
|
||||||
|
echo "acme.sh 已更新到:$ACME_FILE"
|
||||||
|
grep '^VER=' "$ACME_FILE" | head -n 1
|
||||||
+79
@@ -0,0 +1,79 @@
|
|||||||
|
module juhe-factory/api
|
||||||
|
|
||||||
|
go 1.26.0
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/aws/aws-sdk-go-v2 v1.41.1
|
||||||
|
github.com/aws/aws-sdk-go-v2/config v1.32.7
|
||||||
|
github.com/aws/aws-sdk-go-v2/credentials v1.19.7
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.95.1
|
||||||
|
github.com/gin-gonic/gin v1.12.0
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.0
|
||||||
|
github.com/google/uuid v1.6.0
|
||||||
|
github.com/hibiken/asynq v0.26.0
|
||||||
|
github.com/joho/godotenv v1.5.1
|
||||||
|
github.com/redis/go-redis/v9 v9.21.0
|
||||||
|
github.com/richardlehane/mscfb v1.0.7
|
||||||
|
golang.org/x/crypto v0.48.0
|
||||||
|
golang.org/x/text v0.34.0
|
||||||
|
gorm.io/driver/postgres v1.6.0
|
||||||
|
gorm.io/gorm v1.31.2
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.17 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.8 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.17 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/signin v1.0.5 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sso v1.30.9 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.13 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 // indirect
|
||||||
|
github.com/aws/smithy-go v1.24.0 // indirect
|
||||||
|
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||||
|
github.com/bytedance/sonic v1.15.0 // indirect
|
||||||
|
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
|
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
||||||
|
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||||
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
|
github.com/go-playground/validator/v10 v10.30.1 // indirect
|
||||||
|
github.com/goccy/go-json v0.10.5 // indirect
|
||||||
|
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
|
github.com/jackc/pgx/v5 v5.6.0 // indirect
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
|
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||||
|
github.com/jinzhu/now v1.1.5 // indirect
|
||||||
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
|
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||||
|
github.com/leodido/go-urn v1.4.0 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||||
|
github.com/quic-go/qpack v0.6.0 // indirect
|
||||||
|
github.com/quic-go/quic-go v0.59.0 // indirect
|
||||||
|
github.com/richardlehane/msoleps v1.0.3 // indirect
|
||||||
|
github.com/robfig/cron/v3 v3.0.1 // indirect
|
||||||
|
github.com/spf13/cast v1.10.0 // indirect
|
||||||
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
|
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||||
|
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
||||||
|
go.uber.org/atomic v1.11.0 // indirect
|
||||||
|
golang.org/x/arch v0.22.0 // indirect
|
||||||
|
golang.org/x/net v0.51.0 // indirect
|
||||||
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
|
golang.org/x/sys v0.41.0 // indirect
|
||||||
|
golang.org/x/time v0.14.0 // indirect
|
||||||
|
google.golang.org/protobuf v1.36.10 // indirect
|
||||||
|
)
|
||||||
+190
@@ -0,0 +1,190 @@
|
|||||||
|
github.com/aws/aws-sdk-go-v2 v1.41.1 h1:ABlyEARCDLN034NhxlRUSZr4l71mh+T5KAeGh6cerhU=
|
||||||
|
github.com/aws/aws-sdk-go-v2 v1.41.1/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0=
|
||||||
|
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 h1:489krEF9xIGkOaaX3CE/Be2uWjiXrkCH6gUX+bZA/BU=
|
||||||
|
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4/go.mod h1:IOAPF6oT9KCsceNTvvYMNHy0+kMF8akOjeDvPENWxp4=
|
||||||
|
github.com/aws/aws-sdk-go-v2/config v1.32.7 h1:vxUyWGUwmkQ2g19n7JY/9YL8MfAIl7bTesIUykECXmY=
|
||||||
|
github.com/aws/aws-sdk-go-v2/config v1.32.7/go.mod h1:2/Qm5vKUU/r7Y+zUk/Ptt2MDAEKAfUtKc1+3U1Mo3oY=
|
||||||
|
github.com/aws/aws-sdk-go-v2/credentials v1.19.7 h1:tHK47VqqtJxOymRrNtUXN5SP/zUTvZKeLx4tH6PGQc8=
|
||||||
|
github.com/aws/aws-sdk-go-v2/credentials v1.19.7/go.mod h1:qOZk8sPDrxhf+4Wf4oT2urYJrYt3RejHSzgAquYeppw=
|
||||||
|
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 h1:I0GyV8wiYrP8XpA70g1HBcQO1JlQxCMTW9npl5UbDHY=
|
||||||
|
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17/go.mod h1:tyw7BOl5bBe/oqvoIeECFJjMdzXoa/dfVz3QQ5lgHGA=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 h1:xOLELNKGp2vsiteLsvLPwxC+mYmO6OZ8PYgiuPJzF8U=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17/go.mod h1:5M5CI3D12dNOtH3/mk6minaRwI2/37ifCURZISxA/IQ=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 h1:WWLqlh79iO48yLkj1v3ISRNiv+3KdQoZ6JWyfcsyQik=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17/go.mod h1:EhG22vHRrvF8oXSTYStZhJc1aUgKtnJe+aOiFEV90cM=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.17 h1:JqcdRG//czea7Ppjb+g/n4o8i/R50aTBHkA7vu0lK+k=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.17/go.mod h1:CO+WeGmIdj/MlPel2KwID9Gt7CNq4M65HUfBW97liM0=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 h1:0ryTNEdJbzUCEWkVXEXoqlXV72J5keC1GvILMOuD00E=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4/go.mod h1:HQ4qwNZh32C3CBeO6iJLQlgtMzqeG17ziAA/3KDJFow=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.8 h1:Z5EiPIzXKewUQK0QTMkutjiaPVeVYXX7KIqhXu/0fXs=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.8/go.mod h1:FsTpJtvC4U1fyDXk7c71XoDv3HlRm8V3NiYLeYLh5YE=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17 h1:RuNSMoozM8oXlgLG/n6WLaFGoea7/CddrCfIiSA+xdY=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17/go.mod h1:F2xxQ9TZz5gDWsclCtPQscGpP0VUOc8RqgFM3vDENmU=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.17 h1:bGeHBsGZx0Dvu/eJC0Lh9adJa3M1xREcndxLNZlve2U=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.17/go.mod h1:dcW24lbU0CzHusTE8LLHhRLI42ejmINN8Lcr22bwh/g=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.95.1 h1:C2dUPSnEpy4voWFIq3JNd8gN0Y5vYGDo44eUE58a/p8=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.95.1/go.mod h1:5jggDlZ2CLQhwJBiZJb4vfk4f0GxWdEDruWKEJ1xOdo=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/signin v1.0.5 h1:VrhDvQib/i0lxvr3zqlUwLwJP4fpmpyD9wYG1vfSu+Y=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/signin v1.0.5/go.mod h1:k029+U8SY30/3/ras4G/Fnv/b88N4mAfliNn08Dem4M=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sso v1.30.9 h1:v6EiMvhEYBoHABfbGB4alOYmCIrcgyPPiBE1wZAEbqk=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sso v1.30.9/go.mod h1:yifAsgBxgJWn3ggx70A3urX2AN49Y5sJTD1UQFlfqBw=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.13 h1:gd84Omyu9JLriJVCbGApcLzVR3XtmC4ZDPcAI6Ftvds=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.13/go.mod h1:sTGThjphYE4Ohw8vJiRStAcu3rbjtXRsdNB0TvZ5wwo=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 h1:5fFjR/ToSOzB2OQ/XqWpZBmNvmP/pJ1jOWYlFDJTjRQ=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sts v1.41.6/go.mod h1:qgFDZQSD/Kys7nJnVqYlWKnh0SSdMjAi0uSwON4wgYQ=
|
||||||
|
github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk=
|
||||||
|
github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
|
||||||
|
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||||
|
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||||
|
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||||
|
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||||
|
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||||
|
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||||
|
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
||||||
|
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
|
||||||
|
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
|
||||||
|
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
|
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||||
|
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||||
|
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||||
|
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||||
|
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||||
|
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
|
||||||
|
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
|
||||||
|
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||||
|
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||||
|
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||||
|
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||||
|
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||||
|
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||||
|
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
||||||
|
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
||||||
|
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||||
|
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||||
|
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||||
|
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||||
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/hibiken/asynq v0.26.0 h1:1Zxr92MlDnb1Zt/QR5g2vSCqUS03i95lUfqx5X7/wrw=
|
||||||
|
github.com/hibiken/asynq v0.26.0/go.mod h1:Qk4e57bTnWDoyJ67VkchuV6VzSM9IQW2nPvAGuDyw58=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||||
|
github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY=
|
||||||
|
github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
|
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||||
|
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||||
|
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||||
|
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||||
|
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||||
|
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||||
|
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
|
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||||
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
|
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||||
|
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
|
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||||
|
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||||
|
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||||
|
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
|
||||||
|
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
||||||
|
github.com/redis/go-redis/v9 v9.21.0 h1:FPBE4hhbAke+TLmcY3WkpbDffJEomdqPn3HYiqAtL9E=
|
||||||
|
github.com/redis/go-redis/v9 v9.21.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA=
|
||||||
|
github.com/richardlehane/mscfb v1.0.7 h1:oeoiM0WE79vHwE8RpIYYvIAc8ajTH2mb6UZm55/+EB0=
|
||||||
|
github.com/richardlehane/mscfb v1.0.7/go.mod h1:pe0+IUIc0AHh0+teNzBlJCtSyZdFOGgV4ZK9bsoV+Jo=
|
||||||
|
github.com/richardlehane/msoleps v1.0.3 h1:aznSZzrwYRl3rLKRT3gUk9am7T/mLNSnJINvN0AQoVM=
|
||||||
|
github.com/richardlehane/msoleps v1.0.3/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
|
||||||
|
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||||
|
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||||
|
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
||||||
|
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
||||||
|
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
||||||
|
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
|
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
|
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||||
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||||
|
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||||
|
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
||||||
|
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||||
|
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
|
||||||
|
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
|
||||||
|
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
||||||
|
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||||
|
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||||
|
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||||
|
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||||
|
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||||
|
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||||
|
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||||
|
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
|
||||||
|
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||||
|
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||||
|
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||||
|
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
||||||
|
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
||||||
|
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||||
|
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||||
|
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
|
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||||
|
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||||
|
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||||
|
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||||
|
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||||
|
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
|
||||||
|
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
|
||||||
|
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
|
||||||
|
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
|
||||||
|
gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo=
|
||||||
|
gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package billing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"juhe-factory/api/internal/model"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PrechargeGenerationTask creates a task and reserves its full quoted cost in
|
||||||
|
// the same transaction. A zero price is allowed only when the model has an
|
||||||
|
// explicit zero-price record; callers must reject a missing price record.
|
||||||
|
func PrechargeGenerationTask(tx *gorm.DB, task *model.GenerationTask, unitPrice string, quantity int, remark string) error {
|
||||||
|
price, err := strconv.ParseFloat(unitPrice, 64)
|
||||||
|
if err != nil || price < 0 || quantity <= 0 {
|
||||||
|
return errors.New("生成任务价格无效")
|
||||||
|
}
|
||||||
|
var amount string
|
||||||
|
if err := tx.Raw("SELECT round(?::numeric * ?::numeric,2)::text", unitPrice, quantity).Scan(&amount).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if amount == "" {
|
||||||
|
return errors.New("生成任务价格无效")
|
||||||
|
}
|
||||||
|
task.EstimatedPoints = amount
|
||||||
|
task.PrepaidPoints = amount
|
||||||
|
if task.ID == uuid.Nil {
|
||||||
|
task.ID = uuid.New()
|
||||||
|
}
|
||||||
|
|
||||||
|
if amountValue, _ := strconv.ParseFloat(amount, 64); amountValue > 0 {
|
||||||
|
if _, err := DebitPoints(tx, task.UserID, amount, "generation_hold", task.ID.String(), remark, "generation:hold:"+task.ID.String()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := tx.Create(task).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefundGenerationTask refunds a prepaid task exactly once. The caller must
|
||||||
|
// hold a row lock on task inside the same transaction.
|
||||||
|
func RefundGenerationTask(tx *gorm.DB, task *model.GenerationTask, remark string) (bool, error) {
|
||||||
|
amount, _ := strconv.ParseFloat(task.PrepaidPoints, 64)
|
||||||
|
if task.CostRefunded || amount <= 0 {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
refunded, err := RefundDebit(tx, task.UserID, "generation:hold:"+task.ID.String(), "generation:refund:"+task.ID.String(),
|
||||||
|
"generation_refund", task.ID.String(), remark)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
task.CostRefunded = true
|
||||||
|
return refunded, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
// 积分账务模块,负责积分入账、扣减、退款及批次分配记录维护。
|
||||||
|
package billing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrInsufficientPoints = errors.New("积分余额不足,请前往充值")
|
||||||
|
|
||||||
|
// pointGrant 表示参与扣减的可用积分批次。
|
||||||
|
type pointGrant struct {
|
||||||
|
ID uuid.UUID
|
||||||
|
AvailableAmount string
|
||||||
|
}
|
||||||
|
|
||||||
|
// pointAllocation 表示原扣减流水关联的积分批次和分配金额。
|
||||||
|
type pointAllocation struct {
|
||||||
|
GrantID uuid.UUID
|
||||||
|
Amount string
|
||||||
|
}
|
||||||
|
|
||||||
|
// parsePointCents 将最多两位小数的积分字符串转换为整数分值。
|
||||||
|
func parsePointCents(value string) (int64, error) {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value == "" {
|
||||||
|
return 0, errors.New("积分数量为空")
|
||||||
|
}
|
||||||
|
parts := strings.Split(value, ".")
|
||||||
|
if len(parts) > 2 || strings.HasPrefix(parts[0], "-") {
|
||||||
|
return 0, fmt.Errorf("无效积分数量 %q", value)
|
||||||
|
}
|
||||||
|
whole, err := strconv.ParseInt(parts[0], 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("无效积分数量 %q", value)
|
||||||
|
}
|
||||||
|
fraction := ""
|
||||||
|
if len(parts) == 2 {
|
||||||
|
fraction = parts[1]
|
||||||
|
}
|
||||||
|
if len(fraction) > 2 {
|
||||||
|
if strings.Trim(fraction[2:], "0") != "" {
|
||||||
|
return 0, fmt.Errorf("积分数量最多保留两位小数")
|
||||||
|
}
|
||||||
|
fraction = fraction[:2]
|
||||||
|
}
|
||||||
|
fraction += strings.Repeat("0", 2-len(fraction))
|
||||||
|
fractionValue, err := strconv.ParseInt(fraction, 10, 64)
|
||||||
|
if err != nil || whole > (int64(^uint64(0)>>1)-fractionValue)/100 {
|
||||||
|
return 0, fmt.Errorf("无效积分数量 %q", value)
|
||||||
|
}
|
||||||
|
return whole*100 + fractionValue, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatPointCents 将整数分值格式化为两位小数的积分字符串。
|
||||||
|
func formatPointCents(value int64) string {
|
||||||
|
return fmt.Sprintf("%d.%02d", value/100, value%100)
|
||||||
|
}
|
||||||
|
|
||||||
|
// lockUser 锁定用户积分余额,避免并发账务操作导致余额和批次不一致。
|
||||||
|
func lockUser(tx *gorm.DB, userID uuid.UUID, requireActive bool) (string, error) {
|
||||||
|
var balance string
|
||||||
|
userQuery := "SELECT point_balance::text FROM web_users WHERE id=? FOR UPDATE"
|
||||||
|
if requireActive {
|
||||||
|
userQuery = "SELECT point_balance::text FROM web_users WHERE id=? AND enabled=true AND deleted_at IS NULL FOR UPDATE"
|
||||||
|
}
|
||||||
|
if err := tx.Raw(userQuery, userID).Scan(&balance).Error; err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if balance == "" {
|
||||||
|
return "", gorm.ErrRecordNotFound
|
||||||
|
}
|
||||||
|
return balance, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreditPoints 创建永久有效的积分批次,并原子写入用户余额和积分流水。
|
||||||
|
func CreditPoints(tx *gorm.DB, userID uuid.UUID, sourceType, sourceID, points, remark string) (string, bool, error) {
|
||||||
|
amount, err := parsePointCents(points)
|
||||||
|
if err != nil || amount <= 0 {
|
||||||
|
return "", false, errors.New("入账积分必须大于零")
|
||||||
|
}
|
||||||
|
balance, err := lockUser(tx, userID, true)
|
||||||
|
if err != nil {
|
||||||
|
return "", false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
grantID := uuid.New()
|
||||||
|
result := tx.Exec(`INSERT INTO point_grants(id,user_id,source_type,source_id,granted_amount,available_amount)
|
||||||
|
VALUES(?,?,?,?,?::numeric,?::numeric) ON CONFLICT(source_type,source_id) DO NOTHING`,
|
||||||
|
grantID, userID, sourceType, sourceID, points, points)
|
||||||
|
if result.Error != nil {
|
||||||
|
return "", false, result.Error
|
||||||
|
}
|
||||||
|
if result.RowsAffected == 0 {
|
||||||
|
return balance, false, nil
|
||||||
|
}
|
||||||
|
if err := tx.Raw(`UPDATE web_users SET point_balance=point_balance+?::numeric
|
||||||
|
WHERE id=? RETURNING point_balance::text`, points, userID).Scan(&balance).Error; err != nil {
|
||||||
|
return "", false, err
|
||||||
|
}
|
||||||
|
if err := tx.Exec(`INSERT INTO point_ledger(user_id,change_amount,balance_after,business_type,business_id,remark,idempotency_key)
|
||||||
|
VALUES(?,?::numeric,?::numeric,?,?,?,?)`, userID, points, balance, sourceType, sourceID, remark, sourceType+":"+sourceID).Error; err != nil {
|
||||||
|
return "", false, err
|
||||||
|
}
|
||||||
|
return balance, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreditRedemptionPoints 将兑换码对应积分以永久有效批次计入用户账户。
|
||||||
|
func CreditRedemptionPoints(tx *gorm.DB, userID, codeID uuid.UUID, points string) (string, bool, error) {
|
||||||
|
return CreditPoints(tx, userID, "redemption", codeID.String(), points, "兑换码兑换")
|
||||||
|
}
|
||||||
|
|
||||||
|
// DebitPoints 按批次创建顺序扣减积分,并记录分配明细供后续退款恢复。
|
||||||
|
func DebitPoints(tx *gorm.DB, userID uuid.UUID, points, businessType, businessID, remark, idempotencyKey string) (string, error) {
|
||||||
|
requested, err := parsePointCents(points)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
balance, err := lockUser(tx, userID, true)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if requested == 0 {
|
||||||
|
return balance, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var grants []pointGrant
|
||||||
|
if err := tx.Raw(`SELECT id,available_amount::text AS available_amount
|
||||||
|
FROM point_grants WHERE user_id=? AND available_amount>0
|
||||||
|
ORDER BY created_at,id FOR UPDATE`, userID).Scan(&grants).Error; err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
remaining := requested
|
||||||
|
type usedGrant struct {
|
||||||
|
id uuid.UUID
|
||||||
|
amount int64
|
||||||
|
}
|
||||||
|
used := make([]usedGrant, 0, len(grants))
|
||||||
|
for _, grant := range grants {
|
||||||
|
available, parseErr := parsePointCents(grant.AvailableAmount)
|
||||||
|
if parseErr != nil {
|
||||||
|
return "", parseErr
|
||||||
|
}
|
||||||
|
use := available
|
||||||
|
if use > remaining {
|
||||||
|
use = remaining
|
||||||
|
}
|
||||||
|
if use > 0 {
|
||||||
|
used = append(used, usedGrant{id: grant.ID, amount: use})
|
||||||
|
remaining -= use
|
||||||
|
}
|
||||||
|
if remaining == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if remaining > 0 {
|
||||||
|
return "", ErrInsufficientPoints
|
||||||
|
}
|
||||||
|
for _, allocation := range used {
|
||||||
|
if err := tx.Exec(`UPDATE point_grants SET available_amount=available_amount-?::numeric WHERE id=?`,
|
||||||
|
formatPointCents(allocation.amount), allocation.id).Error; err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := tx.Raw(`UPDATE web_users SET point_balance=point_balance-?::numeric
|
||||||
|
WHERE id=? RETURNING point_balance::text`, points, userID).Scan(&balance).Error; err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
var ledgerID int64
|
||||||
|
if err := tx.Raw(`INSERT INTO point_ledger(user_id,change_amount,balance_after,business_type,business_id,remark,idempotency_key)
|
||||||
|
VALUES(?,(-?::numeric),?::numeric,?,?,?,?) RETURNING id`,
|
||||||
|
userID, points, balance, businessType, businessID, remark, idempotencyKey).Scan(&ledgerID).Error; err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
for _, allocation := range used {
|
||||||
|
if err := tx.Exec(`INSERT INTO point_ledger_allocations(ledger_id,grant_id,allocation_type,amount)
|
||||||
|
VALUES(?,?,'consume',?::numeric)`, ledgerID, allocation.id, formatPointCents(allocation.amount)).Error; err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return balance, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefundDebit 将原扣减记录恢复到对应的永久积分批次,并写入退款流水。
|
||||||
|
func RefundDebit(tx *gorm.DB, userID uuid.UUID, originalKey, refundKey, businessType, businessID, remark string) (bool, error) {
|
||||||
|
balance, err := lockUser(tx, userID, false)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
var existing int64
|
||||||
|
if err := tx.Raw("SELECT count(*) FROM point_ledger WHERE idempotency_key=?", refundKey).Scan(&existing).Error; err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if existing > 0 {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var allocations []pointAllocation
|
||||||
|
if err := tx.Raw(`SELECT a.grant_id,a.amount::text AS amount
|
||||||
|
FROM point_ledger original
|
||||||
|
JOIN point_ledger_allocations a ON a.ledger_id=original.id AND a.allocation_type='consume'
|
||||||
|
JOIN point_grants g ON g.id=a.grant_id
|
||||||
|
WHERE original.idempotency_key=? ORDER BY g.created_at,g.id FOR UPDATE OF g`, originalKey).Scan(&allocations).Error; err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if len(allocations) == 0 {
|
||||||
|
return false, errors.New("未找到原积分扣减分配记录")
|
||||||
|
}
|
||||||
|
restored := int64(0)
|
||||||
|
restoredAllocations := make([]pointAllocation, 0, len(allocations))
|
||||||
|
for _, allocation := range allocations {
|
||||||
|
amount, parseErr := parsePointCents(allocation.Amount)
|
||||||
|
if parseErr != nil {
|
||||||
|
return false, parseErr
|
||||||
|
}
|
||||||
|
result := tx.Exec(`UPDATE point_grants SET available_amount=available_amount+?::numeric
|
||||||
|
WHERE id=?`, allocation.Amount, allocation.GrantID)
|
||||||
|
if result.Error != nil {
|
||||||
|
return false, result.Error
|
||||||
|
}
|
||||||
|
if result.RowsAffected > 0 {
|
||||||
|
restored += amount
|
||||||
|
restoredAllocations = append(restoredAllocations, allocation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if restored > 0 {
|
||||||
|
if err := tx.Raw(`UPDATE web_users SET point_balance=point_balance+?::numeric
|
||||||
|
WHERE id=? RETURNING point_balance::text`, formatPointCents(restored), userID).Scan(&balance).Error; err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var refundLedgerID int64
|
||||||
|
if err := tx.Raw(`INSERT INTO point_ledger(user_id,change_amount,balance_after,business_type,business_id,remark,idempotency_key)
|
||||||
|
VALUES(?,?::numeric,?::numeric,?,?,?,?) RETURNING id`, userID, formatPointCents(restored), balance,
|
||||||
|
businessType, businessID, remark, refundKey).Scan(&refundLedgerID).Error; err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
for _, allocation := range restoredAllocations {
|
||||||
|
if err := tx.Exec(`INSERT INTO point_ledger_allocations(ledger_id,grant_id,allocation_type,amount)
|
||||||
|
VALUES(?,?,'refund',?::numeric)`, refundLedgerID, allocation.GrantID, allocation.Amount).Error; err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
// 积分账务单元测试,验证积分精度解析和非法输入处理。
|
||||||
|
package billing
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// TestParsePointCents 验证积分字符串能够稳定转换为整数分值。
|
||||||
|
func TestParsePointCents(t *testing.T) {
|
||||||
|
tests := map[string]int64{
|
||||||
|
"0": 0,
|
||||||
|
"1": 100,
|
||||||
|
"1.2": 120,
|
||||||
|
"1.23": 123,
|
||||||
|
"1.2300": 123,
|
||||||
|
"999.99": 99999,
|
||||||
|
}
|
||||||
|
for input, expected := range tests {
|
||||||
|
actual, err := parsePointCents(input)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse %q: %v", input, err)
|
||||||
|
}
|
||||||
|
if actual != expected {
|
||||||
|
t.Fatalf("parse %q: expected %d, got %d", input, expected, actual)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, input := range []string{"", "-1", "1.234", "abc"} {
|
||||||
|
if _, err := parsePointCents(input); err == nil {
|
||||||
|
t.Fatalf("expected %q to be rejected", input)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,321 @@
|
|||||||
|
package billing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"math/big"
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
|
"juhe-factory/api/internal/model"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
TextBillingPerRequest = "per_request"
|
||||||
|
TextBillingPerToken = "per_token"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TextPricing struct {
|
||||||
|
Mode string `json:"mode"`
|
||||||
|
PerRequest string `json:"per_request,omitempty"`
|
||||||
|
InputPerM string `json:"input_per_m,omitempty"`
|
||||||
|
OutputPerM string `json:"output_per_m,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TextUsage struct {
|
||||||
|
Input int64
|
||||||
|
Output int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func CreateTextGenerationTask(tx *gorm.DB, task *model.GenerationTask, pricing TextPricing, remark string) error {
|
||||||
|
if err := pricing.Validate(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
task.BillingSnapshot = pricing.MarshalSnapshot()
|
||||||
|
if pricing.Mode == TextBillingPerRequest {
|
||||||
|
return PrechargeGenerationTask(tx, task, pricing.PerRequest, 1, remark)
|
||||||
|
}
|
||||||
|
task.EstimatedPoints = "0.00"
|
||||||
|
task.PrepaidPoints = "0.00"
|
||||||
|
if task.ID == uuid.Nil {
|
||||||
|
task.ID = uuid.New()
|
||||||
|
}
|
||||||
|
return tx.Create(task).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func ChargeTextRequest(tx *gorm.DB, userID uuid.UUID, referenceID string, pricing TextPricing, remark string) (string, error) {
|
||||||
|
if pricing.Mode != TextBillingPerRequest {
|
||||||
|
return "", errors.New("文本模型不是按次计费")
|
||||||
|
}
|
||||||
|
businessID := textCallBusinessID(referenceID, "request")
|
||||||
|
idempotencyKey := "generation:hold:" + businessID
|
||||||
|
if existing, found, err := debitAmount(tx, idempotencyKey); err != nil {
|
||||||
|
return "", err
|
||||||
|
} else if found {
|
||||||
|
return existing, nil
|
||||||
|
}
|
||||||
|
if pricing.PerRequest == "0" || pricing.PerRequest == "0.00" || pricing.PerRequest == "0.0000" {
|
||||||
|
return "0.00", nil
|
||||||
|
}
|
||||||
|
_, err := DebitPoints(tx, userID, pricing.PerRequest, "generation_hold", businessID, remark, idempotencyKey)
|
||||||
|
return pricing.PerRequest, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func RefundTextGenerationTask(tx *gorm.DB, task *model.GenerationTask, remark string) (bool, error) {
|
||||||
|
pricing, err := ParseTextPricingSnapshot(task.BillingSnapshot)
|
||||||
|
if err != nil || pricing.Mode == TextBillingPerRequest {
|
||||||
|
return RefundGenerationTask(tx, task, remark)
|
||||||
|
}
|
||||||
|
if task.CostRefunded {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
refunded, err := RefundTextCalls(tx, task.UserID, task.ID.String(), remark)
|
||||||
|
if err == nil {
|
||||||
|
task.CostRefunded = true
|
||||||
|
}
|
||||||
|
return refunded, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func LoadTextPricing(db *gorm.DB, modelID uuid.UUID) (TextPricing, error) {
|
||||||
|
var pricing TextPricing
|
||||||
|
err := db.Table("models m").
|
||||||
|
Select(`coalesce(m.text_billing_mode,'') AS mode,
|
||||||
|
coalesce(max(CASE WHEN lower(p.price_key)='default' THEN p.price::text END),'') AS per_request,
|
||||||
|
coalesce(max(CASE WHEN lower(p.price_key)='input' THEN p.price::text END),'') AS input_per_m,
|
||||||
|
coalesce(max(CASE WHEN lower(p.price_key)='output' THEN p.price::text END),'') AS output_per_m`).
|
||||||
|
Joins("LEFT JOIN model_prices p ON p.model_id=m.id").
|
||||||
|
Where("m.id=? AND m.model_type='text' AND m.enabled=true AND m.deleted_at IS NULL", modelID).
|
||||||
|
Group("m.id,m.text_billing_mode").
|
||||||
|
Take(&pricing).Error
|
||||||
|
if err != nil {
|
||||||
|
return pricing, err
|
||||||
|
}
|
||||||
|
if err := pricing.Validate(); err != nil {
|
||||||
|
return pricing, err
|
||||||
|
}
|
||||||
|
return pricing, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pricing TextPricing) Validate() error {
|
||||||
|
switch pricing.Mode {
|
||||||
|
case TextBillingPerRequest:
|
||||||
|
if !validNonNegativeDecimal(pricing.PerRequest) {
|
||||||
|
return errors.New("文本模型未配置按次价格")
|
||||||
|
}
|
||||||
|
case TextBillingPerToken:
|
||||||
|
if !validNonNegativeDecimal(pricing.InputPerM) || !validNonNegativeDecimal(pricing.OutputPerM) {
|
||||||
|
return errors.New("文本模型未完整配置输入、输出 Token 价格")
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return errors.New("文本模型计费模式无效")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pricing TextPricing) MarshalSnapshot() json.RawMessage {
|
||||||
|
data, _ := json.Marshal(pricing)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParseTextPricingSnapshot(value json.RawMessage) (TextPricing, error) {
|
||||||
|
var pricing TextPricing
|
||||||
|
if len(value) == 0 || string(value) == "{}" {
|
||||||
|
return pricing, errors.New("文本模型计费快照缺失")
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(value, &pricing); err != nil {
|
||||||
|
return pricing, errors.New("文本模型计费快照无效")
|
||||||
|
}
|
||||||
|
return pricing, pricing.Validate()
|
||||||
|
}
|
||||||
|
|
||||||
|
func CalculateTextPoints(pricing TextPricing, usage TextUsage) (string, error) {
|
||||||
|
if pricing.Mode != TextBillingPerToken || usage.Input < 0 || usage.Output < 0 {
|
||||||
|
return "", errors.New("Token 计费参数无效")
|
||||||
|
}
|
||||||
|
inputRate, ok := new(big.Rat).SetString(pricing.InputPerM)
|
||||||
|
if !ok || inputRate.Sign() < 0 {
|
||||||
|
return "", errors.New("输入 Token 价格无效")
|
||||||
|
}
|
||||||
|
outputRate, ok := new(big.Rat).SetString(pricing.OutputPerM)
|
||||||
|
if !ok || outputRate.Sign() < 0 {
|
||||||
|
return "", errors.New("输出 Token 价格无效")
|
||||||
|
}
|
||||||
|
amount := new(big.Rat).Mul(inputRate, new(big.Rat).SetInt64(usage.Input))
|
||||||
|
amount.Add(amount, new(big.Rat).Mul(outputRate, new(big.Rat).SetInt64(usage.Output)))
|
||||||
|
amount.Quo(amount, big.NewRat(1000000, 1))
|
||||||
|
return ceilPointCents(amount), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func EstimateTextTokens(value string) int64 {
|
||||||
|
var tokens int64
|
||||||
|
ascii := 0
|
||||||
|
flush := func() {
|
||||||
|
if ascii > 0 {
|
||||||
|
tokens += int64((ascii + 3) / 4)
|
||||||
|
ascii = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, r := range value {
|
||||||
|
if r <= unicode.MaxASCII {
|
||||||
|
ascii++
|
||||||
|
} else {
|
||||||
|
flush()
|
||||||
|
tokens++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flush()
|
||||||
|
if tokens == 0 && value != "" {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return tokens
|
||||||
|
}
|
||||||
|
|
||||||
|
func ReserveTextCall(tx *gorm.DB, userID uuid.UUID, referenceID, callKey string, pricing TextPricing, estimatedInputTokens int64, remark string) (string, error) {
|
||||||
|
if pricing.Mode != TextBillingPerToken || estimatedInputTokens < 0 {
|
||||||
|
return "", errors.New("Token 预授权参数无效")
|
||||||
|
}
|
||||||
|
amount, err := CalculateTextReservePoints(pricing, estimatedInputTokens)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
businessID := textCallBusinessID(referenceID, callKey) + ":reserve"
|
||||||
|
idempotencyKey := "text:reserve:" + businessID
|
||||||
|
if existing, found, err := debitAmount(tx, idempotencyKey); err != nil {
|
||||||
|
return "", err
|
||||||
|
} else if found {
|
||||||
|
return existing, nil
|
||||||
|
}
|
||||||
|
if amount == "0.00" {
|
||||||
|
return amount, nil
|
||||||
|
}
|
||||||
|
_, err = DebitPoints(tx, userID, amount, "text_token_reserve", businessID, remark+"预授权", idempotencyKey)
|
||||||
|
return amount, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func CalculateTextReservePoints(pricing TextPricing, estimatedInputTokens int64) (string, error) {
|
||||||
|
if estimatedInputTokens < 0 || estimatedInputTokens > math.MaxInt64/2 {
|
||||||
|
return "", errors.New("Token 预授权参数无效")
|
||||||
|
}
|
||||||
|
return CalculateTextPoints(pricing, TextUsage{Input: estimatedInputTokens, Output: estimatedInputTokens * 2})
|
||||||
|
}
|
||||||
|
|
||||||
|
func SettleTextCall(tx *gorm.DB, userID uuid.UUID, referenceID, callKey string, pricing TextPricing, usage TextUsage, remark string) (string, error) {
|
||||||
|
if usage.Input <= 0 && usage.Output <= 0 {
|
||||||
|
return "", errors.New("上游未返回 Token 用量,无法完成计费")
|
||||||
|
}
|
||||||
|
actual, err := CalculateTextPoints(pricing, usage)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
baseID := textCallBusinessID(referenceID, callKey)
|
||||||
|
reserveID := baseID + ":reserve"
|
||||||
|
if _, found, err := debitAmount(tx, "text:reserve:"+reserveID); err != nil {
|
||||||
|
return "", err
|
||||||
|
} else if found {
|
||||||
|
if _, err := RefundDebit(tx, userID, "text:reserve:"+reserveID, "text:reserve-refund:"+reserveID,
|
||||||
|
"text_token_reserve_refund", reserveID, remark+"预授权释放"); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
chargeKey := "generation:hold:" + baseID
|
||||||
|
if existing, found, err := debitAmount(tx, chargeKey); err != nil {
|
||||||
|
return "", err
|
||||||
|
} else if found {
|
||||||
|
return existing, nil
|
||||||
|
}
|
||||||
|
if actual == "0.00" {
|
||||||
|
return actual, nil
|
||||||
|
}
|
||||||
|
_, err = DebitPoints(tx, userID, actual, "generation_hold", baseID, remark, chargeKey)
|
||||||
|
return actual, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func RefundTextCalls(tx *gorm.DB, userID uuid.UUID, referenceID, remark string) (bool, error) {
|
||||||
|
type debit struct {
|
||||||
|
BusinessType string
|
||||||
|
BusinessID string
|
||||||
|
IdempotencyKey string
|
||||||
|
}
|
||||||
|
debits := make([]debit, 0)
|
||||||
|
if err := tx.Table("point_ledger").
|
||||||
|
Select("business_type,business_id,idempotency_key").
|
||||||
|
Where("user_id=? AND change_amount<0 AND business_id LIKE ? AND business_type IN ?", userID, referenceID+":text:%", []string{"generation_hold", "text_token_reserve"}).
|
||||||
|
Order("id").Find(&debits).Error; err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
refunded := false
|
||||||
|
for _, debit := range debits {
|
||||||
|
refundKey := "generation:refund:" + debit.BusinessID
|
||||||
|
refundType := "generation_refund"
|
||||||
|
if debit.BusinessType == "text_token_reserve" {
|
||||||
|
refundKey = "text:reserve-refund:" + debit.BusinessID
|
||||||
|
refundType = "text_token_reserve_refund"
|
||||||
|
}
|
||||||
|
created, err := RefundDebit(tx, userID, debit.IdempotencyKey, refundKey, refundType, debit.BusinessID, remark)
|
||||||
|
if err != nil {
|
||||||
|
return refunded, err
|
||||||
|
}
|
||||||
|
refunded = refunded || created
|
||||||
|
}
|
||||||
|
return refunded, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func CommitTextReserves(tx *gorm.DB, userID uuid.UUID, referenceID, remark string) (string, error) {
|
||||||
|
type reserve struct {
|
||||||
|
BusinessID string
|
||||||
|
IdempotencyKey string
|
||||||
|
}
|
||||||
|
items := make([]reserve, 0)
|
||||||
|
if err := tx.Table("point_ledger").Select("business_id,idempotency_key").
|
||||||
|
Where("user_id=? AND business_type='text_token_reserve' AND business_id LIKE ? AND NOT EXISTS (SELECT 1 FROM point_ledger refund WHERE refund.idempotency_key='text:reserve-refund:'||point_ledger.business_id)", userID, referenceID+":text:%").
|
||||||
|
Find(&items).Error; err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
for _, item := range items {
|
||||||
|
baseID := strings.TrimSuffix(item.BusinessID, ":reserve")
|
||||||
|
if err := tx.Exec("UPDATE point_ledger SET business_type='generation_hold',business_id=?,idempotency_key=?,remark=? WHERE idempotency_key=?", baseID, "generation:hold:"+baseID, remark, item.IdempotencyKey).Error; err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var amount string
|
||||||
|
if err := tx.Table("point_ledger").Select("coalesce(sum(-change_amount),0)::text").
|
||||||
|
Where("user_id=? AND business_type='generation_hold' AND business_id LIKE ?", userID, referenceID+":text:%").Scan(&amount).Error; err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return amount, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validNonNegativeDecimal(value string) bool {
|
||||||
|
number, ok := new(big.Rat).SetString(strings.TrimSpace(value))
|
||||||
|
return ok && number.Sign() >= 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func ceilPointCents(value *big.Rat) string {
|
||||||
|
if value == nil || value.Sign() <= 0 {
|
||||||
|
return "0.00"
|
||||||
|
}
|
||||||
|
cents := new(big.Rat).Mul(value, big.NewRat(100, 1))
|
||||||
|
quotient := new(big.Int).Quo(cents.Num(), cents.Denom())
|
||||||
|
if new(big.Int).Mod(cents.Num(), cents.Denom()).Sign() > 0 {
|
||||||
|
quotient.Add(quotient, big.NewInt(1))
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d.%02d", new(big.Int).Quo(quotient, big.NewInt(100)), new(big.Int).Mod(quotient, big.NewInt(100)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func textCallBusinessID(referenceID, callKey string) string {
|
||||||
|
return referenceID + ":text:" + strings.TrimSpace(callKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
func debitAmount(tx *gorm.DB, idempotencyKey string) (string, bool, error) {
|
||||||
|
var amount string
|
||||||
|
result := tx.Table("point_ledger").Select("(-change_amount)::text").Where("idempotency_key=? AND change_amount<0", idempotencyKey).Limit(1).Scan(&amount)
|
||||||
|
if result.Error != nil {
|
||||||
|
return "", false, result.Error
|
||||||
|
}
|
||||||
|
return amount, strings.TrimSpace(amount) != "", nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package billing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCalculateTextPointsRoundsUpToPointCent(t *testing.T) {
|
||||||
|
pricing := TextPricing{Mode: TextBillingPerToken, InputPerM: "100.0000", OutputPerM: "300.0000"}
|
||||||
|
amount, err := CalculateTextPoints(pricing, TextUsage{Input: 120, Output: 30})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if amount != "0.03" {
|
||||||
|
t.Fatalf("expected 0.03 points, got %s", amount)
|
||||||
|
}
|
||||||
|
amount, err = CalculateTextPoints(pricing, TextUsage{Input: 1})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if amount != "0.01" {
|
||||||
|
t.Fatalf("expected minimum positive charge 0.01, got %s", amount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTextPricingSnapshot(t *testing.T) {
|
||||||
|
pricing := TextPricing{Mode: TextBillingPerToken, InputPerM: "1250.0000", OutputPerM: "2500.0000"}
|
||||||
|
parsed, err := ParseTextPricingSnapshot(pricing.MarshalSnapshot())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if parsed != pricing {
|
||||||
|
t.Fatalf("unexpected snapshot: %s", json.RawMessage(pricing.MarshalSnapshot()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTextPricingSnapshotRejectsLegacyThousandTokenRates(t *testing.T) {
|
||||||
|
_, err := ParseTextPricingSnapshot(json.RawMessage(`{"mode":"per_token","input_per_k":"0.1000","output_per_k":"0.3000","max_output_tokens":1000}`))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected legacy thousand-token snapshot to be rejected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCalculateTextPointsUsesMillionTokens(t *testing.T) {
|
||||||
|
pricing := TextPricing{Mode: TextBillingPerToken, InputPerM: "10", OutputPerM: "20"}
|
||||||
|
amount, err := CalculateTextPoints(pricing, TextUsage{Input: 1_000_000})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if amount != "10.00" {
|
||||||
|
t.Fatalf("expected 10.00 points for one million input tokens, got %s", amount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCalculateTextReservePointsUsesDoubleInputAsEstimatedOutput(t *testing.T) {
|
||||||
|
pricing := TextPricing{Mode: TextBillingPerToken, InputPerM: "10", OutputPerM: "20"}
|
||||||
|
amount, err := CalculateTextReservePoints(pricing, 1_000_000)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if amount != "50.00" {
|
||||||
|
t.Fatalf("expected 50.00 points for input plus double-input output estimate, got %s", amount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEstimateTextTokens(t *testing.T) {
|
||||||
|
if got := EstimateTextTokens("中文ABCD"); got != 3 {
|
||||||
|
t.Fatalf("expected 3 estimated tokens, got %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+25
@@ -0,0 +1,25 @@
|
|||||||
|
package cache
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"juhe-factory/api/internal/config"
|
||||||
|
|
||||||
|
"github.com/redis/go-redis/v9"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Open(cfg config.Config) (*redis.Client, error) {
|
||||||
|
client := redis.NewClient(&redis.Options{
|
||||||
|
Addr: cfg.RedisAddress,
|
||||||
|
Password: cfg.RedisPassword,
|
||||||
|
DB: cfg.RedisDB,
|
||||||
|
})
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := client.Ping(ctx).Err(); err != nil {
|
||||||
|
client.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return client, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
// Package config 读取并校验 API 服务运行所需的环境变量。
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/joho/godotenv"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config 汇总数据库、缓存、认证、腾讯云 COS 和本地媒体处理配置。
|
||||||
|
type Config struct {
|
||||||
|
AppEnv string
|
||||||
|
Debug bool
|
||||||
|
ServerAddress string
|
||||||
|
DatabaseURL string
|
||||||
|
RedisAddress string
|
||||||
|
RedisPassword string
|
||||||
|
RedisDB int
|
||||||
|
AsynqRedisDB int
|
||||||
|
CORSOrigins []string
|
||||||
|
StaticDirectory string
|
||||||
|
JWTSecretKey string
|
||||||
|
JWTAccessMinutes int
|
||||||
|
JWTRefreshHours int
|
||||||
|
BootstrapAdminUsername string
|
||||||
|
BootstrapAdminPassword string
|
||||||
|
Argon2Time uint32
|
||||||
|
Argon2Memory uint32
|
||||||
|
Argon2Parallelism uint8
|
||||||
|
Argon2HashLength uint32
|
||||||
|
Argon2SaltLength uint32
|
||||||
|
EncryptionKeyVersion string
|
||||||
|
EncryptionKey string
|
||||||
|
COSSecretID string
|
||||||
|
COSSecretKey string
|
||||||
|
COSBucket string
|
||||||
|
COSRegion string
|
||||||
|
COSEndpoint string
|
||||||
|
COSPublicBaseURL string
|
||||||
|
COSMaxImageSizeMB int
|
||||||
|
COSMaxVideoSizeMB int
|
||||||
|
COSMaxAudioSizeMB int
|
||||||
|
AIWorkerConcurrency int
|
||||||
|
AIPollIntervalSeconds int
|
||||||
|
AIHTTPMaxConnections int
|
||||||
|
FFmpegPath string
|
||||||
|
FFprobePath string
|
||||||
|
PythonPath string
|
||||||
|
ASRScriptPath string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load 从本地环境文件和进程环境读取配置,并校验数值范围。
|
||||||
|
func Load() (Config, error) {
|
||||||
|
_ = godotenv.Load()
|
||||||
|
|
||||||
|
databasePort, err := envInt("DATABASE_PORT", 25432)
|
||||||
|
if err != nil {
|
||||||
|
return Config{}, err
|
||||||
|
}
|
||||||
|
redisPort, err := envInt("REDIS_PORT", 26379)
|
||||||
|
if err != nil {
|
||||||
|
return Config{}, err
|
||||||
|
}
|
||||||
|
redisDB, err := envInt("REDIS_DB", 0)
|
||||||
|
if err != nil {
|
||||||
|
return Config{}, err
|
||||||
|
}
|
||||||
|
asynqRedisDB, err := envInt("ASYNQ_REDIS_DB", 1)
|
||||||
|
if err != nil {
|
||||||
|
return Config{}, err
|
||||||
|
}
|
||||||
|
jwtAccessMinutes, err := envInt("JWT_ACCESS_TOKEN_EXPIRE_MINUTES", 60)
|
||||||
|
if err != nil {
|
||||||
|
return Config{}, err
|
||||||
|
}
|
||||||
|
jwtRefreshHours, err := envInt("JWT_REFRESH_TOKEN_EXPIRE_HOURS", 168)
|
||||||
|
if err != nil {
|
||||||
|
return Config{}, err
|
||||||
|
}
|
||||||
|
argon2Time, err := envInt("ARGON2_TIME_COST", 2)
|
||||||
|
if err != nil {
|
||||||
|
return Config{}, err
|
||||||
|
}
|
||||||
|
argon2Memory, err := envInt("ARGON2_MEMORY_COST", 19456)
|
||||||
|
if err != nil {
|
||||||
|
return Config{}, err
|
||||||
|
}
|
||||||
|
argon2Parallelism, err := envInt("ARGON2_PARALLELISM", 1)
|
||||||
|
if err != nil {
|
||||||
|
return Config{}, err
|
||||||
|
}
|
||||||
|
argon2HashLength, err := envInt("ARGON2_HASH_LENGTH", 32)
|
||||||
|
if err != nil {
|
||||||
|
return Config{}, err
|
||||||
|
}
|
||||||
|
argon2SaltLength, err := envInt("ARGON2_SALT_LENGTH", 16)
|
||||||
|
if err != nil {
|
||||||
|
return Config{}, err
|
||||||
|
}
|
||||||
|
cosMaxImageSizeMB, err := envInt("COS_MAX_IMAGE_SIZE_MB", 20)
|
||||||
|
if err != nil {
|
||||||
|
return Config{}, err
|
||||||
|
}
|
||||||
|
cosMaxVideoSizeMB, err := envInt("COS_MAX_VIDEO_SIZE_MB", 100)
|
||||||
|
if err != nil {
|
||||||
|
return Config{}, err
|
||||||
|
}
|
||||||
|
cosMaxAudioSizeMB, err := envInt("COS_MAX_AUDIO_SIZE_MB", 30)
|
||||||
|
if err != nil {
|
||||||
|
return Config{}, err
|
||||||
|
}
|
||||||
|
aiWorkerConcurrency, err := envInt("AI_WORKER_CONCURRENCY", 100)
|
||||||
|
if err != nil {
|
||||||
|
return Config{}, err
|
||||||
|
}
|
||||||
|
aiPollIntervalSeconds, err := envInt("AI_POLL_INTERVAL_SECONDS", 10)
|
||||||
|
if err != nil {
|
||||||
|
return Config{}, err
|
||||||
|
}
|
||||||
|
aiHTTPMaxConnections, err := envInt("AI_HTTP_MAX_CONNECTIONS", 200)
|
||||||
|
if err != nil {
|
||||||
|
return Config{}, err
|
||||||
|
}
|
||||||
|
if cosMaxImageSizeMB < 1 || cosMaxVideoSizeMB < 1 || cosMaxAudioSizeMB < 1 {
|
||||||
|
return Config{}, fmt.Errorf("COS 文件大小限制必须为正整数")
|
||||||
|
}
|
||||||
|
if aiWorkerConcurrency < 1 || aiWorkerConcurrency > 1000 {
|
||||||
|
return Config{}, fmt.Errorf("AI_WORKER_CONCURRENCY 必须在 1 至 1000 之间")
|
||||||
|
}
|
||||||
|
if aiPollIntervalSeconds < 2 || aiPollIntervalSeconds > 300 {
|
||||||
|
return Config{}, fmt.Errorf("AI_POLL_INTERVAL_SECONDS 必须在 2 至 300 之间")
|
||||||
|
}
|
||||||
|
if aiHTTPMaxConnections < 10 || aiHTTPMaxConnections > 5000 {
|
||||||
|
return Config{}, fmt.Errorf("AI_HTTP_MAX_CONNECTIONS 必须在 10 至 5000 之间")
|
||||||
|
}
|
||||||
|
debug, err := strconv.ParseBool(env("DEBUG", "true"))
|
||||||
|
if err != nil {
|
||||||
|
return Config{}, fmt.Errorf("DEBUG 必须是布尔值: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
databaseURL := strings.TrimSpace(os.Getenv("DATABASE_URL"))
|
||||||
|
if databaseURL == "" {
|
||||||
|
databaseURL = buildDatabaseURL(databasePort)
|
||||||
|
} else if parsed, parseErr := url.Parse(databaseURL); parseErr == nil {
|
||||||
|
query := parsed.Query()
|
||||||
|
if query.Get("TimeZone") == "" {
|
||||||
|
query.Set("TimeZone", "Asia/Shanghai")
|
||||||
|
}
|
||||||
|
parsed.RawQuery = query.Encode()
|
||||||
|
databaseURL = parsed.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
return Config{
|
||||||
|
AppEnv: env("APP_ENV", "development"),
|
||||||
|
Debug: debug,
|
||||||
|
ServerAddress: env("SERVER_ADDRESS", ":8900"),
|
||||||
|
DatabaseURL: databaseURL,
|
||||||
|
RedisAddress: fmt.Sprintf("%s:%d", env("REDIS_HOST", "127.0.0.1"), redisPort),
|
||||||
|
RedisPassword: os.Getenv("REDIS_PASSWORD"),
|
||||||
|
RedisDB: redisDB,
|
||||||
|
AsynqRedisDB: asynqRedisDB,
|
||||||
|
CORSOrigins: splitCSV(env("CORS_ORIGINS", "http://localhost:5500,http://localhost:5501")),
|
||||||
|
StaticDirectory: env("STATIC_DIRECTORY", "static"),
|
||||||
|
JWTSecretKey: env("JWT_SECRET_KEY", "replace-with-a-strong-random-secret"),
|
||||||
|
JWTAccessMinutes: jwtAccessMinutes,
|
||||||
|
JWTRefreshHours: jwtRefreshHours,
|
||||||
|
BootstrapAdminUsername: env("ADMIN_BOOTSTRAP_USERNAME", "admin"),
|
||||||
|
BootstrapAdminPassword: os.Getenv("ADMIN_BOOTSTRAP_PASSWORD"),
|
||||||
|
Argon2Time: uint32(argon2Time),
|
||||||
|
Argon2Memory: uint32(argon2Memory),
|
||||||
|
Argon2Parallelism: uint8(argon2Parallelism),
|
||||||
|
Argon2HashLength: uint32(argon2HashLength),
|
||||||
|
Argon2SaltLength: uint32(argon2SaltLength),
|
||||||
|
EncryptionKeyVersion: env("CONFIG_ENCRYPTION_KEY_VERSION", "v1"),
|
||||||
|
EncryptionKey: os.Getenv("CONFIG_ENCRYPTION_KEY"),
|
||||||
|
COSSecretID: os.Getenv("COS_SECRET_ID"),
|
||||||
|
COSSecretKey: os.Getenv("COS_SECRET_KEY"),
|
||||||
|
COSBucket: os.Getenv("COS_BUCKET"),
|
||||||
|
COSRegion: env("COS_REGION", "ap-chengdu"),
|
||||||
|
COSEndpoint: os.Getenv("COS_ENDPOINT"),
|
||||||
|
COSPublicBaseURL: strings.TrimRight(os.Getenv("COS_PUBLIC_BASE_URL"), "/"),
|
||||||
|
COSMaxImageSizeMB: cosMaxImageSizeMB,
|
||||||
|
COSMaxVideoSizeMB: cosMaxVideoSizeMB,
|
||||||
|
COSMaxAudioSizeMB: cosMaxAudioSizeMB,
|
||||||
|
AIWorkerConcurrency: aiWorkerConcurrency,
|
||||||
|
AIPollIntervalSeconds: aiPollIntervalSeconds,
|
||||||
|
AIHTTPMaxConnections: aiHTTPMaxConnections,
|
||||||
|
FFmpegPath: env("FFMPEG_PATH", "ffmpeg"),
|
||||||
|
FFprobePath: env("FFPROBE_PATH", "ffprobe"),
|
||||||
|
PythonPath: env("PYTHON_PATH", "python3"),
|
||||||
|
ASRScriptPath: env("ASR_SCRIPT_PATH", "workers/transcribe.py"),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildDatabaseURL(port int) string {
|
||||||
|
credentials := url.UserPassword(
|
||||||
|
env("DATABASE_USER", "postgres"),
|
||||||
|
env("DATABASE_PASSWORD", "juchuang_dev"),
|
||||||
|
).String()
|
||||||
|
return fmt.Sprintf(
|
||||||
|
"postgres://%s@%s:%d/%s?sslmode=disable&TimeZone=Asia/Shanghai",
|
||||||
|
credentials,
|
||||||
|
env("DATABASE_HOST", "127.0.0.1"),
|
||||||
|
port,
|
||||||
|
url.PathEscape(env("DATABASE_NAME", "juchuang_factory")),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func env(key, fallback string) string {
|
||||||
|
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
func envInt(key string, fallback int) (int, error) {
|
||||||
|
value := strings.TrimSpace(os.Getenv(key))
|
||||||
|
if value == "" {
|
||||||
|
return fallback, nil
|
||||||
|
}
|
||||||
|
parsed, err := strconv.Atoi(value)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("%s 必须是整数: %w", key, err)
|
||||||
|
}
|
||||||
|
return parsed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitCSV(value string) []string {
|
||||||
|
items := strings.Split(value, ",")
|
||||||
|
result := make([]string, 0, len(items))
|
||||||
|
for _, item := range items {
|
||||||
|
if item = strings.TrimSpace(item); item != "" {
|
||||||
|
result = append(result, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
_ "embed"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"juhe-factory/api/internal/model"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed schema.sql
|
||||||
|
var baselineSchema string
|
||||||
|
|
||||||
|
func bootstrapEmptyDatabase(db *gorm.DB) error {
|
||||||
|
var businessTableCount int64
|
||||||
|
if err := db.Raw(`SELECT count(*) FROM pg_tables
|
||||||
|
WHERE schemaname = 'public' AND tablename NOT IN ('jcf_schema_migrations', 'schema_migrations')`).
|
||||||
|
Scan(&businessTableCount).Error; err != nil {
|
||||||
|
return fmt.Errorf("inspect database schema: %w", err)
|
||||||
|
}
|
||||||
|
if businessTableCount == 0 {
|
||||||
|
baselineDB := db.Session(&gorm.Session{Logger: logger.Default.LogMode(logger.Silent)})
|
||||||
|
if err := baselineDB.Transaction(func(tx *gorm.DB) error {
|
||||||
|
return tx.Exec(baselineSchema).Error
|
||||||
|
}); err != nil {
|
||||||
|
return fmt.Errorf("initialize database baseline: %w", err)
|
||||||
|
}
|
||||||
|
if err := db.Exec(`SET search_path TO public`).Error; err != nil {
|
||||||
|
return fmt.Errorf("restore database search path: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := autoMigrateMissingTables(db, autoMigrateModels()...); err != nil {
|
||||||
|
return fmt.Errorf("auto migrate database models: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func autoMigrateMissingTables(db *gorm.DB, tableModels ...any) error {
|
||||||
|
for _, tableModel := range tableModels {
|
||||||
|
if db.Migrator().HasTable(tableModel) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := db.AutoMigrate(tableModel); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func autoMigrateModels() []any {
|
||||||
|
return []any{
|
||||||
|
&model.AdminUser{},
|
||||||
|
&model.AdminRefreshToken{},
|
||||||
|
&model.AdminAuditLog{},
|
||||||
|
&model.WebUser{},
|
||||||
|
&model.WebRefreshToken{},
|
||||||
|
&model.MediaAsset{},
|
||||||
|
&model.CreativeProject{},
|
||||||
|
&model.ProjectModelConfig{},
|
||||||
|
&model.UserModelConfig{},
|
||||||
|
&model.ScriptAnalysis{},
|
||||||
|
&model.ScriptAnalysisCharacter{},
|
||||||
|
&model.ProjectEpisode{},
|
||||||
|
&model.ProjectAsset{},
|
||||||
|
&model.EpisodeStoryboard{},
|
||||||
|
&model.EpisodeSource{},
|
||||||
|
&model.DramaImportSession{},
|
||||||
|
&model.DramaParseBatch{},
|
||||||
|
&model.DramaParseTask{},
|
||||||
|
&model.GenerationTask{},
|
||||||
|
&model.GenerationOutput{},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"juhe-factory/api/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
type autoMigrateProbe struct {
|
||||||
|
ID uint `gorm:"primaryKey"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (autoMigrateProbe) TableName() string { return "auto_migrate_probe" }
|
||||||
|
|
||||||
|
func TestBootstrapEmptyDatabase(t *testing.T) {
|
||||||
|
databaseURL := os.Getenv("JCF_BOOTSTRAP_TEST_DATABASE_URL")
|
||||||
|
if databaseURL == "" {
|
||||||
|
t.Skip("JCF_BOOTSTRAP_TEST_DATABASE_URL is not configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
db, sqlDB, err := Open(config.Config{DatabaseURL: databaseURL})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open empty database: %v", err)
|
||||||
|
}
|
||||||
|
defer sqlDB.Close()
|
||||||
|
|
||||||
|
var tableCount int64
|
||||||
|
if err := db.Raw(`SELECT count(*) FROM pg_tables WHERE schemaname = 'public'`).Scan(&tableCount).Error; err != nil {
|
||||||
|
t.Fatalf("count initialized tables: %v", err)
|
||||||
|
}
|
||||||
|
if tableCount != 33 {
|
||||||
|
t.Fatalf("expected 33 initialized tables, got %d", tableCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
var migrationCount int64
|
||||||
|
if err := db.Table("jcf_schema_migrations").Count(&migrationCount).Error; err != nil {
|
||||||
|
t.Fatalf("count baseline migrations: %v", err)
|
||||||
|
}
|
||||||
|
if migrationCount != 0 {
|
||||||
|
t.Fatalf("expected no baseline migrations, got %d", migrationCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := autoMigrateMissingTables(db, &autoMigrateProbe{}); err != nil {
|
||||||
|
t.Fatalf("auto migrate missing table: %v", err)
|
||||||
|
}
|
||||||
|
if !db.Migrator().HasTable(&autoMigrateProbe{}) {
|
||||||
|
t.Fatal("expected AutoMigrate to create missing model table")
|
||||||
|
}
|
||||||
|
if err := db.Migrator().DropTable(&autoMigrateProbe{}); err != nil {
|
||||||
|
t.Fatalf("drop AutoMigrate probe table: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, reopenedSQLDB, err := Open(config.Config{DatabaseURL: databaseURL})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reopen initialized database: %v", err)
|
||||||
|
}
|
||||||
|
defer reopenedSQLDB.Close()
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"juhe-factory/api/internal/config"
|
||||||
|
|
||||||
|
"gorm.io/driver/postgres"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Open(cfg config.Config) (*gorm.DB, *sql.DB, error) {
|
||||||
|
logLevel := logger.Warn
|
||||||
|
if cfg.Debug {
|
||||||
|
logLevel = logger.Info
|
||||||
|
}
|
||||||
|
|
||||||
|
databaseURL := cfg.DatabaseURL
|
||||||
|
if !strings.Contains(strings.ToLower(databaseURL), "timezone=") {
|
||||||
|
separator := "?"
|
||||||
|
if strings.Contains(databaseURL, " ") {
|
||||||
|
separator = " "
|
||||||
|
}
|
||||||
|
if strings.Contains(databaseURL, "?") {
|
||||||
|
separator = "&"
|
||||||
|
}
|
||||||
|
databaseURL += separator + "TimeZone=Asia%2FShanghai"
|
||||||
|
}
|
||||||
|
db, err := gorm.Open(postgres.Open(databaseURL), &gorm.Config{
|
||||||
|
Logger: logger.Default.LogMode(logLevel),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
sqlDB, err := db.DB()
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
sqlDB.SetMaxOpenConns(20)
|
||||||
|
sqlDB.SetMaxIdleConns(5)
|
||||||
|
sqlDB.SetConnMaxLifetime(30 * time.Minute)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := sqlDB.PingContext(ctx); err != nil {
|
||||||
|
sqlDB.Close()
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if err := db.Exec(`SET TIME ZONE 'Asia/Shanghai'`).Error; err != nil {
|
||||||
|
sqlDB.Close()
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if err := bootstrapEmptyDatabase(db); err != nil {
|
||||||
|
sqlDB.Close()
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
// Keep older installations compatible when migrations are applied manually.
|
||||||
|
if err := db.Exec(`ALTER TABLE project_model_configs ADD COLUMN IF NOT EXISTS settings jsonb NOT NULL DEFAULT '{}'::jsonb`).Error; err != nil {
|
||||||
|
sqlDB.Close()
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if err := db.Exec(`ALTER TABLE creative_projects ADD COLUMN IF NOT EXISTS video_resolution varchar(8) NOT NULL DEFAULT '720p'`).Error; err != nil {
|
||||||
|
sqlDB.Close()
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if err := db.Exec(`ALTER TABLE episode_storyboards ADD COLUMN IF NOT EXISTS image_prompt text`).Error; err != nil {
|
||||||
|
sqlDB.Close()
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if err := db.Exec(`ALTER TABLE prompts ADD COLUMN IF NOT EXISTS type varchar(50); ALTER TABLE prompts ADD COLUMN IF NOT EXISTS content text; ALTER TABLE prompts ADD COLUMN IF NOT EXISTS scope varchar(20) NOT NULL DEFAULT 'system'; ALTER TABLE prompts ADD COLUMN IF NOT EXISTS owner_user_id uuid REFERENCES web_users(id);`).Error; err != nil {
|
||||||
|
sqlDB.Close()
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if err := db.Exec(`ALTER TABLE web_users ADD COLUMN IF NOT EXISTS account citext; UPDATE web_users SET account=username WHERE account IS NULL; CREATE UNIQUE INDEX IF NOT EXISTS idx_web_users_account ON web_users(account);`).Error; err != nil {
|
||||||
|
sqlDB.Close()
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if err := db.Exec(`ALTER TABLE redemption_codes ADD COLUMN IF NOT EXISTS code character varying(40);`).Error; err != nil {
|
||||||
|
sqlDB.Close()
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if err := db.Exec(`CREATE TABLE IF NOT EXISTS user_model_configs (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id uuid NOT NULL REFERENCES web_users(id),
|
||||||
|
purpose varchar(32) NOT NULL CHECK (purpose IN ('prompt_reverse','image_generation','video_generation')),
|
||||||
|
model_id uuid NOT NULL REFERENCES models(id),
|
||||||
|
prompt_id uuid REFERENCES prompts(id),
|
||||||
|
settings jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(user_id, purpose)
|
||||||
|
); CREATE INDEX IF NOT EXISTS idx_user_model_configs_user ON user_model_configs(user_id);
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='user_model_configs' AND column_name='model_type') THEN
|
||||||
|
INSERT INTO user_model_configs(id,user_id,project_type,model_type,purpose,model_id,prompt_id,settings)
|
||||||
|
SELECT DISTINCT ON (project.user_id, config.model_type)
|
||||||
|
gen_random_uuid(), project.user_id, 'video_redraw', config.model_type, config.purpose, config.model_id, config.prompt_id, config.settings
|
||||||
|
FROM project_model_configs config
|
||||||
|
JOIN creative_projects project ON project.id=config.project_id
|
||||||
|
WHERE project.deleted_at IS NULL AND project.project_type='video_redraw'
|
||||||
|
ORDER BY project.user_id, config.model_type, project.updated_at DESC, config.updated_at DESC
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
ELSE
|
||||||
|
INSERT INTO user_model_configs(id,user_id,purpose,model_id,prompt_id,settings)
|
||||||
|
SELECT DISTINCT ON (project.user_id, config.purpose)
|
||||||
|
gen_random_uuid(), project.user_id, config.purpose, config.model_id, config.prompt_id, config.settings
|
||||||
|
FROM project_model_configs config
|
||||||
|
JOIN creative_projects project ON project.id=config.project_id
|
||||||
|
WHERE project.deleted_at IS NULL
|
||||||
|
ORDER BY project.user_id, config.purpose, project.updated_at DESC, config.updated_at DESC
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
END IF;
|
||||||
|
END $$;`).Error; err != nil {
|
||||||
|
sqlDB.Close()
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if err := db.Exec(`CREATE TABLE IF NOT EXISTS user_prompt_preferences (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(), user_id uuid NOT NULL REFERENCES web_users(id) ON DELETE CASCADE,
|
||||||
|
prompt_id uuid NOT NULL REFERENCES prompts(id) ON DELETE CASCADE, prompt_type varchar(64) NOT NULL,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(user_id,prompt_type)
|
||||||
|
); CREATE INDEX IF NOT EXISTS idx_user_prompt_preferences_user ON user_prompt_preferences(user_id);`).Error; err != nil {
|
||||||
|
sqlDB.Close()
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
return db, sqlDB, nil
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,30 @@
|
|||||||
|
// Package datetime centralizes the application's Beijing time policy.
|
||||||
|
package datetime
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
const Zone = "Asia/Shanghai"
|
||||||
|
|
||||||
|
var Location = func() *time.Location {
|
||||||
|
location, err := time.LoadLocation(Zone)
|
||||||
|
if err != nil {
|
||||||
|
return time.FixedZone("CST", 8*60*60)
|
||||||
|
}
|
||||||
|
return location
|
||||||
|
}()
|
||||||
|
|
||||||
|
func Now() time.Time { return time.Now().In(Location) }
|
||||||
|
|
||||||
|
// Normalize converts any source instant (including COS metadata) to Beijing time.
|
||||||
|
func Normalize(value time.Time) time.Time {
|
||||||
|
if value.IsZero() { return value }
|
||||||
|
return value.In(Location)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Parse(value string) (time.Time, error) {
|
||||||
|
parsed, err := time.Parse(time.RFC3339Nano, value)
|
||||||
|
if err != nil { return time.Time{}, err }
|
||||||
|
return Normalize(parsed), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func Format(value time.Time) string { return Normalize(value).Format(time.RFC3339Nano) }
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package drama
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"unicode/utf8"
|
||||||
|
)
|
||||||
|
|
||||||
|
const MaxEpisodeCharacters = 5000
|
||||||
|
|
||||||
|
func ValidateEpisodeContent(content string) error {
|
||||||
|
count := utf8.RuneCountInString(strings.TrimSpace(content))
|
||||||
|
if count == 0 {
|
||||||
|
return fmt.Errorf("剧集原文不能为空")
|
||||||
|
}
|
||||||
|
if count > MaxEpisodeCharacters {
|
||||||
|
return fmt.Errorf("每集原文不能超过%d字,当前%d字", MaxEpisodeCharacters, count)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package drama
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestValidateEpisodeContent(t *testing.T) {
|
||||||
|
if err := ValidateEpisodeContent(strings.Repeat("字", MaxEpisodeCharacters)); err != nil {
|
||||||
|
t.Fatalf("expected content at limit to pass: %v", err)
|
||||||
|
}
|
||||||
|
if err := ValidateEpisodeContent(strings.Repeat("字", MaxEpisodeCharacters+1)); err == nil {
|
||||||
|
t.Fatal("expected over-limit content to fail")
|
||||||
|
}
|
||||||
|
if err := ValidateEpisodeContent(" \n\t"); err == nil {
|
||||||
|
t.Fatal("expected blank content to fail")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"juhe-factory/api/internal/model"
|
||||||
|
"juhe-factory/api/internal/service"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
const adminContextKey = "admin_user"
|
||||||
|
|
||||||
|
type AdminAuth struct{ service *service.Auth }
|
||||||
|
|
||||||
|
func NewAdminAuth(service *service.Auth) *AdminAuth { return &AdminAuth{service: service} }
|
||||||
|
|
||||||
|
func (h *AdminAuth) Login(c *gin.Context) {
|
||||||
|
var body struct {
|
||||||
|
Username string `json:"username" binding:"required"`
|
||||||
|
Password string `json:"password" binding:"required"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
fail(c, http.StatusBadRequest, "invalid_request", "请输入管理员账号和密码")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pair, err := h.service.Login(body.Username, body.Password)
|
||||||
|
if err != nil {
|
||||||
|
fail(c, http.StatusUnauthorized, "invalid_credentials", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"data": pair})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AdminAuth) Refresh(c *gin.Context) {
|
||||||
|
var body struct {
|
||||||
|
RefreshToken string `json:"refresh_token" binding:"required"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
fail(c, http.StatusBadRequest, "invalid_request", "缺少刷新令牌")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pair, err := h.service.Refresh(body.RefreshToken)
|
||||||
|
if err != nil {
|
||||||
|
fail(c, http.StatusUnauthorized, "invalid_refresh_token", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"data": pair})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AdminAuth) Logout(c *gin.Context) {
|
||||||
|
var body struct {
|
||||||
|
RefreshToken string `json:"refresh_token"`
|
||||||
|
}
|
||||||
|
_ = c.ShouldBindJSON(&body)
|
||||||
|
if err := h.service.Logout(body.RefreshToken); err != nil {
|
||||||
|
fail(c, http.StatusInternalServerError, "logout_failed", "退出登录失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AdminAuth) ChangePassword(c *gin.Context) {
|
||||||
|
var body struct {
|
||||||
|
CurrentPassword string `json:"current_password" binding:"required"`
|
||||||
|
NewPassword string `json:"new_password" binding:"required"`
|
||||||
|
ConfirmPassword string `json:"confirm_password" binding:"required"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
fail(c, http.StatusBadRequest, "invalid_request", "请完整填写密码")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
admin := currentAdmin(c)
|
||||||
|
if err := h.service.ChangePassword(admin.ID, body.CurrentPassword, body.NewPassword, body.ConfirmPassword); err != nil {
|
||||||
|
if errors.Is(err, service.ErrReauthFailed) || errors.Is(err, service.ErrAdminPasswordConfirmation) || errors.Is(err, service.ErrAdminPasswordLength) {
|
||||||
|
_ = h.service.Audit(admin, "password_change_failed", "admin-auth", admin.ID.String(), err.Error(), c.ClientIP(), c.GetString("trace_id"), nil)
|
||||||
|
fail(c, http.StatusBadRequest, "password_invalid", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fail(c, http.StatusInternalServerError, "password_update_failed", "密码修改失败,请稍后重试")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = h.service.Audit(admin, "password_changed", "admin-auth", admin.ID.String(), "", c.ClientIP(), c.GetString("trace_id"), nil)
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AdminAuth) Middleware() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
raw := strings.TrimSpace(strings.TrimPrefix(c.GetHeader("Authorization"), "Bearer "))
|
||||||
|
admin, err := h.service.Authenticate(raw)
|
||||||
|
if err != nil {
|
||||||
|
fail(c, http.StatusUnauthorized, "unauthorized", err.Error())
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Set(adminContextKey, admin)
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func currentAdmin(c *gin.Context) *model.AdminUser {
|
||||||
|
value, _ := c.Get(adminContextKey)
|
||||||
|
admin, _ := value.(*model.AdminUser)
|
||||||
|
return admin
|
||||||
|
}
|
||||||
|
|
||||||
|
func fail(c *gin.Context, status int, code, message string) {
|
||||||
|
c.JSON(status, gin.H{"code": code, "message": message, "trace_id": c.GetString("trace_id")})
|
||||||
|
}
|
||||||
@@ -0,0 +1,544 @@
|
|||||||
|
// 管理后台接口处理器,负责解析管理端请求并调用对应业务模块组织响应。
|
||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"image"
|
||||||
|
_ "image/gif"
|
||||||
|
_ "image/jpeg"
|
||||||
|
_ "image/png"
|
||||||
|
"log/slog"
|
||||||
|
"mime/multipart"
|
||||||
|
"net/http"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
mediakey "juhe-factory/api/internal/media"
|
||||||
|
adminmodule "juhe-factory/api/internal/modules/admin"
|
||||||
|
"juhe-factory/api/internal/modules/prompt"
|
||||||
|
"juhe-factory/api/internal/service"
|
||||||
|
"juhe-factory/api/internal/storage"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AdminData struct {
|
||||||
|
data *service.AdminData
|
||||||
|
admin *adminmodule.Service
|
||||||
|
prompts *prompt.Service
|
||||||
|
cos *storage.COS
|
||||||
|
}
|
||||||
|
|
||||||
|
type optionalTime struct{ Time *time.Time }
|
||||||
|
|
||||||
|
func (t *optionalTime) UnmarshalJSON(data []byte) error {
|
||||||
|
if string(data) == "null" || strings.TrimSpace(string(data)) == `""` {
|
||||||
|
t.Time = nil
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var value time.Time
|
||||||
|
if err := json.Unmarshal(data, &value); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
t.Time = &value
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAdminData(data *service.AdminData, admin *adminmodule.Service, prompts *prompt.Service, cos *storage.COS) *AdminData {
|
||||||
|
return &AdminData{data: data, admin: admin, prompts: prompts, cos: cos}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AdminData) ListResource(c *gin.Context) {
|
||||||
|
filters := map[string]string{"enabled": c.Query("enabled"), "payment_type": c.Query("payment_type"), "channel_type": c.Query("channel_type"), "status": c.Query("status"), "pinned": c.Query("pinned")}
|
||||||
|
page, err := h.data.ListResource(c.Request.Context(), c.Param("resource"), c.Query("keyword"), filters, c.Query("page"), c.Query("page_size"))
|
||||||
|
if err != nil {
|
||||||
|
h.bad(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"data": page})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AdminData) CreateResource(c *gin.Context) {
|
||||||
|
var body map[string]any
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
h.bad(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, err := h.data.SaveResource(c.Param("resource"), "", body)
|
||||||
|
if err != nil {
|
||||||
|
h.bad(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.audit(c, "create", c.Param("resource"), id, "", body)
|
||||||
|
c.JSON(http.StatusCreated, gin.H{"data": gin.H{"id": id}})
|
||||||
|
}
|
||||||
|
func (h *AdminData) UpdateResource(c *gin.Context) {
|
||||||
|
var body map[string]any
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
h.bad(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, err := h.data.SaveResource(c.Param("resource"), c.Param("id"), body)
|
||||||
|
if err != nil {
|
||||||
|
h.bad(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.audit(c, "update", c.Param("resource"), id, stringValue(body["reason"]), body)
|
||||||
|
c.JSON(http.StatusOK, gin.H{"data": gin.H{"id": id}})
|
||||||
|
}
|
||||||
|
func (h *AdminData) ToggleResource(c *gin.Context) {
|
||||||
|
var body struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
h.bad(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.data.ToggleResource(c.Param("resource"), c.Param("id"), body.Enabled); err != nil {
|
||||||
|
h.bad(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.audit(c, "toggle", c.Param("resource"), c.Param("id"), body.Reason, body)
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
func (h *AdminData) DeleteResource(c *gin.Context) {
|
||||||
|
var body struct {
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
}
|
||||||
|
_ = c.ShouldBindJSON(&body)
|
||||||
|
if err := h.data.DeleteResource(c.Param("resource"), c.Param("id")); err != nil {
|
||||||
|
h.bad(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.audit(c, "delete", c.Param("resource"), c.Param("id"), body.Reason, nil)
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AdminData) ReorderStyles(c *gin.Context) {
|
||||||
|
var body struct {
|
||||||
|
IDs []string `json:"ids"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
h.bad(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.data.ReorderStyles(body.IDs); err != nil {
|
||||||
|
h.bad(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.audit(c, "reorder", "styles", strings.Join(body.IDs, ","), "", map[string]any{"count": len(body.IDs)})
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AdminData) ListUsers(c *gin.Context) {
|
||||||
|
page, err := h.data.ListUsers(c.Query("keyword"), c.Query("enabled"), c.Query("page"), c.Query("page_size"))
|
||||||
|
if err != nil {
|
||||||
|
h.internal(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"data": page})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAuditLogs 返回管理端操作审计记录,不提供修改或删除能力。
|
||||||
|
func (h *AdminData) ListAuditLogs(c *gin.Context) {
|
||||||
|
page, size := parsePage(c)
|
||||||
|
result, err := h.admin.ListAuditLogs(c.Query("keyword"), page, size)
|
||||||
|
if err != nil {
|
||||||
|
h.internal(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"data": result})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AdminData) CreateUser(c *gin.Context) {
|
||||||
|
var body struct {
|
||||||
|
Account string `json:"account"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
DailyLimit any `json:"daily_limit"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
h.bad(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
item, err := h.data.CreateUser(body.Account, body.Password, body.DailyLimit)
|
||||||
|
if err != nil {
|
||||||
|
h.bad(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.audit(c, "create", "users", stringValue(item["id"]), "", map[string]any{"account": item["account"], "username": item["username"], "daily_limit": body.DailyLimit})
|
||||||
|
c.JSON(http.StatusCreated, gin.H{"data": item})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AdminData) BatchCreateUsers(c *gin.Context) {
|
||||||
|
var body struct {
|
||||||
|
Prefix string `json:"prefix"`
|
||||||
|
StartSequence string `json:"start_sequence"`
|
||||||
|
EndSequence string `json:"end_sequence"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
DailyLimit any `json:"daily_limit"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
h.bad(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
items, err := h.data.BatchCreateUsers(body.Prefix, body.StartSequence, body.EndSequence, body.Password, body.DailyLimit)
|
||||||
|
if err != nil {
|
||||||
|
h.bad(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.audit(c, "batch_create", "users", "", "", map[string]any{"prefix": body.Prefix, "start_sequence": body.StartSequence, "end_sequence": body.EndSequence, "count": len(items)})
|
||||||
|
c.JSON(http.StatusCreated, gin.H{"data": items})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AdminData) BatchUpdateUsers(c *gin.Context) {
|
||||||
|
var body struct {
|
||||||
|
IDs []string `json:"ids"`
|
||||||
|
DailyLimit any `json:"daily_limit"`
|
||||||
|
Enabled *bool `json:"enabled"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
h.bad(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updates := map[string]any{}
|
||||||
|
if body.DailyLimit != nil {
|
||||||
|
updates["daily_limit"] = body.DailyLimit
|
||||||
|
}
|
||||||
|
if body.Enabled != nil {
|
||||||
|
updates["enabled"] = *body.Enabled
|
||||||
|
}
|
||||||
|
if err := h.data.UpdateUsers(body.IDs, updates); err != nil {
|
||||||
|
h.bad(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.audit(c, "batch_update", "users", strings.Join(body.IDs, ","), body.Reason, updates)
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AdminData) BatchDeleteUsers(c *gin.Context) {
|
||||||
|
var body struct {
|
||||||
|
IDs []string `json:"ids"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
h.bad(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.data.DeleteUsers(body.IDs); err != nil {
|
||||||
|
h.bad(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.audit(c, "batch_delete", "users", strings.Join(body.IDs, ","), body.Reason, nil)
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GrantUserPoints 校验管理端积分发放请求,完成入账后记录操作审计。
|
||||||
|
func (h *AdminData) GrantUserPoints(c *gin.Context) {
|
||||||
|
userID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
h.bad(c, errors.New("用户标识无效"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
RequestID string `json:"request_id"`
|
||||||
|
Points any `json:"points"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
h.bad(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
body.Reason = strings.TrimSpace(body.Reason)
|
||||||
|
if body.Reason == "" {
|
||||||
|
h.bad(c, errors.New("请填写积分发放原因"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len([]rune(body.Reason)) > 200 {
|
||||||
|
h.bad(c, errors.New("积分发放原因不能超过 200 个字符"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
balance, credited, err := h.admin.GrantUserPoints(userID, body.RequestID, body.Points)
|
||||||
|
if err != nil {
|
||||||
|
h.bad(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if credited {
|
||||||
|
h.audit(c, "grant_points", "users", userID.String(), body.Reason, map[string]any{
|
||||||
|
"points": body.Points, "balance": balance, "request_id": body.RequestID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"data": gin.H{"balance": balance, "credited": credited}})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AdminData) ListModels(c *gin.Context) {
|
||||||
|
page, size := parsePage(c)
|
||||||
|
result, err := h.admin.ListModels(c.Query("keyword"), c.Query("model_type"), c.Query("channel_id"), page, size)
|
||||||
|
if err != nil {
|
||||||
|
h.internal(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"data": result})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AdminData) SaveModel(c *gin.Context) {
|
||||||
|
var body adminmodule.ModelInput
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
h.bad(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, created, normalizedBody, err := h.admin.SaveModel(c.Param("id"), body)
|
||||||
|
if err != nil {
|
||||||
|
h.bad(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
action := "update"
|
||||||
|
status := http.StatusOK
|
||||||
|
if created {
|
||||||
|
action = "create"
|
||||||
|
status = http.StatusCreated
|
||||||
|
}
|
||||||
|
h.audit(c, action, "models", id, "", normalizedBody)
|
||||||
|
c.JSON(status, gin.H{"data": gin.H{"id": id}})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AdminData) ListPrompts(c *gin.Context) {
|
||||||
|
page, size := parsePage(c)
|
||||||
|
items, total, err := h.prompts.ListSystemPrompts(c.Query("keyword"), c.Query("type"), page, size)
|
||||||
|
if err != nil {
|
||||||
|
h.internal(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"data": service.Page{Items: items, Total: total, Page: page, PageSize: size}})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AdminData) SavePrompt(c *gin.Context) {
|
||||||
|
var body struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Category string `json:"category"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
Variables []string `json:"variables"`
|
||||||
|
VersionNote string `json:"version_note"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
h.bad(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(body.Category) == "" {
|
||||||
|
body.Category = body.Type
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(body.Type) == "" {
|
||||||
|
body.Type = body.Category
|
||||||
|
}
|
||||||
|
if err := service.ValidatePromptVariables(body.Content, body.Variables); err != nil {
|
||||||
|
h.bad(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, err := h.prompts.SaveVersionedPrompt(currentAdmin(c).ID, c.Param("id"), prompt.VersionedPromptInput{
|
||||||
|
Code: body.Code, Name: body.Name, Category: body.Category, Type: body.Type,
|
||||||
|
Content: body.Content, Variables: body.Variables, VersionNote: body.VersionNote,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
h.bad(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.audit(c, "save_draft", "prompts", id, body.VersionNote, nil)
|
||||||
|
c.JSON(http.StatusOK, gin.H{"data": gin.H{"id": id}})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SavePromptSimple 保存系统提示词并立即应用,不启用历史版本工作流。
|
||||||
|
func (h *AdminData) SavePromptSimple(c *gin.Context) {
|
||||||
|
var body prompt.SystemPromptInput
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
h.bad(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
item, err := h.prompts.SaveSystemPrompt(c.Param("id"), body)
|
||||||
|
if err != nil {
|
||||||
|
h.bad(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.audit(c, "save", "prompts", item.ID, "", body)
|
||||||
|
c.JSON(http.StatusOK, gin.H{"data": item})
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeletePromptSimple 删除管理端指定的系统提示词。
|
||||||
|
func (h *AdminData) DeletePromptSimple(c *gin.Context) {
|
||||||
|
if err := h.prompts.DeleteSystemPrompt(c.Param("id")); err != nil {
|
||||||
|
h.bad(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AdminData) PromptAction(c *gin.Context) {
|
||||||
|
var body struct {
|
||||||
|
Action string `json:"action"`
|
||||||
|
VersionID string `json:"version_id"`
|
||||||
|
VersionNote string `json:"version_note"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
h.bad(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id := c.Param("id")
|
||||||
|
if err := h.prompts.ApplyPromptAction(currentAdmin(c).ID, id, body.Action, body.VersionID, body.VersionNote); err != nil {
|
||||||
|
h.bad(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.audit(c, body.Action, "prompts", id, body.VersionNote, body)
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AdminData) PromptHistory(c *gin.Context) {
|
||||||
|
items, err := h.prompts.ListPromptHistory(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
h.internal(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"data": items})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AdminData) PromptActionSimple(c *gin.Context) { c.Status(http.StatusNoContent) }
|
||||||
|
func (h *AdminData) PromptHistorySimple(c *gin.Context) {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"data": []any{}})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AdminData) ListRedemptions(c *gin.Context) {
|
||||||
|
page, size := parsePage(c)
|
||||||
|
result, err := h.admin.ListRedemptions(c.Query("keyword"), c.Query("status"), page, size)
|
||||||
|
if err != nil {
|
||||||
|
h.internal(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"data": result})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AdminData) CreateRedemptionBatch(c *gin.Context) {
|
||||||
|
var body struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Points any `json:"points"`
|
||||||
|
Quantity int `json:"quantity"`
|
||||||
|
ExpiresAt optionalTime `json:"expires_at"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
h.bad(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.ExpiresAt.Time == nil {
|
||||||
|
expiresAt := time.Now().AddDate(0, 0, 7)
|
||||||
|
body.ExpiresAt.Time = &expiresAt
|
||||||
|
}
|
||||||
|
admin := currentAdmin(c)
|
||||||
|
batchID, codes, points, err := h.admin.CreateRedemptionBatch(admin.ID, body.Name, body.Points, body.Quantity, *body.ExpiresAt.Time)
|
||||||
|
if err != nil {
|
||||||
|
h.bad(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.audit(c, "generate", "redemption-codes", batchID, "", map[string]any{"quantity": body.Quantity, "points": points})
|
||||||
|
c.JSON(http.StatusCreated, gin.H{"data": gin.H{"batch_id": batchID, "codes": codes}})
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateRedemptionBatchLimits 保留 Handler 包内既有测试入口,实际规则由管理后台模块统一实现。
|
||||||
|
func validateRedemptionBatchLimits(value any, quantity int) (string, error) {
|
||||||
|
return adminmodule.ValidateRedemptionBatchLimits(value, quantity)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AdminData) UploadStyleImage(c *gin.Context) {
|
||||||
|
if h.cos == nil {
|
||||||
|
fail(c, http.StatusServiceUnavailable, "cos_not_configured", "COS 对象存储未配置")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
file, header, err := c.Request.FormFile("file")
|
||||||
|
if err != nil {
|
||||||
|
h.bad(c, errors.New("请选择图片文件"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
if header.Size <= 0 || header.Size > h.cos.MaxImageBytes() {
|
||||||
|
h.bad(c, errors.New("图片大小超出限制"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
contentType := header.Header.Get("Content-Type")
|
||||||
|
if contentType != "image/jpeg" && contentType != "image/png" && contentType != "image/gif" {
|
||||||
|
h.bad(c, errors.New("仅支持 JPEG、PNG 或 GIF 图片"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
width, height, err := imageDimensions(file, contentType)
|
||||||
|
if err != nil {
|
||||||
|
h.bad(c, errors.New("无法识别图片内容"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if width < 64 || height < 64 || width > 8192 || height > 8192 {
|
||||||
|
h.bad(c, errors.New("图片尺寸必须在 64×64 至 8192×8192 之间"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err = file.Seek(0, 0); err != nil {
|
||||||
|
h.internal(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ext := strings.ToLower(filepath.Ext(header.Filename))
|
||||||
|
if ext == "" {
|
||||||
|
ext = ".img"
|
||||||
|
}
|
||||||
|
key := mediakey.StyleImage(uuid.New(), ext)
|
||||||
|
url, err := h.cos.Put(c, key, contentType, file, header.Size)
|
||||||
|
if err != nil {
|
||||||
|
h.internal(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusCreated, gin.H{"data": gin.H{"image_key": key, "image_url": url, "image_mime": contentType, "image_size": header.Size, "image_width": width, "image_height": height}})
|
||||||
|
}
|
||||||
|
|
||||||
|
func imageDimensions(file multipart.File, contentType string) (int, int, error) {
|
||||||
|
if contentType == "image/webp" {
|
||||||
|
return 0, 0, errors.New("WebP 尺寸解析暂不可用")
|
||||||
|
}
|
||||||
|
cfg, _, err := image.DecodeConfig(file)
|
||||||
|
return cfg.Width, cfg.Height, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func parsePage(c *gin.Context) (int, int) {
|
||||||
|
page, _ := strconv.Atoi(c.Query("page"))
|
||||||
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
size, _ := strconv.Atoi(c.Query("page_size"))
|
||||||
|
if size < 1 {
|
||||||
|
size = 20
|
||||||
|
}
|
||||||
|
if size > 100 {
|
||||||
|
size = 100
|
||||||
|
}
|
||||||
|
return page, size
|
||||||
|
}
|
||||||
|
func stringValue(v any) string {
|
||||||
|
if v == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return fmt.Sprint(v)
|
||||||
|
}
|
||||||
|
func (h *AdminData) audit(c *gin.Context, action, resource, id, reason string, detail any) {
|
||||||
|
_ = h.admin.WriteAudit(currentAdmin(c), action, resource, id, reason, c.ClientIP(), c.GetString("trace_id"), detail)
|
||||||
|
}
|
||||||
|
func (h *AdminData) bad(c *gin.Context, err error) {
|
||||||
|
message := err.Error()
|
||||||
|
code := "invalid_request"
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
code = "not_found"
|
||||||
|
}
|
||||||
|
fail(c, http.StatusBadRequest, code, message)
|
||||||
|
}
|
||||||
|
func (h *AdminData) internal(c *gin.Context, err error) {
|
||||||
|
slog.ErrorContext(c.Request.Context(), "admin request failed", "trace_id", c.GetString("trace_id"), "error", err)
|
||||||
|
fail(c, http.StatusInternalServerError, "internal_error", "操作失败,请稍后重试")
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,47 @@
|
|||||||
|
// 本文件负责剧集视频 ZIP 下载请求的参数校验、响应头设置和对象流写入。
|
||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"mime"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"juhe-factory/api/internal/service"
|
||||||
|
"juhe-factory/api/internal/videoarchive"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DownloadEpisodeVideos 下载当前剧集按项目类型筛选后的视频 ZIP。
|
||||||
|
func (h *Creative) DownloadEpisodeVideos(c *gin.Context) {
|
||||||
|
if !h.objectStorageAvailable(c) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
projectID, ok := h.projectID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
episodeID, err := service.ParseUUID(c.Param("episode_id"), "剧集")
|
||||||
|
if err != nil {
|
||||||
|
h.bad(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
manifest, err := h.service.EpisodeVideoArchive(currentWebUser(c).ID, projectID, episodeID)
|
||||||
|
if err != nil {
|
||||||
|
h.bad(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Header("Content-Type", "application/zip")
|
||||||
|
c.Header("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": manifest.Name}))
|
||||||
|
c.Header("X-Content-Type-Options", "nosniff")
|
||||||
|
c.Status(http.StatusOK)
|
||||||
|
err = videoarchive.Write(c.Request.Context(), c.Writer, manifest.Entries, func(ctx context.Context, objectKey string) (io.ReadCloser, error) {
|
||||||
|
body, _, _, openErr := h.cos.Open(ctx, objectKey)
|
||||||
|
return body, openErr
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
_ = c.Error(err)
|
||||||
|
c.Abort()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"juhe-factory/api/internal/service"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (h *Creative) PreviewDramaImport(c *gin.Context) {
|
||||||
|
projectID, ok := h.projectID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 4<<20)
|
||||||
|
if err := c.Request.ParseMultipartForm(4 << 20); err != nil {
|
||||||
|
h.bad(c, errors.New("导入内容无效"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if c.Request.MultipartForm != nil {
|
||||||
|
defer c.Request.MultipartForm.RemoveAll()
|
||||||
|
}
|
||||||
|
rawText := strings.TrimSpace(c.PostForm("raw_text"))
|
||||||
|
var filename string
|
||||||
|
var content []byte
|
||||||
|
file, header, err := c.Request.FormFile("file")
|
||||||
|
if err == nil {
|
||||||
|
defer file.Close()
|
||||||
|
filename = header.Filename
|
||||||
|
content, err = io.ReadAll(io.LimitReader(file, 3*1024*1024+1))
|
||||||
|
if err != nil {
|
||||||
|
h.bad(c, errors.New("读取小说文件失败"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else if rawText == "" {
|
||||||
|
h.bad(c, errors.New("请选择小说文件或粘贴文本"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
preview, err := h.service.PreviewDramaImport(currentWebUser(c).ID, projectID, filename, content, rawText)
|
||||||
|
if err != nil {
|
||||||
|
h.respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"data": preview})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Creative) ConfirmDramaImport(c *gin.Context) {
|
||||||
|
projectID, ok := h.projectID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
ImportToken uuid.UUID `json:"import_token"`
|
||||||
|
ChaptersPerEpisode int `json:"chapters_per_episode"`
|
||||||
|
StartEpisodeNo int `json:"start_episode_no"`
|
||||||
|
TreatAsSingleEpisode bool `json:"treat_as_single_episode"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil || body.ImportToken == uuid.Nil {
|
||||||
|
h.bad(c, errors.New("导入确认参数无效"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
items, err := h.service.ConfirmDramaImport(currentWebUser(c).ID, projectID, body.ImportToken, body.ChaptersPerEpisode, body.StartEpisodeNo, body.TreatAsSingleEpisode)
|
||||||
|
if err != nil {
|
||||||
|
h.respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusCreated, gin.H{"data": items})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Creative) GetDramaEpisodeSource(c *gin.Context) {
|
||||||
|
projectID, episodeID, ok := h.dramaEpisodeIDs(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
source, err := h.service.EpisodeSource(currentWebUser(c).ID, projectID, episodeID)
|
||||||
|
if err != nil {
|
||||||
|
h.respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"data": source})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Creative) SaveDramaEpisodeSource(c *gin.Context) {
|
||||||
|
projectID, episodeID, ok := h.dramaEpisodeIDs(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
RawContent string `json:"raw_content"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
h.bad(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
source, err := h.service.SaveEpisodeSource(currentWebUser(c).ID, projectID, episodeID, body.RawContent, body.Title)
|
||||||
|
if err != nil {
|
||||||
|
h.respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"data": source})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Creative) QueueDramaParse(c *gin.Context) {
|
||||||
|
projectID, episodeID, ok := h.dramaEpisodeIDs(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
task, err := h.service.QueueDramaParse(currentWebUser(c).ID, projectID, episodeID)
|
||||||
|
if err != nil {
|
||||||
|
h.respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusAccepted, gin.H{"data": task})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Creative) ReparseDrama(c *gin.Context) {
|
||||||
|
if !h.objectStorageAvailable(c) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
projectID, episodeID, ok := h.dramaEpisodeIDs(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
userID := currentWebUser(c).ID
|
||||||
|
objectKeys, err := h.service.ResetEpisodeAnalysis(userID, projectID, episodeID)
|
||||||
|
if err != nil {
|
||||||
|
h.respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !h.deleteStoredObjects(c, objectKeys) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
task, err := h.service.QueueDramaParse(userID, projectID, episodeID)
|
||||||
|
if err != nil {
|
||||||
|
h.respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusAccepted, gin.H{"data": task})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Creative) ListDramaParseTasks(c *gin.Context) {
|
||||||
|
projectID, ok := h.projectID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var episodeID *uuid.UUID
|
||||||
|
if raw := strings.TrimSpace(c.Query("episode_id")); raw != "" {
|
||||||
|
parsed, err := service.ParseUUID(raw, "剧集")
|
||||||
|
if err != nil {
|
||||||
|
h.bad(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
episodeID = &parsed
|
||||||
|
}
|
||||||
|
items, err := h.service.ListDramaParseTasks(currentWebUser(c).ID, projectID, episodeID)
|
||||||
|
if err != nil {
|
||||||
|
h.respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"data": items})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Creative) CancelDramaParse(c *gin.Context) {
|
||||||
|
projectID, ok := h.projectID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
taskID, err := service.ParseUUID(c.Param("task_id"), "解析任务")
|
||||||
|
if err != nil {
|
||||||
|
h.bad(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err = h.service.CancelDramaParse(currentWebUser(c).ID, projectID, taskID); err != nil {
|
||||||
|
h.respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Creative) CreateDramaStoryboard(c *gin.Context) {
|
||||||
|
projectID, episodeID, ok := h.dramaEpisodeIDs(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
storyboard, err := h.service.CreateStoryboard(currentWebUser(c).ID, projectID, episodeID)
|
||||||
|
if err != nil {
|
||||||
|
h.respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusCreated, gin.H{"data": storyboard})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Creative) dramaEpisodeIDs(c *gin.Context) (uuid.UUID, uuid.UUID, bool) {
|
||||||
|
projectID, ok := h.projectID(c)
|
||||||
|
if !ok {
|
||||||
|
return uuid.Nil, uuid.Nil, false
|
||||||
|
}
|
||||||
|
episodeID, err := service.ParseUUID(c.Param("episode_id"), "剧集")
|
||||||
|
if err != nil {
|
||||||
|
h.bad(c, err)
|
||||||
|
return uuid.Nil, uuid.Nil, false
|
||||||
|
}
|
||||||
|
return projectID, episodeID, true
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/redis/go-redis/v9"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Health struct {
|
||||||
|
db *sql.DB
|
||||||
|
redis *redis.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHealth(db *sql.DB, redisClient *redis.Client) *Health {
|
||||||
|
return &Health{db: db, redis: redisClient}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Health) Check(c *gin.Context) {
|
||||||
|
ctx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
services := map[string]string{
|
||||||
|
"postgresql": "ok",
|
||||||
|
"redis": "ok",
|
||||||
|
}
|
||||||
|
status := "ok"
|
||||||
|
statusCode := http.StatusOK
|
||||||
|
|
||||||
|
if err := h.db.PingContext(ctx); err != nil {
|
||||||
|
services["postgresql"] = "unavailable"
|
||||||
|
status = "degraded"
|
||||||
|
statusCode = http.StatusServiceUnavailable
|
||||||
|
}
|
||||||
|
if err := h.redis.Ping(ctx).Err(); err != nil {
|
||||||
|
services["redis"] = "unavailable"
|
||||||
|
status = "degraded"
|
||||||
|
statusCode = http.StatusServiceUnavailable
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(statusCode, gin.H{
|
||||||
|
"status": status,
|
||||||
|
"services": services,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
// 图片生成接口处理器,负责生图参数校验、参考图上传、历史查询和结果删除。
|
||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"image"
|
||||||
|
"io"
|
||||||
|
"mime"
|
||||||
|
"net/http"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
mediakey "juhe-factory/api/internal/media"
|
||||||
|
"juhe-factory/api/internal/model"
|
||||||
|
"juhe-factory/api/internal/modules/productimage"
|
||||||
|
"juhe-factory/api/internal/service"
|
||||||
|
"juhe-factory/api/internal/storage"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ProductImage 组合图片生成生图服务与对象存储依赖。
|
||||||
|
type ProductImage struct {
|
||||||
|
service *productimage.Service
|
||||||
|
cos *storage.COS
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewProductImage 创建图片生成 HTTP 处理器。
|
||||||
|
func NewProductImage(service *productimage.Service, cos *storage.COS) *ProductImage {
|
||||||
|
return &ProductImage{service: service, cos: cos}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate 接收当前页面的临时配置和参考图,并创建图片生成任务。
|
||||||
|
func (h *ProductImage) Generate(c *gin.Context) {
|
||||||
|
if h.cos == nil {
|
||||||
|
fail(c, http.StatusServiceUnavailable, "cos_not_configured", "对象存储未配置")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 48<<20)
|
||||||
|
if err := c.Request.ParseMultipartForm(48 << 20); err != nil {
|
||||||
|
fail(c, http.StatusBadRequest, "invalid_request", "生图请求无效")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if c.Request.MultipartForm != nil {
|
||||||
|
defer c.Request.MultipartForm.RemoveAll()
|
||||||
|
}
|
||||||
|
modelID, err := service.ParseUUID(c.PostForm("model_id"), "图片模型")
|
||||||
|
if err != nil {
|
||||||
|
fail(c, http.StatusBadRequest, "invalid_request", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ratio := strings.TrimSpace(c.PostForm("aspect_ratio"))
|
||||||
|
if ratio == "" {
|
||||||
|
ratio = "9:16"
|
||||||
|
}
|
||||||
|
resolution := strings.TrimSpace(c.PostForm("resolution"))
|
||||||
|
if resolution == "" {
|
||||||
|
resolution = "1k"
|
||||||
|
}
|
||||||
|
userID := currentWebUser(c).ID
|
||||||
|
files := c.Request.MultipartForm.File["references"]
|
||||||
|
if len(files) > 4 {
|
||||||
|
fail(c, http.StatusBadRequest, "invalid_request", "最多上传 4 张参考图")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
references := make([]productimage.ReferenceUpload, 0, len(files))
|
||||||
|
objectKeys := make([]string, 0, len(files))
|
||||||
|
for index, header := range files {
|
||||||
|
file, openErr := header.Open()
|
||||||
|
if openErr != nil {
|
||||||
|
if !h.cleanupUploadedObjects(c, objectKeys) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fail(c, http.StatusBadRequest, "invalid_request", "参考图无法读取")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
contentType := cleanProductImageContentType(header.Header.Get("Content-Type"))
|
||||||
|
if contentType == "" {
|
||||||
|
contentType = cleanProductImageContentType(mime.TypeByExtension(strings.ToLower(filepath.Ext(header.Filename))))
|
||||||
|
}
|
||||||
|
allowed := map[string]bool{"image/jpeg": true, "image/png": true, "image/webp": true}
|
||||||
|
if !allowed[contentType] || header.Size <= 0 || header.Size > h.cos.MaxImageBytes() {
|
||||||
|
file.Close()
|
||||||
|
if !h.cleanupUploadedObjects(c, objectKeys) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fail(c, http.StatusBadRequest, "invalid_request", "参考图格式或大小不符合要求")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
config, _, decodeErr := image.DecodeConfig(file)
|
||||||
|
if decodeErr != nil || config.Width <= 0 || config.Height <= 0 {
|
||||||
|
file.Close()
|
||||||
|
if !h.cleanupUploadedObjects(c, objectKeys) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fail(c, http.StatusBadRequest, "invalid_request", "参考图内容无法解析")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err = file.Seek(0, io.SeekStart); err != nil {
|
||||||
|
file.Close()
|
||||||
|
if !h.cleanupUploadedObjects(c, objectKeys) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mediaID := uuid.New()
|
||||||
|
key := mediakey.ProductImageReference(userID, mediaID, header.Filename, contentType)
|
||||||
|
hash := sha256.New()
|
||||||
|
if _, err = io.Copy(hash, file); err != nil {
|
||||||
|
file.Close()
|
||||||
|
if !h.cleanupUploadedObjects(c, objectKeys) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err = file.Seek(0, io.SeekStart); err != nil {
|
||||||
|
file.Close()
|
||||||
|
if !h.cleanupUploadedObjects(c, objectKeys) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
url, putErr := h.cos.Put(c, key, contentType, file, header.Size)
|
||||||
|
file.Close()
|
||||||
|
if putErr != nil {
|
||||||
|
if !h.cleanupUploadedObjects(c, objectKeys) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.respondError(c, putErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
objectKeys = append(objectKeys, key)
|
||||||
|
name := strings.TrimSpace(filepath.Base(header.Filename))
|
||||||
|
if name == "" {
|
||||||
|
name = "参考图" + string(rune('1'+index))
|
||||||
|
}
|
||||||
|
owner := userID
|
||||||
|
references = append(references, productimage.ReferenceUpload{Asset: &model.MediaAsset{ID: mediaID, OwnerUserID: &owner, StorageProvider: "cos", ObjectKey: key, PublicURL: url, OriginalName: name, DisplayName: "图片生成参考图", MimeType: contentType, SizeBytes: header.Size, SHA256: hex.EncodeToString(hash.Sum(nil)), Width: &config.Width, Height: &config.Height}, Name: name})
|
||||||
|
}
|
||||||
|
task, err := h.service.QueueGeneration(userID, productimage.GenerateInput{Prompt: c.PostForm("prompt"), ModelID: modelID, AspectRatio: ratio, Resolution: resolution, References: references})
|
||||||
|
if err != nil {
|
||||||
|
if !h.cleanupUploadedObjects(c, objectKeys) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusAccepted, gin.H{"data": gin.H{"task_id": task.ID}})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListGenerations 返回当前用户最近的图片生成历史。
|
||||||
|
func (h *ProductImage) ListGenerations(c *gin.Context) {
|
||||||
|
items, err := h.service.Generations(currentWebUser(c).ID)
|
||||||
|
if err != nil {
|
||||||
|
h.respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"data": items})
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteGeneration 删除当前用户的一条图片生成记录及其 COS 对象。
|
||||||
|
func (h *ProductImage) DeleteGeneration(c *gin.Context) {
|
||||||
|
taskID, err := service.ParseUUID(c.Param("task_id"), "图片生成任务")
|
||||||
|
if err != nil {
|
||||||
|
fail(c, http.StatusBadRequest, "invalid_request", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err = h.service.DeleteGeneration(currentWebUser(c).ID, taskID, func(keys []string) error {
|
||||||
|
return h.deleteObjects(c.Request.Context(), keys)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
h.respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// deleteObjects 删除一组已经精确解析的 COS 对象键。
|
||||||
|
func (h *ProductImage) deleteObjects(ctx context.Context, keys []string) error {
|
||||||
|
if h.cos == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for _, key := range keys {
|
||||||
|
if err := h.cos.Delete(ctx, key); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanupUploadedObjects 清理提交失败前已经上传的参考图,并统一响应清理错误。
|
||||||
|
func (h *ProductImage) cleanupUploadedObjects(c *gin.Context, keys []string) bool {
|
||||||
|
if err := h.deleteObjects(c.Request.Context(), keys); err != nil {
|
||||||
|
h.respondError(c, err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanProductImageContentType 去除图片生成上传类型的参数部分。
|
||||||
|
func cleanProductImageContentType(value string) string {
|
||||||
|
return strings.TrimSpace(strings.Split(value, ";")[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
// respondError 将图片生成模块错误转换为一致的 Web API 响应。
|
||||||
|
func (h *ProductImage) respondError(c *gin.Context, err error) {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
fail(c, http.StatusNotFound, "not_found", "图片生成记录不存在")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
message := strings.TrimSpace(err.Error())
|
||||||
|
if message == "" {
|
||||||
|
message = "请求处理失败"
|
||||||
|
}
|
||||||
|
if strings.Contains(message, "不能为空") || strings.Contains(message, "不能") || strings.Contains(message, "无效") || strings.Contains(message, "请选择") || strings.Contains(message, "最多") || strings.Contains(message, "不可用") || strings.Contains(message, "未配置") {
|
||||||
|
fail(c, http.StatusBadRequest, "invalid_request", message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.Contains(message, "积分") {
|
||||||
|
fail(c, http.StatusConflict, "insufficient_points", message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.Contains(message, "生成中") {
|
||||||
|
fail(c, http.StatusConflict, "generation_active", message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fail(c, http.StatusInternalServerError, "internal_error", message)
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestValidateRedemptionBatchLimits(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
points any
|
||||||
|
quantity int
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{name: "minimum", points: 1, quantity: 1},
|
||||||
|
{name: "maximum", points: 100, quantity: 10},
|
||||||
|
{name: "decimal points", points: 1.5, quantity: 2},
|
||||||
|
{name: "points below minimum", points: 0.99, quantity: 1, wantErr: true},
|
||||||
|
{name: "points above maximum", points: 100.01, quantity: 1, wantErr: true},
|
||||||
|
{name: "quantity below minimum", points: 1, quantity: 0, wantErr: true},
|
||||||
|
{name: "quantity above maximum", points: 1, quantity: 11, wantErr: true},
|
||||||
|
{name: "invalid points", points: "invalid", quantity: 1, wantErr: true},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
_, err := validateRedemptionBatchLimits(test.points, test.quantity)
|
||||||
|
if (err != nil) != test.wantErr {
|
||||||
|
t.Fatalf("validateRedemptionBatchLimits(%v, %d) error = %v", test.points, test.quantity, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
// 视频重绘接口处理器,负责重绘工作台、分析任务和源媒体上传请求。
|
||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
mediakey "juhe-factory/api/internal/media"
|
||||||
|
"juhe-factory/api/internal/service"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (h *Creative) GetRedrawWorkbench(c *gin.Context) {
|
||||||
|
projectID, ok := h.projectID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data, err := h.service.GetRedrawWorkbench(currentWebUser(c).ID, projectID)
|
||||||
|
if err != nil {
|
||||||
|
h.respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"data": data})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Creative) GetRedrawEpisodeWorkbench(c *gin.Context) {
|
||||||
|
projectID, ok := h.projectID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
episodeID, err := service.ParseUUID(c.Param("episode_id"), "剧集")
|
||||||
|
if err != nil {
|
||||||
|
h.bad(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data, err := h.service.GetRedrawEpisodeWorkbench(currentWebUser(c).ID, projectID, episodeID)
|
||||||
|
if err != nil {
|
||||||
|
h.respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"data": data})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Creative) QueueRedrawEpisodeScript(c *gin.Context) {
|
||||||
|
projectID, ok := h.projectID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
episodeID, err := service.ParseUUID(c.Param("episode_id"), "剧集")
|
||||||
|
if err != nil {
|
||||||
|
h.bad(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
task, err := h.service.QueueRedrawEpisodeScript(currentWebUser(c).ID, projectID, episodeID)
|
||||||
|
if err != nil {
|
||||||
|
h.respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusAccepted, gin.H{"data": task})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveRedrawEpisodeScript 保存用户修改后的剧集反推剧本正文。
|
||||||
|
func (h *Creative) SaveRedrawEpisodeScript(c *gin.Context) {
|
||||||
|
projectID, ok := h.projectID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
episodeID, err := service.ParseUUID(c.Param("episode_id"), "剧集")
|
||||||
|
if err != nil {
|
||||||
|
h.bad(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
h.bad(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.service.SaveRedrawEpisodeScript(currentWebUser(c).ID, projectID, episodeID, body.Content); err != nil {
|
||||||
|
h.respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Creative) UpdateRedrawAnalysisSettings(c *gin.Context) {
|
||||||
|
projectID, ok := h.projectID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
AudioSource string `json:"audio_source"`
|
||||||
|
SourceLanguage string `json:"source_language"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
h.bad(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.service.UpdateRedrawAnalysisSettings(currentWebUser(c).ID, projectID, body.AudioSource, body.SourceLanguage); err != nil {
|
||||||
|
h.respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Creative) QueueRedrawAnalysis(c *gin.Context) {
|
||||||
|
projectID, ok := h.projectID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
task, err := h.service.QueueRedrawAnalysis(currentWebUser(c).ID, projectID)
|
||||||
|
if err != nil {
|
||||||
|
h.respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusAccepted, gin.H{"data": task})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Creative) ReanalyzeRedraw(c *gin.Context) {
|
||||||
|
if !h.objectStorageAvailable(c) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
projectID, ok := h.projectID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
objectKeys, err := h.service.ResetRedrawAnalysis(currentWebUser(c).ID, projectID)
|
||||||
|
if err != nil {
|
||||||
|
h.respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !h.deleteStoredObjects(c, objectKeys) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
task, err := h.service.QueueRedrawAnalysis(currentWebUser(c).ID, projectID)
|
||||||
|
if err != nil {
|
||||||
|
h.respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusAccepted, gin.H{"data": task})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Creative) QueueRedrawStoryboardAnalysis(c *gin.Context) {
|
||||||
|
projectID, ok := h.projectID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
storyboardID, err := service.ParseUUID(c.Param("storyboard_id"), "分镜")
|
||||||
|
if err != nil {
|
||||||
|
h.bad(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
task, err := h.service.QueueRedrawStoryboardAnalysis(currentWebUser(c).ID, projectID, storyboardID)
|
||||||
|
if err != nil {
|
||||||
|
h.respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusAccepted, gin.H{"data": task})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Creative) QueueRedrawScript(c *gin.Context) {
|
||||||
|
projectID, ok := h.projectID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
task, err := h.service.QueueRedrawScript(currentWebUser(c).ID, projectID)
|
||||||
|
if err != nil {
|
||||||
|
h.respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusAccepted, gin.H{"data": task})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Creative) UploadRedrawSource(c *gin.Context) {
|
||||||
|
h.uploadRedrawMedia(c, "source")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Creative) UploadRedrawSubtitle(c *gin.Context) {
|
||||||
|
h.uploadRedrawMedia(c, "subtitle")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Creative) uploadRedrawMedia(c *gin.Context, kind string) {
|
||||||
|
if h.cos == nil {
|
||||||
|
fail(c, http.StatusServiceUnavailable, "cos_not_configured", "COS 对象存储未配置")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
projectID, ok := h.projectID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
target, err := h.service.PrepareRedrawMediaUpload(currentWebUser(c).ID, projectID, kind)
|
||||||
|
if err != nil {
|
||||||
|
h.respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
project := target.Project
|
||||||
|
file, header, err := c.Request.FormFile("file")
|
||||||
|
if err != nil {
|
||||||
|
h.bad(c, errors.New("请选择文件"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
contentType := cleanContentType(header.Header.Get("Content-Type"))
|
||||||
|
maxBytes := int64(50 * 1024 * 1024)
|
||||||
|
ext := strings.ToLower(filepath.Ext(header.Filename))
|
||||||
|
allowedVideoTypes := map[string]bool{"video/mp4": true, "video/webm": true, "video/quicktime": true, "video/x-m4v": true}
|
||||||
|
allowed := allowedVideoTypes[contentType] && map[string]bool{".mp4": true, ".webm": true, ".mov": true, ".m4v": true}[ext]
|
||||||
|
if kind == "subtitle" {
|
||||||
|
maxBytes = 2 * 1024 * 1024
|
||||||
|
allowed = ext == ".srt" || ext == ".vtt" || ext == ".txt"
|
||||||
|
if contentType == "" {
|
||||||
|
contentType = "text/plain"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !allowed || header.Size <= 0 || header.Size > maxBytes {
|
||||||
|
h.bad(c, fmt.Errorf("%s文件格式或大小不符合要求", map[bool]string{true: "字幕", false: "视频"}[kind == "subtitle"]))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
oldMediaID := target.OldMediaID
|
||||||
|
if kind == "source" && project.SourceVideoAssetID != nil {
|
||||||
|
objectKeys, err := h.service.ResetRedrawAnalysis(currentWebUser(c).ID, projectID)
|
||||||
|
if err != nil {
|
||||||
|
h.respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !h.deleteStoredObjects(c, objectKeys) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mediaID := uuid.New()
|
||||||
|
key := mediakey.ProjectSource(projectID, mediaID, header.Filename, contentType)
|
||||||
|
displayName := project.Name + "原视频"
|
||||||
|
if kind == "subtitle" {
|
||||||
|
key = mediakey.ProjectSubtitle(projectID, mediaID, header.Filename, contentType)
|
||||||
|
displayName = project.Name + "字幕"
|
||||||
|
}
|
||||||
|
asset, err := h.storeUpload(c, mediaID, currentWebUser(c).ID, file, header, key, displayName, contentType, nil)
|
||||||
|
if err != nil {
|
||||||
|
h.internal(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.service.PersistRedrawMediaUpload(projectID, kind, project.RedrawStatus, asset, oldMediaID); err != nil {
|
||||||
|
_ = h.cos.Delete(c.Request.Context(), asset.ObjectKey)
|
||||||
|
h.internal(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if target.OldObjectKey != "" && !h.deleteStoredObjects(c, []string{target.OldObjectKey}) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusCreated, gin.H{"data": asset})
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"juhe-factory/api/internal/service"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (h *Creative) ListScriptAnalyses(c *gin.Context) {
|
||||||
|
items, err := h.service.ListScriptAnalyses(currentWebUser(c).ID)
|
||||||
|
if err != nil {
|
||||||
|
h.respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"data": items})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Creative) CreateScriptAnalysis(c *gin.Context) {
|
||||||
|
item, err := h.service.CreateScriptAnalysis(currentWebUser(c).ID)
|
||||||
|
if err != nil {
|
||||||
|
h.respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusCreated, gin.H{"data": item})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Creative) ListScriptAnalysisImportProjects(c *gin.Context) {
|
||||||
|
items, err := h.service.ListScriptAnalysisImportProjects(currentWebUser(c).ID)
|
||||||
|
if err != nil {
|
||||||
|
h.respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"data": items})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Creative) ImportScriptAnalysisProject(c *gin.Context) {
|
||||||
|
id, err := service.ParseUUID(c.Param("script_id"), "剧本")
|
||||||
|
if err != nil {
|
||||||
|
h.bad(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil || body.ProjectID == uuid.Nil {
|
||||||
|
h.bad(c, errors.New("请选择要导入的剧本反推项目"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
item, err := h.service.ImportScriptAnalysisProject(currentWebUser(c).ID, id, body.ProjectID)
|
||||||
|
if err != nil {
|
||||||
|
h.respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"data": item})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Creative) ImportScriptAnalysisFile(c *gin.Context) {
|
||||||
|
id, err := service.ParseUUID(c.Param("script_id"), "剧本")
|
||||||
|
if err != nil {
|
||||||
|
h.bad(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 4<<20)
|
||||||
|
if err := c.Request.ParseMultipartForm(4 << 20); err != nil {
|
||||||
|
h.bad(c, errors.New("导入文件无效"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if c.Request.MultipartForm != nil {
|
||||||
|
defer c.Request.MultipartForm.RemoveAll()
|
||||||
|
}
|
||||||
|
file, header, err := c.Request.FormFile("file")
|
||||||
|
if err != nil {
|
||||||
|
h.bad(c, errors.New("请选择剧本文件"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
content, err := io.ReadAll(io.LimitReader(file, 3*1024*1024+1))
|
||||||
|
if err != nil {
|
||||||
|
h.bad(c, errors.New("读取剧本文件失败"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
item, err := h.service.ImportScriptAnalysisFile(currentWebUser(c).ID, id, header.Filename, content)
|
||||||
|
if err != nil {
|
||||||
|
h.respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"data": item})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Creative) GetScriptAnalysis(c *gin.Context) {
|
||||||
|
id, err := service.ParseUUID(c.Param("script_id"), "剧本")
|
||||||
|
if err != nil {
|
||||||
|
h.bad(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
item, err := h.service.GetScriptAnalysis(currentWebUser(c).ID, id)
|
||||||
|
if err != nil {
|
||||||
|
h.respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"data": item})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Creative) UpdateScriptAnalysis(c *gin.Context) {
|
||||||
|
id, err := service.ParseUUID(c.Param("script_id"), "剧本")
|
||||||
|
if err != nil {
|
||||||
|
h.bad(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var input service.ScriptAnalysisInput
|
||||||
|
if err := c.ShouldBindJSON(&input); err != nil {
|
||||||
|
h.bad(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
item, err := h.service.UpdateScriptAnalysis(currentWebUser(c).ID, id, input)
|
||||||
|
if err != nil {
|
||||||
|
h.respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"data": item})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Creative) DeleteScriptAnalysis(c *gin.Context) {
|
||||||
|
id, err := service.ParseUUID(c.Param("script_id"), "剧本")
|
||||||
|
if err != nil {
|
||||||
|
h.bad(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.service.DeleteScriptAnalysis(currentWebUser(c).ID, id); err != nil {
|
||||||
|
h.respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Creative) QueueScriptAnalysis(c *gin.Context) {
|
||||||
|
id, err := service.ParseUUID(c.Param("script_id"), "剧本")
|
||||||
|
if err != nil {
|
||||||
|
h.bad(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
task, err := h.service.QueueScriptAnalysis(currentWebUser(c).ID, id)
|
||||||
|
if err != nil {
|
||||||
|
h.respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusAccepted, gin.H{"data": task})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Creative) CancelScriptAnalysis(c *gin.Context) {
|
||||||
|
id, err := service.ParseUUID(c.Param("script_id"), "剧本")
|
||||||
|
if err != nil {
|
||||||
|
h.bad(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.service.CancelScriptAnalysis(currentWebUser(c).ID, id); err != nil {
|
||||||
|
h.respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
// WEB 用户接口处理器,负责认证、账户资料、积分查询、头像上传和兑换请求。
|
||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
mediakey "juhe-factory/api/internal/media"
|
||||||
|
"juhe-factory/api/internal/model"
|
||||||
|
"juhe-factory/api/internal/service"
|
||||||
|
"juhe-factory/api/internal/storage"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
const webUserContextKey = "web_user"
|
||||||
|
|
||||||
|
type Web struct {
|
||||||
|
service *service.Web
|
||||||
|
cos *storage.COS
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewWeb(service *service.Web, cos *storage.COS) *Web { return &Web{service: service, cos: cos} }
|
||||||
|
|
||||||
|
func (h *Web) Login(c *gin.Context) {
|
||||||
|
var body struct {
|
||||||
|
Account string `json:"account" binding:"required"`
|
||||||
|
Password string `json:"password" binding:"required"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
fail(c, http.StatusBadRequest, "invalid_request", "请输入账号和密码")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pair, err := h.service.Login(body.Account, body.Password)
|
||||||
|
if err != nil {
|
||||||
|
fail(c, http.StatusUnauthorized, "invalid_credentials", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"data": pair})
|
||||||
|
}
|
||||||
|
func (h *Web) Refresh(c *gin.Context) {
|
||||||
|
var body struct {
|
||||||
|
RefreshToken string `json:"refresh_token" binding:"required"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
fail(c, http.StatusBadRequest, "invalid_request", "缺少刷新令牌")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pair, err := h.service.Refresh(body.RefreshToken)
|
||||||
|
if err != nil {
|
||||||
|
fail(c, http.StatusUnauthorized, "invalid_refresh_token", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"data": pair})
|
||||||
|
}
|
||||||
|
func (h *Web) Logout(c *gin.Context) {
|
||||||
|
var body struct {
|
||||||
|
RefreshToken string `json:"refresh_token"`
|
||||||
|
}
|
||||||
|
_ = c.ShouldBindJSON(&body)
|
||||||
|
if err := h.service.Logout(body.RefreshToken); err != nil {
|
||||||
|
fail(c, http.StatusInternalServerError, "logout_failed", "退出失败,请重试")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
func (h *Web) Middleware() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
raw := strings.TrimSpace(strings.TrimPrefix(c.GetHeader("Authorization"), "Bearer "))
|
||||||
|
user, err := h.service.Authenticate(raw)
|
||||||
|
if err != nil {
|
||||||
|
fail(c, http.StatusUnauthorized, "unauthorized", err.Error())
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Set(webUserContextKey, user)
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Account 返回当前用户账户资料、积分额度和头像上传限制。
|
||||||
|
func (h *Web) Account(c *gin.Context) {
|
||||||
|
data, err := h.service.Account(currentWebUser(c).ID)
|
||||||
|
if err != nil {
|
||||||
|
fail(c, http.StatusInternalServerError, "account_failed", "账户信息加载失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if h.cos != nil {
|
||||||
|
data["avatar_max_size_bytes"] = h.cos.MaxImageBytes()
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"data": data})
|
||||||
|
}
|
||||||
|
func (h *Web) UpdateProfile(c *gin.Context) {
|
||||||
|
var body struct {
|
||||||
|
Username string `json:"username" binding:"required"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
fail(c, http.StatusBadRequest, "invalid_request", "请输入用户名")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data, err := h.service.UpdateProfile(currentWebUser(c).ID, body.Username)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, service.ErrInvalidUsername) || errors.Is(err, service.ErrUsernameExists) {
|
||||||
|
fail(c, http.StatusBadRequest, "profile_invalid", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fail(c, http.StatusInternalServerError, "profile_update_failed", "资料更新失败,请稍后重试")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"data": data})
|
||||||
|
}
|
||||||
|
func (h *Web) ChangePassword(c *gin.Context) {
|
||||||
|
var body struct {
|
||||||
|
OriginalPassword string `json:"original_password" binding:"required"`
|
||||||
|
NewPassword string `json:"new_password" binding:"required"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
fail(c, http.StatusBadRequest, "invalid_request", "请完整填写密码")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.service.ChangePassword(currentWebUser(c).ID, body.OriginalPassword, body.NewPassword); err != nil {
|
||||||
|
if errors.Is(err, service.ErrOriginalPassword) || strings.Contains(err.Error(), "密码长度") {
|
||||||
|
fail(c, http.StatusBadRequest, "password_invalid", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fail(c, http.StatusInternalServerError, "password_update_failed", "密码修改失败,请稍后重试")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
func (h *Web) UploadAvatar(c *gin.Context) {
|
||||||
|
if h.cos == nil {
|
||||||
|
fail(c, http.StatusServiceUnavailable, "cos_not_configured", "对象存储未配置")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
file, header, err := c.Request.FormFile("file")
|
||||||
|
if err != nil {
|
||||||
|
fail(c, http.StatusBadRequest, "invalid_request", "请选择头像图片")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
contentType := strings.TrimSpace(strings.Split(header.Header.Get("Content-Type"), ";")[0])
|
||||||
|
extensions := map[string]string{"image/jpeg": ".jpg", "image/png": ".png", "image/webp": ".webp"}
|
||||||
|
extension, allowed := extensions[contentType]
|
||||||
|
if !allowed || header.Size <= 0 || header.Size > h.cos.MaxImageBytes() {
|
||||||
|
fail(c, http.StatusBadRequest, "avatar_invalid", "头像仅支持 JPEG、PNG 或 WebP,且不得超过图片大小限制")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
headerBytes := make([]byte, 512)
|
||||||
|
read, readErr := file.Read(headerBytes)
|
||||||
|
if readErr != nil && !errors.Is(readErr, io.EOF) {
|
||||||
|
fail(c, http.StatusBadRequest, "avatar_invalid", "头像图片内容无法解析")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if detected := http.DetectContentType(headerBytes[:read]); detected != contentType {
|
||||||
|
fail(c, http.StatusBadRequest, "avatar_invalid", "头像图片内容无法解析")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := file.Seek(0, io.SeekStart); err != nil {
|
||||||
|
fail(c, http.StatusInternalServerError, "avatar_upload_failed", "头像上传失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
userID := currentWebUser(c).ID
|
||||||
|
oldKey, err := h.service.AvatarKey(userID)
|
||||||
|
if err != nil {
|
||||||
|
fail(c, http.StatusInternalServerError, "avatar_upload_failed", "头像上传失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
key := mediakey.UserAvatar(userID, uuid.New(), extension)
|
||||||
|
url, err := h.cos.Put(c.Request.Context(), key, contentType, file, header.Size)
|
||||||
|
if err != nil {
|
||||||
|
fail(c, http.StatusInternalServerError, "avatar_upload_failed", "头像上传失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.service.UpdateAvatar(userID, url, key); err != nil {
|
||||||
|
_ = h.cos.Delete(c.Request.Context(), key)
|
||||||
|
fail(c, http.StatusInternalServerError, "avatar_upload_failed", "头像上传失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if oldKey != "" {
|
||||||
|
_ = h.cos.Delete(c.Request.Context(), oldKey)
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"data": gin.H{"avatar_url": url}})
|
||||||
|
}
|
||||||
|
func (h *Web) Usage30Days(c *gin.Context) {
|
||||||
|
items, err := h.service.Usage30Days(currentWebUser(c).ID)
|
||||||
|
if err != nil {
|
||||||
|
fail(c, http.StatusInternalServerError, "usage_failed", "消耗数据加载失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"data": items})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConsumptionRecords 按请求页码返回当前用户的积分变动流水。
|
||||||
|
func (h *Web) ConsumptionRecords(c *gin.Context) {
|
||||||
|
page, pageSize := parsePage(c)
|
||||||
|
result, err := h.service.ConsumptionRecords(currentWebUser(c).ID, page, pageSize)
|
||||||
|
if err != nil {
|
||||||
|
fail(c, http.StatusInternalServerError, "consumption_records_failed", "消耗记录加载失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"data": result})
|
||||||
|
}
|
||||||
|
func (h *Web) Redeem(c *gin.Context) {
|
||||||
|
var body struct {
|
||||||
|
Code string `json:"code" binding:"required"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
fail(c, http.StatusBadRequest, "invalid_request", "请输入兑换码")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := h.service.Redeem(currentWebUser(c).ID, body.Code)
|
||||||
|
if err != nil {
|
||||||
|
fail(c, http.StatusBadRequest, "redeem_failed", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"data": result})
|
||||||
|
}
|
||||||
|
func currentWebUser(c *gin.Context) *model.WebUser {
|
||||||
|
value, _ := c.Get(webUserContextKey)
|
||||||
|
user, _ := value.(*model.WebUser)
|
||||||
|
return user
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
// Package media 统一生成可长期稳定引用的 COS 媒体对象键和扩展名。
|
||||||
|
package media
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
const objectRoot = "juyou_ran"
|
||||||
|
|
||||||
|
var unsafeKeyPart = regexp.MustCompile(`[^a-z0-9-]+`)
|
||||||
|
var safeExtension = regexp.MustCompile(`^\.[a-z0-9]+$`)
|
||||||
|
|
||||||
|
// objectKey 为所有媒体对象添加统一的业务根目录,避免不同业务直接污染存储桶根目录。
|
||||||
|
func objectKey(format string, args ...any) string {
|
||||||
|
return fmt.Sprintf(objectRoot+"/"+format, args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Slug 将业务名称转换为受控英文路径片段;展示名称保留在数据库中。
|
||||||
|
func Slug(value, fallback string) string {
|
||||||
|
value = strings.ToLower(strings.TrimSpace(value))
|
||||||
|
value = unsafeKeyPart.ReplaceAllString(value, "-")
|
||||||
|
value = strings.Trim(value, "-")
|
||||||
|
if value == "" {
|
||||||
|
value = fallback
|
||||||
|
}
|
||||||
|
if len(value) > 48 {
|
||||||
|
value = strings.Trim(value[:48], "-")
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extension 根据媒体类型或安全的原文件扩展名生成对象扩展名。
|
||||||
|
func Extension(filename, contentType string) string {
|
||||||
|
switch strings.ToLower(contentType) {
|
||||||
|
case "image/jpeg":
|
||||||
|
return ".jpg"
|
||||||
|
case "image/png":
|
||||||
|
return ".png"
|
||||||
|
case "image/webp":
|
||||||
|
return ".webp"
|
||||||
|
case "image/gif":
|
||||||
|
return ".gif"
|
||||||
|
case "video/mp4":
|
||||||
|
return ".mp4"
|
||||||
|
case "video/webm":
|
||||||
|
return ".webm"
|
||||||
|
case "audio/mpeg":
|
||||||
|
return ".mp3"
|
||||||
|
case "audio/wav", "audio/x-wav":
|
||||||
|
return ".wav"
|
||||||
|
case "text/vtt":
|
||||||
|
return ".vtt"
|
||||||
|
}
|
||||||
|
ext := strings.ToLower(filepath.Ext(filename))
|
||||||
|
if len(ext) > 1 && len(ext) <= 8 && safeExtension.MatchString(ext) {
|
||||||
|
return ext
|
||||||
|
}
|
||||||
|
return ".bin"
|
||||||
|
}
|
||||||
|
|
||||||
|
// EpisodeSource 返回剧集原视频的对象键。
|
||||||
|
func EpisodeSource(projectID uuid.UUID, episodeNo int, episodeID, assetID uuid.UUID, filename, contentType string) string {
|
||||||
|
return objectKey("video-redraw/projects/%s/episodes/%03d-%s/source/%s-source%s", projectID, episodeNo, episodeID, assetID, Extension(filename, contentType))
|
||||||
|
}
|
||||||
|
|
||||||
|
// EpisodeSubtitle 返回剧集字幕的对象键。
|
||||||
|
func EpisodeSubtitle(projectID uuid.UUID, episodeNo int, episodeID, assetID uuid.UUID, filename, contentType string) string {
|
||||||
|
return objectKey("video-redraw/projects/%s/episodes/%03d-%s/subtitles/%s-subtitle%s", projectID, episodeNo, episodeID, assetID, Extension(filename, contentType))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProjectSource 返回无剧集项目原视频的对象键。
|
||||||
|
func ProjectSource(projectID, assetID uuid.UUID, filename, contentType string) string {
|
||||||
|
return objectKey("video-redraw/projects/%s/source/%s-source%s", projectID, assetID, Extension(filename, contentType))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProjectSubtitle 返回无剧集项目字幕的对象键。
|
||||||
|
func ProjectSubtitle(projectID, assetID uuid.UUID, filename, contentType string) string {
|
||||||
|
return objectKey("video-redraw/projects/%s/subtitles/%s-subtitle%s", projectID, assetID, Extension(filename, contentType))
|
||||||
|
}
|
||||||
|
|
||||||
|
// AssetReference 返回项目资产参考媒体的对象键。
|
||||||
|
func AssetReference(projectID uuid.UUID, assetType string, assetID, mediaID uuid.UUID, filename, contentType string) string {
|
||||||
|
return objectKey("video-redraw/projects/%s/assets/%s/%s/%s-reference%s", projectID, Slug(assetType, "custom"), assetID, mediaID, Extension(filename, contentType))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProductImageReference 返回独立图片生成工具参考图片的稳定对象键。
|
||||||
|
func ProductImageReference(userID, mediaID uuid.UUID, filename, contentType string) string {
|
||||||
|
return objectKey("product-images/users/%s/references/%s%s", userID, mediaID, Extension(filename, contentType))
|
||||||
|
}
|
||||||
|
|
||||||
|
// AssetOutput 返回项目资产生成结果的对象键。
|
||||||
|
func AssetOutput(projectID uuid.UUID, assetType string, assetID, taskID, mediaID uuid.UUID, contentType string) string {
|
||||||
|
return objectKey("video-redraw/projects/%s/assets/%s/%s/outputs/%s-%s-result%s", projectID, Slug(assetType, "custom"), assetID, taskID, mediaID, Extension("", contentTypeFor("image", contentType)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// StoryboardFrame 返回剧集分镜抽帧的对象键。
|
||||||
|
func StoryboardFrame(projectID, episodeID, storyboardID, mediaID uuid.UUID, sequence int, timestampMS int64) string {
|
||||||
|
return objectKey("video-redraw/projects/%s/episodes/%s/storyboards/%04d-%s/frames/%s-%010d.jpg", projectID, episodeID, sequence, storyboardID, mediaID, timestampMS)
|
||||||
|
}
|
||||||
|
|
||||||
|
// StoryboardAnalysis 返回剧集分镜反推原始响应的对象键。
|
||||||
|
func StoryboardAnalysis(projectID, episodeID, storyboardID, taskID, mediaID uuid.UUID, sequence int) string {
|
||||||
|
return objectKey("video-redraw/projects/%s/episodes/%s/storyboards/%04d-%s/analysis/%s-%s-reverse.json", projectID, episodeID, sequence, storyboardID, taskID, mediaID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TaskOutput 返回剧集分镜生成结果的对象键。
|
||||||
|
func TaskOutput(projectID, episodeID uuid.UUID, sequence int, taskID, mediaID uuid.UUID, outputType, contentType string) string {
|
||||||
|
return objectKey("video-redraw/projects/%s/episodes/%s/storyboards/%04d/outputs/%s-%s-result%s", projectID, episodeID, sequence, taskID, mediaID, Extension("", contentTypeFor(outputType, contentType)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProjectStoryboardFrame 返回无剧集项目分镜抽帧的对象键。
|
||||||
|
func ProjectStoryboardFrame(projectID, storyboardID, mediaID uuid.UUID, sequence int, timestampMS int64) string {
|
||||||
|
return objectKey("video-redraw/projects/%s/storyboards/%04d-%s/frames/%s-%010d.jpg", projectID, sequence, storyboardID, mediaID, timestampMS)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProjectStoryboardAnalysis 返回无剧集项目分镜反推原始响应的对象键。
|
||||||
|
func ProjectStoryboardAnalysis(projectID, storyboardID, taskID, mediaID uuid.UUID, sequence int) string {
|
||||||
|
return objectKey("video-redraw/projects/%s/storyboards/%04d-%s/analysis/%s-%s-reverse.json", projectID, sequence, storyboardID, taskID, mediaID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProjectTaskOutput 返回无剧集项目分镜生成结果的对象键。
|
||||||
|
func ProjectTaskOutput(projectID uuid.UUID, sequence int, taskID, mediaID uuid.UUID, outputType, contentType string) string {
|
||||||
|
return objectKey("video-redraw/projects/%s/storyboards/%04d/outputs/%s-%s-result%s", projectID, sequence, taskID, mediaID, Extension("", contentTypeFor(outputType, contentType)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// StyleImage 返回管理端风格图片的统一对象键。
|
||||||
|
func StyleImage(mediaID uuid.UUID, extension string) string {
|
||||||
|
return objectKey("styles/%s/%s%s", time.Now().Format("2006/01"), mediaID, extension)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserAvatar 返回用户头像的统一对象键。
|
||||||
|
func UserAvatar(userID, mediaID uuid.UUID, extension string) string {
|
||||||
|
return objectKey("users/%s/avatar/%s%s", userID, mediaID, extension)
|
||||||
|
}
|
||||||
|
|
||||||
|
// StandaloneTaskOutput 返回独立生成任务的结果对象键。
|
||||||
|
func StandaloneTaskOutput(userID, taskID uuid.UUID, outputType, contentType string) string {
|
||||||
|
return objectKey("users/%s/tasks/%s/output-1%s", userID, taskID, Extension("", contentTypeFor(outputType, contentType)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// contentTypeFor 返回已知内容类型,缺省时根据输出类型选择默认媒体类型。
|
||||||
|
func contentTypeFor(outputType, contentType string) string {
|
||||||
|
if contentType != "" {
|
||||||
|
return contentType
|
||||||
|
}
|
||||||
|
if outputType == "image" {
|
||||||
|
return "image/png"
|
||||||
|
}
|
||||||
|
return "video/mp4"
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package media
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAssetKeysUseControlledSegments(t *testing.T) {
|
||||||
|
projectID, assetID, taskID, mediaID := uuid.New(), uuid.New(), uuid.New(), uuid.New()
|
||||||
|
key := AssetOutput(projectID, "角色 中文", assetID, taskID, mediaID, "image/webp")
|
||||||
|
wantPrefix := "juyou_ran/video-redraw/projects/" + projectID.String() + "/assets/custom/" + assetID.String() + "/outputs/"
|
||||||
|
if !strings.HasPrefix(key, wantPrefix) || !strings.HasSuffix(key, ".webp") {
|
||||||
|
t.Fatalf("unexpected key: %s", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AdminUser struct {
|
||||||
|
ID uuid.UUID `gorm:"type:uuid;default:gen_random_uuid();primaryKey" json:"id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
PasswordHash string `json:"-"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
LastLoginAt *time.Time `json:"last_login_at"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
DeletedAt gorm.DeletedAt `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AdminRefreshToken struct {
|
||||||
|
ID uuid.UUID `gorm:"type:uuid;default:gen_random_uuid();primaryKey"`
|
||||||
|
AdminID uuid.UUID `gorm:"type:uuid"`
|
||||||
|
TokenHash string
|
||||||
|
ExpiresAt time.Time
|
||||||
|
RevokedAt *time.Time
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type AdminAuditLog struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
AdminID *uuid.UUID `gorm:"type:uuid" json:"admin_id"`
|
||||||
|
AdminUsername string `json:"admin_username"`
|
||||||
|
Action string `json:"action"`
|
||||||
|
ResourceType string `json:"resource_type"`
|
||||||
|
ResourceID string `json:"resource_id"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
Detail string `gorm:"type:jsonb" json:"detail"`
|
||||||
|
IPAddress string `gorm:"type:inet" json:"ip_address"`
|
||||||
|
TraceID string `json:"trace_id"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (AdminUser) TableName() string { return "admin_users" }
|
||||||
|
func (AdminRefreshToken) TableName() string { return "admin_refresh_tokens" }
|
||||||
|
func (AdminAuditLog) TableName() string { return "admin_audit_logs" }
|
||||||
@@ -0,0 +1,299 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type MediaAsset struct {
|
||||||
|
ID uuid.UUID `gorm:"type:uuid;default:gen_random_uuid();primaryKey" json:"id"`
|
||||||
|
OwnerUserID *uuid.UUID `gorm:"type:uuid" json:"owner_user_id"`
|
||||||
|
StorageProvider string `json:"storage_provider"`
|
||||||
|
ObjectKey string `json:"object_key"`
|
||||||
|
PublicURL string `json:"public_url"`
|
||||||
|
OriginalName string `json:"original_name"`
|
||||||
|
DisplayName string `json:"display_name"`
|
||||||
|
MimeType string `json:"mime_type"`
|
||||||
|
SizeBytes int64 `json:"size_bytes"`
|
||||||
|
SHA256 string `json:"sha256"`
|
||||||
|
Width *int `json:"width"`
|
||||||
|
Height *int `json:"height"`
|
||||||
|
DurationMS *int64 `json:"duration_ms"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
DeletedAt *time.Time `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreativeProject struct {
|
||||||
|
ID uuid.UUID `gorm:"type:uuid;default:gen_random_uuid();primaryKey" json:"id"`
|
||||||
|
UserID uuid.UUID `gorm:"type:uuid" json:"user_id"`
|
||||||
|
ProjectType string `json:"project_type"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
CoverAssetID *uuid.UUID `gorm:"type:uuid" json:"cover_asset_id"`
|
||||||
|
StyleID uuid.UUID `gorm:"type:uuid" json:"style_id"`
|
||||||
|
EraType string `json:"era_type"`
|
||||||
|
CustomEra *string `json:"custom_era"`
|
||||||
|
AspectRatio string `json:"aspect_ratio"`
|
||||||
|
Localization *string `json:"localization"`
|
||||||
|
SourceVideoAssetID *uuid.UUID `gorm:"type:uuid" json:"source_video_asset_id"`
|
||||||
|
SubtitleAssetID *uuid.UUID `gorm:"type:uuid" json:"subtitle_asset_id"`
|
||||||
|
AudioSource string `json:"audio_source"`
|
||||||
|
SourceLanguage *string `json:"source_language"`
|
||||||
|
RedrawStatus string `json:"redraw_status"`
|
||||||
|
AnalysisMessage string `json:"analysis_message"`
|
||||||
|
RedrawScript string `json:"redraw_script"`
|
||||||
|
ShortDramaType *string `json:"short_drama_type"`
|
||||||
|
PlayCount *string `json:"play_count"`
|
||||||
|
AudienceProfile *string `json:"audience_profile"`
|
||||||
|
Producer *string `json:"producer"`
|
||||||
|
CastMembers json.RawMessage `gorm:"type:jsonb;not null;default:'[]'" json:"cast_members"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
DeletedAt *time.Time `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (project *CreativeProject) BeforeCreate(_ *gorm.DB) error {
|
||||||
|
if len(project.CastMembers) == 0 {
|
||||||
|
project.CastMembers = json.RawMessage("[]")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProjectModelConfig struct {
|
||||||
|
ID uuid.UUID `gorm:"type:uuid;default:gen_random_uuid();primaryKey" json:"id"`
|
||||||
|
ProjectID uuid.UUID `gorm:"type:uuid" json:"project_id"`
|
||||||
|
Purpose string `json:"purpose"`
|
||||||
|
ModelType string `json:"model_type"`
|
||||||
|
ModelID uuid.UUID `gorm:"type:uuid" json:"model_id"`
|
||||||
|
PromptID *uuid.UUID `gorm:"type:uuid" json:"prompt_id"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserModelConfig struct {
|
||||||
|
ID uuid.UUID `gorm:"type:uuid;default:gen_random_uuid();primaryKey" json:"id"`
|
||||||
|
UserID uuid.UUID `gorm:"type:uuid" json:"user_id"`
|
||||||
|
Purpose string `json:"purpose"`
|
||||||
|
ProjectType string `json:"project_type"`
|
||||||
|
ModelType string `json:"model_type"`
|
||||||
|
ModelID uuid.UUID `gorm:"type:uuid" json:"model_id"`
|
||||||
|
PromptID *uuid.UUID `gorm:"type:uuid" json:"prompt_id"`
|
||||||
|
Settings json.RawMessage `gorm:"type:jsonb" json:"settings"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProjectEpisode struct {
|
||||||
|
ID uuid.UUID `gorm:"type:uuid;default:gen_random_uuid();primaryKey" json:"id"`
|
||||||
|
ProjectID uuid.UUID `gorm:"type:uuid" json:"project_id"`
|
||||||
|
EpisodeNo int `json:"episode_no"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
CoverAssetID *uuid.UUID `gorm:"type:uuid" json:"cover_asset_id"`
|
||||||
|
SourceVideoAssetID *uuid.UUID `gorm:"type:uuid" json:"source_video_asset_id"`
|
||||||
|
SubtitleAssetID *uuid.UUID `gorm:"type:uuid" json:"subtitle_asset_id"`
|
||||||
|
AudioSource string `json:"audio_source"`
|
||||||
|
SourceLanguage *string `json:"source_language"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
AnalysisMessage string `json:"analysis_message"`
|
||||||
|
RedrawScript string `json:"redraw_script"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
DeletedAt *time.Time `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProjectAsset struct {
|
||||||
|
ID uuid.UUID `gorm:"type:uuid;default:gen_random_uuid();primaryKey" json:"id"`
|
||||||
|
ProjectID uuid.UUID `gorm:"type:uuid" json:"project_id"`
|
||||||
|
SourceEpisodeID *uuid.UUID `gorm:"type:uuid" json:"source_episode_id"`
|
||||||
|
AssetType string `json:"asset_type"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
ImagePrompt string `json:"image_prompt"`
|
||||||
|
Appearances json.RawMessage `gorm:"type:jsonb" json:"appearances"`
|
||||||
|
ImageAssetID *uuid.UUID `gorm:"type:uuid" json:"image_asset_id"`
|
||||||
|
AudioAssetID *uuid.UUID `gorm:"type:uuid" json:"audio_asset_id"`
|
||||||
|
UserEdited bool `json:"user_edited"`
|
||||||
|
Aliases json.RawMessage `gorm:"type:jsonb" json:"aliases"`
|
||||||
|
Attributes json.RawMessage `gorm:"type:jsonb" json:"attributes"`
|
||||||
|
AIDescription string `gorm:"column:ai_description" json:"ai_description"`
|
||||||
|
SourceParseTaskID *uuid.UUID `gorm:"type:uuid" json:"source_parse_task_id"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
DeletedAt *time.Time `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (asset *ProjectAsset) BeforeCreate(_ *gorm.DB) error {
|
||||||
|
if len(asset.Appearances) == 0 {
|
||||||
|
asset.Appearances = json.RawMessage("[]")
|
||||||
|
}
|
||||||
|
if len(asset.Aliases) == 0 {
|
||||||
|
asset.Aliases = json.RawMessage("[]")
|
||||||
|
}
|
||||||
|
if len(asset.Attributes) == 0 {
|
||||||
|
asset.Attributes = json.RawMessage("{}")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type EpisodeStoryboard struct {
|
||||||
|
ID uuid.UUID `gorm:"type:uuid;default:gen_random_uuid();primaryKey" json:"id"`
|
||||||
|
EpisodeID *uuid.UUID `gorm:"type:uuid" json:"episode_id"`
|
||||||
|
ProjectID *uuid.UUID `gorm:"type:uuid" json:"project_id"`
|
||||||
|
SequenceNo int `json:"sequence_no"`
|
||||||
|
StableKey string `json:"stable_key"`
|
||||||
|
StartMS int64 `json:"start_ms"`
|
||||||
|
EndMS int64 `json:"end_ms"`
|
||||||
|
DurationSeconds int `json:"duration_seconds"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
ThumbnailAssetID *uuid.UUID `gorm:"type:uuid" json:"thumbnail_asset_id"`
|
||||||
|
PromptContent string `json:"prompt_content"`
|
||||||
|
ImagePrompt string `json:"image_prompt"`
|
||||||
|
ScriptContent string `json:"script_content"`
|
||||||
|
SourceExcerpt string `json:"source_excerpt"`
|
||||||
|
Dialogue json.RawMessage `gorm:"type:jsonb" json:"dialogue"`
|
||||||
|
AssetRefs json.RawMessage `gorm:"type:jsonb" json:"asset_refs"`
|
||||||
|
Locked bool `json:"locked"`
|
||||||
|
ActiveOutputID *uuid.UUID `gorm:"type:uuid" json:"active_output_id"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
SourceParseTaskID *uuid.UUID `gorm:"type:uuid" json:"source_parse_task_id"`
|
||||||
|
UserEdited bool `json:"user_edited"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
DeletedAt *time.Time `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type EpisodeSource struct {
|
||||||
|
ID uuid.UUID `gorm:"type:uuid;default:gen_random_uuid();primaryKey" json:"id"`
|
||||||
|
EpisodeID uuid.UUID `gorm:"type:uuid;uniqueIndex" json:"episode_id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
RawContent string `json:"raw_content"`
|
||||||
|
CharCount int `json:"char_count"`
|
||||||
|
SourceType string `json:"source_type"`
|
||||||
|
SourceFilename string `json:"source_filename"`
|
||||||
|
ContentSHA256 string `gorm:"column:content_sha256" json:"content_sha256"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DramaImportSession struct {
|
||||||
|
ID uuid.UUID `gorm:"type:uuid;default:gen_random_uuid();primaryKey" json:"id"`
|
||||||
|
UserID uuid.UUID `gorm:"type:uuid" json:"user_id"`
|
||||||
|
ProjectID uuid.UUID `gorm:"type:uuid" json:"project_id"`
|
||||||
|
SourceType string `json:"source_type"`
|
||||||
|
SourceFilename string `json:"source_filename"`
|
||||||
|
RawContent string `json:"-"`
|
||||||
|
ContentSHA256 string `gorm:"column:content_sha256" json:"content_sha256"`
|
||||||
|
PreviewData json.RawMessage `gorm:"type:jsonb" json:"preview_data"`
|
||||||
|
ExpiresAt time.Time `json:"expires_at"`
|
||||||
|
ConsumedAt *time.Time `json:"consumed_at"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DramaParseBatch struct {
|
||||||
|
ID uuid.UUID `gorm:"type:uuid;default:gen_random_uuid();primaryKey" json:"id"`
|
||||||
|
ProjectID uuid.UUID `gorm:"type:uuid" json:"project_id"`
|
||||||
|
UserID uuid.UUID `gorm:"type:uuid" json:"user_id"`
|
||||||
|
Mode string `json:"mode"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
TotalCount int `json:"total_count"`
|
||||||
|
CompletedCount int `json:"completed_count"`
|
||||||
|
FailedCount int `json:"failed_count"`
|
||||||
|
CurrentEpisodeID *uuid.UUID `gorm:"type:uuid" json:"current_episode_id"`
|
||||||
|
CancelRequestedAt *time.Time `json:"cancel_requested_at"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
FinishedAt *time.Time `json:"finished_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DramaParseTask struct {
|
||||||
|
ID uuid.UUID `gorm:"type:uuid;default:gen_random_uuid();primaryKey" json:"id"`
|
||||||
|
RequestID string `json:"request_id"`
|
||||||
|
BatchID *uuid.UUID `gorm:"type:uuid" json:"batch_id"`
|
||||||
|
UserID uuid.UUID `gorm:"type:uuid" json:"user_id"`
|
||||||
|
ProjectID uuid.UUID `gorm:"type:uuid" json:"project_id"`
|
||||||
|
EpisodeID uuid.UUID `gorm:"type:uuid" json:"episode_id"`
|
||||||
|
ChannelID uuid.UUID `gorm:"type:uuid" json:"channel_id"`
|
||||||
|
ModelID uuid.UUID `gorm:"type:uuid" json:"model_id"`
|
||||||
|
SourceSHA256 string `gorm:"column:source_sha256" json:"source_sha256"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
AttemptCount int `json:"attempt_count"`
|
||||||
|
ModelSnapshot json.RawMessage `gorm:"type:jsonb" json:"model_snapshot"`
|
||||||
|
PromptSnapshot string `json:"prompt_snapshot"`
|
||||||
|
ContextSnapshot json.RawMessage `gorm:"type:jsonb" json:"context_snapshot"`
|
||||||
|
NormalizedResult json.RawMessage `gorm:"type:jsonb" json:"normalized_result"`
|
||||||
|
InputTokens *int64 `json:"input_tokens"`
|
||||||
|
OutputTokens *int64 `json:"output_tokens"`
|
||||||
|
TotalTokens *int64 `json:"total_tokens"`
|
||||||
|
TokenCountSource string `json:"token_count_source"`
|
||||||
|
UsageRaw json.RawMessage `gorm:"type:jsonb" json:"usage_raw"`
|
||||||
|
BillingSnapshot json.RawMessage `gorm:"type:jsonb;not null;default:'{}'" json:"billing_snapshot"`
|
||||||
|
EstimatedPoints string `gorm:"column:estimated_points" json:"estimated_points"`
|
||||||
|
PrepaidPoints string `gorm:"column:prepaid_points" json:"prepaid_points"`
|
||||||
|
ActualPoints *string `gorm:"column:actual_points" json:"actual_points"`
|
||||||
|
CostRefunded bool `json:"cost_refunded"`
|
||||||
|
RequestCharCount int64 `json:"request_char_count"`
|
||||||
|
ResponseCharCount int64 `json:"response_char_count"`
|
||||||
|
LeaseOwner string `json:"lease_owner"`
|
||||||
|
LeaseUntil *time.Time `json:"lease_until"`
|
||||||
|
CancelRequestedAt *time.Time `json:"cancel_requested_at"`
|
||||||
|
ErrorCode string `json:"error_code"`
|
||||||
|
ErrorMessage string `json:"error_message"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
StartedAt *time.Time `json:"started_at"`
|
||||||
|
FinishedAt *time.Time `json:"finished_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GenerationTask struct {
|
||||||
|
ID uuid.UUID `gorm:"type:uuid;default:gen_random_uuid();primaryKey" json:"id"`
|
||||||
|
RequestID string `json:"request_id"`
|
||||||
|
UserID uuid.UUID `gorm:"type:uuid" json:"user_id"`
|
||||||
|
ChannelID *uuid.UUID `gorm:"type:uuid" json:"channel_id"`
|
||||||
|
ModelID *uuid.UUID `gorm:"type:uuid" json:"model_id"`
|
||||||
|
ProjectID *uuid.UUID `gorm:"type:uuid" json:"project_id"`
|
||||||
|
EpisodeID *uuid.UUID `gorm:"type:uuid" json:"episode_id"`
|
||||||
|
StoryboardID *uuid.UUID `gorm:"type:uuid" json:"storyboard_id"`
|
||||||
|
ScriptAnalysisID *uuid.UUID `gorm:"type:uuid" json:"script_analysis_id"`
|
||||||
|
TaskType string `json:"task_type"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
UpstreamTaskID string `json:"upstream_task_id"`
|
||||||
|
InputData json.RawMessage `gorm:"type:jsonb" json:"input_data"`
|
||||||
|
BillingSnapshot json.RawMessage `gorm:"type:jsonb;not null;default:'{}'" json:"billing_snapshot"`
|
||||||
|
InputTokens *int64 `json:"input_tokens"`
|
||||||
|
OutputTokens *int64 `json:"output_tokens"`
|
||||||
|
TotalTokens *int64 `json:"total_tokens"`
|
||||||
|
TokenCountSource *string `json:"token_count_source"`
|
||||||
|
UsageRaw json.RawMessage `gorm:"type:jsonb" json:"usage_raw"`
|
||||||
|
ResultData json.RawMessage `gorm:"type:jsonb;not null;default:'{}'" json:"result_data"`
|
||||||
|
PromptSnapshot string `json:"prompt_snapshot"`
|
||||||
|
EstimatedPoints string `gorm:"column:estimated_points" json:"estimated_points"`
|
||||||
|
ActualPoints *string `gorm:"column:actual_points" json:"actual_points"`
|
||||||
|
PrepaidPoints string `gorm:"column:prepaid_points" json:"prepaid_points"`
|
||||||
|
CostRefunded bool `json:"cost_refunded"`
|
||||||
|
SlotReserved bool `json:"slot_reserved"`
|
||||||
|
SlotReservedAt *time.Time `json:"slot_reserved_at"`
|
||||||
|
SlotReleasedAt *time.Time `json:"slot_released_at"`
|
||||||
|
SubmitAttempts int `json:"submit_attempts"`
|
||||||
|
PollAttempts int `json:"poll_attempts"`
|
||||||
|
DownloadAttempts int `json:"download_attempts"`
|
||||||
|
NextSubmitAt *time.Time `json:"next_submit_at"`
|
||||||
|
NextPollAt *time.Time `json:"next_poll_at"`
|
||||||
|
LeaseOwner string `json:"lease_owner"`
|
||||||
|
LeaseUntil *time.Time `json:"lease_until"`
|
||||||
|
UpstreamResultURL string `json:"-"`
|
||||||
|
ErrorCode string `json:"error_code"`
|
||||||
|
ErrorMessage string `json:"error_message"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
SubmittedAt *time.Time `json:"submitted_at"`
|
||||||
|
FinishedAt *time.Time `json:"finished_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GenerationOutput struct {
|
||||||
|
ID uuid.UUID `gorm:"type:uuid;default:gen_random_uuid();primaryKey" json:"id"`
|
||||||
|
TaskID uuid.UUID `gorm:"type:uuid" json:"task_id"`
|
||||||
|
MediaAssetID uuid.UUID `gorm:"type:uuid" json:"media_asset_id"`
|
||||||
|
OutputType string `json:"output_type"`
|
||||||
|
SequenceNo int `json:"sequence_no"`
|
||||||
|
Metadata json.RawMessage `gorm:"type:jsonb" json:"metadata"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestProjectAssetBeforeCreateInitializesJSONFields(t *testing.T) {
|
||||||
|
asset := &ProjectAsset{}
|
||||||
|
if err := asset.BeforeCreate(nil); err != nil {
|
||||||
|
t.Fatalf("BeforeCreate returned error: %v", err)
|
||||||
|
}
|
||||||
|
if string(asset.Appearances) != "[]" {
|
||||||
|
t.Fatalf("unexpected appearances default: %s", asset.Appearances)
|
||||||
|
}
|
||||||
|
if string(asset.Aliases) != "[]" {
|
||||||
|
t.Fatalf("unexpected aliases default: %s", asset.Aliases)
|
||||||
|
}
|
||||||
|
if string(asset.Attributes) != "{}" {
|
||||||
|
t.Fatalf("unexpected attributes default: %s", asset.Attributes)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ScriptAnalysis struct {
|
||||||
|
ID uuid.UUID `gorm:"type:uuid;default:gen_random_uuid();primaryKey" json:"id"`
|
||||||
|
UserID uuid.UUID `gorm:"type:uuid;index" json:"user_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
SourceContent string `json:"source_content"`
|
||||||
|
ResultContent string `json:"result_content"`
|
||||||
|
AnalysisResult json.RawMessage `gorm:"type:jsonb;not null;default:'{}'" json:"analysis_result"`
|
||||||
|
AnalysisStatus string `json:"analysis_status"`
|
||||||
|
AnalysisMessage string `json:"analysis_message"`
|
||||||
|
Characters []ScriptAnalysisCharacter `gorm:"foreignKey:ScriptAnalysisID" json:"characters,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
DeletedAt *time.Time `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ScriptAnalysisCharacter struct {
|
||||||
|
ID uuid.UUID `gorm:"type:uuid;default:gen_random_uuid();primaryKey" json:"id"`
|
||||||
|
ScriptAnalysisID uuid.UUID `gorm:"type:uuid;index" json:"script_analysis_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Faction string `json:"faction"`
|
||||||
|
Biography string `json:"biography"`
|
||||||
|
Motivation string `json:"motivation"`
|
||||||
|
Relationship string `json:"relationship"`
|
||||||
|
SortOrder int `json:"sort_order"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type WebUser struct {
|
||||||
|
ID uuid.UUID `gorm:"type:uuid;default:gen_random_uuid();primaryKey" json:"id"`
|
||||||
|
UID string `json:"uid"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Account string `json:"account"`
|
||||||
|
AvatarURL string `json:"avatar_url"`
|
||||||
|
AvatarKey string `json:"-"`
|
||||||
|
PasswordHash string `json:"-"`
|
||||||
|
PointBalance string `gorm:"column:point_balance" json:"point_balance"`
|
||||||
|
DailyLimit string `json:"daily_limit"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
SessionVersion int64 `json:"-"`
|
||||||
|
LastOnlineAt *time.Time `json:"last_online_at"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
DeletedAt gorm.DeletedAt `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type WebRefreshToken struct {
|
||||||
|
ID uuid.UUID `gorm:"type:uuid;default:gen_random_uuid();primaryKey"`
|
||||||
|
UserID uuid.UUID `gorm:"type:uuid"`
|
||||||
|
TokenHash string
|
||||||
|
SessionVersion int64
|
||||||
|
ExpiresAt time.Time
|
||||||
|
RevokedAt *time.Time
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (WebUser) TableName() string { return "web_users" }
|
||||||
|
func (WebRefreshToken) TableName() string { return "web_refresh_tokens" }
|
||||||
@@ -0,0 +1,306 @@
|
|||||||
|
// 管理后台业务模块,封装模型配置、兑换码、用户积分和操作审计的持久化事务。
|
||||||
|
package admin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"juhe-factory/api/internal/billing"
|
||||||
|
"juhe-factory/api/internal/model"
|
||||||
|
legacyservice "juhe-factory/api/internal/service"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Service 提供管理后台模块的公开业务接口,并隐藏底层数据库连接。
|
||||||
|
type Service struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// ModelPriceInput 表示模型的单项计费配置。
|
||||||
|
type ModelPriceInput struct {
|
||||||
|
PriceKey string `json:"price_key"`
|
||||||
|
Unit string `json:"unit"`
|
||||||
|
Price any `json:"price"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ModelInput 表示管理端新增或修改模型时提交的完整配置。
|
||||||
|
type ModelInput struct {
|
||||||
|
ChannelID string `json:"channel_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
ModelType string `json:"model_type"`
|
||||||
|
Multimodal *bool `json:"multimodal"`
|
||||||
|
TextBillingMode string `json:"text_billing_mode"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
Prices []ModelPriceInput `json:"prices"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewService 创建管理后台业务服务。
|
||||||
|
func NewService(db *gorm.DB) *Service {
|
||||||
|
return &Service{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListModels 按管理端筛选条件分页查询模型及价格配置。
|
||||||
|
func (s *Service) ListModels(keyword, modelType, channelID string, page, size int) (legacyservice.Page, error) {
|
||||||
|
query := s.db.Table("models m").Joins("JOIN channels c ON c.id=m.channel_id").Where("m.deleted_at IS NULL")
|
||||||
|
if keyword = strings.TrimSpace(keyword); keyword != "" {
|
||||||
|
query = query.Where("m.name::text ILIKE ?", "%"+keyword+"%")
|
||||||
|
}
|
||||||
|
if modelType = strings.TrimSpace(modelType); modelType != "" {
|
||||||
|
query = query.Where("m.model_type=?", modelType)
|
||||||
|
}
|
||||||
|
if channelID = strings.TrimSpace(channelID); channelID != "" {
|
||||||
|
query = query.Where("m.channel_id=?", channelID)
|
||||||
|
}
|
||||||
|
var total int64
|
||||||
|
if err := query.Count(&total).Error; err != nil {
|
||||||
|
return legacyservice.Page{}, err
|
||||||
|
}
|
||||||
|
items := make([]map[string]any, 0)
|
||||||
|
err := query.Select(`m.id,m.channel_id,c.name AS channel_name,m.name,m.model_type,m.multimodal,m.text_billing_mode,m.enabled,c.enabled AS channel_enabled,
|
||||||
|
coalesce((SELECT jsonb_agg(jsonb_build_object('price_key',p.price_key,'unit',p.unit,'price',p.price) ORDER BY CASE p.price_key WHEN 'default' THEN 0 WHEN '1K' THEN 1 WHEN '2K' THEN 2 WHEN '4K' THEN 3 WHEN '480p' THEN 4 WHEN '720p' THEN 5 WHEN '1080p' THEN 6 ELSE 99 END) FROM model_prices p WHERE p.model_id=m.id),'[]'::jsonb) AS prices`).
|
||||||
|
Order("m.created_at DESC").Offset((page - 1) * size).Limit(size).Find(&items).Error
|
||||||
|
if err != nil {
|
||||||
|
return legacyservice.Page{}, err
|
||||||
|
}
|
||||||
|
for i := range items {
|
||||||
|
items[i]["prices"] = decodePriceList(items[i]["prices"])
|
||||||
|
}
|
||||||
|
return legacyservice.Page{Items: items, Total: total, Page: page, PageSize: size}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveModel 校验模型计费配置,并在同一事务中保存模型及全部价格项。
|
||||||
|
func (s *Service) SaveModel(modelID string, input ModelInput) (string, bool, ModelInput, error) {
|
||||||
|
if err := normalizeModelPrices(&input); err != nil {
|
||||||
|
return "", false, input, err
|
||||||
|
}
|
||||||
|
created := modelID == ""
|
||||||
|
if created {
|
||||||
|
modelID = uuid.NewString()
|
||||||
|
}
|
||||||
|
err := s.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
values := map[string]any{"channel_id": input.ChannelID, "name": input.Name, "model_type": input.ModelType, "enabled": input.Enabled, "text_billing_mode": nil}
|
||||||
|
switch input.ModelType {
|
||||||
|
case "text":
|
||||||
|
values["multimodal"] = input.Multimodal
|
||||||
|
values["text_billing_mode"] = input.TextBillingMode
|
||||||
|
default:
|
||||||
|
values["multimodal"] = nil
|
||||||
|
}
|
||||||
|
if created {
|
||||||
|
values["id"] = modelID
|
||||||
|
if err := tx.Table("models").Create(values).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else if err := tx.Table("models").Where("id=? AND deleted_at IS NULL", modelID).Updates(values).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tx.Exec("DELETE FROM model_prices WHERE model_id=?", modelID).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, price := range input.Prices {
|
||||||
|
if err := tx.Exec("INSERT INTO model_prices(id,model_id,price_key,unit,price) VALUES(?,?,?,?,?)", uuid.NewString(), modelID, price.PriceKey, price.Unit, price.Price).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return modelID, created, input, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListRedemptions 按关键字和状态分页查询兑换码记录。
|
||||||
|
func (s *Service) ListRedemptions(keyword, status string, page, size int) (legacyservice.Page, error) {
|
||||||
|
query := s.db.Table("redemption_codes r").Joins("JOIN redemption_batches b ON b.id=r.batch_id").Joins("LEFT JOIN web_users u ON u.id=r.redeemed_by")
|
||||||
|
if keyword = strings.TrimSpace(keyword); keyword != "" {
|
||||||
|
query = query.Where("r.code ILIKE ? OR r.code_mask ILIKE ? OR b.name::text ILIKE ? OR u.uid=?", "%"+keyword+"%", "%"+keyword+"%", "%"+keyword+"%", keyword)
|
||||||
|
}
|
||||||
|
if status = strings.TrimSpace(status); status != "" {
|
||||||
|
query = query.Where("CASE WHEN r.status='unused' AND r.expires_at<CURRENT_TIMESTAMP THEN 'expired' ELSE r.status END=?", status)
|
||||||
|
}
|
||||||
|
var total int64
|
||||||
|
if err := query.Count(&total).Error; err != nil {
|
||||||
|
return legacyservice.Page{}, err
|
||||||
|
}
|
||||||
|
items := make([]map[string]any, 0)
|
||||||
|
err := query.Select("r.id,r.code,b.name AS batch_name,r.points,CASE WHEN r.status='unused' AND r.expires_at<CURRENT_TIMESTAMP THEN 'expired' ELSE r.status END AS status,u.uid AS redeemed_uid,r.redeemed_at,r.expires_at,a.username AS created_by,r.created_at").
|
||||||
|
Joins("JOIN admin_users a ON a.id=b.created_by").Order("r.created_at DESC").Offset((page - 1) * size).Limit(size).Find(&items).Error
|
||||||
|
if err != nil {
|
||||||
|
return legacyservice.Page{}, err
|
||||||
|
}
|
||||||
|
return legacyservice.Page{Items: items, Total: total, Page: page, PageSize: size}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAuditLogs 按关键字分页查询管理端操作审计记录,仅提供只读访问。
|
||||||
|
func (s *Service) ListAuditLogs(keyword string, page, size int) (legacyservice.Page, error) {
|
||||||
|
query := s.db.Table("admin_audit_logs")
|
||||||
|
if keyword = strings.TrimSpace(keyword); keyword != "" {
|
||||||
|
pattern := "%" + keyword + "%"
|
||||||
|
query = query.Where(`admin_username ILIKE ? OR action::text ILIKE ? OR resource_type::text ILIKE ?
|
||||||
|
OR coalesce(resource_id,'') ILIKE ? OR coalesce(reason,'') ILIKE ? OR coalesce(trace_id,'') ILIKE ?`,
|
||||||
|
pattern, pattern, pattern, pattern, pattern, pattern)
|
||||||
|
if resourceTypes := matchingAuditResourceTypes(keyword); len(resourceTypes) > 0 {
|
||||||
|
query = query.Or("resource_type IN ?", resourceTypes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var total int64
|
||||||
|
if err := query.Count(&total).Error; err != nil {
|
||||||
|
return legacyservice.Page{}, err
|
||||||
|
}
|
||||||
|
items := make([]map[string]any, 0)
|
||||||
|
err := query.Select(`id,admin_id,admin_username,action,resource_type,coalesce(resource_id,'') AS resource_id,
|
||||||
|
coalesce(reason,'') AS reason,coalesce(trace_id,'') AS trace_id,created_at`).
|
||||||
|
Order("created_at DESC,id DESC").Offset((page - 1) * size).Limit(size).Find(&items).Error
|
||||||
|
return legacyservice.Page{Items: items, Total: total, Page: page, PageSize: size}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// matchingAuditResourceTypes 将中文资源名称转换为数据库内部代码,支持管理端直接使用中文搜索。
|
||||||
|
func matchingAuditResourceTypes(keyword string) []string {
|
||||||
|
keyword = strings.TrimSpace(keyword)
|
||||||
|
if keyword == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
labels := map[string]string{
|
||||||
|
"users": "用户",
|
||||||
|
"redemption-codes": "兑换码",
|
||||||
|
"styles": "风格",
|
||||||
|
"prompts": "提示词",
|
||||||
|
"channels": "渠道",
|
||||||
|
"models": "模型",
|
||||||
|
"admin-auth": "管理员账号",
|
||||||
|
}
|
||||||
|
result := make([]string, 0, len(labels))
|
||||||
|
for resourceType, label := range labels {
|
||||||
|
if strings.Contains(label, keyword) {
|
||||||
|
result = append(result, resourceType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateRedemptionBatch 校验生成限制,并在事务中创建兑换批次和明文返回值。
|
||||||
|
func (s *Service) CreateRedemptionBatch(adminID uuid.UUID, name string, pointsValue any, quantity int, expiresAt time.Time) (string, []string, string, error) {
|
||||||
|
points, err := ValidateRedemptionBatchLimits(pointsValue, quantity)
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, "", err
|
||||||
|
}
|
||||||
|
batchID := uuid.NewString()
|
||||||
|
codes := make([]string, 0, quantity)
|
||||||
|
err = s.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := tx.Exec("INSERT INTO redemption_batches(id,name,points,quantity,expires_at,created_by) VALUES(?,?,?,?,?,?)", batchID, name, points, quantity, expiresAt, adminID).Error; err != nil {
|
||||||
|
// 命中 redemption_batches_name_key 唯一约束时返回业务友好提示,避免把底层 SQL 错误透传给前端
|
||||||
|
if strings.Contains(err.Error(), "23505") {
|
||||||
|
return errors.New("批次名称已存在,请更换后重试")
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for i := 0; i < quantity; i++ {
|
||||||
|
plain, hash, mask, err := legacyservice.GenerateRedemptionCode()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tx.Exec("INSERT INTO redemption_codes(id,batch_id,code_hash,code_mask,code,points,expires_at) VALUES(?,?,?,?,?,?,?)", uuid.NewString(), batchID, hash, mask, plain, points, expiresAt).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
codes = append(codes, plain)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return batchID, codes, points, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GrantUserPoints 通过永久积分批次为指定用户增加积分,并使用请求号保证重复提交不会重复入账。
|
||||||
|
func (s *Service) GrantUserPoints(userID uuid.UUID, requestID string, pointsValue any) (string, bool, error) {
|
||||||
|
requestID = strings.TrimSpace(requestID)
|
||||||
|
if _, err := uuid.Parse(requestID); err != nil {
|
||||||
|
return "", false, errors.New("积分发放请求号无效")
|
||||||
|
}
|
||||||
|
points := strings.TrimSpace(fmt.Sprint(pointsValue))
|
||||||
|
sourceID := userID.String() + ":" + requestID
|
||||||
|
var balance string
|
||||||
|
var credited bool
|
||||||
|
err := s.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
var err error
|
||||||
|
balance, credited, err = billing.CreditPoints(tx, userID, "admin_grant", sourceID, points, "后台发放积分")
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
return balance, credited, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteAudit 记录管理端操作审计信息,调用方无需接触数据库连接。
|
||||||
|
func (s *Service) WriteAudit(adminUser *model.AdminUser, action, resource, resourceID, reason, ip, traceID string, detail any) error {
|
||||||
|
return legacyservice.WriteAudit(s.db, adminUser, action, resource, resourceID, reason, ip, traceID, detail)
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizeModelPrices 校验并标准化文本模型的计费键、单位和金额。
|
||||||
|
func normalizeModelPrices(input *ModelInput) error {
|
||||||
|
if input.ModelType != "text" {
|
||||||
|
input.TextBillingMode = ""
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
expected := map[string]string{}
|
||||||
|
switch input.TextBillingMode {
|
||||||
|
case "per_request":
|
||||||
|
expected["default"] = "次"
|
||||||
|
case "per_token":
|
||||||
|
expected["input"] = "M Token"
|
||||||
|
expected["output"] = "M Token"
|
||||||
|
default:
|
||||||
|
return errors.New("文本模型计费模式无效")
|
||||||
|
}
|
||||||
|
if len(input.Prices) != len(expected) {
|
||||||
|
return errors.New("文本模型价格配置不完整")
|
||||||
|
}
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for index := range input.Prices {
|
||||||
|
key := strings.ToLower(strings.TrimSpace(input.Prices[index].PriceKey))
|
||||||
|
unit, ok := expected[key]
|
||||||
|
value, err := strconv.ParseFloat(fmt.Sprint(input.Prices[index].Price), 64)
|
||||||
|
if !ok || seen[key] || err != nil || value < 0 || math.IsNaN(value) || math.IsInf(value, 0) {
|
||||||
|
return errors.New("文本模型价格配置无效")
|
||||||
|
}
|
||||||
|
seen[key] = true
|
||||||
|
input.Prices[index].PriceKey = key
|
||||||
|
input.Prices[index].Unit = unit
|
||||||
|
input.Prices[index].Price = math.Round(value*100) / 100
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// decodePriceList 将 PostgreSQL JSON 聚合结果转换为稳定的价格数组。
|
||||||
|
func decodePriceList(value any) []map[string]any {
|
||||||
|
var raw []byte
|
||||||
|
switch data := value.(type) {
|
||||||
|
case []byte:
|
||||||
|
raw = data
|
||||||
|
case json.RawMessage:
|
||||||
|
raw = data
|
||||||
|
case string:
|
||||||
|
raw = []byte(data)
|
||||||
|
default:
|
||||||
|
return []map[string]any{}
|
||||||
|
}
|
||||||
|
prices := make([]map[string]any, 0)
|
||||||
|
if err := json.Unmarshal(raw, &prices); err != nil {
|
||||||
|
return []map[string]any{}
|
||||||
|
}
|
||||||
|
return prices
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateRedemptionBatchLimits 校验单码积分和单批生成数量的安全范围。
|
||||||
|
func ValidateRedemptionBatchLimits(value any, quantity int) (string, error) {
|
||||||
|
points := strings.TrimSpace(fmt.Sprint(value))
|
||||||
|
parsed, err := strconv.ParseFloat(points, 64)
|
||||||
|
if err != nil || math.IsNaN(parsed) || math.IsInf(parsed, 0) || parsed < 1 || parsed > 100 {
|
||||||
|
return "", errors.New("单码积分数量必须为 1 至 100")
|
||||||
|
}
|
||||||
|
if quantity < 1 || quantity > 10 {
|
||||||
|
return "", errors.New("生成数量必须为 1 至 10")
|
||||||
|
}
|
||||||
|
return points, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
// 管理后台模块单元测试,验证模型计费、兑换码批次和审计搜索的纯业务规则。
|
||||||
|
package admin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"slices"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestNormalizeModelPrices 验证按 Token 计费时价格键、单位和金额标准化。
|
||||||
|
func TestNormalizeModelPrices(t *testing.T) {
|
||||||
|
input := ModelInput{
|
||||||
|
ModelType: "text",
|
||||||
|
TextBillingMode: "per_token",
|
||||||
|
Prices: []ModelPriceInput{
|
||||||
|
{PriceKey: " INPUT ", Price: "1.236"},
|
||||||
|
{PriceKey: "output", Price: 2},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := normalizeModelPrices(&input); err != nil {
|
||||||
|
t.Fatalf("normalize model prices: %v", err)
|
||||||
|
}
|
||||||
|
if input.Prices[0].PriceKey != "input" || input.Prices[0].Unit != "M Token" || input.Prices[0].Price != 1.24 {
|
||||||
|
t.Fatalf("unexpected normalized input price: %#v", input.Prices[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNormalizeModelPricesRejectsIncompleteConfig 验证文本模型缺少价格项时拒绝保存。
|
||||||
|
func TestNormalizeModelPricesRejectsIncompleteConfig(t *testing.T) {
|
||||||
|
input := ModelInput{ModelType: "text", TextBillingMode: "per_token", Prices: []ModelPriceInput{{PriceKey: "input", Price: 1}}}
|
||||||
|
if err := normalizeModelPrices(&input); err == nil {
|
||||||
|
t.Fatal("expected incomplete token prices to be rejected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestValidateRedemptionBatchLimits 验证兑换码单码积分与批次数量边界。
|
||||||
|
func TestValidateRedemptionBatchLimits(t *testing.T) {
|
||||||
|
if points, err := ValidateRedemptionBatchLimits("10.5", 10); err != nil || points != "10.5" {
|
||||||
|
t.Fatalf("expected valid redemption limits, points=%q err=%v", points, err)
|
||||||
|
}
|
||||||
|
for _, test := range []struct {
|
||||||
|
points any
|
||||||
|
quantity int
|
||||||
|
}{{0, 1}, {101, 1}, {10, 0}, {10, 11}} {
|
||||||
|
if _, err := ValidateRedemptionBatchLimits(test.points, test.quantity); err == nil {
|
||||||
|
t.Fatalf("expected limits to be rejected: %#v", test)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMatchingAuditResourceTypes 验证中文资源名称可转换为审计记录使用的内部代码。
|
||||||
|
func TestMatchingAuditResourceTypes(t *testing.T) {
|
||||||
|
for _, test := range []struct {
|
||||||
|
keyword string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{keyword: "渠道", want: "channels"},
|
||||||
|
{keyword: "兑换", want: "redemption-codes"},
|
||||||
|
{keyword: "管理员账号", want: "admin-auth"},
|
||||||
|
} {
|
||||||
|
if got := matchingAuditResourceTypes(test.keyword); !slices.Contains(got, test.want) {
|
||||||
|
t.Fatalf("keyword %q matched %v, want %q", test.keyword, got, test.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got := matchingAuditResourceTypes("不存在的资源"); len(got) != 0 {
|
||||||
|
t.Fatalf("unexpected resource matches: %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,323 @@
|
|||||||
|
// 图片生成生图业务模块,负责提交用户级图片任务、查询历史并清理生成资源。
|
||||||
|
package productimage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"juhe-factory/api/internal/billing"
|
||||||
|
"juhe-factory/api/internal/model"
|
||||||
|
queuepkg "juhe-factory/api/internal/queue"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/hibiken/asynq"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
|
)
|
||||||
|
|
||||||
|
var activeTaskStatuses = []string{"pending_submission", "submitting", "submitted", "processing", "result_ready", "downloading", "cancel_requested"}
|
||||||
|
|
||||||
|
// ReferenceUpload 表示已经上传到对象存储、等待绑定到生图任务的参考图。
|
||||||
|
type ReferenceUpload struct {
|
||||||
|
Asset *model.MediaAsset
|
||||||
|
Name string
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateInput 是图片生成生图接口的配置参数和临时参考图。
|
||||||
|
type GenerateInput struct {
|
||||||
|
Prompt string
|
||||||
|
ModelID uuid.UUID
|
||||||
|
AspectRatio string
|
||||||
|
Resolution string
|
||||||
|
References []ReferenceUpload
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerationView 汇总任务状态和已经落库的图片结果。
|
||||||
|
type GenerationView struct {
|
||||||
|
TaskID uuid.UUID `json:"task_id"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Prompt string `json:"prompt"`
|
||||||
|
ModelName string `json:"model_name"`
|
||||||
|
ErrorMessage string `json:"error_message"`
|
||||||
|
MediaAssetID *uuid.UUID `json:"media_asset_id"`
|
||||||
|
PublicURL string `json:"public_url"`
|
||||||
|
MimeType string `json:"mime_type"`
|
||||||
|
EstimatedPoints string `json:"estimated_points"`
|
||||||
|
ActualPoints *string `json:"actual_points"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
FinishedAt *time.Time `json:"finished_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Service 组合图片生成模块需要的数据库和任务队列依赖。
|
||||||
|
type Service struct {
|
||||||
|
DB *gorm.DB
|
||||||
|
Queue *asynq.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewService 创建图片生成生图服务。
|
||||||
|
func NewService(db *gorm.DB, queue *asynq.Client) *Service { return &Service{DB: db, Queue: queue} }
|
||||||
|
|
||||||
|
// QueueGeneration 校验配置、保存临时参考图并提交一个独立图片任务。
|
||||||
|
func (s *Service) QueueGeneration(userID uuid.UUID, input GenerateInput) (*model.GenerationTask, error) {
|
||||||
|
if s.Queue == nil {
|
||||||
|
return nil, errors.New("生成任务队列不可用")
|
||||||
|
}
|
||||||
|
prompt := strings.TrimSpace(input.Prompt)
|
||||||
|
if prompt == "" {
|
||||||
|
return nil, errors.New("提示词不能为空")
|
||||||
|
}
|
||||||
|
if utf8.RuneCountInString(prompt) > 5000 {
|
||||||
|
return nil, errors.New("提示词不能超过 5000 个字符")
|
||||||
|
}
|
||||||
|
if len(input.References) > 4 {
|
||||||
|
return nil, errors.New("最多上传 4 张参考图")
|
||||||
|
}
|
||||||
|
if input.ModelID == uuid.Nil {
|
||||||
|
return nil, errors.New("请选择图片模型")
|
||||||
|
}
|
||||||
|
if !validRatio(input.AspectRatio) || !validResolution(input.Resolution) {
|
||||||
|
return nil, errors.New("图片生成参数无效")
|
||||||
|
}
|
||||||
|
var selected struct {
|
||||||
|
ModelID uuid.UUID
|
||||||
|
ChannelID uuid.UUID
|
||||||
|
ModelName string
|
||||||
|
Price string
|
||||||
|
PriceExists bool
|
||||||
|
}
|
||||||
|
if err := s.DB.Raw(`SELECT model.id AS model_id,model.channel_id,model.name AS model_name,
|
||||||
|
coalesce(price.price,0)::text AS price,(price.id IS NOT NULL) AS price_exists
|
||||||
|
FROM models model JOIN channels channel ON channel.id=model.channel_id
|
||||||
|
LEFT JOIN model_prices price ON price.model_id=model.id AND lower(price.price_key)=lower(?)
|
||||||
|
WHERE model.id=? AND model.model_type='image' AND model.enabled=true AND model.deleted_at IS NULL
|
||||||
|
AND channel.enabled=true AND channel.deleted_at IS NULL`, input.Resolution, input.ModelID).Scan(&selected).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if selected.ModelID == uuid.Nil {
|
||||||
|
return nil, errors.New("图片模型不可用")
|
||||||
|
}
|
||||||
|
if !selected.PriceExists {
|
||||||
|
return nil, fmt.Errorf("当前图片模型未配置 %s 价格", input.Resolution)
|
||||||
|
}
|
||||||
|
imageURLs := make([]string, 0, len(input.References))
|
||||||
|
referenceIDs := make([]string, 0, len(input.References))
|
||||||
|
referenceNames := make([]string, 0, len(input.References))
|
||||||
|
seenNames := make(map[string]struct{}, len(input.References))
|
||||||
|
for _, reference := range input.References {
|
||||||
|
if reference.Asset == nil || strings.TrimSpace(reference.Asset.PublicURL) == "" {
|
||||||
|
return nil, errors.New("参考图信息无效")
|
||||||
|
}
|
||||||
|
name := strings.TrimSpace(reference.Name)
|
||||||
|
if name == "" {
|
||||||
|
return nil, errors.New("参考图名称不能为空")
|
||||||
|
}
|
||||||
|
if _, exists := seenNames[name]; exists {
|
||||||
|
return nil, errors.New("参考图名称不能重复")
|
||||||
|
}
|
||||||
|
seenNames[name] = struct{}{}
|
||||||
|
imageURLs = append(imageURLs, reference.Asset.PublicURL)
|
||||||
|
referenceIDs = append(referenceIDs, reference.Asset.ID.String())
|
||||||
|
referenceNames = append(referenceNames, name)
|
||||||
|
}
|
||||||
|
providerPrompt := buildProviderPrompt(prompt, referenceNames)
|
||||||
|
payload, err := json.Marshal(map[string]any{
|
||||||
|
"product_image": true, "prompt": providerPrompt, "display_prompt": prompt,
|
||||||
|
"model": selected.ModelName, "size": input.AspectRatio, "resolution": input.Resolution,
|
||||||
|
"image_urls": imageURLs, "reference_media_asset_ids": referenceIDs, "reference_names": referenceNames, "n": 1,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
task := &model.GenerationTask{ID: uuid.New(), RequestID: "product_image_" + uuid.NewString(), UserID: userID, ChannelID: &selected.ChannelID, ModelID: &selected.ModelID, TaskType: "image_generation", Status: "pending_submission", InputData: payload}
|
||||||
|
err = s.DB.Transaction(func(tx *gorm.DB) error {
|
||||||
|
for _, reference := range input.References {
|
||||||
|
owner := userID
|
||||||
|
reference.Asset.OwnerUserID = &owner
|
||||||
|
if err := tx.Create(reference.Asset).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return billing.PrechargeGenerationTask(tx, task, selected.Price, 1, "图片生成")
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := queuepkg.EnqueueID(s.Queue, queuepkg.TypeDispatchChannel, selected.ChannelID, 0); err != nil {
|
||||||
|
if refundErr := s.failQueuedTask(task.ID, err.Error()); refundErr != nil {
|
||||||
|
return nil, fmt.Errorf("生成任务入队失败且预扣返还失败: %w", refundErr)
|
||||||
|
}
|
||||||
|
return nil, errors.New("生成任务队列暂时不可用,请稍后重试")
|
||||||
|
}
|
||||||
|
return task, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generations 返回当前用户最近五十条图片生成记录。
|
||||||
|
func (s *Service) Generations(userID uuid.UUID) ([]GenerationView, error) {
|
||||||
|
items := make([]GenerationView, 0)
|
||||||
|
err := s.DB.Raw(`SELECT task.id AS task_id,task.status,
|
||||||
|
coalesce(task.input_data->>'display_prompt',task.input_data->>'prompt','') AS prompt,
|
||||||
|
coalesce(model.name,task.input_data->>'model','') AS model_name,task.error_message,
|
||||||
|
output.media_asset_id,coalesce(media.public_url,'') AS public_url,coalesce(media.mime_type,'') AS mime_type,
|
||||||
|
task.estimated_points,task.actual_points,task.created_at,task.finished_at
|
||||||
|
FROM generation_tasks task
|
||||||
|
LEFT JOIN models model ON model.id=task.model_id
|
||||||
|
LEFT JOIN generation_outputs output ON output.task_id=task.id AND output.sequence_no=1
|
||||||
|
LEFT JOIN media_assets media ON media.id=output.media_asset_id AND media.deleted_at IS NULL
|
||||||
|
WHERE task.user_id=? AND task.task_type='image_generation' AND task.input_data->>'product_image'='true'
|
||||||
|
ORDER BY task.created_at DESC LIMIT 50`, userID).Scan(&items).Error
|
||||||
|
return items, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteGeneration 清理当前用户的图片任务、生成媒体和参考媒体;活动任务也允许主动删除。
|
||||||
|
func (s *Service) DeleteGeneration(userID, taskID uuid.UUID, deleteObjects func([]string) error) error {
|
||||||
|
objectKeys := make([]string, 0)
|
||||||
|
var channelID *uuid.UUID
|
||||||
|
err := s.DB.Transaction(func(tx *gorm.DB) error {
|
||||||
|
var task model.GenerationTask
|
||||||
|
if err := tx.Where("id=? AND user_id=? AND task_type='image_generation' AND input_data->>'product_image'='true'", taskID, userID).Take(&task).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !canDeleteGeneration(task.Status, task.ErrorMessage) {
|
||||||
|
return errors.New("图片仍在正常生成中,暂时不能删除")
|
||||||
|
}
|
||||||
|
channelID = task.ChannelID
|
||||||
|
var input struct {
|
||||||
|
ReferenceMediaAssetIDs []uuid.UUID `json:"reference_media_asset_ids"`
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal(task.InputData, &input)
|
||||||
|
mediaIDs := append([]uuid.UUID(nil), input.ReferenceMediaAssetIDs...)
|
||||||
|
var outputs []struct {
|
||||||
|
MediaAssetID uuid.UUID
|
||||||
|
ObjectKey string
|
||||||
|
}
|
||||||
|
if err := tx.Table("generation_outputs output").Select("output.media_asset_id,media.object_key").Joins("JOIN media_assets media ON media.id=output.media_asset_id").Where("output.task_id=?", taskID).Scan(&outputs).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, output := range outputs {
|
||||||
|
mediaIDs = append(mediaIDs, output.MediaAssetID)
|
||||||
|
if output.ObjectKey != "" {
|
||||||
|
objectKeys = append(objectKeys, output.ObjectKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(mediaIDs) > 0 {
|
||||||
|
var refs []struct{ ObjectKey string }
|
||||||
|
if err := tx.Table("media_assets").Select("object_key").Where("id IN ?", mediaIDs).Scan(&refs).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, ref := range refs {
|
||||||
|
if ref.ObjectKey != "" {
|
||||||
|
objectKeys = append(objectKeys, ref.ObjectKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if deleteObjects != nil {
|
||||||
|
if err := deleteObjects(uniqueStrings(objectKeys)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := tx.Exec("DELETE FROM generation_outputs WHERE task_id=?", taskID).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(mediaIDs) > 0 {
|
||||||
|
if err := tx.Exec("DELETE FROM channel_asset_cache WHERE media_asset_id IN ?", mediaIDs).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tx.Exec("DELETE FROM media_assets WHERE id IN ?", mediaIDs).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tx.Exec("DELETE FROM generation_tasks WHERE id=?", taskID).Error
|
||||||
|
})
|
||||||
|
if err == nil && channelID != nil && s.Queue != nil {
|
||||||
|
_ = queuepkg.EnqueueID(s.Queue, queuepkg.TypeDispatchChannel, *channelID, 0)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// canDeleteGeneration 判断图片任务是否已结束,或因错误停留在活动状态而允许用户清理。
|
||||||
|
func canDeleteGeneration(status, errorMessage string) bool {
|
||||||
|
for _, activeStatus := range activeTaskStatuses {
|
||||||
|
if status == activeStatus {
|
||||||
|
return strings.TrimSpace(errorMessage) != ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// failQueuedTask 在调度队列不可用时结束任务、返还积分并删除参考媒体记录。
|
||||||
|
func (s *Service) failQueuedTask(taskID uuid.UUID, message string) error {
|
||||||
|
return s.DB.Transaction(func(tx *gorm.DB) error {
|
||||||
|
var task model.GenerationTask
|
||||||
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id=?", taskID).Take(&task).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
refunded, err := billing.RefundGenerationTask(tx, &task, "图片生成入队失败返还")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var input struct {
|
||||||
|
ReferenceMediaAssetIDs []uuid.UUID `json:"reference_media_asset_ids"`
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal(task.InputData, &input)
|
||||||
|
if len(input.ReferenceMediaAssetIDs) > 0 {
|
||||||
|
if err := tx.Exec("DELETE FROM media_assets WHERE id IN ?", input.ReferenceMediaAssetIDs).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updates := map[string]any{"status": "failed", "error_code": "queue_unavailable", "error_message": message, "finished_at": time.Now(), "actual_points": "0.00"}
|
||||||
|
if refunded {
|
||||||
|
updates["cost_refunded"] = true
|
||||||
|
}
|
||||||
|
return tx.Model(&model.GenerationTask{}).Where("id=?", taskID).Updates(updates).Error
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// validRatio 判断画幅比例是否属于图片生成工具公开支持的取值。
|
||||||
|
func validRatio(value string) bool {
|
||||||
|
for _, allowed := range []string{"9:21", "1:1", "3:4", "4:3", "9:16", "16:9"} {
|
||||||
|
if value == allowed {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// validResolution 判断分辨率是否属于图片生成工具公开支持的档位。
|
||||||
|
func validResolution(value string) bool {
|
||||||
|
for _, allowed := range []string{"1k", "2k", "4k"} {
|
||||||
|
if value == allowed {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildProviderPrompt 将文件名提及与参考图数组顺序建立明确对应关系。
|
||||||
|
func buildProviderPrompt(prompt string, names []string) string {
|
||||||
|
if len(names) == 0 {
|
||||||
|
return prompt
|
||||||
|
}
|
||||||
|
mappings := make([]string, 0, len(names))
|
||||||
|
for index, name := range names {
|
||||||
|
mappings = append(mappings, fmt.Sprintf("第%d张参考图名称为“%s”", index+1, name))
|
||||||
|
}
|
||||||
|
return "参考图对应关系:" + strings.Join(mappings, ";") + "。\n" + prompt
|
||||||
|
}
|
||||||
|
|
||||||
|
// uniqueStrings 按首次出现顺序去除空对象键和重复对象键。
|
||||||
|
func uniqueStrings(values []string) []string {
|
||||||
|
seen := make(map[string]struct{}, len(values))
|
||||||
|
result := make([]string, 0, len(values))
|
||||||
|
for _, value := range values {
|
||||||
|
if _, ok := seen[value]; ok || value == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[value] = struct{}{}
|
||||||
|
result = append(result, value)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
// 图片生成服务测试,验证参考图文件名映射和对象键去重规则。
|
||||||
|
package productimage
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// TestBuildProviderPrompt 验证文件名提及会与参考图数组顺序建立稳定对应关系。
|
||||||
|
func TestBuildProviderPrompt(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
prompt := "让@正面图.png中的商品使用@包装图.webp的包装"
|
||||||
|
got := buildProviderPrompt(prompt, []string{"正面图.png", "包装图.webp"})
|
||||||
|
want := "参考图对应关系:第1张参考图名称为“正面图.png”;第2张参考图名称为“包装图.webp”。\n" + prompt
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("buildProviderPrompt() = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
if withoutReferences := buildProviderPrompt(prompt, nil); withoutReferences != prompt {
|
||||||
|
t.Fatalf("buildProviderPrompt() without references = %q, want %q", withoutReferences, prompt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestUniqueStrings 验证重复或空对象键不会触发重复删除请求。
|
||||||
|
func TestUniqueStrings(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
got := uniqueStrings([]string{"a", "", "b", "a", "b", "c"})
|
||||||
|
want := []string{"a", "b", "c"}
|
||||||
|
if len(got) != len(want) {
|
||||||
|
t.Fatalf("uniqueStrings() length = %d, want %d", len(got), len(want))
|
||||||
|
}
|
||||||
|
for index := range want {
|
||||||
|
if got[index] != want[index] {
|
||||||
|
t.Fatalf("uniqueStrings()[%d] = %q, want %q", index, got[index], want[index])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCanDeleteGeneration 验证失败停滞的活动任务可删除,而正常生成中的任务仍受保护。
|
||||||
|
func TestCanDeleteGeneration(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
status string
|
||||||
|
errorMessage string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{name: "正常生成", status: "processing", want: false},
|
||||||
|
{name: "连接超时", status: "pending_submission", errorMessage: "dial tcp: i/o timeout", want: true},
|
||||||
|
{name: "生成失败", status: "failed", want: true},
|
||||||
|
{name: "生成成功", status: "succeeded", want: true},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
if got := canDeleteGeneration(test.status, test.errorMessage); got != test.want {
|
||||||
|
t.Fatalf("canDeleteGeneration(%q, %q) = %t, want %t", test.status, test.errorMessage, got, test.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,328 @@
|
|||||||
|
// 提示词业务模块,统一封装用户提示词规则、选择关系和持久化操作。
|
||||||
|
package prompt
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Service 提供提示词模块的公开业务接口,并隐藏底层数据库实现。
|
||||||
|
type Service struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// CustomPromptInput 表示用户自定义提示词的可编辑内容。
|
||||||
|
type CustomPromptInput struct {
|
||||||
|
Name string
|
||||||
|
Type string
|
||||||
|
Content string
|
||||||
|
}
|
||||||
|
|
||||||
|
// CustomPrompt 表示返回给接口层的用户自定义提示词。
|
||||||
|
type CustomPrompt struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
Scope string `json:"scope"`
|
||||||
|
Editable bool `json:"editable"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SystemPromptInput 表示管理端可维护的系统提示词内容。
|
||||||
|
type SystemPromptInput struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SystemPrompt 表示管理端保存后返回的系统提示词。
|
||||||
|
type SystemPrompt struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// VersionedPromptInput 表示旧版提示词工作流中的草稿和版本字段。
|
||||||
|
type VersionedPromptInput struct {
|
||||||
|
Code string
|
||||||
|
Name string
|
||||||
|
Category string
|
||||||
|
Type string
|
||||||
|
Content string
|
||||||
|
Variables []string
|
||||||
|
VersionNote string
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserPromptList 表示指定类型的可用提示词及用户当前选择。
|
||||||
|
type UserPromptList struct {
|
||||||
|
Prompts []map[string]any
|
||||||
|
SelectedID *uuid.UUID
|
||||||
|
}
|
||||||
|
|
||||||
|
// PersistenceError 标识应由接口层转换为内部错误的数据库故障。
|
||||||
|
type PersistenceError struct {
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error 返回底层数据库错误文本,供日志与测试定位。
|
||||||
|
func (e PersistenceError) Error() string {
|
||||||
|
return e.Err.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unwrap 暴露底层错误,保留 errors.Is 和 errors.As 语义。
|
||||||
|
func (e PersistenceError) Unwrap() error {
|
||||||
|
return e.Err
|
||||||
|
}
|
||||||
|
|
||||||
|
var fixedDramaParsePromptTypes = []string{"剧本解析", "角色、场景、道具解析"}
|
||||||
|
|
||||||
|
// NewService 创建提示词服务,数据库连接仅在模块内部使用。
|
||||||
|
func NewService(db *gorm.DB) *Service {
|
||||||
|
return &Service{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsFixedDramaParseType 判断提示词类型是否由后端固定配置且禁止用户编辑。
|
||||||
|
func IsFixedDramaParseType(promptType string) bool {
|
||||||
|
for _, fixedType := range fixedDramaParsePromptTypes {
|
||||||
|
if strings.TrimSpace(promptType) == fixedType {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsPersistenceError 判断错误是否属于需要隐藏细节的数据库故障。
|
||||||
|
func IsPersistenceError(err error) bool {
|
||||||
|
var target PersistenceError
|
||||||
|
return errors.As(err, &target)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListSystemPrompts 查询管理端可维护的系统提示词,不返回后端固定配置。
|
||||||
|
func (s *Service) ListSystemPrompts(keyword, promptType string, page, size int) ([]map[string]any, int64, error) {
|
||||||
|
query := s.db.Table("prompts p").Where("p.deleted_at IS NULL AND p.scope='system' AND p.type NOT IN ?", fixedDramaParsePromptTypes)
|
||||||
|
if keyword = strings.TrimSpace(keyword); keyword != "" {
|
||||||
|
query = query.Where("p.name::text ILIKE ? OR p.content ILIKE ?", "%"+keyword+"%", "%"+keyword+"%")
|
||||||
|
}
|
||||||
|
if promptType = strings.TrimSpace(promptType); promptType != "" {
|
||||||
|
query = query.Where("p.type=?", promptType)
|
||||||
|
}
|
||||||
|
var total int64
|
||||||
|
if err := query.Count(&total).Error; err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
items := make([]map[string]any, 0)
|
||||||
|
err := query.Select("p.id,p.name,p.type,p.content,p.scope,p.owner_user_id,p.created_at,p.updated_at").
|
||||||
|
Order("p.updated_at DESC").Offset((page - 1) * size).Limit(size).Find(&items).Error
|
||||||
|
return items, total, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveSystemPrompt 校验并新增或更新管理端系统提示词。
|
||||||
|
func (s *Service) SaveSystemPrompt(promptID string, input SystemPromptInput) (SystemPrompt, error) {
|
||||||
|
input.Name = strings.TrimSpace(input.Name)
|
||||||
|
input.Type = strings.TrimSpace(input.Type)
|
||||||
|
input.Content = strings.TrimSpace(input.Content)
|
||||||
|
if input.Name == "" || input.Type == "" || input.Content == "" {
|
||||||
|
return SystemPrompt{}, errors.New("name、type、content不能为空")
|
||||||
|
}
|
||||||
|
if IsFixedDramaParseType(input.Type) {
|
||||||
|
return SystemPrompt{}, errors.New("该提示词已固定在后端代码中,不支持管理")
|
||||||
|
}
|
||||||
|
if promptID == "" {
|
||||||
|
promptID = uuid.NewString()
|
||||||
|
if err := s.db.Exec("INSERT INTO prompts(id,name,type,content,scope,owner_user_id) VALUES(?,?,?,?,'system',NULL)", promptID, input.Name, input.Type, input.Content).Error; err != nil {
|
||||||
|
return SystemPrompt{}, err
|
||||||
|
}
|
||||||
|
} else if err := s.db.Exec("UPDATE prompts SET name=?,type=?,content=?,updated_at=CURRENT_TIMESTAMP WHERE id=? AND scope='system' AND deleted_at IS NULL", input.Name, input.Type, input.Content, promptID).Error; err != nil {
|
||||||
|
return SystemPrompt{}, err
|
||||||
|
}
|
||||||
|
return SystemPrompt{ID: promptID, Name: input.Name, Type: input.Type, Content: input.Content}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteSystemPrompt 删除管理端指定的系统提示词。
|
||||||
|
func (s *Service) DeleteSystemPrompt(promptID string) error {
|
||||||
|
return s.db.Exec("DELETE FROM prompts WHERE id=? AND scope='system'", promptID).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveVersionedPrompt 保存旧版管理接口使用的提示词草稿及版本快照。
|
||||||
|
func (s *Service) SaveVersionedPrompt(adminID uuid.UUID, promptID string, input VersionedPromptInput) (string, error) {
|
||||||
|
if strings.TrimSpace(input.Category) == "" {
|
||||||
|
input.Category = input.Type
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(input.Type) == "" {
|
||||||
|
input.Type = input.Category
|
||||||
|
}
|
||||||
|
err := s.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
version := 1
|
||||||
|
if promptID == "" {
|
||||||
|
promptID = uuid.NewString()
|
||||||
|
if err := tx.Exec("INSERT INTO prompts(id,code,name,category,type,content,status) VALUES(?,?,?,?,?,?,'draft')", promptID, input.Code, input.Name, input.Category, input.Type, input.Content).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if err := tx.Raw("SELECT coalesce(max(version),0)+1 FROM prompt_versions WHERE prompt_id=?", promptID).Scan(&version).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tx.Exec("UPDATE prompts SET code=?,name=?,category=?,type=?,content=? WHERE id=? AND deleted_at IS NULL", input.Code, input.Name, input.Category, input.Type, input.Content, promptID).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
variables, _ := json.Marshal(input.Variables)
|
||||||
|
versionID := uuid.NewString()
|
||||||
|
if err := tx.Exec("INSERT INTO prompt_versions(id,prompt_id,version,content,variables,version_note,status,operator_id) VALUES(?,?,?,?,?::jsonb,?,'draft',?)", versionID, promptID, version, input.Content, string(variables), input.VersionNote, adminID).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Exec("UPDATE prompts SET current_version_id=?,status='draft' WHERE id=?", versionID, promptID).Error
|
||||||
|
})
|
||||||
|
return promptID, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApplyPromptAction 执行旧版提示词接口的发布、停用或版本回滚事务。
|
||||||
|
func (s *Service) ApplyPromptAction(adminID uuid.UUID, promptID, action, versionID, versionNote string) error {
|
||||||
|
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
switch action {
|
||||||
|
case "publish":
|
||||||
|
if err := tx.Exec("UPDATE prompt_versions SET status=CASE WHEN id=(SELECT current_version_id FROM prompts WHERE id=?) THEN 'published' ELSE 'retired' END WHERE prompt_id=?", promptID, promptID).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Exec("UPDATE prompts SET status='published' WHERE id=?", promptID).Error
|
||||||
|
case "disable":
|
||||||
|
return tx.Exec("UPDATE prompts SET status='disabled' WHERE id=?", promptID).Error
|
||||||
|
case "rollback":
|
||||||
|
var source struct {
|
||||||
|
Content string
|
||||||
|
Variables string
|
||||||
|
}
|
||||||
|
if err := tx.Raw("SELECT content,variables::text FROM prompt_versions WHERE id=? AND prompt_id=?", versionID, promptID).Scan(&source).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var version int
|
||||||
|
if err := tx.Raw("SELECT coalesce(max(version),0)+1 FROM prompt_versions WHERE prompt_id=?", promptID).Scan(&version).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
newVersionID := uuid.NewString()
|
||||||
|
if err := tx.Exec("INSERT INTO prompt_versions(id,prompt_id,version,content,variables,version_note,status,operator_id) VALUES(?,?,?,?,?::jsonb,?,'draft',?)", newVersionID, promptID, version, source.Content, source.Variables, versionNote, adminID).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Exec("UPDATE prompts SET current_version_id=?,status='draft' WHERE id=?", newVersionID, promptID).Error
|
||||||
|
default:
|
||||||
|
return errors.New("不支持的提示词操作")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListPromptHistory 查询旧版提示词接口保留的版本历史。
|
||||||
|
func (s *Service) ListPromptHistory(promptID string) ([]map[string]any, error) {
|
||||||
|
items := make([]map[string]any, 0)
|
||||||
|
err := s.db.Table("prompt_versions v").
|
||||||
|
Select("v.id,v.version,v.content,v.variables,v.version_note,v.status,v.created_at,a.username AS operator").
|
||||||
|
Joins("LEFT JOIN admin_users a ON a.id=v.operator_id").Where("v.prompt_id=?", promptID).
|
||||||
|
Order("v.version DESC").Find(&items).Error
|
||||||
|
return items, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListUserPrompts 查询系统提示词、当前用户自定义提示词及用户选择。
|
||||||
|
func (s *Service) ListUserPrompts(userID uuid.UUID, promptType string) (UserPromptList, error) {
|
||||||
|
if IsFixedDramaParseType(promptType) {
|
||||||
|
return UserPromptList{Prompts: []map[string]any{}}, nil
|
||||||
|
}
|
||||||
|
var prompts []map[string]any
|
||||||
|
err := s.db.Table("prompts").Select("id,name,type,content,scope,owner_user_id,created_at,updated_at,(scope='user') AS editable").
|
||||||
|
Where("deleted_at IS NULL AND type=? AND (scope='system' OR (scope='user' AND owner_user_id=?))", promptType, userID).
|
||||||
|
Order("CASE WHEN scope='system' THEN 0 ELSE 1 END, updated_at DESC").Find(&prompts).Error
|
||||||
|
if err != nil {
|
||||||
|
return UserPromptList{}, err
|
||||||
|
}
|
||||||
|
var selected struct{ PromptID *uuid.UUID }
|
||||||
|
// 用户未设置偏好时返回空属于正常情况,用 Limit(1).Find 避免触发 GORM 的 record not found 日志
|
||||||
|
s.db.Table("user_prompt_preferences").Select("prompt_id").Where("user_id=? AND prompt_type=?", userID, promptType).Limit(1).Find(&selected)
|
||||||
|
return UserPromptList{Prompts: prompts, SelectedID: selected.PromptID}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SelectUserPrompt 更新用户对指定提示词类型的选择,空值表示关闭选择。
|
||||||
|
func (s *Service) SelectUserPrompt(userID uuid.UUID, promptType string, promptID *uuid.UUID) error {
|
||||||
|
if IsFixedDramaParseType(promptType) {
|
||||||
|
return errors.New("该提示词由平台固定配置,不支持选择")
|
||||||
|
}
|
||||||
|
if promptID == nil {
|
||||||
|
if err := s.db.Exec("DELETE FROM user_prompt_preferences WHERE user_id=? AND prompt_type=?", userID, promptType).Error; err != nil {
|
||||||
|
return PersistenceError{Err: err}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var count int64
|
||||||
|
if err := s.db.Table("prompts").Where("id=? AND type=? AND deleted_at IS NULL AND (scope='system' OR (scope='user' AND owner_user_id=?))", *promptID, promptType, userID).Count(&count).Error; err != nil {
|
||||||
|
return PersistenceError{Err: err}
|
||||||
|
}
|
||||||
|
if count == 0 {
|
||||||
|
return gorm.ErrRecordNotFound
|
||||||
|
}
|
||||||
|
if err := s.db.Exec(`INSERT INTO user_prompt_preferences(id,user_id,prompt_id,prompt_type) VALUES(gen_random_uuid(),?,?,?) ON CONFLICT(user_id,prompt_type) DO UPDATE SET prompt_id=excluded.prompt_id,updated_at=CURRENT_TIMESTAMP`, userID, *promptID, promptType).Error; err != nil {
|
||||||
|
return PersistenceError{Err: err}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateCustomPrompt 校验并创建当前用户拥有的自定义提示词。
|
||||||
|
func (s *Service) CreateCustomPrompt(userID uuid.UUID, input CustomPromptInput) (CustomPrompt, error) {
|
||||||
|
input = normalizeCustomPromptInput(input)
|
||||||
|
if input.Name == "" || input.Type == "" || input.Content == "" {
|
||||||
|
return CustomPrompt{}, errors.New("name、type、content不能为空")
|
||||||
|
}
|
||||||
|
if IsFixedDramaParseType(input.Type) {
|
||||||
|
return CustomPrompt{}, errors.New("该提示词由平台固定配置,不支持自定义")
|
||||||
|
}
|
||||||
|
item := CustomPrompt{ID: uuid.New(), Name: input.Name, Type: input.Type, Content: input.Content, Scope: "user", Editable: true}
|
||||||
|
if err := s.db.Exec("INSERT INTO prompts(id,name,type,content,scope,owner_user_id) VALUES(?,?,?,?,'user',?)", item.ID, item.Name, item.Type, item.Content, userID).Error; err != nil {
|
||||||
|
return CustomPrompt{}, err
|
||||||
|
}
|
||||||
|
return item, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateCustomPrompt 校验并更新当前用户拥有的自定义提示词。
|
||||||
|
func (s *Service) UpdateCustomPrompt(userID, promptID uuid.UUID, input CustomPromptInput) (CustomPrompt, error) {
|
||||||
|
input = normalizeCustomPromptInput(input)
|
||||||
|
if input.Name == "" || input.Content == "" {
|
||||||
|
return CustomPrompt{}, errors.New("name、content不能为空")
|
||||||
|
}
|
||||||
|
if IsFixedDramaParseType(input.Type) {
|
||||||
|
return CustomPrompt{}, errors.New("该提示词由平台固定配置,不支持自定义")
|
||||||
|
}
|
||||||
|
result := s.db.Exec("UPDATE prompts SET name=?,type=COALESCE(NULLIF(?,''),type),content=?,updated_at=CURRENT_TIMESTAMP WHERE id=? AND scope='user' AND owner_user_id=? AND deleted_at IS NULL", input.Name, input.Type, input.Content, promptID, userID)
|
||||||
|
if result.Error != nil {
|
||||||
|
return CustomPrompt{}, result.Error
|
||||||
|
}
|
||||||
|
if result.RowsAffected == 0 {
|
||||||
|
return CustomPrompt{}, gorm.ErrRecordNotFound
|
||||||
|
}
|
||||||
|
return CustomPrompt{ID: promptID, Name: input.Name, Type: input.Type, Content: input.Content, Scope: "user", Editable: true}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteCustomPrompt 在同一事务中删除用户选择关系及其自定义提示词。
|
||||||
|
func (s *Service) DeleteCustomPrompt(userID, promptID uuid.UUID) error {
|
||||||
|
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := tx.Exec("DELETE FROM user_prompt_preferences WHERE prompt_id=? AND user_id=?", promptID, userID).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
result := tx.Exec("DELETE FROM prompts WHERE id=? AND scope='user' AND owner_user_id=?", promptID, userID)
|
||||||
|
if result.Error != nil {
|
||||||
|
return result.Error
|
||||||
|
}
|
||||||
|
if result.RowsAffected == 0 {
|
||||||
|
return gorm.ErrRecordNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizeCustomPromptInput 清理用户输入两端空白,保证校验和写入使用一致值。
|
||||||
|
func normalizeCustomPromptInput(input CustomPromptInput) CustomPromptInput {
|
||||||
|
input.Name = strings.TrimSpace(input.Name)
|
||||||
|
input.Type = strings.TrimSpace(input.Type)
|
||||||
|
input.Content = strings.TrimSpace(input.Content)
|
||||||
|
return input
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
// 提示词模块单元测试,验证固定类型规则和用户输入标准化行为。
|
||||||
|
package prompt
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// TestIsFixedDramaParseType 验证固定提示词类型及空白清理规则。
|
||||||
|
func TestIsFixedDramaParseType(t *testing.T) {
|
||||||
|
for _, value := range []string{"剧本解析", " 角色、场景、道具解析 "} {
|
||||||
|
if !IsFixedDramaParseType(value) {
|
||||||
|
t.Fatalf("expected %q to be fixed", value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if IsFixedDramaParseType("剧本分析") {
|
||||||
|
t.Fatal("剧本分析不应被识别为固定解析提示词")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNormalizeCustomPromptInput 验证提示词字段在校验和写入前统一清理两端空白。
|
||||||
|
func TestNormalizeCustomPromptInput(t *testing.T) {
|
||||||
|
input := normalizeCustomPromptInput(CustomPromptInput{Name: " 名称 ", Type: " 类型\n", Content: " 内容 "})
|
||||||
|
if input.Name != "名称" || input.Type != "类型" || input.Content != "内容" {
|
||||||
|
t.Fatalf("unexpected normalized input: %#v", input)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package apimart
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"juhe-factory/api/internal/provider"
|
||||||
|
)
|
||||||
|
|
||||||
|
type BalanceResult struct {
|
||||||
|
RemainBalance float64 `json:"remain_balance"`
|
||||||
|
RemainCredits float64 `json:"remain_credits"`
|
||||||
|
UsedBalance float64 `json:"used_balance"`
|
||||||
|
UsedCredits float64 `json:"used_credits"`
|
||||||
|
UnlimitedQuota bool `json:"unlimited_quota"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c Client) Balance(ctx context.Context, baseURL, apiKey string) (BalanceResult, error) {
|
||||||
|
var result BalanceResult
|
||||||
|
request, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.TrimRight(baseURL, "/")+"/user/balance", nil)
|
||||||
|
if err != nil {
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
request.Header.Set("Authorization", "Bearer "+apiKey)
|
||||||
|
|
||||||
|
client := c.HTTPClient
|
||||||
|
if client == nil {
|
||||||
|
client = http.DefaultClient
|
||||||
|
}
|
||||||
|
response, err := client.Do(request)
|
||||||
|
if err != nil {
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
|
||||||
|
data, err := io.ReadAll(io.LimitReader(response.Body, 1024*1024))
|
||||||
|
if err != nil {
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||||
|
return result, &provider.HTTPError{StatusCode: response.StatusCode, Body: safeBody(data), RetryAfter: response.Header.Get("Retry-After")}
|
||||||
|
}
|
||||||
|
|
||||||
|
var decoded struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
BalanceResult
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &decoded); err != nil {
|
||||||
|
return result, fmt.Errorf("余额响应不是有效 JSON: %w", err)
|
||||||
|
}
|
||||||
|
if !decoded.Success {
|
||||||
|
if decoded.Message == "" {
|
||||||
|
return result, errors.New("获取渠道余额失败")
|
||||||
|
}
|
||||||
|
return result, errors.New(decoded.Message)
|
||||||
|
}
|
||||||
|
return decoded.BalanceResult, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package apimart
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBalanceUsesConfiguredBaseURLAndAPIKey(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
|
||||||
|
if request.URL.Path != "/v1/user/balance" {
|
||||||
|
t.Fatalf("unexpected path: %s", request.URL.Path)
|
||||||
|
}
|
||||||
|
if request.Header.Get("Authorization") != "Bearer test-key" {
|
||||||
|
t.Fatalf("unexpected authorization header: %s", request.Header.Get("Authorization"))
|
||||||
|
}
|
||||||
|
fmt.Fprint(response, `{"success":true,"remain_balance":10.5,"remain_credits":105.25,"used_balance":2.3,"used_credits":2005.21388,"unlimited_quota":false}`)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
result, err := NewClient(server.Client()).Balance(context.Background(), server.URL+"/v1", "test-key")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Balance returned error: %v", err)
|
||||||
|
}
|
||||||
|
if result.RemainBalance != 10.5 || result.RemainCredits != 105.25 || result.UsedBalance != 2.3 || result.UsedCredits != 2005.21388 || result.UnlimitedQuota {
|
||||||
|
t.Fatalf("unexpected result: %#v", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBalanceReturnsUpstreamMessage(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) {
|
||||||
|
fmt.Fprint(response, `{"success":false,"message":"record not found"}`)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
_, err := NewClient(server.Client()).Balance(context.Background(), server.URL, "test-key")
|
||||||
|
if err == nil || err.Error() != "record not found" {
|
||||||
|
t.Fatalf("expected upstream message, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
package apimart
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"juhe-factory/api/internal/provider"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ChatResult struct {
|
||||||
|
Content string
|
||||||
|
Raw json.RawMessage
|
||||||
|
FinishReason string
|
||||||
|
InputTokens int64
|
||||||
|
OutputTokens int64
|
||||||
|
TotalTokens int64
|
||||||
|
UsageRaw json.RawMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c Client) Chat(ctx context.Context, baseURL, apiKey string, payload map[string]any) (string, json.RawMessage, error) {
|
||||||
|
result, err := c.ChatWithUsage(ctx, baseURL, apiKey, payload)
|
||||||
|
return result.Content, result.Raw, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c Client) ChatWithUsage(ctx context.Context, baseURL, apiKey string, payload map[string]any) (ChatResult, error) {
|
||||||
|
var result ChatResult
|
||||||
|
requestPayload := make(map[string]any, len(payload)+1)
|
||||||
|
for key, value := range payload {
|
||||||
|
requestPayload[key] = value
|
||||||
|
}
|
||||||
|
requestPayload["stream"] = false
|
||||||
|
body, err := json.Marshal(requestPayload)
|
||||||
|
if err != nil {
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
request, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(baseURL, "/")+"/chat/completions", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
request.Header.Set("Authorization", "Bearer "+apiKey)
|
||||||
|
request.Header.Set("Content-Type", "application/json")
|
||||||
|
response, err := c.HTTPClient.Do(request)
|
||||||
|
if err != nil {
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
data, err := io.ReadAll(io.LimitReader(response.Body, 16*1024*1024))
|
||||||
|
if err != nil {
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||||
|
result.Raw = append(json.RawMessage(nil), data...)
|
||||||
|
return result, &provider.HTTPError{StatusCode: response.StatusCode, Body: safeBody(data), RetryAfter: response.Header.Get("Retry-After")}
|
||||||
|
}
|
||||||
|
type chatChoice struct {
|
||||||
|
Message struct {
|
||||||
|
Content any `json:"content"`
|
||||||
|
} `json:"message"`
|
||||||
|
FinishReason string `json:"finish_reason"`
|
||||||
|
}
|
||||||
|
var decoded struct {
|
||||||
|
Choices []chatChoice `json:"choices"`
|
||||||
|
Usage json.RawMessage `json:"usage"`
|
||||||
|
Data struct {
|
||||||
|
Choices []chatChoice `json:"choices"`
|
||||||
|
Usage json.RawMessage `json:"usage"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &decoded); err != nil {
|
||||||
|
result.Raw = append(json.RawMessage(nil), data...)
|
||||||
|
return result, fmt.Errorf("文本模型响应不是有效 JSON: %w;上游响应: %s", err, safeBody(data))
|
||||||
|
}
|
||||||
|
result.Raw = append(json.RawMessage(nil), data...)
|
||||||
|
result.UsageRaw = decoded.Usage
|
||||||
|
choices := decoded.Choices
|
||||||
|
if len(choices) == 0 {
|
||||||
|
choices = decoded.Data.Choices
|
||||||
|
if len(result.UsageRaw) == 0 {
|
||||||
|
result.UsageRaw = decoded.Data.Usage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(choices) == 0 {
|
||||||
|
return result, fmt.Errorf("文本模型响应缺少 choices;上游响应: %s", safeBody(data))
|
||||||
|
}
|
||||||
|
result.FinishReason = choices[0].FinishReason
|
||||||
|
if len(result.UsageRaw) > 0 {
|
||||||
|
var usage map[string]any
|
||||||
|
if json.Unmarshal(result.UsageRaw, &usage) == nil {
|
||||||
|
result.InputTokens = intValue(usage["prompt_tokens"], usage["input_tokens"])
|
||||||
|
result.OutputTokens = intValue(usage["completion_tokens"], usage["output_tokens"])
|
||||||
|
result.TotalTokens = intValue(usage["total_tokens"])
|
||||||
|
if result.TotalTokens == 0 {
|
||||||
|
result.TotalTokens = result.InputTokens + result.OutputTokens
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch content := choices[0].Message.Content.(type) {
|
||||||
|
case string:
|
||||||
|
result.Content = content
|
||||||
|
return result, nil
|
||||||
|
case []any:
|
||||||
|
parts := make([]string, 0)
|
||||||
|
for _, item := range content {
|
||||||
|
if block, ok := item.(map[string]any); ok {
|
||||||
|
if text, ok := block["text"].(string); ok {
|
||||||
|
parts = append(parts, text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.Content = strings.Join(parts, "\n")
|
||||||
|
return result, nil
|
||||||
|
default:
|
||||||
|
return result, errors.New("文本模型响应内容无效")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func intValue(values ...any) int64 {
|
||||||
|
for _, value := range values {
|
||||||
|
switch number := value.(type) {
|
||||||
|
case float64:
|
||||||
|
if number > 0 {
|
||||||
|
return int64(number)
|
||||||
|
}
|
||||||
|
case int64:
|
||||||
|
if number > 0 {
|
||||||
|
return number
|
||||||
|
}
|
||||||
|
case json.Number:
|
||||||
|
if parsed, err := number.Int64(); err == nil && parsed > 0 {
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package apimart
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"juhe-factory/api/internal/provider"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestChatWithUsageReadsOpenAICompatibleAndNestedUsage(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
response string
|
||||||
|
content string
|
||||||
|
input int64
|
||||||
|
output int64
|
||||||
|
total int64
|
||||||
|
finish string
|
||||||
|
}{
|
||||||
|
{name: "top level", response: `{"choices":[{"message":{"content":"result"},"finish_reason":"stop"}],"usage":{"prompt_tokens":120,"completion_tokens":30,"total_tokens":150}}`, content: "result", input: 120, output: 30, total: 150, finish: "stop"},
|
||||||
|
{name: "nested alternate names", response: `{"data":{"choices":[{"message":{"content":[{"type":"text","text":"part one"},{"type":"text","text":"part two"}]}}],"usage":{"input_tokens":44,"output_tokens":11}}}`, content: "part one\npart two", input: 44, output: 11, total: 55},
|
||||||
|
}
|
||||||
|
for _, test := range cases {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
|
||||||
|
if request.URL.Path != "/chat/completions" {
|
||||||
|
t.Fatalf("unexpected path: %s", request.URL.Path)
|
||||||
|
}
|
||||||
|
response.Header().Set("Content-Type", "application/json")
|
||||||
|
fmt.Fprint(response, test.response)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
result, err := NewClient(server.Client()).ChatWithUsage(context.Background(), server.URL, "key", map[string]any{"model": "text-model"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ChatWithUsage returned error: %v", err)
|
||||||
|
}
|
||||||
|
if result.Content != test.content || result.InputTokens != test.input || result.OutputTokens != test.output || result.TotalTokens != test.total || result.FinishReason != test.finish {
|
||||||
|
t.Fatalf("unexpected result: %#v", result)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChatWithUsageReturnsExplicitUpstreamFailure(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) {
|
||||||
|
response.WriteHeader(http.StatusBadGateway)
|
||||||
|
fmt.Fprint(response, `{"error":"relay unavailable"}`)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
_, err := NewClient(server.Client()).ChatWithUsage(context.Background(), server.URL, "key", map[string]any{"model": "text-model"})
|
||||||
|
httpErr, ok := err.(*provider.HTTPError)
|
||||||
|
if !ok || httpErr.StatusCode != http.StatusBadGateway {
|
||||||
|
t.Fatalf("expected HTTPError with relay status, got %T %v", err, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package apimart
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"juhe-factory/api/internal/provider"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Client struct {
|
||||||
|
HTTPClient *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewClient(client *http.Client) Client {
|
||||||
|
return Client{HTTPClient: client}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c Client) requestJSON(ctx context.Context, method, baseURL, path, apiKey, requestID string, payload map[string]any) ([]byte, error) {
|
||||||
|
var body io.Reader
|
||||||
|
if payload != nil {
|
||||||
|
encoded, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
body = bytes.NewReader(encoded)
|
||||||
|
}
|
||||||
|
request, err := http.NewRequestWithContext(ctx, method, strings.TrimRight(baseURL, "/")+path, body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
request.Header.Set("Authorization", "Bearer "+apiKey)
|
||||||
|
if payload != nil {
|
||||||
|
request.Header.Set("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
if requestID != "" {
|
||||||
|
request.Header.Set("Idempotency-Key", requestID)
|
||||||
|
request.Header.Set("X-Request-ID", requestID)
|
||||||
|
}
|
||||||
|
response, err := c.HTTPClient.Do(request)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
data, err := io.ReadAll(io.LimitReader(response.Body, 4*1024*1024))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||||
|
return nil, &provider.HTTPError{StatusCode: response.StatusCode, Body: safeBody(data), RetryAfter: response.Header.Get("Retry-After")}
|
||||||
|
}
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func safeBody(data []byte) string {
|
||||||
|
value := strings.TrimSpace(string(data))
|
||||||
|
if len(value) > 2000 {
|
||||||
|
value = value[:2000]
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
package apimart
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"juhe-factory/api/internal/provider"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (c Client) SubmitImage(ctx context.Context, baseURL, apiKey, requestID string, payload map[string]any) (provider.SubmitResult, error) {
|
||||||
|
data, err := c.requestJSON(ctx, http.MethodPost, baseURL, "/images/generations", apiKey, requestID, payload)
|
||||||
|
if err != nil {
|
||||||
|
return provider.SubmitResult{}, err
|
||||||
|
}
|
||||||
|
result := provider.SubmitResult{Raw: append(json.RawMessage(nil), data...)}
|
||||||
|
var decoded any
|
||||||
|
if err := json.Unmarshal(data, &decoded); err != nil {
|
||||||
|
return provider.SubmitResult{}, errors.New("中转站提交响应不是有效 JSON")
|
||||||
|
}
|
||||||
|
result.TaskID = findString(decoded, "task_id", "id", "request_id")
|
||||||
|
result.URL = findURL(decoded)
|
||||||
|
if result.TaskID == "" && result.URL == "" {
|
||||||
|
return provider.SubmitResult{}, errors.New("中转站提交响应缺少任务 ID 或结果 URL")
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PollImage 通过 APIMart 统一任务接口查询图片生成状态和结果。
|
||||||
|
func (c Client) PollImage(ctx context.Context, baseURL, apiKey, taskID string) (provider.PollResult, error) {
|
||||||
|
data, err := c.requestJSON(ctx, http.MethodGet, baseURL, "/tasks/"+taskID, apiKey, "", nil)
|
||||||
|
if err != nil {
|
||||||
|
return provider.PollResult{}, err
|
||||||
|
}
|
||||||
|
var decoded any
|
||||||
|
if err := json.Unmarshal(data, &decoded); err != nil {
|
||||||
|
return provider.PollResult{}, errors.New("中转站轮询响应不是有效 JSON")
|
||||||
|
}
|
||||||
|
status := strings.ToLower(findString(decoded, "task_status", "status", "state"))
|
||||||
|
if status == "" {
|
||||||
|
status = "processing"
|
||||||
|
}
|
||||||
|
return provider.PollResult{Status: status, URL: findURL(decoded), Error: findString(decoded, "error_message", "error", "message", "detail"), Raw: append(json.RawMessage(nil), data...)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func findURL(value any) string {
|
||||||
|
var walk func(any, string) string
|
||||||
|
walk = func(node any, parentKey string) string {
|
||||||
|
switch data := node.(type) {
|
||||||
|
case string:
|
||||||
|
if (strings.Contains(strings.ToLower(parentKey), "url") || strings.HasPrefix(data, "http://") || strings.HasPrefix(data, "https://")) && (strings.HasPrefix(data, "https://") || strings.HasPrefix(data, "http://")) {
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
case map[string]any:
|
||||||
|
for key, child := range data {
|
||||||
|
if found := walk(child, key); found != "" {
|
||||||
|
return found
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case []any:
|
||||||
|
for _, child := range data {
|
||||||
|
if found := walk(child, parentKey); found != "" {
|
||||||
|
return found
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return walk(value, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func findString(value any, keys ...string) string {
|
||||||
|
wanted := map[string]bool{}
|
||||||
|
for _, key := range keys {
|
||||||
|
wanted[strings.ToLower(key)] = true
|
||||||
|
}
|
||||||
|
return findMatchingString(value, func(key, _ string) bool { return wanted[strings.ToLower(key)] })
|
||||||
|
}
|
||||||
|
|
||||||
|
func findMatchingString(value any, match func(key, value string) bool) string {
|
||||||
|
switch data := value.(type) {
|
||||||
|
case map[string]any:
|
||||||
|
for key, child := range data {
|
||||||
|
if text, ok := child.(string); ok && match(key, text) {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, child := range data {
|
||||||
|
if found := findMatchingString(child, match); found != "" {
|
||||||
|
return found
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case []any:
|
||||||
|
for _, child := range data {
|
||||||
|
if found := findMatchingString(child, match); found != "" {
|
||||||
|
return found
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
// 图片生成中转站适配器测试,验证任务提交、状态轮询及成功结果解析契约。
|
||||||
|
package apimart
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestSubmitImageUsesGenerationEndpoint 验证图片生成使用配置渠道下的标准提交接口。
|
||||||
|
func TestSubmitImageUsesGenerationEndpoint(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
|
||||||
|
if request.Method != http.MethodPost || request.URL.Path != "/v1/images/generations" {
|
||||||
|
t.Fatalf("unexpected request: %s %s", request.Method, request.URL.Path)
|
||||||
|
}
|
||||||
|
fmt.Fprint(response, `{"code":200,"data":[{"status":"submitted","task_id":"task-image-1"}]}`)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
result, err := NewClient(server.Client()).SubmitImage(context.Background(), server.URL+"/v1", "test-key", "request-1", map[string]any{"model": "gpt-image-2"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SubmitImage returned error: %v", err)
|
||||||
|
}
|
||||||
|
if result.TaskID != "task-image-1" {
|
||||||
|
t.Fatalf("unexpected task id: %s", result.TaskID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPollImageUsesTaskEndpoint 验证图片任务使用 APIMart 统一任务接口查询并解析完成结果。
|
||||||
|
func TestPollImageUsesTaskEndpoint(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
|
||||||
|
if request.Method != http.MethodGet || request.URL.Path != "/v1/tasks/task-image-1" {
|
||||||
|
t.Fatalf("unexpected request: %s %s", request.Method, request.URL.Path)
|
||||||
|
}
|
||||||
|
fmt.Fprint(response, `{"code":200,"data":{"id":"task-image-1","status":"completed","result":{"images":[{"url":["https://example.com/result.png"]}]}}}`)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
result, err := NewClient(server.Client()).PollImage(context.Background(), server.URL+"/v1", "test-key", "task-image-1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("PollImage returned error: %v", err)
|
||||||
|
}
|
||||||
|
if result.Status != "completed" || result.URL != "https://example.com/result.png" {
|
||||||
|
t.Fatalf("unexpected result: %#v", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
package apimart
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"juhe-factory/api/internal/provider"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Seedance2 struct {
|
||||||
|
Gateway Client
|
||||||
|
}
|
||||||
|
|
||||||
|
type ReferenceCapability struct {
|
||||||
|
Supported bool `json:"supported"`
|
||||||
|
MaxCount int `json:"max_count"`
|
||||||
|
MinTotalDurationMS int64 `json:"min_total_duration_ms,omitempty"`
|
||||||
|
MaxTotalDurationMS int64 `json:"max_total_duration_ms,omitempty"`
|
||||||
|
MinShortSidePixels int `json:"min_short_side_pixels,omitempty"`
|
||||||
|
MaxShortSidePixels int `json:"max_short_side_pixels,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type VideoModelCapabilities struct {
|
||||||
|
// DurationMinSeconds / DurationMaxSeconds 表示视频模型支持的生成时长范围(秒),供前端可视化选择与后端校验。
|
||||||
|
DurationMinSeconds int `json:"duration_min_seconds"`
|
||||||
|
DurationMaxSeconds int `json:"duration_max_seconds"`
|
||||||
|
ReferenceImages ReferenceCapability `json:"reference_images"`
|
||||||
|
ReferenceAudios ReferenceCapability `json:"reference_audios"`
|
||||||
|
ReferenceVideos ReferenceCapability `json:"reference_videos"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var seedance2Capabilities = map[string]VideoModelCapabilities{
|
||||||
|
"doubao-seedance-2.0": seedance2ReferenceCapabilities(),
|
||||||
|
"doubao-seedance-2.0-fast": seedance2ReferenceCapabilities(),
|
||||||
|
"doubao-seedance-2.0-mini": seedance2ReferenceCapabilities(),
|
||||||
|
}
|
||||||
|
|
||||||
|
func seedance2ReferenceCapabilities() VideoModelCapabilities {
|
||||||
|
return VideoModelCapabilities{
|
||||||
|
DurationMinSeconds: 5,
|
||||||
|
DurationMaxSeconds: 15,
|
||||||
|
ReferenceImages: ReferenceCapability{Supported: true, MaxCount: 9},
|
||||||
|
ReferenceAudios: ReferenceCapability{Supported: true, MaxCount: 3, MaxTotalDurationMS: 15_000},
|
||||||
|
ReferenceVideos: ReferenceCapability{Supported: true, MaxCount: 3, MinTotalDurationMS: 1_800, MaxTotalDurationMS: 15_200, MinShortSidePixels: 480, MaxShortSidePixels: 720},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func VideoCapabilities(modelName string) (VideoModelCapabilities, bool) {
|
||||||
|
capabilities, ok := seedance2Capabilities[strings.TrimSpace(modelName)]
|
||||||
|
return capabilities, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSeedance2(client *http.Client) Seedance2 {
|
||||||
|
return Seedance2{Gateway: NewClient(client)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func BuildSeedance2Payload(modelName string, inputData json.RawMessage) (map[string]any, error) {
|
||||||
|
modelName = strings.TrimSpace(modelName)
|
||||||
|
capabilities, supported := VideoCapabilities(modelName)
|
||||||
|
if !supported {
|
||||||
|
return nil, fmt.Errorf("暂不支持视频模型 %q", modelName)
|
||||||
|
}
|
||||||
|
input := map[string]any{}
|
||||||
|
if err := json.Unmarshal(inputData, &input); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
prompt := strings.TrimSpace(fmt.Sprint(input["prompt"]))
|
||||||
|
if prompt == "" || prompt == "<nil>" {
|
||||||
|
return nil, errors.New("生成提示词不能为空")
|
||||||
|
}
|
||||||
|
size := strings.TrimSpace(fmt.Sprint(input["size"]))
|
||||||
|
if size != "16:9" && size != "9:16" {
|
||||||
|
return nil, errors.New("项目画面比例不受 Seedance 2 支持")
|
||||||
|
}
|
||||||
|
duration, err := strconv.Atoi(fmt.Sprint(input["duration"]))
|
||||||
|
if err != nil || duration < 5 || duration > 15 {
|
||||||
|
return nil, errors.New("Seedance 2 视频时长必须为 5 到 15 秒")
|
||||||
|
}
|
||||||
|
images, err := seedanceURLList(input["image_urls"])
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("Seedance 2 参考图无效: %w", err)
|
||||||
|
}
|
||||||
|
if len(images) > capabilities.ReferenceImages.MaxCount {
|
||||||
|
return nil, fmt.Errorf("Seedance 2 最多支持 %d 张参考图", capabilities.ReferenceImages.MaxCount)
|
||||||
|
}
|
||||||
|
audios, err := seedanceURLList(input["audio_urls"])
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("Seedance 2 参考音频无效: %w", err)
|
||||||
|
}
|
||||||
|
if len(audios) > capabilities.ReferenceAudios.MaxCount {
|
||||||
|
return nil, fmt.Errorf("Seedance 2 最多支持 %d 个参考音频", capabilities.ReferenceAudios.MaxCount)
|
||||||
|
}
|
||||||
|
videos, err := seedanceURLList(input["video_urls"])
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("Seedance 2 参考视频无效: %w", err)
|
||||||
|
}
|
||||||
|
if len(videos) > capabilities.ReferenceVideos.MaxCount {
|
||||||
|
return nil, fmt.Errorf("Seedance 2 最多支持 %d 个参考视频", capabilities.ReferenceVideos.MaxCount)
|
||||||
|
}
|
||||||
|
if len(audios) > 0 && len(images) == 0 && len(videos) == 0 {
|
||||||
|
return nil, errors.New("Seedance 2 参考音频必须与参考图片或参考视频同时使用")
|
||||||
|
}
|
||||||
|
payload := map[string]any{
|
||||||
|
"model": modelName, "prompt": prompt, "size": size, "duration": duration,
|
||||||
|
"resolution": seedanceString(input["resolution"], "480p"),
|
||||||
|
}
|
||||||
|
if len(images) > 0 {
|
||||||
|
payload["image_urls"] = images
|
||||||
|
}
|
||||||
|
if len(audios) > 0 {
|
||||||
|
payload["audio_urls"] = audios
|
||||||
|
}
|
||||||
|
if len(videos) > 0 {
|
||||||
|
payload["video_urls"] = videos
|
||||||
|
}
|
||||||
|
if value, ok := input["generate_audio"].(bool); ok {
|
||||||
|
payload["generate_audio"] = value
|
||||||
|
}
|
||||||
|
if value, ok := input["return_last_frame"].(bool); ok {
|
||||||
|
payload["return_last_frame"] = value
|
||||||
|
}
|
||||||
|
return payload, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s Seedance2) Submit(ctx context.Context, baseURL, apiKey, requestID string, payload map[string]any) (provider.SubmitResult, error) {
|
||||||
|
data, err := s.Gateway.requestJSON(ctx, http.MethodPost, baseURL, "/videos/generations", apiKey, requestID, payload)
|
||||||
|
if err != nil {
|
||||||
|
return provider.SubmitResult{}, err
|
||||||
|
}
|
||||||
|
var decoded any
|
||||||
|
if err := json.Unmarshal(data, &decoded); err != nil {
|
||||||
|
return provider.SubmitResult{}, errors.New("Seedance 2 提交响应不是有效 JSON")
|
||||||
|
}
|
||||||
|
result := provider.SubmitResult{TaskID: findString(decoded, "task_id", "id"), URL: findSeedanceVideoURL(decoded), Raw: append(json.RawMessage(nil), data...)}
|
||||||
|
if result.TaskID == "" && result.URL == "" {
|
||||||
|
return provider.SubmitResult{}, errors.New("Seedance 2 提交响应缺少任务 ID 或视频地址")
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s Seedance2) Poll(ctx context.Context, baseURL, apiKey, taskID string) (provider.PollResult, error) {
|
||||||
|
data, err := s.Gateway.requestJSON(ctx, http.MethodGet, baseURL, "/tasks/"+taskID, apiKey, "", nil)
|
||||||
|
if err != nil {
|
||||||
|
return provider.PollResult{}, err
|
||||||
|
}
|
||||||
|
var decoded any
|
||||||
|
if err := json.Unmarshal(data, &decoded); err != nil {
|
||||||
|
return provider.PollResult{}, errors.New("Seedance 2 轮询响应不是有效 JSON")
|
||||||
|
}
|
||||||
|
status := strings.ToLower(findString(decoded, "task_status", "status", "state"))
|
||||||
|
if status == "" {
|
||||||
|
status = "processing"
|
||||||
|
}
|
||||||
|
return provider.PollResult{
|
||||||
|
Status: status, URL: findSeedanceVideoURL(decoded),
|
||||||
|
Error: findString(decoded, "error_message", "message", "detail"), Raw: append(json.RawMessage(nil), data...),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func seedanceURLList(value any) ([]string, error) {
|
||||||
|
if value == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
raw, ok := value.([]any)
|
||||||
|
if !ok {
|
||||||
|
return nil, errors.New("必须是 URL 数组")
|
||||||
|
}
|
||||||
|
result := make([]string, 0, len(raw))
|
||||||
|
for _, item := range raw {
|
||||||
|
url := strings.TrimSpace(fmt.Sprint(item))
|
||||||
|
if !strings.HasPrefix(url, "https://") && !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "asset://") {
|
||||||
|
return nil, errors.New("包含不可访问的 URL")
|
||||||
|
}
|
||||||
|
result = append(result, url)
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func seedanceString(value any, fallback string) string {
|
||||||
|
text := strings.TrimSpace(fmt.Sprint(value))
|
||||||
|
if text == "" || text == "<nil>" {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
func findSeedanceVideoURL(value any) string {
|
||||||
|
if url := findURLInSeedanceVideoField(value); url != "" {
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
return findURL(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func findURLInSeedanceVideoField(value any) string {
|
||||||
|
switch data := value.(type) {
|
||||||
|
case map[string]any:
|
||||||
|
for key, child := range data {
|
||||||
|
if strings.Contains(strings.ToLower(key), "video") {
|
||||||
|
if text, ok := child.(string); ok && (strings.HasPrefix(text, "https://") || strings.HasPrefix(text, "http://")) {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
if url := findURL(child); url != "" {
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, child := range data {
|
||||||
|
if url := findURLInSeedanceVideoField(child); url != "" {
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case []any:
|
||||||
|
for _, child := range data {
|
||||||
|
if url := findURLInSeedanceVideoField(child); url != "" {
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package apimart
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestVideoCapabilities(t *testing.T) {
|
||||||
|
capabilities, ok := VideoCapabilities("doubao-seedance-2.0")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected Seedance 2 capabilities")
|
||||||
|
}
|
||||||
|
if capabilities.ReferenceImages.MaxCount != 9 || capabilities.ReferenceAudios.MaxCount != 3 || capabilities.ReferenceVideos.MaxCount != 3 {
|
||||||
|
t.Fatalf("unexpected reference limits: %+v", capabilities)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildSeedance2PayloadSupportsVideoAndAudioReferences(t *testing.T) {
|
||||||
|
input := json.RawMessage(`{
|
||||||
|
"prompt":"test",
|
||||||
|
"size":"16:9",
|
||||||
|
"duration":5,
|
||||||
|
"video_urls":["https://example.com/reference.mp4"],
|
||||||
|
"audio_urls":["https://example.com/reference.mp3"]
|
||||||
|
}`)
|
||||||
|
payload, err := BuildSeedance2Payload("doubao-seedance-2.0", input)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildSeedance2Payload() error = %v", err)
|
||||||
|
}
|
||||||
|
if videos, ok := payload["video_urls"].([]string); !ok || len(videos) != 1 {
|
||||||
|
t.Fatalf("video_urls = %#v", payload["video_urls"])
|
||||||
|
}
|
||||||
|
if audios, ok := payload["audio_urls"].([]string); !ok || len(audios) != 1 {
|
||||||
|
t.Fatalf("audio_urls = %#v", payload["audio_urls"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildSeedance2PayloadRejectsTooManyVideos(t *testing.T) {
|
||||||
|
input := json.RawMessage(`{
|
||||||
|
"prompt":"test",
|
||||||
|
"size":"16:9",
|
||||||
|
"duration":5,
|
||||||
|
"video_urls":["https://example.com/1.mp4","https://example.com/2.mp4","https://example.com/3.mp4","https://example.com/4.mp4"]
|
||||||
|
}`)
|
||||||
|
if _, err := BuildSeedance2Payload("doubao-seedance-2.0", input); err == nil {
|
||||||
|
t.Fatal("expected too many reference videos to be rejected")
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user