@@ -0,0 +1,24 @@
|
||||
# 依赖与构建产物(容器内重新生成)
|
||||
node_modules/
|
||||
.nuxt/
|
||||
.output/
|
||||
prisma/generated/
|
||||
|
||||
# 本地开发配置
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Git 与编辑器
|
||||
.git/
|
||||
.gitignore
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# 日志
|
||||
*.log
|
||||
logs/
|
||||
|
||||
# 系统文件
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
@@ -0,0 +1,125 @@
|
||||
name: ocs-nuxt
|
||||
run-name: "${{ gitea.actor }} 正在部署 OCS 题库服务"
|
||||
|
||||
env:
|
||||
IMAGE_NAME: ocs-nuxt
|
||||
BASE_IMAGE: ocs-base
|
||||
HOST_PORT: 32205
|
||||
DOCKER_BUILDKIT: "0"
|
||||
|
||||
on:
|
||||
- push
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on:
|
||||
- vps_jp
|
||||
|
||||
steps:
|
||||
- name: 环境准备
|
||||
run: |
|
||||
STEP_START=$(date +%s)
|
||||
|
||||
if ! command -v docker &> /dev/null; then
|
||||
echo "📦 安装 docker cli..."
|
||||
apk add docker-cli
|
||||
fi
|
||||
if ! command -v git &> /dev/null; then
|
||||
echo "📦 安装 git..."
|
||||
apk add git
|
||||
fi
|
||||
docker --version
|
||||
echo "🧱 BuildKit: ${DOCKER_BUILDKIT}"
|
||||
echo "✅ 环境准备耗时: $(( $(date +%s) - STEP_START ))s"
|
||||
|
||||
- name: 克隆仓库代码
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: 构建基础镜像(首次或 Dockerfile.base 变更时)
|
||||
run: |
|
||||
STEP_START=$(date +%s)
|
||||
|
||||
# 通过 Dockerfile.base 的内容 hash 判断是否需要重新构建
|
||||
BASE_HASH=$(sha256sum Dockerfile.base | cut -c1-12)
|
||||
CURRENT_HASH=$(docker inspect $BASE_IMAGE:latest --format '{{index .Config.Labels "base.hash"}}' 2>/dev/null || echo "none")
|
||||
|
||||
if [ "$BASE_HASH" != "$CURRENT_HASH" ]; then
|
||||
echo "📦 基础镜像需要构建 (hash: $BASE_HASH)"
|
||||
docker build \
|
||||
--label "base.hash=$BASE_HASH" \
|
||||
-f Dockerfile.base \
|
||||
-t $BASE_IMAGE:latest .
|
||||
echo "✅ 基础镜像构建完成"
|
||||
else
|
||||
echo "✅ 基础镜像已是最新,跳过 (hash: $BASE_HASH)"
|
||||
fi
|
||||
echo "✅ 基础镜像步骤耗时: $(( $(date +%s) - STEP_START ))s"
|
||||
|
||||
- name: 构建应用镜像
|
||||
run: |
|
||||
STEP_START=$(date +%s)
|
||||
|
||||
echo "📦 开始构建应用镜像..."
|
||||
docker build -t $IMAGE_NAME:latest .
|
||||
echo "✅ 应用镜像构建完成"
|
||||
docker images $IMAGE_NAME:latest --format " 大小: {{.Size}}"
|
||||
echo "✅ 应用镜像步骤耗时: $(( $(date +%s) - STEP_START ))s"
|
||||
|
||||
- name: 部署容器
|
||||
run: |
|
||||
STEP_START=$(date +%s)
|
||||
|
||||
# 停止并删除旧容器
|
||||
echo "🔄 停止旧容器..."
|
||||
if docker ps -a --filter "name=^${IMAGE_NAME}$" -q | grep -q .; then
|
||||
docker stop --timeout 15 $IMAGE_NAME || true
|
||||
docker rm $IMAGE_NAME || true
|
||||
else
|
||||
echo " 无旧容器需要清理"
|
||||
fi
|
||||
|
||||
# 启动新容器
|
||||
echo "🚀 启动新容器..."
|
||||
docker run -d \
|
||||
--name $IMAGE_NAME \
|
||||
-p 127.0.0.1:$HOST_PORT:3000 \
|
||||
-e DATABASE_URL=${{ secrets.DATABASE_URL }} \
|
||||
-e BETTER_AUTH_SECRET=${{ secrets.BETTER_AUTH_SECRET }} \
|
||||
-e BETTER_AUTH_URL=${{ secrets.BETTER_AUTH_URL }} \
|
||||
--restart unless-stopped \
|
||||
$IMAGE_NAME:latest
|
||||
|
||||
# 等待并检查容器状态
|
||||
echo "⏳ 等待容器启动/健康检查..."
|
||||
MAX_WAIT=60
|
||||
WAITED=0
|
||||
while [ $WAITED -lt $MAX_WAIT ]; do
|
||||
RUNNING_ID=$(docker ps --filter "name=^${IMAGE_NAME}$" --filter "status=running" -q)
|
||||
if [ -n "$RUNNING_ID" ]; then
|
||||
HEALTH=$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' $IMAGE_NAME 2>/dev/null || echo "unknown")
|
||||
if [ "$HEALTH" = "healthy" ] || [ "$HEALTH" = "none" ]; then
|
||||
break
|
||||
fi
|
||||
fi
|
||||
sleep 2
|
||||
WAITED=$((WAITED + 2))
|
||||
done
|
||||
|
||||
if docker ps --filter "name=$IMAGE_NAME" --filter "status=running" -q | grep -q .; then
|
||||
echo "✅ 容器运行中"
|
||||
docker ps --filter "name=$IMAGE_NAME" --format " 状态: {{.Status}}"
|
||||
echo " 启动等待: ${WAITED}s"
|
||||
else
|
||||
echo "❌ 容器启动失败"
|
||||
docker inspect $IMAGE_NAME --format ' ExitCode={{.State.ExitCode}} Error={{.State.Error}} StartedAt={{.State.StartedAt}} FinishedAt={{.State.FinishedAt}}' 2>/dev/null || true
|
||||
docker logs --tail 30 $IMAGE_NAME
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ 部署步骤耗时: $(( $(date +%s) - STEP_START ))s"
|
||||
|
||||
- name: 查看启动日志
|
||||
run: |
|
||||
STEP_START=$(date +%s)
|
||||
echo "📋 容器最近日志:"
|
||||
docker logs --tail 15 $IMAGE_NAME
|
||||
echo "✅ 日志步骤耗时: $(( $(date +%s) - STEP_START ))s"
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
# ocs-nuxt 应用镜像
|
||||
# 构建流程:pnpm install(含 postinstall: prisma generate + nuxt prepare)→ nuxt build → node 启动
|
||||
FROM ocs-base:latest
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 复制所有源码(node_modules/.nuxt/.output 已在 .dockerignore 中排除)
|
||||
COPY . .
|
||||
|
||||
# 安装依赖并触发 postinstall(prisma generate + nuxt prepare)
|
||||
# --frozen-lockfile 确保版本与 pnpm-lock.yaml 严格一致
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
# 构建 Nuxt 应用,输出到 .output/
|
||||
RUN pnpm build
|
||||
|
||||
# Nuxt server 默认监听 3000,可通过 PORT 环境变量覆盖
|
||||
ENV PORT=3000
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["node", ".output/server/index.mjs"]
|
||||
@@ -0,0 +1,6 @@
|
||||
# ocs-base - Node.js 22 Alpine + pnpm
|
||||
# 仅当 Node 版本或 pnpm 版本需要升级时才重新构建此镜像
|
||||
FROM node:22-alpine
|
||||
|
||||
# 启用 corepack 并激活 pnpm(版本与 vps 主机保持一致即可)
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
@@ -21,8 +21,14 @@ import { useSettingsStore } from "@/stores/settings";
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
const { userSettings, systemConfig, userLoading, userFetching, sysLoading } =
|
||||
storeToRefs(settingsStore);
|
||||
const {
|
||||
userSettings,
|
||||
systemConfig,
|
||||
userLoading,
|
||||
userFetching,
|
||||
sysLoading,
|
||||
sysFetching
|
||||
} = storeToRefs(settingsStore);
|
||||
|
||||
const open = ref(false);
|
||||
const isSuperAdmin = computed(() => authStore.user?.role === "superadmin");
|
||||
@@ -49,15 +55,16 @@ const sysForm = reactive({
|
||||
openAiModel: "",
|
||||
maxTokens: "" as string,
|
||||
temperature: "" as string,
|
||||
maxSseLineBytes: "" as string
|
||||
maxSseLineBytes: "" as string,
|
||||
allowRegistration: true
|
||||
});
|
||||
|
||||
/** 打开时拉取最新配置,并回填表单 */
|
||||
watch(open, async (val) => {
|
||||
watch(open, (val) => {
|
||||
if (!val) return;
|
||||
await settingsStore.fetchUserSettings();
|
||||
settingsStore.fetchUserSettings();
|
||||
if (isSuperAdmin.value) {
|
||||
await settingsStore.fetchSystemConfig();
|
||||
settingsStore.fetchSystemConfig();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -88,6 +95,7 @@ watch(
|
||||
sysForm.maxTokens = String(s.maxTokens);
|
||||
sysForm.temperature = String(s.temperature);
|
||||
sysForm.maxSseLineBytes = String(s.maxSseLineBytes);
|
||||
sysForm.allowRegistration = s.allowRegistration;
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
@@ -121,7 +129,8 @@ const saveSystemConfig = async () => {
|
||||
temperature: sysForm.temperature ? Number(sysForm.temperature) : undefined,
|
||||
maxSseLineBytes: sysForm.maxSseLineBytes
|
||||
? Number(sysForm.maxSseLineBytes)
|
||||
: undefined
|
||||
: undefined,
|
||||
allowRegistration: sysForm.allowRegistration
|
||||
});
|
||||
};
|
||||
</script>
|
||||
@@ -344,15 +353,35 @@ const saveSystemConfig = async () => {
|
||||
min="1024"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 注册开关 -->
|
||||
<div class="flex items-center gap-3 pt-1">
|
||||
<Switch
|
||||
id="sys-allow-registration"
|
||||
v-model="sysForm.allowRegistration"
|
||||
/>
|
||||
<Label
|
||||
for="sys-allow-registration"
|
||||
class="cursor-pointer select-none"
|
||||
>
|
||||
开放注册
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
class="w-full"
|
||||
variant="secondary"
|
||||
:disabled="sysLoading"
|
||||
:disabled="sysLoading || sysFetching || !sysForm.openAiApiBase"
|
||||
@click="saveSystemConfig"
|
||||
>
|
||||
{{ sysLoading ? "更新中..." : "更新系统配置" }}
|
||||
{{
|
||||
sysFetching
|
||||
? "加载中..."
|
||||
: sysLoading
|
||||
? "更新中..."
|
||||
: "更新系统配置"
|
||||
}}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// app/interfaces/index.ts - 前端共享类型统一出口
|
||||
export * from "./answer";
|
||||
export type {
|
||||
IRegisterStatusPublic,
|
||||
IRegisterStatusResponse,
|
||||
ISystemConfigPublic,
|
||||
ISystemConfigResponse,
|
||||
ISystemConfigUpdateRequest,
|
||||
|
||||
+16
-1
@@ -2,6 +2,7 @@
|
||||
<script lang="ts" setup>
|
||||
import { toTypedSchema } from "@vee-validate/zod";
|
||||
import { useForm } from "vee-validate";
|
||||
import { toast } from "vue-sonner";
|
||||
import * as z from "zod";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { useSettingsStore } from "@/stores/settings";
|
||||
|
||||
definePageMeta({
|
||||
pageOrder: 4,
|
||||
@@ -23,12 +25,22 @@ definePageMeta({
|
||||
useHead({ title: "注册 - OCS AI 答题服务" });
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
const { registrationOpen } = storeToRefs(settingsStore);
|
||||
|
||||
// 已登录用户访问注册页,直接跳首页
|
||||
watchEffect(() => {
|
||||
if (authStore.isOnline) navigateTo("/");
|
||||
});
|
||||
|
||||
// 进入页面时拉取注册开放状态,关闭时弹 toast 提示
|
||||
onMounted(async () => {
|
||||
await settingsStore.fetchRegistrationStatus();
|
||||
if (registrationOpen.value === false) {
|
||||
toast.error("当前未开启注册,请联系管理员");
|
||||
}
|
||||
});
|
||||
|
||||
const formSchema = toTypedSchema(
|
||||
z.object({
|
||||
name: z
|
||||
@@ -121,7 +133,10 @@ const onSubmit = handleSubmit(async (values) => {
|
||||
<Button
|
||||
type="submit"
|
||||
:disabled="
|
||||
isSubmitting || authStore.loading || !authStore.initialized
|
||||
registrationOpen === false ||
|
||||
isSubmitting ||
|
||||
authStore.loading ||
|
||||
!authStore.initialized
|
||||
"
|
||||
class="w-full"
|
||||
>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// app/services/settings_service.ts - 用户设置和系统配置 API 封装
|
||||
import type {
|
||||
IApiResponse,
|
||||
IRegisterStatusResponse,
|
||||
ISystemConfigResponse,
|
||||
ISystemConfigUpdateRequest,
|
||||
IUserSettingsResponse,
|
||||
@@ -20,10 +21,10 @@ export class SettingsService {
|
||||
|
||||
/** 更新当前用户的自定义 API 配置 */
|
||||
public static updateUserSettings(req: IUserSettingsUpdateRequest) {
|
||||
return BaseClientService.post<IApiResponse<null>, IUserSettingsUpdateRequest>(
|
||||
`${SettingsService.basePath}/user/settings`,
|
||||
req
|
||||
);
|
||||
return BaseClientService.post<
|
||||
IApiResponse<null>,
|
||||
IUserSettingsUpdateRequest
|
||||
>(`${SettingsService.basePath}/user/settings`, req);
|
||||
}
|
||||
|
||||
/** 读取系统配置(仅 superadmin) */
|
||||
@@ -35,9 +36,16 @@ export class SettingsService {
|
||||
|
||||
/** 更新系统配置(仅 superadmin) */
|
||||
public static updateSystemConfig(req: ISystemConfigUpdateRequest) {
|
||||
return BaseClientService.put<IApiResponse<null>, ISystemConfigUpdateRequest>(
|
||||
`${SettingsService.basePath}/admin/system-config`,
|
||||
req
|
||||
return BaseClientService.put<
|
||||
IApiResponse<null>,
|
||||
ISystemConfigUpdateRequest
|
||||
>(`${SettingsService.basePath}/admin/system-config`, req);
|
||||
}
|
||||
|
||||
/** 查询注册是否开放(公开接口,无需登录) */
|
||||
public static getRegisterStatus() {
|
||||
return BaseClientService.get<IRegisterStatusResponse>(
|
||||
`${SettingsService.basePath}/auth/register-open`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+18
-1
@@ -20,6 +20,8 @@ export const useSettingsStore = defineStore("settings", () => {
|
||||
const userSettings = ref<IUserSettings | null>(null);
|
||||
/** 系统配置(仅 superadmin),null 表示尚未加载或无权限 */
|
||||
const systemConfig = ref<ISystemConfigPublic | null>(null);
|
||||
/** 注册是否开放,null 表示尚未加载 */
|
||||
const registrationOpen = ref<boolean | null>(null);
|
||||
/** 加载/保存中状态 */
|
||||
const userLoading = ref(false); // 仅用于保存
|
||||
const userFetching = ref(false); // 仅用于 GET 拉取
|
||||
@@ -101,9 +103,23 @@ export const useSettingsStore = defineStore("settings", () => {
|
||||
}
|
||||
};
|
||||
|
||||
/** 查询注册是否开放(公开接口,无需登录) */
|
||||
const fetchRegistrationStatus = async () => {
|
||||
try {
|
||||
const res = await SettingsService.getRegisterStatus();
|
||||
if (res.data.code === 0 && res.data.data !== undefined) {
|
||||
registrationOpen.value = res.data.data.allowRegistration;
|
||||
}
|
||||
} catch {
|
||||
// 静默失败,注册页面兜底允许展示按钮
|
||||
registrationOpen.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
userSettings,
|
||||
systemConfig,
|
||||
registrationOpen,
|
||||
userLoading,
|
||||
userFetching,
|
||||
sysLoading,
|
||||
@@ -111,6 +127,7 @@ export const useSettingsStore = defineStore("settings", () => {
|
||||
fetchUserSettings,
|
||||
updateUserSettings,
|
||||
fetchSystemConfig,
|
||||
updateSystemConfig
|
||||
updateSystemConfig,
|
||||
fetchRegistrationStatus
|
||||
};
|
||||
});
|
||||
|
||||
@@ -7,27 +7,31 @@
|
||||
// - 此接口不返回任何用户数据
|
||||
|
||||
import { setResponseStatus } from "h3";
|
||||
import { getSysConfig, maskApiKey } from "~~/server/utils/sysConfig";
|
||||
|
||||
import { apiErr, apiOk } from "~~/server/utils/response";
|
||||
import { getSysConfig, maskApiKey } from "~~/server/utils/sysConfig";
|
||||
import type { ISystemConfigResponse } from "~~/shared/types/settings";
|
||||
|
||||
export default defineEventHandler(async (event): Promise<ISystemConfigResponse> => {
|
||||
// 二次校验:必须是 superadmin 才能访问系统配置
|
||||
const role = (event.context.auth?.user as { role?: string } | undefined)
|
||||
?.role;
|
||||
if (role !== "superadmin") {
|
||||
setResponseStatus(event, 403);
|
||||
return apiErr(403, "权限不足") as ISystemConfigResponse;
|
||||
export default defineEventHandler(
|
||||
async (event): Promise<ISystemConfigResponse> => {
|
||||
// 二次校验:必须是 superadmin 才能访问系统配置
|
||||
const role = (event.context.auth?.user as { role?: string } | undefined)
|
||||
?.role;
|
||||
if (role !== "superadmin") {
|
||||
setResponseStatus(event, 403);
|
||||
return apiErr(403, "权限不足") as unknown as ISystemConfigResponse;
|
||||
}
|
||||
|
||||
const cfg = await getSysConfig();
|
||||
|
||||
return apiOk({
|
||||
openAiApiBase: cfg.openAiApiBase,
|
||||
openAiApiKeyPreview: maskApiKey(cfg.openAiApiKey),
|
||||
openAiModel: cfg.openAiModel,
|
||||
maxTokens: cfg.maxTokens,
|
||||
temperature: cfg.temperature,
|
||||
maxSseLineBytes: cfg.maxSseLineBytes,
|
||||
allowRegistration: cfg.allowRegistration
|
||||
});
|
||||
}
|
||||
|
||||
const cfg = await getSysConfig();
|
||||
|
||||
return apiOk({
|
||||
openAiApiBase: cfg.openAiApiBase,
|
||||
openAiApiKeyPreview: maskApiKey(cfg.openAiApiKey),
|
||||
openAiModel: cfg.openAiModel,
|
||||
maxTokens: cfg.maxTokens,
|
||||
temperature: cfg.temperature,
|
||||
maxSseLineBytes: cfg.maxSseLineBytes
|
||||
});
|
||||
});
|
||||
);
|
||||
|
||||
@@ -7,12 +7,11 @@
|
||||
// - 更新成功后立即失效系统配置缓存,下次请求即生效
|
||||
|
||||
import { readBody, setResponseStatus } from "h3";
|
||||
|
||||
import { prisma } from "~~/server/utils/db";
|
||||
import { createApiLogger, toSafeLogError } from "~~/server/utils/logging";
|
||||
import { apiErr, apiOk } from "~~/server/utils/response";
|
||||
import {
|
||||
invalidateSysConfigCache
|
||||
} from "~~/server/utils/sysConfig";
|
||||
import { invalidateSysConfigCache } from "~~/server/utils/sysConfig";
|
||||
import type { ISystemConfigUpdateRequest } from "~~/shared/types/settings";
|
||||
|
||||
const isRecord = (v: unknown): v is Record<string, unknown> =>
|
||||
@@ -25,7 +24,8 @@ const SYS_KEY = {
|
||||
openAiModel: "openai_model",
|
||||
maxTokens: "max_tokens",
|
||||
temperature: "temperature",
|
||||
maxSseLineBytes: "openai_stream_max_sse_line_bytes"
|
||||
maxSseLineBytes: "openai_stream_max_sse_line_bytes",
|
||||
allowRegistration: "allow_registration"
|
||||
} as const;
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
@@ -63,6 +63,9 @@ export default defineEventHandler(async (event) => {
|
||||
if (typeof body.maxSseLineBytes === "number") {
|
||||
req.maxSseLineBytes = body.maxSseLineBytes;
|
||||
}
|
||||
if (typeof body.allowRegistration === "boolean") {
|
||||
req.allowRegistration = body.allowRegistration;
|
||||
}
|
||||
|
||||
// 构建需要写入的 KV 对(key 为空时跳过)
|
||||
type SysKv = { key: string; value: string };
|
||||
@@ -90,13 +93,19 @@ export default defineEventHandler(async (event) => {
|
||||
value: String(req.maxSseLineBytes)
|
||||
});
|
||||
}
|
||||
if (req.allowRegistration !== undefined) {
|
||||
upserts.push({
|
||||
key: SYS_KEY.allowRegistration,
|
||||
value: String(req.allowRegistration)
|
||||
});
|
||||
}
|
||||
|
||||
if (upserts.length === 0) {
|
||||
return apiOk(null);
|
||||
}
|
||||
|
||||
try {
|
||||
await prisma.$transaction(
|
||||
await Promise.all(
|
||||
upserts.map((item) =>
|
||||
prisma.systemConfig.upsert({
|
||||
where: { key: item.key },
|
||||
@@ -115,7 +124,9 @@ export default defineEventHandler(async (event) => {
|
||||
|
||||
return apiOk(null);
|
||||
} catch (error) {
|
||||
logger.error("system_config_update_failed", { error: toSafeLogError(error) });
|
||||
logger.error("system_config_update_failed", {
|
||||
error: toSafeLogError(error)
|
||||
});
|
||||
setResponseStatus(event, 500);
|
||||
return apiErr(500, "服务器内部错误");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
// server/api/auth/register-open.get.ts - 公开接口:查询当前是否开放注册
|
||||
//
|
||||
// 该接口无需登录,供前端注册页面展示禁用提示使用。
|
||||
// 路径位于 /api/auth/** 公开区内,不经过鉴权中间件。
|
||||
// 不返回任何用户数据或系统敏感信息。
|
||||
|
||||
import { apiOk } from "~~/server/utils/response";
|
||||
import { getSysConfig } from "~~/server/utils/sysConfig";
|
||||
import type { IRegisterStatusResponse } from "~~/shared/types/settings";
|
||||
|
||||
export default defineEventHandler(
|
||||
async (): Promise<IRegisterStatusResponse> => {
|
||||
const cfg = await getSysConfig();
|
||||
return apiOk({ allowRegistration: cfg.allowRegistration });
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,22 @@
|
||||
// server/middleware/registration-guard.ts - 注册开关守卫
|
||||
//
|
||||
// 拦截 POST /api/auth/sign-up/email,若系统配置关闭了注册,立即返回 403。
|
||||
// 此中间件优先于 Better Auth catch-all handler 执行,无需改动 auth.ts。
|
||||
|
||||
import { getRequestURL, setResponseStatus } from "h3";
|
||||
|
||||
import { apiErr } from "~~/server/utils/response";
|
||||
import { getSysConfig } from "~~/server/utils/sysConfig";
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const { pathname } = getRequestURL(event);
|
||||
|
||||
// 只拦截注册接口
|
||||
if (pathname !== "/api/auth/sign-up/email" || event.method !== "POST") return;
|
||||
|
||||
const cfg = await getSysConfig();
|
||||
if (!cfg.allowRegistration) {
|
||||
setResponseStatus(event, 403);
|
||||
return apiErr(403, "当前未开启注册");
|
||||
}
|
||||
});
|
||||
@@ -17,6 +17,8 @@ export interface SysConfig {
|
||||
temperature: number;
|
||||
/** 单行 SSE data 最大字节数,防止异常流无限堆内存 */
|
||||
maxSseLineBytes: number;
|
||||
/** 是否允许新用户注册;false 时注册接口直接拒绝 */
|
||||
allowRegistration: boolean;
|
||||
}
|
||||
|
||||
// system_config 表 key 字段的常量映射
|
||||
@@ -26,7 +28,8 @@ const CONFIG_KEYS = {
|
||||
openAiModel: "openai_model",
|
||||
maxTokens: "max_tokens",
|
||||
temperature: "temperature",
|
||||
maxSseLineBytes: "openai_stream_max_sse_line_bytes"
|
||||
maxSseLineBytes: "openai_stream_max_sse_line_bytes",
|
||||
allowRegistration: "allow_registration"
|
||||
} as const;
|
||||
|
||||
// 硬编码默认值,与原 env.ts 的 fallback 保持一致
|
||||
@@ -36,7 +39,9 @@ const DEFAULTS: SysConfig = {
|
||||
openAiModel: "gpt-5.2",
|
||||
maxTokens: 500,
|
||||
temperature: 0.7,
|
||||
maxSseLineBytes: 1_048_576
|
||||
maxSseLineBytes: 1_048_576,
|
||||
// 默认开放注册;管理员可通过系统配置关闭
|
||||
allowRegistration: true
|
||||
};
|
||||
|
||||
// 内存缓存:减少每请求查 DB 的开销
|
||||
@@ -82,7 +87,10 @@ export const getSysConfig = async (): Promise<SysConfig> => {
|
||||
openAiModel: str(CONFIG_KEYS.openAiModel, DEFAULTS.openAiModel),
|
||||
maxTokens: int(CONFIG_KEYS.maxTokens, DEFAULTS.maxTokens),
|
||||
temperature: float(CONFIG_KEYS.temperature, DEFAULTS.temperature),
|
||||
maxSseLineBytes: int(CONFIG_KEYS.maxSseLineBytes, DEFAULTS.maxSseLineBytes)
|
||||
maxSseLineBytes: int(CONFIG_KEYS.maxSseLineBytes, DEFAULTS.maxSseLineBytes),
|
||||
// "true" 以外的值均视为关闭;空值(未设置)走默认值 true
|
||||
allowRegistration:
|
||||
(map.get(CONFIG_KEYS.allowRegistration) ?? "true") !== "false"
|
||||
};
|
||||
|
||||
_cachedConfig = config;
|
||||
@@ -144,7 +152,9 @@ export const getUserEffectiveApiConfig = async (
|
||||
openAiModel: customModel.length > 0 ? customModel : sysCfg.openAiModel,
|
||||
maxTokens: userConfig.customMaxTokens ?? sysCfg.maxTokens,
|
||||
temperature: userConfig.customTemperature ?? sysCfg.temperature,
|
||||
maxSseLineBytes: sysCfg.maxSseLineBytes
|
||||
maxSseLineBytes: sysCfg.maxSseLineBytes,
|
||||
// 用户有效配置不改变注册开关,手带系统值
|
||||
allowRegistration: sysCfg.allowRegistration
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -68,8 +68,19 @@ export interface ISystemConfigPublic {
|
||||
temperature: number;
|
||||
/** SSE 单行最大字节数 */
|
||||
maxSseLineBytes: number;
|
||||
/** 是否允许新用户注册 */
|
||||
allowRegistration: boolean;
|
||||
}
|
||||
|
||||
/** 注册状态公开响应(无需登录可访问) */
|
||||
export interface IRegisterStatusPublic {
|
||||
/** 是否允许新用户注册 */
|
||||
allowRegistration: boolean;
|
||||
}
|
||||
|
||||
/** 注册状态接口响应 */
|
||||
export type IRegisterStatusResponse = IApiResponse<IRegisterStatusPublic>;
|
||||
|
||||
/** 更新系统配置的请求体(仅 superadmin 可访问) */
|
||||
export interface ISystemConfigUpdateRequest {
|
||||
/** API base URL */
|
||||
@@ -87,6 +98,8 @@ export interface ISystemConfigUpdateRequest {
|
||||
temperature?: number;
|
||||
/** SSE 单行最大字节数 */
|
||||
maxSseLineBytes?: number;
|
||||
/** 是否允许新用户注册 */
|
||||
allowRegistration?: boolean;
|
||||
}
|
||||
|
||||
/** 系统配置接口响应 */
|
||||
|
||||
Reference in New Issue
Block a user