初始化

This commit is contained in:
Ran
2026-08-25 17:59:42 +08:00
commit 4b7380dd9b
408 changed files with 327400 additions and 0 deletions
+21
View File
@@ -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
+11
View File
@@ -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
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+13
View File
@@ -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>
+2912
View File
File diff suppressed because it is too large Load Diff
+32
View File
@@ -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

+9
View File
@@ -0,0 +1,9 @@
<script setup lang="ts">
import { RouterView } from "vue-router";
import ToastContainer from "@/components/ToastContainer.vue";
</script>
<template>
<RouterView />
<ToastContainer />
</template>
+67
View File
@@ -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;
+3
View File
@@ -0,0 +1,3 @@
import logoUrl from "../../../WEB/src/assets/logo.png";
export default logoUrl;
+837
View File
@@ -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);
}
+36
View File
@@ -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;
}
+41
View File
@@ -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>
+123
View File
@@ -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>
+29
View File
@@ -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>
+326
View File
@@ -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>
+100
View File
@@ -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>
+11
View File
@@ -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>
+78
View File
@@ -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>
+148
View File
@@ -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>
+20
View File
@@ -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");
+16
View File
@@ -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;
+76
View File
@@ -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;
+31
View File
@@ -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;
}
},
},
});
+15
View File
@@ -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 };
});
+50
View File
@@ -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];
}
+243
View File
@@ -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>
+345
View File
@@ -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>
+85
View File
@@ -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>
+548
View File
@@ -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>
+475
View File
@@ -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>
+271
View File
@@ -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>
+181
View File
@@ -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: "密码长度必须为 820 位", 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="请输入 820 位新密码"
/>
</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>
+437
View File
@@ -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支持 JPEGPNGGIF最大 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>
+730
View File
@@ -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>
+13
View File
@@ -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/*"]
}
}
}
+11
View File
@@ -0,0 +1,11 @@
{
"files": [],
"references": [
{
"path": "./tsconfig.node.json"
},
{
"path": "./tsconfig.app.json"
}
]
}
+14
View File
@@ -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"]
}
}
+42
View File
@@ -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,
},
},
},
})
+226
View File
@@ -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>
+87
View File
@@ -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
+5
View File
@@ -0,0 +1,5 @@
# acme.sh 本地目录
这里保存部署使用的 acme.sh 单文件版本,不从 GitHub clone。
更新方式:在 Linux 服务器或 WSL 中执行 `bash ../deploy/update_acme.sh`
+9226
View File
File diff suppressed because it is too large Load Diff
+138
View File
@@ -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)
}
}
+53
View File
@@ -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。
+212
View File
@@ -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"
+30
View File
@@ -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())
+52
View File
@@ -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"
+166
View File
@@ -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 已启动。"
+69
View File
@@ -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.
+24
View File
@@ -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
View File
@@ -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
View File
@@ -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=
+59
View File
@@ -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
}
+250
View File
@@ -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
}
+30
View File
@@ -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)
}
}
}
+321
View File
@@ -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
}
+70
View File
@@ -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)
}
}
+25
View File
@@ -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
}
+241
View File
@@ -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
}
+76
View File
@@ -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{},
}
}
+59
View File
@@ -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()
}
+133
View File
@@ -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
+30
View File
@@ -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) }
+20
View File
@@ -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
}
+18
View File
@@ -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")
}
}
+111
View File
@@ -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")})
}
+544
View File
@@ -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()
}
}
+210
View File
@@ -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
}
+48
View File
@@ -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,
})
}
+236
View File
@@ -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)
}
+29
View File
@@ -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)
}
})
}
}
+257
View File
@@ -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})
}
+166
View File
@@ -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)
}
+226
View File
@@ -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
}
+156
View File
@@ -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"
}
+17
View File
@@ -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)
}
}
+46
View File
@@ -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" }
+299
View File
@@ -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"`
}
+19
View File
@@ -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)
}
}
+36
View File
@@ -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"`
}
+39
View File
@@ -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" }
+306
View File
@@ -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)
}
})
}
}
+328
View File
@@ -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)
}
}
+64
View File
@@ -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)
}
}
+141
View File
@@ -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)
}
}
+64
View File
@@ -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
}
+103
View File
@@ -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)
}
}
+225
View File
@@ -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