初始化
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