fix: Phase 2 可运行性保障 - 修复JWT用户ID字段不一致、添加缺失的Auth Guard、修复测试

关键修复:
- req.user?.id → req.user?.userId: Order/Notification/Payment 3个Controller共18处
- NotificationController/PaymentController 添加 @UseGuards(JwtAuthGuard)
- alipay/wechat auth 测试: oauthAccount → oAuthAccount, 补全mock数据
- order.controller.spec.ts: mockRequest.user.id → userId
- OpenTelemetry 改为可选加载 (OTEL_ENABLED)
- 前端: telemetry.ts改为no-op, web-vitals v4 API, Tailwind v4兼容
- Docker: postgres使用本地镜像, DATABASE_URL匹配docker-compose
- 布局: (auth)/(dashboard)分离, Sidebar仅Dashboard显示

验证结果:
- 后端331测试全部通过
- 前端构建成功(20页面)
- API端到端冒烟验证12个端点全部正常
- Docker PG+Redis启动, Prisma Migration 30张表创建成功
This commit is contained in:
fischer 2026-05-25 17:44:37 +08:00
parent 3d867331ae
commit bdb509a611
54 changed files with 5481 additions and 396 deletions

View File

@ -1,25 +1,88 @@
NODE_ENV=development
DB_HOST=postgres
# API Server
API_PORT=3001
WEB_PORT=3000
# Database (Prisma)
DATABASE_URL="postgresql://fischerx:fischerx@localhost:5432/fischerx?schema=public"
DB_HOST=localhost
DB_PORT=5432
DB_USER=fischerx
DB_PASSWORD=fischerx
DB_NAME=fischerx
REDIS_HOST=redis
# Redis
REDIS_URL="redis://localhost:6379"
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PASSWORD=
API_PORT=3001
WEB_PORT=3000
# JWT
JWT_SECRET="your-super-secret-jwt-key-change-this-in-production"
JWT_EXPIRATION="7d"
# Frontend
NEXT_PUBLIC_API_URL=http://localhost:3001
JWT_SECRET=your-super-secret-jwt-key-change-in-production
JWT_EXPIRES_IN=7d
# Storage Configuration
STORAGE_TYPE="local"
ALIBABA_CLOUD_ACCESS_KEY_ID=
ALIBABA_CLOUD_ACCESS_KEY_SECRET=
OSS_REGION=
OSS_BUCKET=
OSS_ENDPOINT=
# Local Storage
LOCAL_STORAGE_PATH="./uploads"
LOCAL_STORAGE_URL="http://localhost:3001/uploads"
# Aliyun OSS
ALIYUN_OSS_REGION="oss-cn-hangzhou"
ALIYUN_OSS_ACCESS_KEY_ID="your-aliyun-access-key-id"
ALIYUN_OSS_ACCESS_KEY_SECRET="your-aliyun-access-key-secret"
ALIYUN_OSS_BUCKET="fischerx"
ALIYUN_OSS_ENDPOINT="https://oss-cn-hangzhou.aliyuncs.com"
ALIYUN_OSS_INTERNAL=false
ALIYUN_OSS_SECURE=true
# Tencent COS
TENCENT_COS_SECRET_ID="your-tencent-secret-id"
TENCENT_COS_SECRET_KEY="your-tencent-secret-key"
TENCENT_COS_BUCKET="fischerx-1234567890"
TENCENT_COS_REGION="ap-guangzhou"
# MinIO
MINIO_END_POINT="localhost"
MINIO_PORT=9000
MINIO_USE_SSL=false
MINIO_ACCESS_KEY="minioadmin"
MINIO_SECRET_KEY="minioadmin"
MINIO_BUCKET="fischerx"
# CDN
CDN_ENABLED=false
CDN_BASE_URL="https://cdn.yourdomain.com"
# Upload Settings
UPLOAD_MAX_FILE_SIZE=104857600
UPLOAD_ALLOWED_MIME_TYPES="*/*"
# Notification - Mock Mode (set to "true" in development to skip actual sending)
NOTIFICATION_MOCK_MODE="true"
# Notification - SMTP Email
SMTP_HOST="smtpdm.aliyun.com"
SMTP_PORT="465"
SMTP_USER="noreply@yourdomain.com"
SMTP_PASS="your-smtp-password"
SMTP_FROM='"FischerX" <noreply@yourdomain.com>'
# Notification - Aliyun SMS
ALIYUN_ACCESS_KEY_ID="your-aliyun-access-key-id"
ALIYUN_ACCESS_KEY_SECRET="your-aliyun-access-key-secret"
ALIYUN_SMS_SIGN_NAME="FischerX"
ALIYUN_SMS_TEMPLATE_CODE_VERIFY="SMS_123456"
# Notification - WeCom Robot Webhook
WECOM_WEBHOOK_URL="https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=your-default-key"
WECOM_WEBHOOK_URL_ALERT="https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=your-alert-key"
# OpenTelemetry (optional, set to "true" to enable)
OTEL_ENABLED=false
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318/v1/traces

View File

@ -13,6 +13,7 @@
"test:e2e:ui": "playwright test --ui"
},
"dependencies": {
"@fischerx/types": "workspace:*",
"@hookform/resolvers": "^5.4.0",
"@radix-ui/react-slot": "^1.2.4",
"@tanstack/react-query": "^5.100.13",

View File

@ -4,7 +4,7 @@ export default function AuthLayout({
children: React.ReactNode
}) {
return (
<div className="flex min-h-screen items-center justify-center">
<div className="min-h-screen flex items-center justify-center bg-gray-50">
{children}
</div>
)

View File

@ -1,7 +1,20 @@
import { Navbar } from "@/components/layout/navbar"
import { Sidebar } from "@/components/layout/sidebar"
export default function DashboardLayout({
children,
}: {
children: React.ReactNode
}) {
return <>{children}</>
return (
<div className="relative flex min-h-screen flex-col">
<Navbar />
<div className="flex flex-1">
<Sidebar />
<main className="flex-1 md:ml-64">
<div className="container py-6">{children}</div>
</main>
</div>
</div>
)
}

View File

@ -97,7 +97,7 @@ export default function ProfilePage() {
const updatedUser = response.data.data;
setUser({
...user,
...user!,
avatar: updatedUser.avatar,
});
setMessage('Avatar updated successfully');

View File

@ -1,6 +1,30 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@import "tailwindcss";
@theme inline {
--color-background: hsl(var(--background));
--color-foreground: hsl(var(--foreground));
--color-card: hsl(var(--card));
--color-card-foreground: hsl(var(--card-foreground));
--color-popover: hsl(var(--popover));
--color-popover-foreground: hsl(var(--popover-foreground));
--color-primary: hsl(var(--primary));
--color-primary-foreground: hsl(var(--primary-foreground));
--color-secondary: hsl(var(--secondary));
--color-secondary-foreground: hsl(var(--secondary-foreground));
--color-muted: hsl(var(--muted));
--color-muted-foreground: hsl(var(--muted-foreground));
--color-accent: hsl(var(--accent));
--color-accent-foreground: hsl(var(--accent-foreground));
--color-destructive: hsl(var(--destructive));
--color-destructive-foreground: hsl(var(--destructive-foreground));
--color-border: hsl(var(--border));
--color-input: hsl(var(--input));
--color-ring: hsl(var(--ring));
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
}
@layer base {
:root {

View File

@ -2,7 +2,6 @@ import type { Metadata } from "next"
import { Geist, Geist_Mono } from "next/font/google"
import "./globals.css"
import { QueryProvider } from "@/providers/query-provider"
import { Navbar, Sidebar } from "@/components/layout"
import { ErrorBoundary } from "@/components/error-boundary"
import { MonitoringProvider } from "@/providers/monitoring-provider"
@ -27,20 +26,12 @@ export default function RootLayout({
children: React.ReactNode
}>) {
return (
<html lang="en" className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`} suppressHydrationWarning>
<html lang="zh-CN" className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`} suppressHydrationWarning>
<body className="min-h-full">
<MonitoringProvider>
<QueryProvider>
<ErrorBoundary>
<div className="relative flex min-h-screen flex-col">
<Navbar />
<div className="flex flex-1">
<Sidebar />
<main className="flex-1 md:ml-64">
<div className="container py-6">{children}</div>
</main>
</div>
</div>
{children}
</ErrorBoundary>
</QueryProvider>
</MonitoringProvider>

View File

@ -4,10 +4,10 @@ import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { useDownloadFile, useDeleteFile } from '@/hooks/use-files';
import type { File } from '@fischerx/types';
import type { File as FileType } from '@fischerx/types';
interface FilePreviewProps {
file: File;
file: FileType;
onDelete?: () => void;
}

View File

@ -1,6 +1,6 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { fileApi } from '@/lib/file-api';
import type { File, FileUploadOptions, FileListParams, ProcessImageOptions } from '@fischerx/types';
import type { File as FileType, FileUploadOptions, FileListParams, ProcessImageOptions } from '@fischerx/types';
export const useFiles = (params?: FileListParams) => {
return useQuery({
@ -21,7 +21,7 @@ export const useUploadFile = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ file, options }: { file: File | Blob; options?: FileUploadOptions }) =>
mutationFn: ({ file, options }: { file: globalThis.File | Blob; options?: FileUploadOptions }) =>
fileApi.uploadFile(file, options),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['files'] });
@ -33,7 +33,7 @@ export const useUploadMultipleFiles = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ files, options }: { files: (File | Blob)[]; options?: FileUploadOptions }) =>
mutationFn: ({ files, options }: { files: (globalThis.File | Blob)[]; options?: FileUploadOptions }) =>
fileApi.uploadMultipleFiles(files, options),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['files'] });
@ -45,7 +45,7 @@ export const useUpdateFile = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: Partial<Pick<File, 'name' | 'category' | 'tags'>> }) =>
mutationFn: ({ id, data }: { id: string; data: Partial<Pick<FileType, 'name' | 'category' | 'tags'>> }) =>
fileApi.updateFile(id, data),
onSuccess: (_, { id }) => {
queryClient.invalidateQueries({ queryKey: ['files'] });

View File

@ -1,8 +1,8 @@
import api from './api';
import type { File, FileUploadOptions, FileListParams, ProcessImageOptions } from '@fischerx/types';
import type { File as FileType, FileUploadOptions, FileListParams, ProcessImageOptions } from '@fischerx/types';
export const fileApi = {
uploadFile: async (file: File | Blob, options?: FileUploadOptions): Promise<{ success: boolean; data: File }> => {
uploadFile: async (file: globalThis.File | Blob, options?: FileUploadOptions): Promise<{ success: boolean; data: FileType }> => {
const formData = new FormData();
formData.append('file', file);
if (options?.category) formData.append('category', options.category);
@ -18,9 +18,9 @@ export const fileApi = {
},
uploadMultipleFiles: async (
files: (File | Blob)[],
files: (globalThis.File | Blob)[],
options?: FileUploadOptions
): Promise<{ success: boolean; data: File[]; errors: Array<{ index: number; error: string }> }> => {
): Promise<{ success: boolean; data: FileType[]; errors: Array<{ index: number; error: string }> }> => {
const formData = new FormData();
files.forEach((file) => formData.append('files', file));
if (options?.category) formData.append('category', options.category);
@ -35,12 +35,12 @@ export const fileApi = {
return response.data;
},
listFiles: async (params?: FileListParams): Promise<{ success: boolean; data: File[]; meta: { page: number; limit: number; total: number } }> => {
listFiles: async (params?: FileListParams): Promise<{ success: boolean; data: FileType[]; meta: { page: number; limit: number; total: number } }> => {
const response = await api.get('/files', { params });
return response.data;
},
getFile: async (id: string): Promise<{ success: boolean; data: File }> => {
getFile: async (id: string): Promise<{ success: boolean; data: FileType }> => {
const response = await api.get(`/files/${id}`);
return response.data;
},
@ -65,7 +65,7 @@ export const fileApi = {
return response.data;
},
updateFile: async (id: string, data: Partial<Pick<File, 'name' | 'category' | 'tags'>>): Promise<{ success: boolean; data: File }> => {
updateFile: async (id: string, data: Partial<Pick<FileType, 'name' | 'category' | 'tags'>>): Promise<{ success: boolean; data: FileType }> => {
const response = await api.put(`/files/${id}`, data);
return response.data;
},

View File

@ -1,38 +1,7 @@
import { WebTracerProvider } from '@opentelemetry/sdk-trace-web';
import { SimpleSpanProcessor, ConsoleSpanExporter } from '@opentelemetry/sdk-trace-base';
import { ZoneContextManager } from '@opentelemetry/context-zone';
import { FetchInstrumentation } from '@opentelemetry/instrumentation-fetch';
import { XMLHttpRequestInstrumentation } from '@opentelemetry/instrumentation-xml-http-request';
import { registerInstrumentations } from '@opentelemetry/instrumentation';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
import { Resource } from '@opentelemetry/resources';
export const initTelemetry = async () => {
if (process.env.NODE_ENV !== 'production') return;
const endpoint = process.env.NEXT_PUBLIC_OTEL_EXPORTER_OTLP_ENDPOINT;
if (!endpoint) return;
const isProduction = process.env.NODE_ENV === 'production';
const resource = new Resource({
'service.name': 'fischerx-web',
});
const provider = new WebTracerProvider({ resource });
const traceExporter = isProduction
? new OTLPTraceExporter({
url: process.env.NEXT_PUBLIC_OTEL_EXPORTER_OTLP_ENDPOINT || 'http://localhost:4318/v1/traces',
})
: new ConsoleSpanExporter();
provider.addSpanProcessor(new SimpleSpanProcessor(traceExporter));
provider.register({
contextManager: new ZoneContextManager(),
});
registerInstrumentations({
instrumentations: [
new FetchInstrumentation({
ignoreUrls: ['/health', '/metrics'],
}),
new XMLHttpRequestInstrumentation(),
],
});
export const tracer = provider.getTracer('fischerx-web');
console.warn('OpenTelemetry packages not installed. Install @opentelemetry/* packages to enable tracing.');
};

View File

@ -1,15 +1,12 @@
import { ReportCallback } from 'web-vitals';
export const reportWebVitals = (onPerfEntry?: ReportCallback) => {
if (onPerfEntry && onPerfEntry instanceof Function) {
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
getCLS(onPerfEntry);
getFID(onPerfEntry);
getFCP(onPerfEntry);
getLCP(onPerfEntry);
getTTFB(onPerfEntry);
});
}
export const reportWebVitals = (onPerfEntry?: (metric: any) => void) => {
if (!onPerfEntry) return;
import('web-vitals').then(({ onCLS, onINP, onFCP, onLCP, onTTFB }) => {
onCLS(onPerfEntry);
onINP(onPerfEntry);
onFCP(onPerfEntry);
onLCP(onPerfEntry);
onTTFB(onPerfEntry);
}).catch(() => {});
};
export const logVitals = (metric: any) => {

View File

@ -1,7 +1,6 @@
'use client';
import React, { useEffect, ReactNode } from 'react';
import { logVitals, reportWebVitals } from '@/lib/web-vitals';
interface Props {
children: ReactNode;
@ -9,12 +8,16 @@ interface Props {
export function MonitoringProvider({ children }: Props) {
useEffect(() => {
reportWebVitals(logVitals);
if (typeof window !== 'undefined') {
import('@/lib/web-vitals').then(({ reportWebVitals, logVitals }) => {
reportWebVitals(logVitals);
}).catch(() => {});
}
}, []);
useEffect(() => {
if (process.env.NODE_ENV === 'production') {
import('@/lib/telemetry').catch(console.error);
if (typeof window !== 'undefined' && process.env.NODE_ENV === 'production') {
import('@/lib/telemetry').then(m => m.initTelemetry()).catch(() => {});
}
}, []);

View File

@ -1,12 +1,6 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
interface User {
id: string
name: string
email: string
avatar?: string
}
import { User } from '@/lib/user-api'
interface UserState {
user: User | null

View File

@ -20,7 +20,8 @@
],
"paths": {
"@/*": ["./src/*"]
}
},
"types": ["react", "react-dom", "node"]
},
"include": [
"next-env.d.ts",

View File

@ -1,10 +1,6 @@
version: '3.8'
services:
postgres:
build:
context: ./infra/db
dockerfile: Dockerfile
image: postgres:15-alpine
container_name: fischerx-postgres
environment:
POSTGRES_USER: ${DB_USER:-fischerx}
@ -14,6 +10,7 @@ services:
- "${DB_PORT:-5432}:5432"
volumes:
- postgres-data:/var/lib/postgresql/data
- ./infra/db/init.sql:/docker-entrypoint-initdb.d/init.sql
networks:
- fischerx-network
healthcheck:

View File

@ -30,10 +30,55 @@ export interface File {
mimeType: string;
size: number;
url: string;
cdnUrl?: string;
storageType: 'aliyun' | 'tencent' | 'minio' | 'local';
bucket: string;
path: string;
fileHash?: string;
category?: string;
tags?: string[];
accessCount: number;
lastAccessedAt?: Date;
ownerId: string;
createdAt: Date;
updatedAt: Date;
}
export interface FileUploadOptions {
category?: string;
tags?: string[];
description?: string;
}
export interface FileListParams {
page?: number;
limit?: number;
category?: string;
search?: string;
sortBy?: 'createdAt' | 'size' | 'name';
sortOrder?: 'asc' | 'desc';
}
export interface ProcessImageOptions {
compress?: {
quality: number;
};
resize?: {
width?: number;
height?: number;
fit?: 'cover' | 'contain' | 'fill' | 'inside' | 'outside';
};
crop?: {
x: number;
y: number;
width: number;
height: number;
};
watermark?: {
text: string;
fontSize?: number;
color?: string;
position?: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right' | 'center';
opacity?: number;
};
}
export interface ApiResponse<T = unknown> {
success: boolean;
data?: T;
@ -63,4 +108,3 @@ export interface AuthResponse {
refreshToken: string;
user: User;
}
//# sourceMappingURL=index.d.ts.map

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,5 @@
# Database
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/fischerx?schema=public"
DATABASE_URL="postgresql://fischerx:fischerx@localhost:5432/fischerx?schema=public"
# JWT
JWT_SECRET="your-super-secret-jwt-key-change-this-in-production"
@ -73,3 +73,6 @@ ALIYUN_SMS_TEMPLATE_CODE_VERIFY="SMS_123456"
# Notification - WeCom Robot Webhook
WECOM_WEBHOOK_URL="https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=your-default-key"
WECOM_WEBHOOK_URL_ALERT="https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=your-alert-key"
OTEL_ENABLED=false
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318/v1/traces

View File

@ -0,0 +1,848 @@
-- CreateEnum
CREATE TYPE "OrderStatus" AS ENUM ('PENDING', 'PAID', 'SHIPPED', 'COMPLETED', 'CANCELLED', 'REFUNDED');
-- CreateTable
CREATE TABLE "users" (
"id" TEXT NOT NULL,
"email" TEXT,
"phone" TEXT,
"username" TEXT NOT NULL,
"password" TEXT,
"firstName" TEXT,
"lastName" TEXT,
"avatar" TEXT,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"emailVerified" BOOLEAN NOT NULL DEFAULT false,
"phoneVerified" BOOLEAN NOT NULL DEFAULT false,
"lastLoginAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"mfaSecret" TEXT,
"mfaEnabled" BOOLEAN NOT NULL DEFAULT false,
"loginAttempts" INTEGER NOT NULL DEFAULT 0,
"lockedUntil" TIMESTAMP(3),
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "roles" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"isSystem" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "roles_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "permissions" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"resource" TEXT NOT NULL,
"action" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "permissions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "user_roles" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"roleId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "user_roles_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "role_permissions" (
"id" TEXT NOT NULL,
"roleId" TEXT NOT NULL,
"permissionId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "role_permissions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "files" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"originalName" TEXT NOT NULL,
"mimeType" TEXT NOT NULL,
"size" INTEGER NOT NULL,
"path" TEXT NOT NULL,
"url" TEXT,
"cdnUrl" TEXT,
"storageType" TEXT NOT NULL DEFAULT 'local',
"bucket" TEXT,
"fileHash" TEXT,
"category" TEXT,
"tags" TEXT[],
"isPublic" BOOLEAN NOT NULL DEFAULT false,
"accessCount" INTEGER NOT NULL DEFAULT 0,
"lastAccessedAt" TIMESTAMP(3),
"ownerId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "files_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "sessions" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"token" TEXT NOT NULL,
"refreshToken" TEXT,
"expiresAt" TIMESTAMP(3) NOT NULL,
"refreshExpiresAt" TIMESTAMP(3),
"ipAddress" TEXT,
"userAgent" TEXT,
"deviceName" TEXT,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "sessions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "oauth_accounts" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"provider" TEXT NOT NULL,
"providerId" TEXT NOT NULL,
"providerData" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "oauth_accounts_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "realname_auth" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"realName" TEXT NOT NULL,
"idCardNumber" TEXT NOT NULL,
"idCardFrontUrl" TEXT,
"idCardBackUrl" TEXT,
"faceImageUrl" TEXT,
"status" TEXT NOT NULL DEFAULT 'pending',
"rejectReason" TEXT,
"verifiedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "realname_auth_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "login_history" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"ipAddress" TEXT,
"userAgent" TEXT,
"deviceName" TEXT,
"loginMethod" TEXT NOT NULL,
"status" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "login_history_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "payment_channels" (
"id" TEXT NOT NULL,
"code" TEXT NOT NULL,
"name" TEXT NOT NULL,
"type" TEXT NOT NULL,
"isEnabled" BOOLEAN NOT NULL DEFAULT true,
"isSandbox" BOOLEAN NOT NULL DEFAULT true,
"config" JSONB NOT NULL,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "payment_channels_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "payment_orders" (
"id" TEXT NOT NULL,
"orderNo" TEXT NOT NULL,
"channelOrderNo" TEXT,
"userId" TEXT NOT NULL,
"channelId" TEXT,
"amount" DECIMAL(12,2) NOT NULL,
"currency" TEXT NOT NULL DEFAULT 'CNY',
"subject" TEXT NOT NULL,
"body" TEXT,
"status" TEXT NOT NULL DEFAULT 'pending',
"paidAt" TIMESTAMP(3),
"cancelledAt" TIMESTAMP(3),
"clientIp" TEXT,
"userAgent" TEXT,
"metadata" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "payment_orders_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "payment_refunds" (
"id" TEXT NOT NULL,
"refundNo" TEXT NOT NULL,
"channelRefundNo" TEXT,
"orderId" TEXT NOT NULL,
"channelId" TEXT,
"userId" TEXT NOT NULL,
"amount" DECIMAL(12,2) NOT NULL,
"reason" TEXT,
"status" TEXT NOT NULL DEFAULT 'pending',
"processedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "payment_refunds_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "payment_logs" (
"id" TEXT NOT NULL,
"orderId" TEXT,
"refundId" TEXT,
"type" TEXT NOT NULL,
"action" TEXT NOT NULL,
"request" JSONB,
"response" JSONB,
"status" TEXT NOT NULL,
"error" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "payment_logs_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "orders" (
"id" TEXT NOT NULL,
"orderNo" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"status" "OrderStatus" NOT NULL DEFAULT 'PENDING',
"totalAmount" DECIMAL(12,2) NOT NULL,
"paidAmount" DECIMAL(12,2) NOT NULL,
"discountAmount" DECIMAL(12,2) NOT NULL DEFAULT 0,
"paymentMethod" TEXT,
"paymentOrderId" TEXT,
"remark" TEXT,
"paidAt" TIMESTAMP(3),
"shippedAt" TIMESTAMP(3),
"completedAt" TIMESTAMP(3),
"cancelledAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "orders_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "order_items" (
"id" TEXT NOT NULL,
"orderId" TEXT NOT NULL,
"productId" TEXT NOT NULL,
"productName" TEXT NOT NULL,
"productImage" TEXT,
"quantity" INTEGER NOT NULL,
"unitPrice" DECIMAL(12,2) NOT NULL,
"totalAmount" DECIMAL(12,2) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "order_items_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "notifications" (
"id" TEXT NOT NULL,
"userId" TEXT,
"type" TEXT NOT NULL,
"title" TEXT NOT NULL,
"content" TEXT NOT NULL,
"contentHtml" TEXT,
"channel" TEXT NOT NULL,
"channelData" JSONB,
"status" TEXT NOT NULL DEFAULT 'pending',
"sentAt" TIMESTAMP(3),
"readAt" TIMESTAMP(3),
"priority" TEXT NOT NULL DEFAULT 'normal',
"metadata" JSONB,
"expiresAt" TIMESTAMP(3),
"templateId" TEXT,
"templateVersion" INTEGER,
"variables" JSONB,
"retryCount" INTEGER NOT NULL DEFAULT 0,
"maxRetries" INTEGER NOT NULL DEFAULT 3,
"errorMessage" TEXT,
"deduplicationId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "notifications_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "notification_templates" (
"id" TEXT NOT NULL,
"code" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"channels" TEXT[],
"version" INTEGER NOT NULL DEFAULT 1,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "notification_templates_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "notification_template_locales" (
"id" TEXT NOT NULL,
"templateId" TEXT NOT NULL,
"locale" TEXT NOT NULL,
"subject" TEXT,
"title" TEXT,
"content" TEXT NOT NULL,
"contentHtml" TEXT,
"variables" TEXT[],
"isDefault" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "notification_template_locales_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "notification_channels" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"type" TEXT NOT NULL,
"provider" TEXT NOT NULL,
"config" JSONB NOT NULL,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"priority" INTEGER NOT NULL DEFAULT 0,
"metadata" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "notification_channels_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "notification_preferences" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"emailEnabled" BOOLEAN NOT NULL DEFAULT true,
"smsEnabled" BOOLEAN NOT NULL DEFAULT true,
"pushEnabled" BOOLEAN NOT NULL DEFAULT true,
"inAppEnabled" BOOLEAN NOT NULL DEFAULT true,
"securityEnabled" BOOLEAN NOT NULL DEFAULT true,
"marketingEnabled" BOOLEAN NOT NULL DEFAULT false,
"systemEnabled" BOOLEAN NOT NULL DEFAULT true,
"quietStartHour" INTEGER,
"quietEndHour" INTEGER,
"timezone" TEXT DEFAULT 'Asia/Shanghai',
"metadata" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "notification_preferences_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "notification_batches" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"type" TEXT NOT NULL,
"scheduledAt" TIMESTAMP(3),
"status" TEXT NOT NULL DEFAULT 'pending',
"totalCount" INTEGER NOT NULL DEFAULT 0,
"sentCount" INTEGER NOT NULL DEFAULT 0,
"failedCount" INTEGER NOT NULL DEFAULT 0,
"creatorId" TEXT,
"metadata" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "notification_batches_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "notification_batch_items" (
"id" TEXT NOT NULL,
"batchId" TEXT NOT NULL,
"notificationId" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'pending',
"sentAt" TIMESTAMP(3),
"errorMessage" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "notification_batch_items_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "articles" (
"id" TEXT NOT NULL,
"title" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"summary" TEXT,
"content" TEXT NOT NULL,
"contentHtml" TEXT,
"coverImage" TEXT,
"authorId" TEXT NOT NULL,
"categoryId" TEXT,
"status" TEXT NOT NULL DEFAULT 'draft',
"viewCount" INTEGER NOT NULL DEFAULT 0,
"likeCount" INTEGER NOT NULL DEFAULT 0,
"commentCount" INTEGER NOT NULL DEFAULT 0,
"isTop" BOOLEAN NOT NULL DEFAULT false,
"isRecommend" BOOLEAN NOT NULL DEFAULT false,
"publishedAt" TIMESTAMP(3),
"reviewedAt" TIMESTAMP(3),
"reviewedBy" TEXT,
"reviewComment" TEXT,
"seoTitle" TEXT,
"seoKeywords" TEXT,
"seoDescription" TEXT,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"version" INTEGER NOT NULL DEFAULT 1,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "articles_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "article_versions" (
"id" TEXT NOT NULL,
"articleId" TEXT NOT NULL,
"version" INTEGER NOT NULL,
"title" TEXT NOT NULL,
"content" TEXT NOT NULL,
"contentHtml" TEXT,
"summary" TEXT,
"changeLog" TEXT,
"authorId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "article_versions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "categories" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"description" TEXT,
"icon" TEXT,
"parentId" TEXT,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"articleCount" INTEGER NOT NULL DEFAULT 0,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"seoTitle" TEXT,
"seoKeywords" TEXT,
"seoDescription" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "categories_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "tags" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"description" TEXT,
"articleCount" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "tags_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "article_tags" (
"id" TEXT NOT NULL,
"articleId" TEXT NOT NULL,
"tagId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "article_tags_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "comments" (
"id" TEXT NOT NULL,
"content" TEXT NOT NULL,
"articleId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"parentId" TEXT,
"status" TEXT NOT NULL DEFAULT 'pending',
"likeCount" INTEGER NOT NULL DEFAULT 0,
"ipAddress" TEXT,
"userAgent" TEXT,
"reviewedAt" TIMESTAMP(3),
"reviewedBy" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "comments_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "users_email_key" ON "users"("email");
-- CreateIndex
CREATE UNIQUE INDEX "users_phone_key" ON "users"("phone");
-- CreateIndex
CREATE UNIQUE INDEX "users_username_key" ON "users"("username");
-- CreateIndex
CREATE UNIQUE INDEX "roles_name_key" ON "roles"("name");
-- CreateIndex
CREATE UNIQUE INDEX "permissions_name_key" ON "permissions"("name");
-- CreateIndex
CREATE UNIQUE INDEX "user_roles_userId_roleId_key" ON "user_roles"("userId", "roleId");
-- CreateIndex
CREATE UNIQUE INDEX "role_permissions_roleId_permissionId_key" ON "role_permissions"("roleId", "permissionId");
-- CreateIndex
CREATE INDEX "files_ownerId_idx" ON "files"("ownerId");
-- CreateIndex
CREATE INDEX "files_storageType_idx" ON "files"("storageType");
-- CreateIndex
CREATE INDEX "files_category_idx" ON "files"("category");
-- CreateIndex
CREATE INDEX "files_fileHash_idx" ON "files"("fileHash");
-- CreateIndex
CREATE UNIQUE INDEX "sessions_token_key" ON "sessions"("token");
-- CreateIndex
CREATE UNIQUE INDEX "sessions_refreshToken_key" ON "sessions"("refreshToken");
-- CreateIndex
CREATE UNIQUE INDEX "oauth_accounts_provider_providerId_key" ON "oauth_accounts"("provider", "providerId");
-- CreateIndex
CREATE UNIQUE INDEX "realname_auth_userId_key" ON "realname_auth"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "payment_channels_code_key" ON "payment_channels"("code");
-- CreateIndex
CREATE INDEX "payment_channels_type_idx" ON "payment_channels"("type");
-- CreateIndex
CREATE INDEX "payment_channels_isEnabled_idx" ON "payment_channels"("isEnabled");
-- CreateIndex
CREATE UNIQUE INDEX "payment_orders_orderNo_key" ON "payment_orders"("orderNo");
-- CreateIndex
CREATE INDEX "payment_orders_userId_idx" ON "payment_orders"("userId");
-- CreateIndex
CREATE INDEX "payment_orders_orderNo_idx" ON "payment_orders"("orderNo");
-- CreateIndex
CREATE INDEX "payment_orders_status_idx" ON "payment_orders"("status");
-- CreateIndex
CREATE INDEX "payment_orders_createdAt_idx" ON "payment_orders"("createdAt");
-- CreateIndex
CREATE UNIQUE INDEX "payment_refunds_refundNo_key" ON "payment_refunds"("refundNo");
-- CreateIndex
CREATE INDEX "payment_refunds_orderId_idx" ON "payment_refunds"("orderId");
-- CreateIndex
CREATE INDEX "payment_refunds_userId_idx" ON "payment_refunds"("userId");
-- CreateIndex
CREATE INDEX "payment_refunds_status_idx" ON "payment_refunds"("status");
-- CreateIndex
CREATE INDEX "payment_logs_orderId_idx" ON "payment_logs"("orderId");
-- CreateIndex
CREATE INDEX "payment_logs_refundId_idx" ON "payment_logs"("refundId");
-- CreateIndex
CREATE INDEX "payment_logs_createdAt_idx" ON "payment_logs"("createdAt");
-- CreateIndex
CREATE UNIQUE INDEX "orders_orderNo_key" ON "orders"("orderNo");
-- CreateIndex
CREATE UNIQUE INDEX "orders_paymentOrderId_key" ON "orders"("paymentOrderId");
-- CreateIndex
CREATE INDEX "orders_userId_idx" ON "orders"("userId");
-- CreateIndex
CREATE INDEX "orders_orderNo_idx" ON "orders"("orderNo");
-- CreateIndex
CREATE INDEX "orders_status_idx" ON "orders"("status");
-- CreateIndex
CREATE INDEX "orders_createdAt_idx" ON "orders"("createdAt");
-- CreateIndex
CREATE INDEX "order_items_orderId_idx" ON "order_items"("orderId");
-- CreateIndex
CREATE INDEX "order_items_productId_idx" ON "order_items"("productId");
-- CreateIndex
CREATE INDEX "notifications_userId_idx" ON "notifications"("userId");
-- CreateIndex
CREATE INDEX "notifications_status_idx" ON "notifications"("status");
-- CreateIndex
CREATE INDEX "notifications_channel_idx" ON "notifications"("channel");
-- CreateIndex
CREATE INDEX "notifications_type_idx" ON "notifications"("type");
-- CreateIndex
CREATE INDEX "notifications_priority_idx" ON "notifications"("priority");
-- CreateIndex
CREATE INDEX "notifications_deduplicationId_idx" ON "notifications"("deduplicationId");
-- CreateIndex
CREATE UNIQUE INDEX "notification_templates_code_key" ON "notification_templates"("code");
-- CreateIndex
CREATE INDEX "notification_templates_code_idx" ON "notification_templates"("code");
-- CreateIndex
CREATE INDEX "notification_templates_isActive_idx" ON "notification_templates"("isActive");
-- CreateIndex
CREATE INDEX "notification_template_locales_locale_idx" ON "notification_template_locales"("locale");
-- CreateIndex
CREATE UNIQUE INDEX "notification_template_locales_templateId_locale_key" ON "notification_template_locales"("templateId", "locale");
-- CreateIndex
CREATE INDEX "notification_channels_type_idx" ON "notification_channels"("type");
-- CreateIndex
CREATE INDEX "notification_channels_isActive_idx" ON "notification_channels"("isActive");
-- CreateIndex
CREATE UNIQUE INDEX "notification_preferences_userId_key" ON "notification_preferences"("userId");
-- CreateIndex
CREATE INDEX "notification_batches_status_idx" ON "notification_batches"("status");
-- CreateIndex
CREATE INDEX "notification_batches_creatorId_idx" ON "notification_batches"("creatorId");
-- CreateIndex
CREATE INDEX "notification_batch_items_batchId_idx" ON "notification_batch_items"("batchId");
-- CreateIndex
CREATE INDEX "notification_batch_items_notificationId_idx" ON "notification_batch_items"("notificationId");
-- CreateIndex
CREATE INDEX "notification_batch_items_status_idx" ON "notification_batch_items"("status");
-- CreateIndex
CREATE UNIQUE INDEX "articles_slug_key" ON "articles"("slug");
-- CreateIndex
CREATE INDEX "articles_authorId_idx" ON "articles"("authorId");
-- CreateIndex
CREATE INDEX "articles_categoryId_idx" ON "articles"("categoryId");
-- CreateIndex
CREATE INDEX "articles_status_idx" ON "articles"("status");
-- CreateIndex
CREATE INDEX "articles_publishedAt_idx" ON "articles"("publishedAt");
-- CreateIndex
CREATE INDEX "articles_slug_idx" ON "articles"("slug");
-- CreateIndex
CREATE INDEX "article_versions_articleId_idx" ON "article_versions"("articleId");
-- CreateIndex
CREATE UNIQUE INDEX "article_versions_articleId_version_key" ON "article_versions"("articleId", "version");
-- CreateIndex
CREATE UNIQUE INDEX "categories_slug_key" ON "categories"("slug");
-- CreateIndex
CREATE INDEX "categories_parentId_idx" ON "categories"("parentId");
-- CreateIndex
CREATE INDEX "categories_slug_idx" ON "categories"("slug");
-- CreateIndex
CREATE UNIQUE INDEX "tags_name_key" ON "tags"("name");
-- CreateIndex
CREATE UNIQUE INDEX "tags_slug_key" ON "tags"("slug");
-- CreateIndex
CREATE INDEX "tags_slug_idx" ON "tags"("slug");
-- CreateIndex
CREATE INDEX "article_tags_articleId_idx" ON "article_tags"("articleId");
-- CreateIndex
CREATE INDEX "article_tags_tagId_idx" ON "article_tags"("tagId");
-- CreateIndex
CREATE UNIQUE INDEX "article_tags_articleId_tagId_key" ON "article_tags"("articleId", "tagId");
-- CreateIndex
CREATE INDEX "comments_articleId_idx" ON "comments"("articleId");
-- CreateIndex
CREATE INDEX "comments_userId_idx" ON "comments"("userId");
-- CreateIndex
CREATE INDEX "comments_parentId_idx" ON "comments"("parentId");
-- CreateIndex
CREATE INDEX "comments_status_idx" ON "comments"("status");
-- AddForeignKey
ALTER TABLE "user_roles" ADD CONSTRAINT "user_roles_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "user_roles" ADD CONSTRAINT "user_roles_roleId_fkey" FOREIGN KEY ("roleId") REFERENCES "roles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "role_permissions" ADD CONSTRAINT "role_permissions_roleId_fkey" FOREIGN KEY ("roleId") REFERENCES "roles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "role_permissions" ADD CONSTRAINT "role_permissions_permissionId_fkey" FOREIGN KEY ("permissionId") REFERENCES "permissions"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "files" ADD CONSTRAINT "files_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "sessions" ADD CONSTRAINT "sessions_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "oauth_accounts" ADD CONSTRAINT "oauth_accounts_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "realname_auth" ADD CONSTRAINT "realname_auth_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "payment_orders" ADD CONSTRAINT "payment_orders_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "payment_orders" ADD CONSTRAINT "payment_orders_channelId_fkey" FOREIGN KEY ("channelId") REFERENCES "payment_channels"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "payment_refunds" ADD CONSTRAINT "payment_refunds_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "payment_orders"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "payment_refunds" ADD CONSTRAINT "payment_refunds_channelId_fkey" FOREIGN KEY ("channelId") REFERENCES "payment_channels"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "payment_refunds" ADD CONSTRAINT "payment_refunds_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "payment_logs" ADD CONSTRAINT "payment_logs_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "payment_orders"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "payment_logs" ADD CONSTRAINT "payment_logs_refundId_fkey" FOREIGN KEY ("refundId") REFERENCES "payment_refunds"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "orders" ADD CONSTRAINT "orders_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "orders" ADD CONSTRAINT "orders_paymentOrderId_fkey" FOREIGN KEY ("paymentOrderId") REFERENCES "payment_orders"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "order_items" ADD CONSTRAINT "order_items_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "orders"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "notifications" ADD CONSTRAINT "notifications_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "notifications" ADD CONSTRAINT "notifications_templateId_fkey" FOREIGN KEY ("templateId") REFERENCES "notification_templates"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "notification_template_locales" ADD CONSTRAINT "notification_template_locales_templateId_fkey" FOREIGN KEY ("templateId") REFERENCES "notification_templates"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "notification_preferences" ADD CONSTRAINT "notification_preferences_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "notification_batches" ADD CONSTRAINT "notification_batches_creatorId_fkey" FOREIGN KEY ("creatorId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "notification_batch_items" ADD CONSTRAINT "notification_batch_items_batchId_fkey" FOREIGN KEY ("batchId") REFERENCES "notification_batches"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "notification_batch_items" ADD CONSTRAINT "notification_batch_items_notificationId_fkey" FOREIGN KEY ("notificationId") REFERENCES "notifications"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "articles" ADD CONSTRAINT "articles_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "articles" ADD CONSTRAINT "articles_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "categories"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "article_versions" ADD CONSTRAINT "article_versions_articleId_fkey" FOREIGN KEY ("articleId") REFERENCES "articles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "article_versions" ADD CONSTRAINT "article_versions_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "categories" ADD CONSTRAINT "categories_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "categories"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "article_tags" ADD CONSTRAINT "article_tags_articleId_fkey" FOREIGN KEY ("articleId") REFERENCES "articles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "article_tags" ADD CONSTRAINT "article_tags_tagId_fkey" FOREIGN KEY ("tagId") REFERENCES "tags"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "comments" ADD CONSTRAINT "comments_articleId_fkey" FOREIGN KEY ("articleId") REFERENCES "articles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "comments" ADD CONSTRAINT "comments_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "comments" ADD CONSTRAINT "comments_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "comments"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"

View File

@ -37,5 +37,9 @@ export class GlobalExceptionFilter implements ExceptionFilter {
message,
data: null,
});
if (!(exception instanceof HttpException)) {
console.error('[Unhandled Exception]', exception);
}
}
}

View File

@ -9,7 +9,9 @@ import { HttpMetricsInterceptor } from './modules/monitoring/http-metrics.interc
import { startTracing } from './tracing';
async function bootstrap() {
startTracing();
if (process.env.OTEL_ENABLED === 'true') {
startTracing();
}
const app = await NestFactory.create<NestExpressApplication>(AppModule);
@ -35,6 +37,6 @@ async function bootstrap() {
await app.listen(port);
console.log(`Application is running on: http://localhost:${port}`);
console.log(`Metrics available at: http://localhost:${port}/metrics`);
console.log(`Tracing enabled: ${process.env.OTEL_ENABLED !== 'false'}`);
console.log(`Tracing enabled: ${process.env.OTEL_ENABLED === 'true'}`);
}
void bootstrap();

View File

@ -14,7 +14,7 @@ describe('AlipayAuthService', () => {
create: jest.fn(),
update: jest.fn(),
},
oauthAccount: {
oAuthAccount: {
findUnique: jest.fn(),
create: jest.fn(),
},
@ -68,17 +68,20 @@ describe('AlipayAuthService', () => {
email: null,
phone: null,
username: 'alipay_user',
password: null,
isActive: true,
roles: [],
roles: [{ role: { name: 'user' } }],
};
jest
.spyOn(service, 'getAlipayUserInfo')
.mockResolvedValue(mockAlipayUser);
mockPrisma.oauthAccount.findUnique.mockResolvedValue({
.mockReturnValue(Promise.resolve(mockAlipayUser));
mockPrisma.oAuthAccount.findUnique.mockResolvedValue({
userId: 'user-123',
});
mockPrisma.user.findUnique.mockResolvedValue(mockExistingUser);
mockPrisma.user.update.mockResolvedValue({ ...mockExistingUser, lastLoginAt: new Date() });
mockPrisma.loginHistory.create.mockResolvedValue({});
mockSessionService.createSession.mockResolvedValue({
token: 'access-token',
refreshToken: 'refresh-token',
@ -101,17 +104,23 @@ describe('AlipayAuthService', () => {
avatar: 'https://example.com/alipay-avatar.jpg',
};
jest
.spyOn(service, 'getAlipayUserInfo')
.mockResolvedValue(mockAlipayUser);
mockPrisma.oauthAccount.findUnique.mockResolvedValue(null);
mockPrisma.user.create.mockResolvedValue({
const mockNewUser = {
id: 'user-456',
username: 'alipay_user_456',
password: null,
isActive: true,
roles: [],
});
mockPrisma.oauthAccount.create.mockResolvedValue({});
roles: [{ role: { name: 'user' } }],
};
jest
.spyOn(service, 'getAlipayUserInfo')
.mockReturnValue(Promise.resolve(mockAlipayUser));
mockPrisma.oAuthAccount.findUnique.mockResolvedValue(null);
mockPrisma.role.findUnique.mockResolvedValue({ id: 'role-user-id' });
mockPrisma.user.create.mockResolvedValue(mockNewUser);
mockPrisma.oAuthAccount.create.mockResolvedValue({});
mockPrisma.user.update.mockResolvedValue({ ...mockNewUser, lastLoginAt: new Date() });
mockPrisma.loginHistory.create.mockResolvedValue({});
mockSessionService.createSession.mockResolvedValue({
token: 'access-token',
refreshToken: 'refresh-token',
@ -120,7 +129,7 @@ describe('AlipayAuthService', () => {
const result = await service.loginWithAlipay(mockCode, {});
expect(mockPrisma.user.create).toHaveBeenCalled();
expect(mockPrisma.oauthAccount.create).toHaveBeenCalled();
expect(mockPrisma.oAuthAccount.create).toHaveBeenCalled();
expect(result).toHaveProperty('accessToken');
});
});

View File

@ -19,7 +19,7 @@ export class AlipayAuthService {
) {
const alipayUser = await this.getAlipayUserInfo(code);
const existingAccount = await this.prisma.oauthAccount.findUnique({
const existingAccount = await this.prisma.oAuthAccount.findUnique({
where: {
provider_providerId: {
provider: 'alipay',
@ -61,7 +61,7 @@ export class AlipayAuthService {
where: { name: 'user' },
select: { id: true },
})
)?.id,
)!.id,
},
},
},
@ -76,7 +76,7 @@ export class AlipayAuthService {
},
});
await this.prisma.oauthAccount.create({
await this.prisma.oAuthAccount.create({
data: {
userId: user.id,
provider: 'alipay',

View File

@ -40,7 +40,7 @@ export class AuthController {
@Post('login')
async login(@Body() loginDto: LoginDto, @Req() req: any) {
const identifier = loginDto.email || loginDto.phone;
const identifier = loginDto.email || loginDto.phone || '';
const user = await this.authService.validateUser(
identifier,
loginDto.password,

View File

@ -14,7 +14,7 @@ export class AuthService {
private jwtService: JwtService,
) {}
async validateUser(identifier: string, password: string) {
async validateUser(identifier: string, inputPassword: string) {
let user;
if (this.isPhone(identifier)) {
@ -37,7 +37,7 @@ export class AuthService {
throw new UnauthorizedException('Account is disabled');
}
const passwordValid = await this.comparePassword(password, user.password);
const passwordValid = await this.comparePassword(inputPassword, user.password);
if (!passwordValid) {
throw new UnauthorizedException('Invalid credentials');
}

View File

@ -93,6 +93,7 @@ export class SessionService {
select: {
id: true,
email: true,
isActive: true,
roles: {
select: {
role: {

View File

@ -27,7 +27,7 @@ export class SmsAuthService {
const attemptsKey = `sms:attempts:${phone}`;
const attempts = await this.cacheService.get(attemptsKey);
if (attempts && parseInt(attempts) >= 5) {
if (attempts && parseInt(String(attempts)) >= 5) {
throw new BadRequestException(
'Too many attempts, please try again later',
);
@ -65,7 +65,7 @@ export class SmsAuthService {
if (storedCode !== code) {
const attemptsKey = `sms:attempts:${phone}`;
const attempts = await this.cacheService.get(attemptsKey);
const newAttempts = attempts ? parseInt(attempts) + 1 : 1;
const newAttempts = attempts ? parseInt(String(attempts)) + 1 : 1;
if (newAttempts >= 5) {
await this.cacheService.del(codeKey);

View File

@ -14,7 +14,7 @@ describe('WechatAuthService', () => {
create: jest.fn(),
update: jest.fn(),
},
oauthAccount: {
oAuthAccount: {
findUnique: jest.fn(),
create: jest.fn(),
},
@ -68,17 +68,20 @@ describe('WechatAuthService', () => {
email: null,
phone: null,
username: 'wechat_user',
password: null,
isActive: true,
roles: [],
roles: [{ role: { name: 'user' } }],
};
jest
.spyOn(service, 'getWechatUserInfo')
.mockResolvedValue(mockWechatUser);
mockPrisma.oauthAccount.findUnique.mockResolvedValue({
.mockReturnValue(Promise.resolve(mockWechatUser));
mockPrisma.oAuthAccount.findUnique.mockResolvedValue({
userId: 'user-123',
});
mockPrisma.user.findUnique.mockResolvedValue(mockExistingUser);
mockPrisma.user.update.mockResolvedValue({ ...mockExistingUser, lastLoginAt: new Date() });
mockPrisma.loginHistory.create.mockResolvedValue({});
mockSessionService.createSession.mockResolvedValue({
token: 'access-token',
refreshToken: 'refresh-token',
@ -101,17 +104,23 @@ describe('WechatAuthService', () => {
headimgurl: 'https://example.com/avatar.jpg',
};
jest
.spyOn(service, 'getWechatUserInfo')
.mockResolvedValue(mockWechatUser);
mockPrisma.oauthAccount.findUnique.mockResolvedValue(null);
mockPrisma.user.create.mockResolvedValue({
const mockNewUser = {
id: 'user-456',
username: 'wechat_user_456',
password: null,
isActive: true,
roles: [],
});
mockPrisma.oauthAccount.create.mockResolvedValue({});
roles: [{ role: { name: 'user' } }],
};
jest
.spyOn(service, 'getWechatUserInfo')
.mockReturnValue(Promise.resolve(mockWechatUser));
mockPrisma.oAuthAccount.findUnique.mockResolvedValue(null);
mockPrisma.role.findUnique.mockResolvedValue({ id: 'role-user-id' });
mockPrisma.user.create.mockResolvedValue(mockNewUser);
mockPrisma.oAuthAccount.create.mockResolvedValue({});
mockPrisma.user.update.mockResolvedValue({ ...mockNewUser, lastLoginAt: new Date() });
mockPrisma.loginHistory.create.mockResolvedValue({});
mockSessionService.createSession.mockResolvedValue({
token: 'access-token',
refreshToken: 'refresh-token',
@ -120,7 +129,7 @@ describe('WechatAuthService', () => {
const result = await service.loginWithWechat(mockCode, {});
expect(mockPrisma.user.create).toHaveBeenCalled();
expect(mockPrisma.oauthAccount.create).toHaveBeenCalled();
expect(mockPrisma.oAuthAccount.create).toHaveBeenCalled();
expect(result).toHaveProperty('accessToken');
});
});

View File

@ -19,7 +19,7 @@ export class WechatAuthService {
) {
const wechatUser = await this.getWechatUserInfo(code);
const existingAccount = await this.prisma.oauthAccount.findUnique({
const existingAccount = await this.prisma.oAuthAccount.findUnique({
where: {
provider_providerId: {
provider: 'wechat',
@ -61,7 +61,7 @@ export class WechatAuthService {
where: { name: 'user' },
select: { id: true },
})
)?.id,
)!.id,
},
},
},
@ -76,7 +76,7 @@ export class WechatAuthService {
},
});
await this.prisma.oauthAccount.create({
await this.prisma.oAuthAccount.create({
data: {
userId: user.id,
provider: 'wechat',

View File

@ -14,7 +14,7 @@ import { CacheService } from './cache.service';
if (redisUrl) {
return {
store: redisStore,
store: redisStore as any,
url: redisUrl,
ttl: 600,
};
@ -22,7 +22,7 @@ import { CacheService } from './cache.service';
return {
ttl: 600,
};
} as any;
},
inject: [ConfigService],
}),

View File

@ -25,7 +25,7 @@ export class ArticleService {
authorId,
tags: tagIds
? { create: tagIds.map((tagId) => ({ tag: { connect: { id: tagId } } })) }
: [],
: undefined,
versions: {
create: {
version: 1,

View File

@ -28,7 +28,7 @@ import {
FileQueryDto,
ProcessImageDto,
} from './dto';
import { Response as ExpressResponse } from 'express';
import type { Response as ExpressResponse } from 'express';
@Controller('files')
@UseGuards(JwtAuthGuard)

View File

@ -165,7 +165,7 @@ export class FileService {
}
const storage = this.storageFactory.getAdapter(file.storageType as StorageType);
const buffer = await storage.download(file.path, file.bucket);
const buffer = await storage.download(file.path, file.bucket ?? undefined);
await this.incrementAccessCount(fileId);
@ -191,7 +191,7 @@ export class FileService {
const storage = this.storageFactory.getAdapter(file.storageType as StorageType);
try {
await storage.delete(file.path, file.bucket);
await storage.delete(file.path, file.bucket ?? undefined);
} catch (error) {
this.logger.warn(`Failed to delete file from storage: ${error}`);
}
@ -305,7 +305,7 @@ export class FileService {
}
const storage = this.storageFactory.getAdapter(file.storageType as StorageType);
const originalBuffer = await storage.download(file.path, file.bucket);
const originalBuffer = await storage.download(file.path, file.bucket ?? undefined);
const processedBuffer = await this.imageProcessor.processImage(
originalBuffer,
@ -346,7 +346,7 @@ export class FileService {
}
const storage = this.storageFactory.getAdapter(file.storageType as StorageType);
return storage.getUrl(file.path, file.bucket, file.isPublic ? undefined : expiresIn);
return storage.getUrl(file.path, file.bucket ?? undefined, file.isPublic ? undefined : expiresIn);
}
private async findFileByHash(hash: string, ownerId: string) {

View File

@ -83,7 +83,6 @@ export class ImageProcessorService {
input: watermarkBuffer,
left: position.left,
top: position.top,
opacity: options.watermarkOpacity || 0.3,
},
]);
}

View File

@ -68,10 +68,11 @@ export class LocalStorageAdapter implements CloudStorageAdapter {
await writeFile(fullPath, file);
} else {
const writeStream = fs.createWriteStream(fullPath);
const readable = file as NodeJS.ReadableStream;
await new Promise((resolve, reject) => {
file.pipe(writeStream);
file.on('end', resolve);
file.on('error', reject);
readable.pipe(writeStream);
readable.on('end', resolve);
readable.on('error', reject);
});
}

View File

@ -41,7 +41,7 @@ export class MinIOAdapter implements CloudStorageAdapter {
let etag: string;
if (file instanceof Buffer) {
etag = await this.client.putObject(
const result = await this.client.putObject(
bucket,
path,
file,
@ -50,16 +50,18 @@ export class MinIOAdapter implements CloudStorageAdapter {
? { 'Content-Type': options.contentType }
: undefined,
);
etag = result.etag || '';
} else {
etag = await this.client.putObject(
const result = await this.client.putObject(
bucket,
path,
file,
file as any,
undefined,
options?.contentType
? { 'Content-Type': options.contentType }
: undefined,
);
etag = result.etag || '';
}
const url = await this.getUrl(path, bucket);

View File

@ -43,7 +43,7 @@ export class TencentCOSAdapter implements CloudStorageAdapter {
Bucket: bucket,
Region: region,
Key: path,
Body: file,
Body: file as any,
ContentType: options?.contentType,
},
(err, data) => {
@ -175,7 +175,7 @@ export class TencentCOSAdapter implements CloudStorageAdapter {
const region = this.defaultRegion;
return new Promise((resolve, reject) => {
this.client.copyObject(
(this.client as any).copyObject(
{
Bucket: tgtBkt,
Region: region,

View File

@ -3,24 +3,23 @@ import {
NestInterceptor,
ExecutionContext,
CallHandler,
Inject,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';
import {
Histogram,
Counter,
makeHistogramProvider,
makeCounterProvider,
} from '@willsoto/nestjs-prometheus';
import { Inject } from '@nestjs/common';
import { Histogram, Counter } from 'prom-client';
@Injectable()
export class HttpMetricsInterceptor implements NestInterceptor {
constructor(
@Inject('HTTP_REQUEST_DURATION')
private readonly httpRequestDuration: Histogram,
private readonly httpRequestDuration: Histogram<string>,
@Inject('HTTP_REQUESTS_TOTAL')
private readonly httpRequestsTotal: Counter,
private readonly httpRequestsTotal: Counter<string>,
) {}
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
@ -54,15 +53,31 @@ export class HttpMetricsInterceptor implements NestInterceptor {
}
}
export const httpRequestDurationProvider = makeHistogramProvider({
name: 'http_request_duration_seconds',
help: 'Duration of HTTP requests in seconds',
labelNames: ['method', 'route', 'status'],
buckets: [0.01, 0.05, 0.1, 0.5, 1, 2, 5, 10],
});
export const httpRequestDurationProvider = {
provide: 'HTTP_REQUEST_DURATION',
useFactory: () => {
const { Registry, Histogram } = require('prom-client');
const registry = new Registry();
return new Histogram({
name: 'http_request_duration_seconds',
help: 'Duration of HTTP requests in seconds',
labelNames: ['method', 'route', 'status'],
buckets: [0.01, 0.05, 0.1, 0.5, 1, 2, 5, 10],
registers: [registry],
});
},
};
export const httpRequestsTotalProvider = makeCounterProvider({
name: 'http_requests_total',
help: 'Total number of HTTP requests',
labelNames: ['method', 'route', 'status'],
});
export const httpRequestsTotalProvider = {
provide: 'HTTP_REQUESTS_TOTAL',
useFactory: () => {
const { Registry, Counter } = require('prom-client');
const registry = new Registry();
return new Counter({
name: 'http_requests_total',
help: 'Total number of HTTP requests',
labelNames: ['method', 'route', 'status'],
registers: [registry],
});
},
};

View File

@ -55,7 +55,7 @@ export class EmailChannelService extends BaseChannelService {
async send(payload: NotificationPayload): Promise<NotificationSendResult> {
try {
if (this.isMockMode(this.configService)) {
if (this.isMockMode()) {
return this.createMockResult('email');
}

View File

@ -46,7 +46,7 @@ export class SmsChannelService extends BaseChannelService {
async send(payload: NotificationPayload): Promise<NotificationSendResult> {
try {
if (this.isMockMode(this.configService)) {
if (this.isMockMode()) {
return this.createMockResult('sms');
}

View File

@ -12,6 +12,7 @@ import {
import { NotificationService } from './notification.service';
import { TemplateService } from './template.service';
import { PreferenceService } from './preference.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import {
SendNotificationDto,
SendWithTemplateDto,
@ -22,6 +23,7 @@ import {
} from './dto/send-notification.dto';
@Controller('notifications')
@UseGuards(JwtAuthGuard)
export class NotificationController {
constructor(
private notificationService: NotificationService,
@ -33,7 +35,7 @@ export class NotificationController {
async send(@Body() dto: SendNotificationDto) {
const { useQueue, ...payload } = dto;
return this.notificationService.send(
{ ...payload, type: payload.type || 'info' },
{ ...payload, type: (payload.type || 'info') as 'info' | 'warning' | 'error' | 'success', priority: payload.priority as 'low' | 'normal' | 'high' | 'urgent' | undefined, expiresAt: payload.expiresAt ? new Date(payload.expiresAt) : undefined },
useQueue !== false,
);
}
@ -74,7 +76,7 @@ export class NotificationController {
@Query('status') status?: string,
@Query('channel') channel?: string,
) {
const userId = req.user?.id;
const userId = req.user?.userId;
return this.notificationService.findAll(
userId,
parseInt(page),
@ -86,7 +88,7 @@ export class NotificationController {
@Get('unread-count')
async getUnreadCount(@Request() req) {
const userId = req.user?.id;
const userId = req.user?.userId;
return this.notificationService.getUnreadCount(userId);
}
@ -97,13 +99,13 @@ export class NotificationController {
@Put(':id/read')
async markAsRead(@Param('id') id: string, @Request() req) {
const userId = req.user?.id;
const userId = req.user?.userId;
return this.notificationService.markAsRead(id, userId);
}
@Put('read-all')
async markAllAsRead(@Request() req) {
const userId = req.user?.id;
const userId = req.user?.userId;
return this.notificationService.markAllAsRead(userId);
}
@ -140,13 +142,13 @@ export class NotificationController {
@Get('preferences/my')
async getMyPreferences(@Request() req) {
const userId = req.user?.id;
const userId = req.user?.userId;
return this.preferenceService.getOrCreate(userId);
}
@Put('preferences/my')
async updateMyPreferences(@Request() req, @Body() dto: UpdatePreferenceDto) {
const userId = req.user?.id;
const userId = req.user?.userId;
return this.preferenceService.update(userId, dto);
}

View File

@ -10,7 +10,7 @@ import { SmsChannelService } from './channels/sms-channel.service';
import { PushChannelService } from './channels/push-channel.service';
import { InAppChannelService } from './channels/in-app-channel.service';
import { WeComChannelService } from './channels/wecom-channel.service';
import { PrismaModule } from '../prisma/prisma.module';
import { PrismaModule } from '../../prisma/prisma.module';
@Module({
imports: [PrismaModule],

View File

@ -101,7 +101,7 @@ export class NotificationService {
const targetChannels = channels || content.template.channels;
const results = [];
const results: any[] = [];
for (const channel of targetChannels) {
const payload: NotificationPayload = {

View File

@ -78,7 +78,9 @@ export class PreferenceService {
if (
preference.quietStartHour === undefined ||
preference.quietEndHour === undefined
preference.quietStartHour === null ||
preference.quietEndHour === undefined ||
preference.quietEndHour === null
) {
return false;
}
@ -86,7 +88,8 @@ export class PreferenceService {
const now = new Date();
const hour = now.getHours();
const { quietStartHour, quietEndHour } = preference;
const quietStartHour = preference.quietStartHour as number;
const quietEndHour = preference.quietEndHour as number;
if (quietStartHour <= quietEndHour) {
return hour >= quietStartHour && hour < quietEndHour;

View File

@ -17,7 +17,7 @@ describe('OrderController', () => {
};
const mockRequest = {
user: { id: 'test-user-id' },
user: { userId: 'test-user-id' },
};
beforeEach(async () => {

View File

@ -21,7 +21,7 @@ export class OrderController {
@Post()
async create(@Body() createOrderDto: CreateOrderDto, @Request() req: any) {
const userId = req.user?.id;
const userId = req.user?.userId;
return this.orderService.create(userId, createOrderDto);
}
@ -32,7 +32,7 @@ export class OrderController {
@Query('status') status?: string,
@Request() req?: any,
) {
const userId = req.user?.id;
const userId = req.user?.userId;
return this.orderService.findMany({
page: parseInt(page, 10),
limit: parseInt(limit, 10),
@ -43,13 +43,13 @@ export class OrderController {
@Get('stats')
async getStats(@Request() req: any) {
const userId = req.user?.id;
const userId = req.user?.userId;
return this.orderService.getStats(userId);
}
@Get(':id')
async findOne(@Param('id') id: string, @Request() req: any) {
const userId = req.user?.id;
const userId = req.user?.userId;
return this.orderService.findOne(id, userId);
}
@ -63,7 +63,7 @@ export class OrderController {
@Post(':id/cancel')
async cancel(@Param('id') id: string, @Request() req: any) {
const userId = req.user?.id;
const userId = req.user?.userId;
return this.orderService.cancel(id, userId);
}
}

View File

@ -8,13 +8,16 @@ import {
Request,
Ip,
Headers,
UseGuards,
} from '@nestjs/common';
import { PaymentService } from './services/payment.service';
import { RefundService } from './services/refund.service';
import { CreatePaymentDto } from './dto/create-payment.dto';
import { CreateRefundDto } from './dto/create-refund.dto';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
@Controller('payment')
@UseGuards(JwtAuthGuard)
export class PaymentController {
constructor(
private readonly paymentService: PaymentService,
@ -28,7 +31,7 @@ export class PaymentController {
@Headers('user-agent') userAgent: string,
@Request() req: any,
) {
const userId = req.user?.id || 'test-user-id';
const userId = req.user?.userId;
return this.paymentService.createPayment(userId, createPaymentDto, ip, userAgent);
}
@ -38,13 +41,13 @@ export class PaymentController {
@Query('limit') limit: string = '20',
@Request() req: any,
) {
const userId = req.user?.id;
const userId = req.user?.userId;
return this.paymentService.findAll(userId, parseInt(page), parseInt(limit));
}
@Get('orders/:id')
findOneOrder(@Param('id') id: string, @Request() req: any) {
const userId = req.user?.id;
const userId = req.user?.userId;
return this.paymentService.findOne(id, userId);
}
@ -55,13 +58,13 @@ export class PaymentController {
@Post('orders/:id/cancel')
cancelOrder(@Param('id') id: string, @Request() req: any) {
const userId = req.user?.id;
const userId = req.user?.userId;
return this.paymentService.cancelOrder(id, userId);
}
@Post('refunds')
createRefund(@Body() createRefundDto: CreateRefundDto, @Request() req: any) {
const userId = req.user?.id || 'test-user-id';
const userId = req.user?.userId;
return this.refundService.createRefund(userId, createRefundDto);
}
@ -71,13 +74,13 @@ export class PaymentController {
@Query('limit') limit: string = '20',
@Request() req: any,
) {
const userId = req.user?.id;
const userId = req.user?.userId;
return this.refundService.findAll(userId, parseInt(page), parseInt(limit));
}
@Get('refunds/:id')
findOneRefund(@Param('id') id: string, @Request() req: any) {
const userId = req.user?.id;
const userId = req.user?.userId;
return this.refundService.findOne(id, userId);
}

View File

@ -54,7 +54,7 @@ export class PaymentService {
const adapter = this.adapterFactory.createAdapter(
channel.type as PaymentChannelType,
{ ...channel.config, isSandbox: channel.isSandbox },
{ ...(channel.config as any), isSandbox: channel.isSandbox },
);
const result = await adapter.createPayment({
@ -151,7 +151,7 @@ export class PaymentService {
}
async queryOrderStatus(orderId: string) {
const order = await this.findOne(orderId);
const order = await this.findOne(orderId) as any;
if (!order.channel) {
throw new BadRequestException('Payment channel not found for this order');
@ -159,12 +159,12 @@ export class PaymentService {
const adapter = this.adapterFactory.createAdapter(
order.channel.type as PaymentChannelType,
{ ...order.channel.config, isSandbox: order.channel.isSandbox },
{ ...(order.channel.config as any), isSandbox: order.channel.isSandbox },
);
const result = await adapter.queryPayment({
orderNo: order.orderNo,
channelOrderNo: order.channelOrderNo,
channelOrderNo: order.channelOrderNo ?? undefined,
});
await this.createPaymentLog(

View File

@ -15,7 +15,7 @@ export class ReconciliationService {
const order = await this.prisma.paymentOrder.findUnique({
where: { id: orderId },
include: { channel: true },
});
}) as any;
if (!order || !order.channel) {
return { success: false, error: 'Order or channel not found' };
@ -23,12 +23,12 @@ export class ReconciliationService {
const adapter = this.adapterFactory.createAdapter(
order.channel.type as PaymentChannelType,
{ ...order.channel.config, isSandbox: order.channel.isSandbox },
{ ...(order.channel.config as any), isSandbox: order.channel.isSandbox },
);
const result = await adapter.queryPayment({
orderNo: order.orderNo,
channelOrderNo: order.channelOrderNo,
channelOrderNo: order.channelOrderNo ?? undefined,
});
let statusUpdated = false;
@ -65,7 +65,7 @@ export class ReconciliationService {
}
async reconcileOrders(orderIds: string[]) {
const results = [];
const results: any[] = [];
for (const orderId of orderIds) {
try {

View File

@ -23,7 +23,7 @@ export class RefundService {
}
async createRefund(userId: string, createRefundDto: CreateRefundDto) {
const order = await this.paymentService.findOne(createRefundDto.orderId, userId);
const order = await this.paymentService.findOne(createRefundDto.orderId, userId) as any;
if (order.status !== 'paid') {
throw new BadRequestException('Only paid orders can be refunded');
@ -55,7 +55,7 @@ export class RefundService {
data: {
refundNo,
orderId: order.id,
channelId: order.channel.id,
channelId: order.channelId,
userId,
amount: refundAmount,
reason: createRefundDto.reason,
@ -64,7 +64,7 @@ export class RefundService {
const adapter = this.adapterFactory.createAdapter(
order.channel.type as PaymentChannelType,
{ ...order.channel.config, isSandbox: order.channel.isSandbox },
{ ...(order.channel.config as any), isSandbox: order.channel.isSandbox },
);
const result = await adapter.refundPayment({
@ -72,7 +72,7 @@ export class RefundService {
refundNo,
amount: createRefundDto.amount,
reason: createRefundDto.reason,
channelOrderNo: order.channelOrderNo,
channelOrderNo: order.channelOrderNo ?? undefined,
});
await this.createRefundLog(

View File

@ -16,7 +16,7 @@ import { FileInterceptor } from '@nestjs/platform-express';
import { UserService } from './user.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { UpdateUserDto } from './dto/update-user.dto';
import { Express } from 'express';
import type { Express } from 'express';
@Controller('users')
@UseGuards(JwtAuthGuard)

View File

@ -1,37 +1,39 @@
import { NodeSDK } from '@opentelemetry/sdk-node';
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-proto';
import { ConsoleSpanExporter } from '@opentelemetry/sdk-trace-base';
import { SimpleSpanProcessor, BatchSpanProcessor } from '@opentelemetry/sdk-trace-base';
const startTracing = () => {
if (process.env.OTEL_ENABLED !== 'true') {
return;
}
const isProduction = process.env.NODE_ENV === 'production';
try {
const { NodeSDK } = require('@opentelemetry/sdk-node');
const { getNodeAutoInstrumentations } = require('@opentelemetry/auto-instrumentations-node');
const { OTLPTraceExporter } = require('@opentelemetry/exporter-trace-otlp-proto');
const { ConsoleSpanExporter } = require('@opentelemetry/sdk-trace-base');
const traceExporter = isProduction
? new OTLPTraceExporter({
url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT || 'http://localhost:4318/v1/traces',
})
: new ConsoleSpanExporter();
const isProduction = process.env.NODE_ENV === 'production';
const sdk = new NodeSDK({
instrumentations: [
getNodeAutoInstrumentations({
'@opentelemetry/instrumentation-http': {
ignoreIncomingPaths: ['/metrics', '/health'],
},
}),
],
spanProcessor: isProduction
? new BatchSpanProcessor(traceExporter)
: new SimpleSpanProcessor(traceExporter),
serviceName: 'fischerx-api',
});
const traceExporter = isProduction
? new OTLPTraceExporter({
url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT || 'http://localhost:4318/v1/traces',
})
: new ConsoleSpanExporter();
export const startTracing = () => {
sdk.start();
console.log('OpenTelemetry tracing started');
const sdk = new NodeSDK({
instrumentations: [
getNodeAutoInstrumentations({
'@opentelemetry/instrumentation-http': {
ignoreIncomingPaths: ['/metrics', '/health'],
},
}),
],
traceExporter,
serviceName: 'fischerx-api',
});
sdk.start();
console.log('OpenTelemetry tracing started');
} catch (error) {
console.warn('OpenTelemetry initialization failed:', error);
}
};
export const shutdownTracing = async () => {
await sdk.shutdown();
console.log('OpenTelemetry tracing shutdown');
};
export { startTracing };