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:
parent
3d867331ae
commit
bdb509a611
85
.env.example
85
.env.example
|
|
@ -1,25 +1,88 @@
|
||||||
NODE_ENV=development
|
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_PORT=5432
|
||||||
DB_USER=fischerx
|
DB_USER=fischerx
|
||||||
DB_PASSWORD=fischerx
|
DB_PASSWORD=fischerx
|
||||||
DB_NAME=fischerx
|
DB_NAME=fischerx
|
||||||
|
|
||||||
REDIS_HOST=redis
|
# Redis
|
||||||
|
REDIS_URL="redis://localhost:6379"
|
||||||
|
REDIS_HOST=localhost
|
||||||
REDIS_PORT=6379
|
REDIS_PORT=6379
|
||||||
REDIS_PASSWORD=
|
REDIS_PASSWORD=
|
||||||
|
|
||||||
API_PORT=3001
|
# JWT
|
||||||
WEB_PORT=3000
|
JWT_SECRET="your-super-secret-jwt-key-change-this-in-production"
|
||||||
|
JWT_EXPIRATION="7d"
|
||||||
|
|
||||||
|
# Frontend
|
||||||
NEXT_PUBLIC_API_URL=http://localhost:3001
|
NEXT_PUBLIC_API_URL=http://localhost:3001
|
||||||
|
|
||||||
JWT_SECRET=your-super-secret-jwt-key-change-in-production
|
# Storage Configuration
|
||||||
JWT_EXPIRES_IN=7d
|
STORAGE_TYPE="local"
|
||||||
|
|
||||||
ALIBABA_CLOUD_ACCESS_KEY_ID=
|
# Local Storage
|
||||||
ALIBABA_CLOUD_ACCESS_KEY_SECRET=
|
LOCAL_STORAGE_PATH="./uploads"
|
||||||
OSS_REGION=
|
LOCAL_STORAGE_URL="http://localhost:3001/uploads"
|
||||||
OSS_BUCKET=
|
|
||||||
OSS_ENDPOINT=
|
# 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
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@
|
||||||
"test:e2e:ui": "playwright test --ui"
|
"test:e2e:ui": "playwright test --ui"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@fischerx/types": "workspace:*",
|
||||||
"@hookform/resolvers": "^5.4.0",
|
"@hookform/resolvers": "^5.4.0",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
"@tanstack/react-query": "^5.100.13",
|
"@tanstack/react-query": "^5.100.13",
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ export default function AuthLayout({
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
}) {
|
}) {
|
||||||
return (
|
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}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,20 @@
|
||||||
|
import { Navbar } from "@/components/layout/navbar"
|
||||||
|
import { Sidebar } from "@/components/layout/sidebar"
|
||||||
|
|
||||||
export default function DashboardLayout({
|
export default function DashboardLayout({
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode
|
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>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -97,7 +97,7 @@ export default function ProfilePage() {
|
||||||
|
|
||||||
const updatedUser = response.data.data;
|
const updatedUser = response.data.data;
|
||||||
setUser({
|
setUser({
|
||||||
...user,
|
...user!,
|
||||||
avatar: updatedUser.avatar,
|
avatar: updatedUser.avatar,
|
||||||
});
|
});
|
||||||
setMessage('Avatar updated successfully');
|
setMessage('Avatar updated successfully');
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,30 @@
|
||||||
@tailwind base;
|
@import "tailwindcss";
|
||||||
@tailwind components;
|
|
||||||
@tailwind utilities;
|
@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 {
|
@layer base {
|
||||||
:root {
|
:root {
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ import type { Metadata } from "next"
|
||||||
import { Geist, Geist_Mono } from "next/font/google"
|
import { Geist, Geist_Mono } from "next/font/google"
|
||||||
import "./globals.css"
|
import "./globals.css"
|
||||||
import { QueryProvider } from "@/providers/query-provider"
|
import { QueryProvider } from "@/providers/query-provider"
|
||||||
import { Navbar, Sidebar } from "@/components/layout"
|
|
||||||
import { ErrorBoundary } from "@/components/error-boundary"
|
import { ErrorBoundary } from "@/components/error-boundary"
|
||||||
import { MonitoringProvider } from "@/providers/monitoring-provider"
|
import { MonitoringProvider } from "@/providers/monitoring-provider"
|
||||||
|
|
||||||
|
|
@ -27,20 +26,12 @@ export default function RootLayout({
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
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">
|
<body className="min-h-full">
|
||||||
<MonitoringProvider>
|
<MonitoringProvider>
|
||||||
<QueryProvider>
|
<QueryProvider>
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<div className="relative flex min-h-screen flex-col">
|
{children}
|
||||||
<Navbar />
|
|
||||||
<div className="flex flex-1">
|
|
||||||
<Sidebar />
|
|
||||||
<main className="flex-1 md:ml-64">
|
|
||||||
<div className="container py-6">{children}</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
</QueryProvider>
|
</QueryProvider>
|
||||||
</MonitoringProvider>
|
</MonitoringProvider>
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,10 @@ import { useState } from 'react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import { useDownloadFile, useDeleteFile } from '@/hooks/use-files';
|
import { useDownloadFile, useDeleteFile } from '@/hooks/use-files';
|
||||||
import type { File } from '@fischerx/types';
|
import type { File as FileType } from '@fischerx/types';
|
||||||
|
|
||||||
interface FilePreviewProps {
|
interface FilePreviewProps {
|
||||||
file: File;
|
file: FileType;
|
||||||
onDelete?: () => void;
|
onDelete?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { fileApi } from '@/lib/file-api';
|
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) => {
|
export const useFiles = (params?: FileListParams) => {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
|
|
@ -21,7 +21,7 @@ export const useUploadFile = () => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: ({ file, options }: { file: File | Blob; options?: FileUploadOptions }) =>
|
mutationFn: ({ file, options }: { file: globalThis.File | Blob; options?: FileUploadOptions }) =>
|
||||||
fileApi.uploadFile(file, options),
|
fileApi.uploadFile(file, options),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['files'] });
|
queryClient.invalidateQueries({ queryKey: ['files'] });
|
||||||
|
|
@ -33,7 +33,7 @@ export const useUploadMultipleFiles = () => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: ({ files, options }: { files: (File | Blob)[]; options?: FileUploadOptions }) =>
|
mutationFn: ({ files, options }: { files: (globalThis.File | Blob)[]; options?: FileUploadOptions }) =>
|
||||||
fileApi.uploadMultipleFiles(files, options),
|
fileApi.uploadMultipleFiles(files, options),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['files'] });
|
queryClient.invalidateQueries({ queryKey: ['files'] });
|
||||||
|
|
@ -45,7 +45,7 @@ export const useUpdateFile = () => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
return useMutation({
|
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),
|
fileApi.updateFile(id, data),
|
||||||
onSuccess: (_, { id }) => {
|
onSuccess: (_, { id }) => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['files'] });
|
queryClient.invalidateQueries({ queryKey: ['files'] });
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import api from './api';
|
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 = {
|
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();
|
const formData = new FormData();
|
||||||
formData.append('file', file);
|
formData.append('file', file);
|
||||||
if (options?.category) formData.append('category', options.category);
|
if (options?.category) formData.append('category', options.category);
|
||||||
|
|
@ -18,9 +18,9 @@ export const fileApi = {
|
||||||
},
|
},
|
||||||
|
|
||||||
uploadMultipleFiles: async (
|
uploadMultipleFiles: async (
|
||||||
files: (File | Blob)[],
|
files: (globalThis.File | Blob)[],
|
||||||
options?: FileUploadOptions
|
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();
|
const formData = new FormData();
|
||||||
files.forEach((file) => formData.append('files', file));
|
files.forEach((file) => formData.append('files', file));
|
||||||
if (options?.category) formData.append('category', options.category);
|
if (options?.category) formData.append('category', options.category);
|
||||||
|
|
@ -35,12 +35,12 @@ export const fileApi = {
|
||||||
return response.data;
|
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 });
|
const response = await api.get('/files', { params });
|
||||||
return response.data;
|
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}`);
|
const response = await api.get(`/files/${id}`);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
@ -65,7 +65,7 @@ export const fileApi = {
|
||||||
return response.data;
|
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);
|
const response = await api.put(`/files/${id}`, data);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,38 +1,7 @@
|
||||||
import { WebTracerProvider } from '@opentelemetry/sdk-trace-web';
|
export const initTelemetry = async () => {
|
||||||
import { SimpleSpanProcessor, ConsoleSpanExporter } from '@opentelemetry/sdk-trace-base';
|
if (process.env.NODE_ENV !== 'production') return;
|
||||||
import { ZoneContextManager } from '@opentelemetry/context-zone';
|
const endpoint = process.env.NEXT_PUBLIC_OTEL_EXPORTER_OTLP_ENDPOINT;
|
||||||
import { FetchInstrumentation } from '@opentelemetry/instrumentation-fetch';
|
if (!endpoint) return;
|
||||||
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';
|
|
||||||
|
|
||||||
const isProduction = process.env.NODE_ENV === 'production';
|
console.warn('OpenTelemetry packages not installed. Install @opentelemetry/* packages to enable tracing.');
|
||||||
|
};
|
||||||
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');
|
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,12 @@
|
||||||
import { ReportCallback } from 'web-vitals';
|
export const reportWebVitals = (onPerfEntry?: (metric: any) => void) => {
|
||||||
|
if (!onPerfEntry) return;
|
||||||
export const reportWebVitals = (onPerfEntry?: ReportCallback) => {
|
import('web-vitals').then(({ onCLS, onINP, onFCP, onLCP, onTTFB }) => {
|
||||||
if (onPerfEntry && onPerfEntry instanceof Function) {
|
onCLS(onPerfEntry);
|
||||||
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
|
onINP(onPerfEntry);
|
||||||
getCLS(onPerfEntry);
|
onFCP(onPerfEntry);
|
||||||
getFID(onPerfEntry);
|
onLCP(onPerfEntry);
|
||||||
getFCP(onPerfEntry);
|
onTTFB(onPerfEntry);
|
||||||
getLCP(onPerfEntry);
|
}).catch(() => {});
|
||||||
getTTFB(onPerfEntry);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const logVitals = (metric: any) => {
|
export const logVitals = (metric: any) => {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useEffect, ReactNode } from 'react';
|
import React, { useEffect, ReactNode } from 'react';
|
||||||
import { logVitals, reportWebVitals } from '@/lib/web-vitals';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
|
|
@ -9,12 +8,16 @@ interface Props {
|
||||||
|
|
||||||
export function MonitoringProvider({ children }: Props) {
|
export function MonitoringProvider({ children }: Props) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
reportWebVitals(logVitals);
|
if (typeof window !== 'undefined') {
|
||||||
|
import('@/lib/web-vitals').then(({ reportWebVitals, logVitals }) => {
|
||||||
|
reportWebVitals(logVitals);
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (process.env.NODE_ENV === 'production') {
|
if (typeof window !== 'undefined' && process.env.NODE_ENV === 'production') {
|
||||||
import('@/lib/telemetry').catch(console.error);
|
import('@/lib/telemetry').then(m => m.initTelemetry()).catch(() => {});
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,6 @@
|
||||||
import { create } from 'zustand'
|
import { create } from 'zustand'
|
||||||
import { persist } from 'zustand/middleware'
|
import { persist } from 'zustand/middleware'
|
||||||
|
import { User } from '@/lib/user-api'
|
||||||
interface User {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
email: string
|
|
||||||
avatar?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UserState {
|
interface UserState {
|
||||||
user: User | null
|
user: User | null
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,8 @@
|
||||||
],
|
],
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["./src/*"]
|
"@/*": ["./src/*"]
|
||||||
}
|
},
|
||||||
|
"types": ["react", "react-dom", "node"]
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
"next-env.d.ts",
|
"next-env.d.ts",
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,6 @@
|
||||||
version: '3.8'
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
build:
|
image: postgres:15-alpine
|
||||||
context: ./infra/db
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
container_name: fischerx-postgres
|
container_name: fischerx-postgres
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: ${DB_USER:-fischerx}
|
POSTGRES_USER: ${DB_USER:-fischerx}
|
||||||
|
|
@ -14,6 +10,7 @@ services:
|
||||||
- "${DB_PORT:-5432}:5432"
|
- "${DB_PORT:-5432}:5432"
|
||||||
volumes:
|
volumes:
|
||||||
- postgres-data:/var/lib/postgresql/data
|
- postgres-data:/var/lib/postgresql/data
|
||||||
|
- ./infra/db/init.sql:/docker-entrypoint-initdb.d/init.sql
|
||||||
networks:
|
networks:
|
||||||
- fischerx-network
|
- fischerx-network
|
||||||
healthcheck:
|
healthcheck:
|
||||||
|
|
|
||||||
|
|
@ -30,10 +30,55 @@ export interface File {
|
||||||
mimeType: string;
|
mimeType: string;
|
||||||
size: number;
|
size: number;
|
||||||
url: string;
|
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;
|
ownerId: string;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: 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> {
|
export interface ApiResponse<T = unknown> {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
data?: T;
|
data?: T;
|
||||||
|
|
@ -63,4 +108,3 @@ export interface AuthResponse {
|
||||||
refreshToken: string;
|
refreshToken: string;
|
||||||
user: User;
|
user: User;
|
||||||
}
|
}
|
||||||
//# sourceMappingURL=index.d.ts.map
|
|
||||||
4370
pnpm-lock.yaml
4370
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
|
|
@ -1,5 +1,5 @@
|
||||||
# Database
|
# Database
|
||||||
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/fischerx?schema=public"
|
DATABASE_URL="postgresql://fischerx:fischerx@localhost:5432/fischerx?schema=public"
|
||||||
|
|
||||||
# JWT
|
# JWT
|
||||||
JWT_SECRET="your-super-secret-jwt-key-change-this-in-production"
|
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
|
# Notification - WeCom Robot Webhook
|
||||||
WECOM_WEBHOOK_URL="https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=your-default-key"
|
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"
|
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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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"
|
||||||
|
|
@ -37,5 +37,9 @@ export class GlobalExceptionFilter implements ExceptionFilter {
|
||||||
message,
|
message,
|
||||||
data: null,
|
data: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!(exception instanceof HttpException)) {
|
||||||
|
console.error('[Unhandled Exception]', exception);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,9 @@ import { HttpMetricsInterceptor } from './modules/monitoring/http-metrics.interc
|
||||||
import { startTracing } from './tracing';
|
import { startTracing } from './tracing';
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
startTracing();
|
if (process.env.OTEL_ENABLED === 'true') {
|
||||||
|
startTracing();
|
||||||
|
}
|
||||||
|
|
||||||
const app = await NestFactory.create<NestExpressApplication>(AppModule);
|
const app = await NestFactory.create<NestExpressApplication>(AppModule);
|
||||||
|
|
||||||
|
|
@ -35,6 +37,6 @@ async function bootstrap() {
|
||||||
await app.listen(port);
|
await app.listen(port);
|
||||||
console.log(`Application is running on: http://localhost:${port}`);
|
console.log(`Application is running on: http://localhost:${port}`);
|
||||||
console.log(`Metrics available at: http://localhost:${port}/metrics`);
|
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();
|
void bootstrap();
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ describe('AlipayAuthService', () => {
|
||||||
create: jest.fn(),
|
create: jest.fn(),
|
||||||
update: jest.fn(),
|
update: jest.fn(),
|
||||||
},
|
},
|
||||||
oauthAccount: {
|
oAuthAccount: {
|
||||||
findUnique: jest.fn(),
|
findUnique: jest.fn(),
|
||||||
create: jest.fn(),
|
create: jest.fn(),
|
||||||
},
|
},
|
||||||
|
|
@ -68,17 +68,20 @@ describe('AlipayAuthService', () => {
|
||||||
email: null,
|
email: null,
|
||||||
phone: null,
|
phone: null,
|
||||||
username: 'alipay_user',
|
username: 'alipay_user',
|
||||||
|
password: null,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
roles: [],
|
roles: [{ role: { name: 'user' } }],
|
||||||
};
|
};
|
||||||
|
|
||||||
jest
|
jest
|
||||||
.spyOn(service, 'getAlipayUserInfo')
|
.spyOn(service, 'getAlipayUserInfo')
|
||||||
.mockResolvedValue(mockAlipayUser);
|
.mockReturnValue(Promise.resolve(mockAlipayUser));
|
||||||
mockPrisma.oauthAccount.findUnique.mockResolvedValue({
|
mockPrisma.oAuthAccount.findUnique.mockResolvedValue({
|
||||||
userId: 'user-123',
|
userId: 'user-123',
|
||||||
});
|
});
|
||||||
mockPrisma.user.findUnique.mockResolvedValue(mockExistingUser);
|
mockPrisma.user.findUnique.mockResolvedValue(mockExistingUser);
|
||||||
|
mockPrisma.user.update.mockResolvedValue({ ...mockExistingUser, lastLoginAt: new Date() });
|
||||||
|
mockPrisma.loginHistory.create.mockResolvedValue({});
|
||||||
mockSessionService.createSession.mockResolvedValue({
|
mockSessionService.createSession.mockResolvedValue({
|
||||||
token: 'access-token',
|
token: 'access-token',
|
||||||
refreshToken: 'refresh-token',
|
refreshToken: 'refresh-token',
|
||||||
|
|
@ -101,17 +104,23 @@ describe('AlipayAuthService', () => {
|
||||||
avatar: 'https://example.com/alipay-avatar.jpg',
|
avatar: 'https://example.com/alipay-avatar.jpg',
|
||||||
};
|
};
|
||||||
|
|
||||||
jest
|
const mockNewUser = {
|
||||||
.spyOn(service, 'getAlipayUserInfo')
|
|
||||||
.mockResolvedValue(mockAlipayUser);
|
|
||||||
mockPrisma.oauthAccount.findUnique.mockResolvedValue(null);
|
|
||||||
mockPrisma.user.create.mockResolvedValue({
|
|
||||||
id: 'user-456',
|
id: 'user-456',
|
||||||
username: 'alipay_user_456',
|
username: 'alipay_user_456',
|
||||||
|
password: null,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
roles: [],
|
roles: [{ role: { name: 'user' } }],
|
||||||
});
|
};
|
||||||
mockPrisma.oauthAccount.create.mockResolvedValue({});
|
|
||||||
|
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({
|
mockSessionService.createSession.mockResolvedValue({
|
||||||
token: 'access-token',
|
token: 'access-token',
|
||||||
refreshToken: 'refresh-token',
|
refreshToken: 'refresh-token',
|
||||||
|
|
@ -120,7 +129,7 @@ describe('AlipayAuthService', () => {
|
||||||
const result = await service.loginWithAlipay(mockCode, {});
|
const result = await service.loginWithAlipay(mockCode, {});
|
||||||
|
|
||||||
expect(mockPrisma.user.create).toHaveBeenCalled();
|
expect(mockPrisma.user.create).toHaveBeenCalled();
|
||||||
expect(mockPrisma.oauthAccount.create).toHaveBeenCalled();
|
expect(mockPrisma.oAuthAccount.create).toHaveBeenCalled();
|
||||||
expect(result).toHaveProperty('accessToken');
|
expect(result).toHaveProperty('accessToken');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ export class AlipayAuthService {
|
||||||
) {
|
) {
|
||||||
const alipayUser = await this.getAlipayUserInfo(code);
|
const alipayUser = await this.getAlipayUserInfo(code);
|
||||||
|
|
||||||
const existingAccount = await this.prisma.oauthAccount.findUnique({
|
const existingAccount = await this.prisma.oAuthAccount.findUnique({
|
||||||
where: {
|
where: {
|
||||||
provider_providerId: {
|
provider_providerId: {
|
||||||
provider: 'alipay',
|
provider: 'alipay',
|
||||||
|
|
@ -61,7 +61,7 @@ export class AlipayAuthService {
|
||||||
where: { name: 'user' },
|
where: { name: 'user' },
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
})
|
})
|
||||||
)?.id,
|
)!.id,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -76,7 +76,7 @@ export class AlipayAuthService {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.prisma.oauthAccount.create({
|
await this.prisma.oAuthAccount.create({
|
||||||
data: {
|
data: {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
provider: 'alipay',
|
provider: 'alipay',
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@ export class AuthController {
|
||||||
|
|
||||||
@Post('login')
|
@Post('login')
|
||||||
async login(@Body() loginDto: LoginDto, @Req() req: any) {
|
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(
|
const user = await this.authService.validateUser(
|
||||||
identifier,
|
identifier,
|
||||||
loginDto.password,
|
loginDto.password,
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ export class AuthService {
|
||||||
private jwtService: JwtService,
|
private jwtService: JwtService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async validateUser(identifier: string, password: string) {
|
async validateUser(identifier: string, inputPassword: string) {
|
||||||
let user;
|
let user;
|
||||||
|
|
||||||
if (this.isPhone(identifier)) {
|
if (this.isPhone(identifier)) {
|
||||||
|
|
@ -37,7 +37,7 @@ export class AuthService {
|
||||||
throw new UnauthorizedException('Account is disabled');
|
throw new UnauthorizedException('Account is disabled');
|
||||||
}
|
}
|
||||||
|
|
||||||
const passwordValid = await this.comparePassword(password, user.password);
|
const passwordValid = await this.comparePassword(inputPassword, user.password);
|
||||||
if (!passwordValid) {
|
if (!passwordValid) {
|
||||||
throw new UnauthorizedException('Invalid credentials');
|
throw new UnauthorizedException('Invalid credentials');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -93,6 +93,7 @@ export class SessionService {
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
email: true,
|
email: true,
|
||||||
|
isActive: true,
|
||||||
roles: {
|
roles: {
|
||||||
select: {
|
select: {
|
||||||
role: {
|
role: {
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ export class SmsAuthService {
|
||||||
const attemptsKey = `sms:attempts:${phone}`;
|
const attemptsKey = `sms:attempts:${phone}`;
|
||||||
const attempts = await this.cacheService.get(attemptsKey);
|
const attempts = await this.cacheService.get(attemptsKey);
|
||||||
|
|
||||||
if (attempts && parseInt(attempts) >= 5) {
|
if (attempts && parseInt(String(attempts)) >= 5) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
'Too many attempts, please try again later',
|
'Too many attempts, please try again later',
|
||||||
);
|
);
|
||||||
|
|
@ -65,7 +65,7 @@ export class SmsAuthService {
|
||||||
if (storedCode !== code) {
|
if (storedCode !== code) {
|
||||||
const attemptsKey = `sms:attempts:${phone}`;
|
const attemptsKey = `sms:attempts:${phone}`;
|
||||||
const attempts = await this.cacheService.get(attemptsKey);
|
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) {
|
if (newAttempts >= 5) {
|
||||||
await this.cacheService.del(codeKey);
|
await this.cacheService.del(codeKey);
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ describe('WechatAuthService', () => {
|
||||||
create: jest.fn(),
|
create: jest.fn(),
|
||||||
update: jest.fn(),
|
update: jest.fn(),
|
||||||
},
|
},
|
||||||
oauthAccount: {
|
oAuthAccount: {
|
||||||
findUnique: jest.fn(),
|
findUnique: jest.fn(),
|
||||||
create: jest.fn(),
|
create: jest.fn(),
|
||||||
},
|
},
|
||||||
|
|
@ -68,17 +68,20 @@ describe('WechatAuthService', () => {
|
||||||
email: null,
|
email: null,
|
||||||
phone: null,
|
phone: null,
|
||||||
username: 'wechat_user',
|
username: 'wechat_user',
|
||||||
|
password: null,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
roles: [],
|
roles: [{ role: { name: 'user' } }],
|
||||||
};
|
};
|
||||||
|
|
||||||
jest
|
jest
|
||||||
.spyOn(service, 'getWechatUserInfo')
|
.spyOn(service, 'getWechatUserInfo')
|
||||||
.mockResolvedValue(mockWechatUser);
|
.mockReturnValue(Promise.resolve(mockWechatUser));
|
||||||
mockPrisma.oauthAccount.findUnique.mockResolvedValue({
|
mockPrisma.oAuthAccount.findUnique.mockResolvedValue({
|
||||||
userId: 'user-123',
|
userId: 'user-123',
|
||||||
});
|
});
|
||||||
mockPrisma.user.findUnique.mockResolvedValue(mockExistingUser);
|
mockPrisma.user.findUnique.mockResolvedValue(mockExistingUser);
|
||||||
|
mockPrisma.user.update.mockResolvedValue({ ...mockExistingUser, lastLoginAt: new Date() });
|
||||||
|
mockPrisma.loginHistory.create.mockResolvedValue({});
|
||||||
mockSessionService.createSession.mockResolvedValue({
|
mockSessionService.createSession.mockResolvedValue({
|
||||||
token: 'access-token',
|
token: 'access-token',
|
||||||
refreshToken: 'refresh-token',
|
refreshToken: 'refresh-token',
|
||||||
|
|
@ -101,17 +104,23 @@ describe('WechatAuthService', () => {
|
||||||
headimgurl: 'https://example.com/avatar.jpg',
|
headimgurl: 'https://example.com/avatar.jpg',
|
||||||
};
|
};
|
||||||
|
|
||||||
jest
|
const mockNewUser = {
|
||||||
.spyOn(service, 'getWechatUserInfo')
|
|
||||||
.mockResolvedValue(mockWechatUser);
|
|
||||||
mockPrisma.oauthAccount.findUnique.mockResolvedValue(null);
|
|
||||||
mockPrisma.user.create.mockResolvedValue({
|
|
||||||
id: 'user-456',
|
id: 'user-456',
|
||||||
username: 'wechat_user_456',
|
username: 'wechat_user_456',
|
||||||
|
password: null,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
roles: [],
|
roles: [{ role: { name: 'user' } }],
|
||||||
});
|
};
|
||||||
mockPrisma.oauthAccount.create.mockResolvedValue({});
|
|
||||||
|
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({
|
mockSessionService.createSession.mockResolvedValue({
|
||||||
token: 'access-token',
|
token: 'access-token',
|
||||||
refreshToken: 'refresh-token',
|
refreshToken: 'refresh-token',
|
||||||
|
|
@ -120,7 +129,7 @@ describe('WechatAuthService', () => {
|
||||||
const result = await service.loginWithWechat(mockCode, {});
|
const result = await service.loginWithWechat(mockCode, {});
|
||||||
|
|
||||||
expect(mockPrisma.user.create).toHaveBeenCalled();
|
expect(mockPrisma.user.create).toHaveBeenCalled();
|
||||||
expect(mockPrisma.oauthAccount.create).toHaveBeenCalled();
|
expect(mockPrisma.oAuthAccount.create).toHaveBeenCalled();
|
||||||
expect(result).toHaveProperty('accessToken');
|
expect(result).toHaveProperty('accessToken');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ export class WechatAuthService {
|
||||||
) {
|
) {
|
||||||
const wechatUser = await this.getWechatUserInfo(code);
|
const wechatUser = await this.getWechatUserInfo(code);
|
||||||
|
|
||||||
const existingAccount = await this.prisma.oauthAccount.findUnique({
|
const existingAccount = await this.prisma.oAuthAccount.findUnique({
|
||||||
where: {
|
where: {
|
||||||
provider_providerId: {
|
provider_providerId: {
|
||||||
provider: 'wechat',
|
provider: 'wechat',
|
||||||
|
|
@ -61,7 +61,7 @@ export class WechatAuthService {
|
||||||
where: { name: 'user' },
|
where: { name: 'user' },
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
})
|
})
|
||||||
)?.id,
|
)!.id,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -76,7 +76,7 @@ export class WechatAuthService {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.prisma.oauthAccount.create({
|
await this.prisma.oAuthAccount.create({
|
||||||
data: {
|
data: {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
provider: 'wechat',
|
provider: 'wechat',
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ import { CacheService } from './cache.service';
|
||||||
|
|
||||||
if (redisUrl) {
|
if (redisUrl) {
|
||||||
return {
|
return {
|
||||||
store: redisStore,
|
store: redisStore as any,
|
||||||
url: redisUrl,
|
url: redisUrl,
|
||||||
ttl: 600,
|
ttl: 600,
|
||||||
};
|
};
|
||||||
|
|
@ -22,7 +22,7 @@ import { CacheService } from './cache.service';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ttl: 600,
|
ttl: 600,
|
||||||
};
|
} as any;
|
||||||
},
|
},
|
||||||
inject: [ConfigService],
|
inject: [ConfigService],
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ export class ArticleService {
|
||||||
authorId,
|
authorId,
|
||||||
tags: tagIds
|
tags: tagIds
|
||||||
? { create: tagIds.map((tagId) => ({ tag: { connect: { id: tagId } } })) }
|
? { create: tagIds.map((tagId) => ({ tag: { connect: { id: tagId } } })) }
|
||||||
: [],
|
: undefined,
|
||||||
versions: {
|
versions: {
|
||||||
create: {
|
create: {
|
||||||
version: 1,
|
version: 1,
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ import {
|
||||||
FileQueryDto,
|
FileQueryDto,
|
||||||
ProcessImageDto,
|
ProcessImageDto,
|
||||||
} from './dto';
|
} from './dto';
|
||||||
import { Response as ExpressResponse } from 'express';
|
import type { Response as ExpressResponse } from 'express';
|
||||||
|
|
||||||
@Controller('files')
|
@Controller('files')
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
|
|
|
||||||
|
|
@ -165,7 +165,7 @@ export class FileService {
|
||||||
}
|
}
|
||||||
|
|
||||||
const storage = this.storageFactory.getAdapter(file.storageType as StorageType);
|
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);
|
await this.incrementAccessCount(fileId);
|
||||||
|
|
||||||
|
|
@ -191,7 +191,7 @@ export class FileService {
|
||||||
|
|
||||||
const storage = this.storageFactory.getAdapter(file.storageType as StorageType);
|
const storage = this.storageFactory.getAdapter(file.storageType as StorageType);
|
||||||
try {
|
try {
|
||||||
await storage.delete(file.path, file.bucket);
|
await storage.delete(file.path, file.bucket ?? undefined);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.warn(`Failed to delete file from storage: ${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 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(
|
const processedBuffer = await this.imageProcessor.processImage(
|
||||||
originalBuffer,
|
originalBuffer,
|
||||||
|
|
@ -346,7 +346,7 @@ export class FileService {
|
||||||
}
|
}
|
||||||
|
|
||||||
const storage = this.storageFactory.getAdapter(file.storageType as StorageType);
|
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) {
|
private async findFileByHash(hash: string, ownerId: string) {
|
||||||
|
|
|
||||||
|
|
@ -83,7 +83,6 @@ export class ImageProcessorService {
|
||||||
input: watermarkBuffer,
|
input: watermarkBuffer,
|
||||||
left: position.left,
|
left: position.left,
|
||||||
top: position.top,
|
top: position.top,
|
||||||
opacity: options.watermarkOpacity || 0.3,
|
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -68,10 +68,11 @@ export class LocalStorageAdapter implements CloudStorageAdapter {
|
||||||
await writeFile(fullPath, file);
|
await writeFile(fullPath, file);
|
||||||
} else {
|
} else {
|
||||||
const writeStream = fs.createWriteStream(fullPath);
|
const writeStream = fs.createWriteStream(fullPath);
|
||||||
|
const readable = file as NodeJS.ReadableStream;
|
||||||
await new Promise((resolve, reject) => {
|
await new Promise((resolve, reject) => {
|
||||||
file.pipe(writeStream);
|
readable.pipe(writeStream);
|
||||||
file.on('end', resolve);
|
readable.on('end', resolve);
|
||||||
file.on('error', reject);
|
readable.on('error', reject);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@ export class MinIOAdapter implements CloudStorageAdapter {
|
||||||
|
|
||||||
let etag: string;
|
let etag: string;
|
||||||
if (file instanceof Buffer) {
|
if (file instanceof Buffer) {
|
||||||
etag = await this.client.putObject(
|
const result = await this.client.putObject(
|
||||||
bucket,
|
bucket,
|
||||||
path,
|
path,
|
||||||
file,
|
file,
|
||||||
|
|
@ -50,16 +50,18 @@ export class MinIOAdapter implements CloudStorageAdapter {
|
||||||
? { 'Content-Type': options.contentType }
|
? { 'Content-Type': options.contentType }
|
||||||
: undefined,
|
: undefined,
|
||||||
);
|
);
|
||||||
|
etag = result.etag || '';
|
||||||
} else {
|
} else {
|
||||||
etag = await this.client.putObject(
|
const result = await this.client.putObject(
|
||||||
bucket,
|
bucket,
|
||||||
path,
|
path,
|
||||||
file,
|
file as any,
|
||||||
undefined,
|
undefined,
|
||||||
options?.contentType
|
options?.contentType
|
||||||
? { 'Content-Type': options.contentType }
|
? { 'Content-Type': options.contentType }
|
||||||
: undefined,
|
: undefined,
|
||||||
);
|
);
|
||||||
|
etag = result.etag || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = await this.getUrl(path, bucket);
|
const url = await this.getUrl(path, bucket);
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,7 @@ export class TencentCOSAdapter implements CloudStorageAdapter {
|
||||||
Bucket: bucket,
|
Bucket: bucket,
|
||||||
Region: region,
|
Region: region,
|
||||||
Key: path,
|
Key: path,
|
||||||
Body: file,
|
Body: file as any,
|
||||||
ContentType: options?.contentType,
|
ContentType: options?.contentType,
|
||||||
},
|
},
|
||||||
(err, data) => {
|
(err, data) => {
|
||||||
|
|
@ -175,7 +175,7 @@ export class TencentCOSAdapter implements CloudStorageAdapter {
|
||||||
const region = this.defaultRegion;
|
const region = this.defaultRegion;
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
this.client.copyObject(
|
(this.client as any).copyObject(
|
||||||
{
|
{
|
||||||
Bucket: tgtBkt,
|
Bucket: tgtBkt,
|
||||||
Region: region,
|
Region: region,
|
||||||
|
|
|
||||||
|
|
@ -3,24 +3,23 @@ import {
|
||||||
NestInterceptor,
|
NestInterceptor,
|
||||||
ExecutionContext,
|
ExecutionContext,
|
||||||
CallHandler,
|
CallHandler,
|
||||||
|
Inject,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { Observable } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
import { tap } from 'rxjs/operators';
|
import { tap } from 'rxjs/operators';
|
||||||
import {
|
import {
|
||||||
Histogram,
|
|
||||||
Counter,
|
|
||||||
makeHistogramProvider,
|
makeHistogramProvider,
|
||||||
makeCounterProvider,
|
makeCounterProvider,
|
||||||
} from '@willsoto/nestjs-prometheus';
|
} from '@willsoto/nestjs-prometheus';
|
||||||
import { Inject } from '@nestjs/common';
|
import { Histogram, Counter } from 'prom-client';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class HttpMetricsInterceptor implements NestInterceptor {
|
export class HttpMetricsInterceptor implements NestInterceptor {
|
||||||
constructor(
|
constructor(
|
||||||
@Inject('HTTP_REQUEST_DURATION')
|
@Inject('HTTP_REQUEST_DURATION')
|
||||||
private readonly httpRequestDuration: Histogram,
|
private readonly httpRequestDuration: Histogram<string>,
|
||||||
@Inject('HTTP_REQUESTS_TOTAL')
|
@Inject('HTTP_REQUESTS_TOTAL')
|
||||||
private readonly httpRequestsTotal: Counter,
|
private readonly httpRequestsTotal: Counter<string>,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
|
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
|
||||||
|
|
@ -54,15 +53,31 @@ export class HttpMetricsInterceptor implements NestInterceptor {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const httpRequestDurationProvider = makeHistogramProvider({
|
export const httpRequestDurationProvider = {
|
||||||
name: 'http_request_duration_seconds',
|
provide: 'HTTP_REQUEST_DURATION',
|
||||||
help: 'Duration of HTTP requests in seconds',
|
useFactory: () => {
|
||||||
labelNames: ['method', 'route', 'status'],
|
const { Registry, Histogram } = require('prom-client');
|
||||||
buckets: [0.01, 0.05, 0.1, 0.5, 1, 2, 5, 10],
|
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({
|
export const httpRequestsTotalProvider = {
|
||||||
name: 'http_requests_total',
|
provide: 'HTTP_REQUESTS_TOTAL',
|
||||||
help: 'Total number of HTTP requests',
|
useFactory: () => {
|
||||||
labelNames: ['method', 'route', 'status'],
|
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],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,7 @@ export class EmailChannelService extends BaseChannelService {
|
||||||
|
|
||||||
async send(payload: NotificationPayload): Promise<NotificationSendResult> {
|
async send(payload: NotificationPayload): Promise<NotificationSendResult> {
|
||||||
try {
|
try {
|
||||||
if (this.isMockMode(this.configService)) {
|
if (this.isMockMode()) {
|
||||||
return this.createMockResult('email');
|
return this.createMockResult('email');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,7 @@ export class SmsChannelService extends BaseChannelService {
|
||||||
|
|
||||||
async send(payload: NotificationPayload): Promise<NotificationSendResult> {
|
async send(payload: NotificationPayload): Promise<NotificationSendResult> {
|
||||||
try {
|
try {
|
||||||
if (this.isMockMode(this.configService)) {
|
if (this.isMockMode()) {
|
||||||
return this.createMockResult('sms');
|
return this.createMockResult('sms');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import {
|
||||||
import { NotificationService } from './notification.service';
|
import { NotificationService } from './notification.service';
|
||||||
import { TemplateService } from './template.service';
|
import { TemplateService } from './template.service';
|
||||||
import { PreferenceService } from './preference.service';
|
import { PreferenceService } from './preference.service';
|
||||||
|
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||||
import {
|
import {
|
||||||
SendNotificationDto,
|
SendNotificationDto,
|
||||||
SendWithTemplateDto,
|
SendWithTemplateDto,
|
||||||
|
|
@ -22,6 +23,7 @@ import {
|
||||||
} from './dto/send-notification.dto';
|
} from './dto/send-notification.dto';
|
||||||
|
|
||||||
@Controller('notifications')
|
@Controller('notifications')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
export class NotificationController {
|
export class NotificationController {
|
||||||
constructor(
|
constructor(
|
||||||
private notificationService: NotificationService,
|
private notificationService: NotificationService,
|
||||||
|
|
@ -33,7 +35,7 @@ export class NotificationController {
|
||||||
async send(@Body() dto: SendNotificationDto) {
|
async send(@Body() dto: SendNotificationDto) {
|
||||||
const { useQueue, ...payload } = dto;
|
const { useQueue, ...payload } = dto;
|
||||||
return this.notificationService.send(
|
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,
|
useQueue !== false,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -74,7 +76,7 @@ export class NotificationController {
|
||||||
@Query('status') status?: string,
|
@Query('status') status?: string,
|
||||||
@Query('channel') channel?: string,
|
@Query('channel') channel?: string,
|
||||||
) {
|
) {
|
||||||
const userId = req.user?.id;
|
const userId = req.user?.userId;
|
||||||
return this.notificationService.findAll(
|
return this.notificationService.findAll(
|
||||||
userId,
|
userId,
|
||||||
parseInt(page),
|
parseInt(page),
|
||||||
|
|
@ -86,7 +88,7 @@ export class NotificationController {
|
||||||
|
|
||||||
@Get('unread-count')
|
@Get('unread-count')
|
||||||
async getUnreadCount(@Request() req) {
|
async getUnreadCount(@Request() req) {
|
||||||
const userId = req.user?.id;
|
const userId = req.user?.userId;
|
||||||
return this.notificationService.getUnreadCount(userId);
|
return this.notificationService.getUnreadCount(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -97,13 +99,13 @@ export class NotificationController {
|
||||||
|
|
||||||
@Put(':id/read')
|
@Put(':id/read')
|
||||||
async markAsRead(@Param('id') id: string, @Request() req) {
|
async markAsRead(@Param('id') id: string, @Request() req) {
|
||||||
const userId = req.user?.id;
|
const userId = req.user?.userId;
|
||||||
return this.notificationService.markAsRead(id, userId);
|
return this.notificationService.markAsRead(id, userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Put('read-all')
|
@Put('read-all')
|
||||||
async markAllAsRead(@Request() req) {
|
async markAllAsRead(@Request() req) {
|
||||||
const userId = req.user?.id;
|
const userId = req.user?.userId;
|
||||||
return this.notificationService.markAllAsRead(userId);
|
return this.notificationService.markAllAsRead(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -140,13 +142,13 @@ export class NotificationController {
|
||||||
|
|
||||||
@Get('preferences/my')
|
@Get('preferences/my')
|
||||||
async getMyPreferences(@Request() req) {
|
async getMyPreferences(@Request() req) {
|
||||||
const userId = req.user?.id;
|
const userId = req.user?.userId;
|
||||||
return this.preferenceService.getOrCreate(userId);
|
return this.preferenceService.getOrCreate(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Put('preferences/my')
|
@Put('preferences/my')
|
||||||
async updateMyPreferences(@Request() req, @Body() dto: UpdatePreferenceDto) {
|
async updateMyPreferences(@Request() req, @Body() dto: UpdatePreferenceDto) {
|
||||||
const userId = req.user?.id;
|
const userId = req.user?.userId;
|
||||||
return this.preferenceService.update(userId, dto);
|
return this.preferenceService.update(userId, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ import { SmsChannelService } from './channels/sms-channel.service';
|
||||||
import { PushChannelService } from './channels/push-channel.service';
|
import { PushChannelService } from './channels/push-channel.service';
|
||||||
import { InAppChannelService } from './channels/in-app-channel.service';
|
import { InAppChannelService } from './channels/in-app-channel.service';
|
||||||
import { WeComChannelService } from './channels/wecom-channel.service';
|
import { WeComChannelService } from './channels/wecom-channel.service';
|
||||||
import { PrismaModule } from '../prisma/prisma.module';
|
import { PrismaModule } from '../../prisma/prisma.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PrismaModule],
|
imports: [PrismaModule],
|
||||||
|
|
|
||||||
|
|
@ -101,7 +101,7 @@ export class NotificationService {
|
||||||
|
|
||||||
const targetChannels = channels || content.template.channels;
|
const targetChannels = channels || content.template.channels;
|
||||||
|
|
||||||
const results = [];
|
const results: any[] = [];
|
||||||
|
|
||||||
for (const channel of targetChannels) {
|
for (const channel of targetChannels) {
|
||||||
const payload: NotificationPayload = {
|
const payload: NotificationPayload = {
|
||||||
|
|
|
||||||
|
|
@ -78,7 +78,9 @@ export class PreferenceService {
|
||||||
|
|
||||||
if (
|
if (
|
||||||
preference.quietStartHour === undefined ||
|
preference.quietStartHour === undefined ||
|
||||||
preference.quietEndHour === undefined
|
preference.quietStartHour === null ||
|
||||||
|
preference.quietEndHour === undefined ||
|
||||||
|
preference.quietEndHour === null
|
||||||
) {
|
) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
@ -86,7 +88,8 @@ export class PreferenceService {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const hour = now.getHours();
|
const hour = now.getHours();
|
||||||
|
|
||||||
const { quietStartHour, quietEndHour } = preference;
|
const quietStartHour = preference.quietStartHour as number;
|
||||||
|
const quietEndHour = preference.quietEndHour as number;
|
||||||
|
|
||||||
if (quietStartHour <= quietEndHour) {
|
if (quietStartHour <= quietEndHour) {
|
||||||
return hour >= quietStartHour && hour < quietEndHour;
|
return hour >= quietStartHour && hour < quietEndHour;
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ describe('OrderController', () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockRequest = {
|
const mockRequest = {
|
||||||
user: { id: 'test-user-id' },
|
user: { userId: 'test-user-id' },
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ export class OrderController {
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
async create(@Body() createOrderDto: CreateOrderDto, @Request() req: any) {
|
async create(@Body() createOrderDto: CreateOrderDto, @Request() req: any) {
|
||||||
const userId = req.user?.id;
|
const userId = req.user?.userId;
|
||||||
return this.orderService.create(userId, createOrderDto);
|
return this.orderService.create(userId, createOrderDto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -32,7 +32,7 @@ export class OrderController {
|
||||||
@Query('status') status?: string,
|
@Query('status') status?: string,
|
||||||
@Request() req?: any,
|
@Request() req?: any,
|
||||||
) {
|
) {
|
||||||
const userId = req.user?.id;
|
const userId = req.user?.userId;
|
||||||
return this.orderService.findMany({
|
return this.orderService.findMany({
|
||||||
page: parseInt(page, 10),
|
page: parseInt(page, 10),
|
||||||
limit: parseInt(limit, 10),
|
limit: parseInt(limit, 10),
|
||||||
|
|
@ -43,13 +43,13 @@ export class OrderController {
|
||||||
|
|
||||||
@Get('stats')
|
@Get('stats')
|
||||||
async getStats(@Request() req: any) {
|
async getStats(@Request() req: any) {
|
||||||
const userId = req.user?.id;
|
const userId = req.user?.userId;
|
||||||
return this.orderService.getStats(userId);
|
return this.orderService.getStats(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
async findOne(@Param('id') id: string, @Request() req: any) {
|
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);
|
return this.orderService.findOne(id, userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -63,7 +63,7 @@ export class OrderController {
|
||||||
|
|
||||||
@Post(':id/cancel')
|
@Post(':id/cancel')
|
||||||
async cancel(@Param('id') id: string, @Request() req: any) {
|
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);
|
return this.orderService.cancel(id, userId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,13 +8,16 @@ import {
|
||||||
Request,
|
Request,
|
||||||
Ip,
|
Ip,
|
||||||
Headers,
|
Headers,
|
||||||
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { PaymentService } from './services/payment.service';
|
import { PaymentService } from './services/payment.service';
|
||||||
import { RefundService } from './services/refund.service';
|
import { RefundService } from './services/refund.service';
|
||||||
import { CreatePaymentDto } from './dto/create-payment.dto';
|
import { CreatePaymentDto } from './dto/create-payment.dto';
|
||||||
import { CreateRefundDto } from './dto/create-refund.dto';
|
import { CreateRefundDto } from './dto/create-refund.dto';
|
||||||
|
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||||
|
|
||||||
@Controller('payment')
|
@Controller('payment')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
export class PaymentController {
|
export class PaymentController {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly paymentService: PaymentService,
|
private readonly paymentService: PaymentService,
|
||||||
|
|
@ -28,7 +31,7 @@ export class PaymentController {
|
||||||
@Headers('user-agent') userAgent: string,
|
@Headers('user-agent') userAgent: string,
|
||||||
@Request() req: any,
|
@Request() req: any,
|
||||||
) {
|
) {
|
||||||
const userId = req.user?.id || 'test-user-id';
|
const userId = req.user?.userId;
|
||||||
return this.paymentService.createPayment(userId, createPaymentDto, ip, userAgent);
|
return this.paymentService.createPayment(userId, createPaymentDto, ip, userAgent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -38,13 +41,13 @@ export class PaymentController {
|
||||||
@Query('limit') limit: string = '20',
|
@Query('limit') limit: string = '20',
|
||||||
@Request() req: any,
|
@Request() req: any,
|
||||||
) {
|
) {
|
||||||
const userId = req.user?.id;
|
const userId = req.user?.userId;
|
||||||
return this.paymentService.findAll(userId, parseInt(page), parseInt(limit));
|
return this.paymentService.findAll(userId, parseInt(page), parseInt(limit));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('orders/:id')
|
@Get('orders/:id')
|
||||||
findOneOrder(@Param('id') id: string, @Request() req: any) {
|
findOneOrder(@Param('id') id: string, @Request() req: any) {
|
||||||
const userId = req.user?.id;
|
const userId = req.user?.userId;
|
||||||
return this.paymentService.findOne(id, userId);
|
return this.paymentService.findOne(id, userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -55,13 +58,13 @@ export class PaymentController {
|
||||||
|
|
||||||
@Post('orders/:id/cancel')
|
@Post('orders/:id/cancel')
|
||||||
cancelOrder(@Param('id') id: string, @Request() req: any) {
|
cancelOrder(@Param('id') id: string, @Request() req: any) {
|
||||||
const userId = req.user?.id;
|
const userId = req.user?.userId;
|
||||||
return this.paymentService.cancelOrder(id, userId);
|
return this.paymentService.cancelOrder(id, userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('refunds')
|
@Post('refunds')
|
||||||
createRefund(@Body() createRefundDto: CreateRefundDto, @Request() req: any) {
|
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);
|
return this.refundService.createRefund(userId, createRefundDto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -71,13 +74,13 @@ export class PaymentController {
|
||||||
@Query('limit') limit: string = '20',
|
@Query('limit') limit: string = '20',
|
||||||
@Request() req: any,
|
@Request() req: any,
|
||||||
) {
|
) {
|
||||||
const userId = req.user?.id;
|
const userId = req.user?.userId;
|
||||||
return this.refundService.findAll(userId, parseInt(page), parseInt(limit));
|
return this.refundService.findAll(userId, parseInt(page), parseInt(limit));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('refunds/:id')
|
@Get('refunds/:id')
|
||||||
findOneRefund(@Param('id') id: string, @Request() req: any) {
|
findOneRefund(@Param('id') id: string, @Request() req: any) {
|
||||||
const userId = req.user?.id;
|
const userId = req.user?.userId;
|
||||||
return this.refundService.findOne(id, userId);
|
return this.refundService.findOne(id, userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,7 @@ export class PaymentService {
|
||||||
|
|
||||||
const adapter = this.adapterFactory.createAdapter(
|
const adapter = this.adapterFactory.createAdapter(
|
||||||
channel.type as PaymentChannelType,
|
channel.type as PaymentChannelType,
|
||||||
{ ...channel.config, isSandbox: channel.isSandbox },
|
{ ...(channel.config as any), isSandbox: channel.isSandbox },
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = await adapter.createPayment({
|
const result = await adapter.createPayment({
|
||||||
|
|
@ -151,7 +151,7 @@ export class PaymentService {
|
||||||
}
|
}
|
||||||
|
|
||||||
async queryOrderStatus(orderId: string) {
|
async queryOrderStatus(orderId: string) {
|
||||||
const order = await this.findOne(orderId);
|
const order = await this.findOne(orderId) as any;
|
||||||
|
|
||||||
if (!order.channel) {
|
if (!order.channel) {
|
||||||
throw new BadRequestException('Payment channel not found for this order');
|
throw new BadRequestException('Payment channel not found for this order');
|
||||||
|
|
@ -159,12 +159,12 @@ export class PaymentService {
|
||||||
|
|
||||||
const adapter = this.adapterFactory.createAdapter(
|
const adapter = this.adapterFactory.createAdapter(
|
||||||
order.channel.type as PaymentChannelType,
|
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({
|
const result = await adapter.queryPayment({
|
||||||
orderNo: order.orderNo,
|
orderNo: order.orderNo,
|
||||||
channelOrderNo: order.channelOrderNo,
|
channelOrderNo: order.channelOrderNo ?? undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.createPaymentLog(
|
await this.createPaymentLog(
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ export class ReconciliationService {
|
||||||
const order = await this.prisma.paymentOrder.findUnique({
|
const order = await this.prisma.paymentOrder.findUnique({
|
||||||
where: { id: orderId },
|
where: { id: orderId },
|
||||||
include: { channel: true },
|
include: { channel: true },
|
||||||
});
|
}) as any;
|
||||||
|
|
||||||
if (!order || !order.channel) {
|
if (!order || !order.channel) {
|
||||||
return { success: false, error: 'Order or channel not found' };
|
return { success: false, error: 'Order or channel not found' };
|
||||||
|
|
@ -23,12 +23,12 @@ export class ReconciliationService {
|
||||||
|
|
||||||
const adapter = this.adapterFactory.createAdapter(
|
const adapter = this.adapterFactory.createAdapter(
|
||||||
order.channel.type as PaymentChannelType,
|
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({
|
const result = await adapter.queryPayment({
|
||||||
orderNo: order.orderNo,
|
orderNo: order.orderNo,
|
||||||
channelOrderNo: order.channelOrderNo,
|
channelOrderNo: order.channelOrderNo ?? undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
let statusUpdated = false;
|
let statusUpdated = false;
|
||||||
|
|
@ -65,7 +65,7 @@ export class ReconciliationService {
|
||||||
}
|
}
|
||||||
|
|
||||||
async reconcileOrders(orderIds: string[]) {
|
async reconcileOrders(orderIds: string[]) {
|
||||||
const results = [];
|
const results: any[] = [];
|
||||||
|
|
||||||
for (const orderId of orderIds) {
|
for (const orderId of orderIds) {
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ export class RefundService {
|
||||||
}
|
}
|
||||||
|
|
||||||
async createRefund(userId: string, createRefundDto: CreateRefundDto) {
|
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') {
|
if (order.status !== 'paid') {
|
||||||
throw new BadRequestException('Only paid orders can be refunded');
|
throw new BadRequestException('Only paid orders can be refunded');
|
||||||
|
|
@ -55,7 +55,7 @@ export class RefundService {
|
||||||
data: {
|
data: {
|
||||||
refundNo,
|
refundNo,
|
||||||
orderId: order.id,
|
orderId: order.id,
|
||||||
channelId: order.channel.id,
|
channelId: order.channelId,
|
||||||
userId,
|
userId,
|
||||||
amount: refundAmount,
|
amount: refundAmount,
|
||||||
reason: createRefundDto.reason,
|
reason: createRefundDto.reason,
|
||||||
|
|
@ -64,7 +64,7 @@ export class RefundService {
|
||||||
|
|
||||||
const adapter = this.adapterFactory.createAdapter(
|
const adapter = this.adapterFactory.createAdapter(
|
||||||
order.channel.type as PaymentChannelType,
|
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({
|
const result = await adapter.refundPayment({
|
||||||
|
|
@ -72,7 +72,7 @@ export class RefundService {
|
||||||
refundNo,
|
refundNo,
|
||||||
amount: createRefundDto.amount,
|
amount: createRefundDto.amount,
|
||||||
reason: createRefundDto.reason,
|
reason: createRefundDto.reason,
|
||||||
channelOrderNo: order.channelOrderNo,
|
channelOrderNo: order.channelOrderNo ?? undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.createRefundLog(
|
await this.createRefundLog(
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ import { FileInterceptor } from '@nestjs/platform-express';
|
||||||
import { UserService } from './user.service';
|
import { UserService } from './user.service';
|
||||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||||
import { UpdateUserDto } from './dto/update-user.dto';
|
import { UpdateUserDto } from './dto/update-user.dto';
|
||||||
import { Express } from 'express';
|
import type { Express } from 'express';
|
||||||
|
|
||||||
@Controller('users')
|
@Controller('users')
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
|
|
|
||||||
|
|
@ -1,37 +1,39 @@
|
||||||
import { NodeSDK } from '@opentelemetry/sdk-node';
|
const startTracing = () => {
|
||||||
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
|
if (process.env.OTEL_ENABLED !== 'true') {
|
||||||
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-proto';
|
return;
|
||||||
import { ConsoleSpanExporter } from '@opentelemetry/sdk-trace-base';
|
}
|
||||||
import { SimpleSpanProcessor, BatchSpanProcessor } from '@opentelemetry/sdk-trace-base';
|
|
||||||
|
|
||||||
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
|
const isProduction = process.env.NODE_ENV === 'production';
|
||||||
? new OTLPTraceExporter({
|
|
||||||
url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT || 'http://localhost:4318/v1/traces',
|
|
||||||
})
|
|
||||||
: new ConsoleSpanExporter();
|
|
||||||
|
|
||||||
const sdk = new NodeSDK({
|
const traceExporter = isProduction
|
||||||
instrumentations: [
|
? new OTLPTraceExporter({
|
||||||
getNodeAutoInstrumentations({
|
url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT || 'http://localhost:4318/v1/traces',
|
||||||
'@opentelemetry/instrumentation-http': {
|
})
|
||||||
ignoreIncomingPaths: ['/metrics', '/health'],
|
: new ConsoleSpanExporter();
|
||||||
},
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
spanProcessor: isProduction
|
|
||||||
? new BatchSpanProcessor(traceExporter)
|
|
||||||
: new SimpleSpanProcessor(traceExporter),
|
|
||||||
serviceName: 'fischerx-api',
|
|
||||||
});
|
|
||||||
|
|
||||||
export const startTracing = () => {
|
const sdk = new NodeSDK({
|
||||||
sdk.start();
|
instrumentations: [
|
||||||
console.log('OpenTelemetry tracing started');
|
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 () => {
|
export { startTracing };
|
||||||
await sdk.shutdown();
|
|
||||||
console.log('OpenTelemetry tracing shutdown');
|
|
||||||
};
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue