feat: 添加注册开关功能,优化系统配置与注册接口
ocs-nuxt / deploy (push) Failing after 11s

This commit is contained in:
2026-05-23 23:48:30 +08:00
parent bc785ac1ce
commit 5e99e28e8b
15 changed files with 371 additions and 48 deletions
+24
View File
@@ -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
+125
View File
@@ -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
View File
@@ -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 . .
# 安装依赖并触发 postinstallprisma 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"]
+6
View File
@@ -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
+38 -9
View File
@@ -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>
+2
View File
@@ -1,6 +1,8 @@
// app/interfaces/index.ts - 前端共享类型统一出口
export * from "./answer";
export type {
IRegisterStatusPublic,
IRegisterStatusResponse,
ISystemConfigPublic,
ISystemConfigResponse,
ISystemConfigUpdateRequest,
+16 -1
View File
@@ -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"
>
+15 -7
View File
@@ -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
View File
@@ -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
};
});
+24 -20
View File
@@ -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
});
});
);
+17 -6
View File
@@ -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, "服务器内部错误");
}
+16
View File
@@ -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 });
}
);
+22
View File
@@ -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, "当前未开启注册");
}
});
+14 -4
View File
@@ -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
};
};
+13
View File
@@ -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;
}
/** 系统配置接口响应 */