feat: 初始化 ChatOA 企业协作 OA 系统
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { StorageModule } from './common/storage/storage.module';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { UsersModule } from './users/users.module';
|
||||
import { ContactsModule } from './contacts/contacts.module';
|
||||
import { ChatModule } from './chat/chat.module';
|
||||
import { PresenceModule } from './presence/presence.module';
|
||||
import { ScheduleModule } from './schedule/schedule.module';
|
||||
import { FormsModule } from './forms/forms.module';
|
||||
import { KnowledgeModule } from './knowledge/knowledge.module';
|
||||
import { SeedModule } from './seed/seed.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
StorageModule,
|
||||
AuthModule,
|
||||
UsersModule,
|
||||
ContactsModule,
|
||||
PresenceModule,
|
||||
ChatModule,
|
||||
ScheduleModule,
|
||||
FormsModule,
|
||||
KnowledgeModule,
|
||||
SeedModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Body, Controller, Get, Post, Req, UseGuards } from '@nestjs/common';
|
||||
import { AuthService } from './auth.service';
|
||||
import { JwtAuthGuard } from './jwt-auth.guard';
|
||||
import { CurrentUser } from './current-user.decorator';
|
||||
import { PublicUser } from './auth.service';
|
||||
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(private readonly auth: AuthService) {}
|
||||
|
||||
@Post('register')
|
||||
register(@Body() dto: { username: string; password: string; nickname?: string }) {
|
||||
return this.auth.register(dto);
|
||||
}
|
||||
|
||||
@Post('login')
|
||||
login(@Body() dto: { username: string; password: string }) {
|
||||
return this.auth.login(dto);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get('me')
|
||||
me(@CurrentUser() user: PublicUser) {
|
||||
return user;
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Post('logout')
|
||||
logout(@Req() _req: any) {
|
||||
// JWT 无状态,客户端删除 token 即可
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { AuthService } from './auth.service';
|
||||
import { AuthController } from './auth.controller';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [AuthService],
|
||||
controllers: [AuthController],
|
||||
exports: [AuthService],
|
||||
})
|
||||
export class AuthModule {}
|
||||
@@ -0,0 +1,93 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import * as jwt from 'jsonwebtoken';
|
||||
import { CONFIG } from '../config';
|
||||
import { StorageService } from '../common/storage/storage.service';
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
passwordHash: string;
|
||||
nickname: string;
|
||||
avatar?: string;
|
||||
email?: string;
|
||||
department?: string;
|
||||
title?: string;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export type PublicUser = Omit<User, 'passwordHash'>;
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(private readonly storage: StorageService) {}
|
||||
|
||||
private users() {
|
||||
return this.storage.collection<User>('users');
|
||||
}
|
||||
|
||||
/** 注册 */
|
||||
async register(dto: {
|
||||
username: string;
|
||||
password: string;
|
||||
nickname?: string;
|
||||
}) {
|
||||
const username = (dto.username || '').trim();
|
||||
const password = dto.password || '';
|
||||
if (!/^[a-zA-Z0-9_]{3,20}$/.test(username)) {
|
||||
throw new BadRequestException('用户名需为 3-20 位字母、数字或下划线');
|
||||
}
|
||||
if (password.length < 6) {
|
||||
throw new BadRequestException('密码至少 6 位');
|
||||
}
|
||||
const exists = await this.users().findOne({ username });
|
||||
if (exists) {
|
||||
throw new BadRequestException('用户名已存在');
|
||||
}
|
||||
const user = await this.users().insertOne({
|
||||
username,
|
||||
passwordHash: bcrypt.hashSync(password, 10),
|
||||
nickname: (dto.nickname || username).trim(),
|
||||
});
|
||||
return this.toPublic(user);
|
||||
}
|
||||
|
||||
/** 登录 */
|
||||
async login(dto: { username: string; password: string }) {
|
||||
const user = await this.users().findOne({
|
||||
username: (dto.username || '').trim(),
|
||||
});
|
||||
if (!user || !bcrypt.compareSync(dto.password || '', user.passwordHash)) {
|
||||
throw new UnauthorizedException('用户名或密码错误');
|
||||
}
|
||||
const token = jwt.sign(
|
||||
{ sub: user.id, username: user.username },
|
||||
CONFIG.jwt.secret,
|
||||
{ expiresIn: CONFIG.jwt.expiresIn as jwt.SignOptions['expiresIn'] },
|
||||
);
|
||||
return { token, user: this.toPublic(user) };
|
||||
}
|
||||
|
||||
/** 根据 token 解析用户 */
|
||||
async verifyToken(token: string): Promise<PublicUser> {
|
||||
try {
|
||||
const payload = jwt.verify(token, CONFIG.jwt.secret) as {
|
||||
sub: string;
|
||||
};
|
||||
const user = await this.users().findById(payload.sub);
|
||||
if (!user) throw new UnauthorizedException('用户不存在');
|
||||
return this.toPublic(user);
|
||||
} catch {
|
||||
throw new UnauthorizedException('登录已失效,请重新登录');
|
||||
}
|
||||
}
|
||||
|
||||
toPublic(user: User): PublicUser {
|
||||
const { passwordHash, ...rest } = user;
|
||||
return rest;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||
|
||||
/** 获取当前登录用户(配合 JwtAuthGuard 使用) */
|
||||
export const CurrentUser = createParamDecorator(
|
||||
(_data: unknown, ctx: ExecutionContext) => {
|
||||
const req = ctx.switchToHttp().getRequest();
|
||||
return req.user;
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,23 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
} from '@nestjs/common';
|
||||
import { AuthService } from './auth.service';
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthGuard implements CanActivate {
|
||||
constructor(private readonly auth: AuthService) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const req = context.switchToHttp().getRequest();
|
||||
const authHeader: string = req.headers?.authorization || '';
|
||||
const token = authHeader.startsWith('Bearer ')
|
||||
? authHeader.slice(7)
|
||||
: '';
|
||||
if (!token) return false;
|
||||
const user = await this.auth.verifyToken(token);
|
||||
req.user = user;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
Post,
|
||||
Query,
|
||||
Req,
|
||||
UploadedFile,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { diskStorage } from 'multer';
|
||||
import { extname, join } from 'path';
|
||||
import { Request } from 'express';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import { PublicUser } from '../auth/auth.service';
|
||||
import { ChatService } from './chat.service';
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('chat')
|
||||
export class ChatController {
|
||||
constructor(private readonly chat: ChatService) {}
|
||||
|
||||
@Get('conversations')
|
||||
conversations(@CurrentUser() me: PublicUser) {
|
||||
return this.chat.getConversations(me.id);
|
||||
}
|
||||
|
||||
@Get('unread')
|
||||
async unread(@CurrentUser() me: PublicUser) {
|
||||
return { total: await this.chat.totalUnread(me.id) };
|
||||
}
|
||||
|
||||
@Get('conversations/:peerId/messages')
|
||||
history(
|
||||
@CurrentUser() me: PublicUser,
|
||||
@Param('peerId') peerId: string,
|
||||
@Query('before') before?: string,
|
||||
@Query('limit') limit?: string,
|
||||
) {
|
||||
return this.chat.getHistory(
|
||||
me.id,
|
||||
peerId,
|
||||
before ? Number(before) : undefined,
|
||||
limit ? Math.min(Number(limit), 200) : 50,
|
||||
);
|
||||
}
|
||||
|
||||
@Post('conversations/:peerId/read')
|
||||
read(@CurrentUser() me: PublicUser, @Param('peerId') peerId: string) {
|
||||
return this.chat.markRead(me.id, peerId);
|
||||
}
|
||||
|
||||
/** 图片/文件上传(开发期存本地 uploads 目录) */
|
||||
@Post('upload')
|
||||
@UseInterceptors(
|
||||
FileInterceptor('file', {
|
||||
storage: diskStorage({
|
||||
destination: join(process.cwd(), 'uploads'),
|
||||
filename: (_req, file, cb) => {
|
||||
const name =
|
||||
Date.now().toString(36) +
|
||||
'-' +
|
||||
Math.random().toString(36).slice(2, 8);
|
||||
cb(null, name + extname(file.originalname || '.bin'));
|
||||
},
|
||||
}),
|
||||
limits: { fileSize: 20 * 1024 * 1024 },
|
||||
}),
|
||||
)
|
||||
upload(
|
||||
@Req() req: Request,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
) {
|
||||
if (!file) return { success: false, message: '未收到文件' };
|
||||
const url = `${req.protocol}://${req.get('host')}/uploads/${file.filename}`;
|
||||
return { success: true, url, filename: file.originalname };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import {
|
||||
OnGatewayConnection,
|
||||
OnGatewayDisconnect,
|
||||
SubscribeMessage,
|
||||
WebSocketGateway,
|
||||
WebSocketServer,
|
||||
WsException,
|
||||
} from '@nestjs/websockets';
|
||||
import { Server, Socket } from 'socket.io';
|
||||
import { AuthService, PublicUser } from '../auth/auth.service';
|
||||
import { PresenceService } from '../presence/presence.service';
|
||||
import { ChatService } from './chat.service';
|
||||
import { SendMessagePayload } from './chat.types';
|
||||
|
||||
@WebSocketGateway({ cors: { origin: '*' } })
|
||||
export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
||||
@WebSocketServer()
|
||||
server: Server;
|
||||
|
||||
constructor(
|
||||
private readonly auth: AuthService,
|
||||
private readonly chat: ChatService,
|
||||
private readonly presence: PresenceService,
|
||||
) {}
|
||||
|
||||
afterInit(server: Server) {
|
||||
server.use(async (socket: Socket, next) => {
|
||||
try {
|
||||
const token: string = socket.handshake.auth?.token;
|
||||
if (!token) throw new Error('missing token');
|
||||
const user = await this.auth.verifyToken(token);
|
||||
socket.data.user = user;
|
||||
next();
|
||||
} catch (e) {
|
||||
next(new Error((e as Error).message || 'unauthorized'));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async handleConnection(socket: Socket) {
|
||||
const user = socket.data.user as PublicUser;
|
||||
if (!user) {
|
||||
socket.disconnect(true);
|
||||
return;
|
||||
}
|
||||
socket.join(`user:${user.id}`);
|
||||
const p = this.presence.markOnline(user.id, socket.id);
|
||||
// 推送当前所有在线状态 + 通知好友上线
|
||||
socket.emit('presence:init', this.presence.getAll());
|
||||
socket.to(`user:${user.id}`).emit('presence:update', p);
|
||||
this.server.emit('presence:update', p);
|
||||
}
|
||||
|
||||
async handleDisconnect(socket: Socket) {
|
||||
const user = socket.data.user as PublicUser | undefined;
|
||||
if (!user) return;
|
||||
const p = this.presence.markOffline(user.id, socket.id);
|
||||
if (p) this.server.emit('presence:update', p);
|
||||
}
|
||||
|
||||
@SubscribeMessage('chat:send')
|
||||
async onSend(client: Socket, payload: SendMessagePayload) {
|
||||
const user = client.data.user as PublicUser;
|
||||
try {
|
||||
const msg = await this.chat.sendMessage(user.id, payload);
|
||||
// 发给接收方(其可能多端在线)
|
||||
this.server.to(`user:${msg.toUserId}`).emit('chat:message', msg);
|
||||
// 回执给发送方
|
||||
client.emit('chat:message', msg);
|
||||
return { ok: true, message: msg };
|
||||
} catch (e) {
|
||||
throw new WsException((e as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
@SubscribeMessage('chat:read')
|
||||
async onRead(client: Socket, payload: { peerId: string }) {
|
||||
const user = client.data.user as PublicUser;
|
||||
await this.chat.markRead(user.id, payload.peerId);
|
||||
this.server
|
||||
.to(`user:${payload.peerId}`)
|
||||
.emit('chat:read', { byUserId: user.id, peerId: payload.peerId });
|
||||
return { ok: true };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ChatService } from './chat.service';
|
||||
import { ChatGateway } from './chat.gateway';
|
||||
import { ChatController } from './chat.controller';
|
||||
|
||||
@Module({
|
||||
providers: [ChatService, ChatGateway],
|
||||
controllers: [ChatController],
|
||||
exports: [ChatService],
|
||||
})
|
||||
export class ChatModule {}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { StorageService } from '../common/storage/storage.service';
|
||||
import { PresenceService } from '../presence/presence.service';
|
||||
import { User } from '../auth/auth.service';
|
||||
import {
|
||||
Conversation,
|
||||
ConversationView,
|
||||
Message,
|
||||
MessageType,
|
||||
SendMessagePayload,
|
||||
} from './chat.types';
|
||||
|
||||
export const conversationKey = (a: string, b: string) =>
|
||||
[a, b].sort().join('__');
|
||||
|
||||
@Injectable()
|
||||
export class ChatService {
|
||||
constructor(
|
||||
private readonly storage: StorageService,
|
||||
private readonly presence: PresenceService,
|
||||
) {}
|
||||
|
||||
private messages() {
|
||||
return this.storage.collection<Message>('messages');
|
||||
}
|
||||
|
||||
private conversations() {
|
||||
return this.storage.collection<Conversation>('conversations');
|
||||
}
|
||||
|
||||
private users() {
|
||||
return this.storage.collection<User>('users');
|
||||
}
|
||||
|
||||
/** 发送单聊消息(服务端入口,供网关调用) */
|
||||
async sendMessage(
|
||||
fromUserId: string,
|
||||
payload: SendMessagePayload,
|
||||
): Promise<Message> {
|
||||
const { toUserId } = payload;
|
||||
if (toUserId === fromUserId) {
|
||||
throw new Error('不能给自己发消息');
|
||||
}
|
||||
const to = await this.users().findById(toUserId);
|
||||
if (!to) throw new NotFoundException('对方不存在');
|
||||
|
||||
const type: MessageType = payload.type === 'image' || payload.type === 'file'
|
||||
? payload.type
|
||||
: 'text';
|
||||
const content = (payload.content || '').trim();
|
||||
if (!content) throw new Error('消息内容不能为空');
|
||||
|
||||
const convId = conversationKey(fromUserId, toUserId);
|
||||
const message = await this.messages().insertOne({
|
||||
conversationId: convId,
|
||||
fromUserId,
|
||||
toUserId,
|
||||
type,
|
||||
content,
|
||||
read: false,
|
||||
});
|
||||
|
||||
// 更新会话(未读数在前端侧计算,服务端存最后消息摘要)
|
||||
const conv = await this.conversations().findOne({ id: convId });
|
||||
if (conv) {
|
||||
await this.conversations().updateById(convId, {
|
||||
memberIds: [fromUserId, toUserId],
|
||||
lastMessage: {
|
||||
type,
|
||||
content,
|
||||
fromUserId,
|
||||
createdAt: message.createdAt,
|
||||
},
|
||||
updatedAt: message.createdAt,
|
||||
});
|
||||
} else {
|
||||
await this.conversations().insertOne({
|
||||
id: convId,
|
||||
memberIds: [fromUserId, toUserId],
|
||||
lastMessage: {
|
||||
type,
|
||||
content,
|
||||
fromUserId,
|
||||
createdAt: message.createdAt,
|
||||
},
|
||||
updatedAt: message.createdAt,
|
||||
});
|
||||
}
|
||||
return message;
|
||||
}
|
||||
|
||||
/** 会话列表(带对方信息 + 未读数) */
|
||||
async getConversations(userId: string): Promise<ConversationView[]> {
|
||||
const convs = await this.conversations().find({
|
||||
filter: { memberIds: userId },
|
||||
sort: { updatedAt: -1 },
|
||||
});
|
||||
const views: ConversationView[] = [];
|
||||
for (const c of convs) {
|
||||
const peerId = c.memberIds.find((m) => m !== userId);
|
||||
const peer = peerId ? await this.users().findById(peerId) : null;
|
||||
const unreadCount = await this.messages().count({
|
||||
conversationId: c.id,
|
||||
toUserId: userId,
|
||||
read: false,
|
||||
});
|
||||
views.push({
|
||||
...c,
|
||||
unreadCount,
|
||||
peer: peer
|
||||
? { id: peer.id, nickname: peer.nickname, avatar: peer.avatar }
|
||||
: null,
|
||||
});
|
||||
}
|
||||
return views;
|
||||
}
|
||||
|
||||
/** 与某人的历史消息(时间正序) */
|
||||
async getHistory(
|
||||
userId: string,
|
||||
peerId: string,
|
||||
before?: number,
|
||||
limit = 50,
|
||||
) {
|
||||
const convId = conversationKey(userId, peerId);
|
||||
const filter: Record<string, any> = { conversationId: convId };
|
||||
if (before) filter.createdAt = { $lt: before };
|
||||
// $lt 支持:扩展 storage 查询
|
||||
const list = await this.messages().find({
|
||||
filter: filter as any,
|
||||
sort: { createdAt: -1 },
|
||||
limit,
|
||||
});
|
||||
return list.reverse();
|
||||
}
|
||||
|
||||
/** 标记与某人的消息已读 */
|
||||
async markRead(userId: string, peerId: string) {
|
||||
const convId = conversationKey(userId, peerId);
|
||||
const list = await this.messages().find({
|
||||
filter: { conversationId: convId, toUserId: userId, read: false },
|
||||
});
|
||||
for (const m of list) {
|
||||
await this.messages().updateById(m.id, { read: true });
|
||||
}
|
||||
return { success: true, count: list.length };
|
||||
}
|
||||
|
||||
/** 未读总数 */
|
||||
async totalUnread(userId: string) {
|
||||
return this.messages().count({ toUserId: userId, read: false });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
export type MessageType = 'text' | 'image' | 'file';
|
||||
|
||||
export interface Message {
|
||||
id: string;
|
||||
/** 单聊会话键:两方 id 排序后拼接 */
|
||||
conversationId: string;
|
||||
fromUserId: string;
|
||||
toUserId: string;
|
||||
type: MessageType;
|
||||
content: string;
|
||||
read: boolean;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export interface Conversation {
|
||||
id: string;
|
||||
/** 会话双方的 userId 数组(单聊固定 2 个) */
|
||||
memberIds: string[];
|
||||
/** 最后一条消息摘要 */
|
||||
lastMessage?: {
|
||||
type: MessageType;
|
||||
content: string;
|
||||
fromUserId: string;
|
||||
createdAt: number;
|
||||
};
|
||||
updatedAt: number;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export interface SendMessagePayload {
|
||||
toUserId: string;
|
||||
type?: MessageType;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface ConversationView extends Conversation {
|
||||
/** 相对某个用户的未读数 */
|
||||
unreadCount: number;
|
||||
peer: { id: string; nickname: string; avatar?: string } | null;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { StorageService } from './storage.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [StorageService],
|
||||
exports: [StorageService],
|
||||
})
|
||||
export class StorageModule {}
|
||||
@@ -0,0 +1,274 @@
|
||||
import {
|
||||
Injectable,
|
||||
OnModuleDestroy,
|
||||
OnModuleInit,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
||||
import { dirname, join } from 'path';
|
||||
|
||||
/** 过滤条件:字段精确匹配;支持 $or 数组 */
|
||||
export interface Filter {
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface MongoLikeQuery<T> {
|
||||
filter?: Filter;
|
||||
sort?: { [key: string]: 1 | -1 };
|
||||
skip?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 存储抽象层(模拟 MongoDB Collection 的常用接口)。
|
||||
* 开发期使用「内存 + JSON 文件持久化」,后续接入 MongoDB 时,
|
||||
* 只需将 Collection 的底层实现替换为 mongoose/driver 实现,业务代码无需改动。
|
||||
*/
|
||||
export class Collection<T extends { id: string } = any> {
|
||||
constructor(
|
||||
private readonly name: string,
|
||||
private readonly map: Map<string, T>,
|
||||
private readonly schedulePersist: () => void,
|
||||
private readonly storage: StorageService,
|
||||
) {}
|
||||
|
||||
private compare(a: any, op: string, b: any): boolean {
|
||||
switch (op) {
|
||||
case '$lt': return a < b;
|
||||
case '$lte': return a <= b;
|
||||
case '$gt': return a > b;
|
||||
case '$gte': return a >= b;
|
||||
case '$ne': return a !== b;
|
||||
case '$in': return Array.isArray(b) && b.includes(a);
|
||||
case '$nin': return Array.isArray(b) && !b.includes(a);
|
||||
default: return a === b;
|
||||
}
|
||||
}
|
||||
|
||||
private matches(doc: T, filter?: Filter): boolean {
|
||||
if (!filter || Object.keys(filter).length === 0) return true;
|
||||
return Object.entries(filter).every(([key, value]) => {
|
||||
if (key === '$or' && Array.isArray(value)) {
|
||||
return value.some((sub: Filter) => this.matches(doc, sub));
|
||||
}
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||
// 比较操作符对象,如 { createdAt: { $lt: 123 } }
|
||||
const ops = value as Record<string, any>;
|
||||
return Object.entries(ops).every(([op, v]) =>
|
||||
this.compare((doc as any)[key], op, v),
|
||||
);
|
||||
}
|
||||
const docVal = (doc as any)[key];
|
||||
if (Array.isArray(value)) {
|
||||
// 查询值为数组:文档值数组需包含全部(MongoDB $all 语义)
|
||||
return Array.isArray(docVal) && value.every((x) => docVal.includes(x));
|
||||
}
|
||||
if (Array.isArray(docVal)) {
|
||||
// 文档值是数组、查询值为标量:任一元素相等(MongoDB 数组包含语义)
|
||||
return docVal.includes(value);
|
||||
}
|
||||
return docVal === value;
|
||||
});
|
||||
}
|
||||
|
||||
private applySort(list: T[], sort?: { [key: string]: 1 | -1 }): T[] {
|
||||
if (!sort) return list;
|
||||
const keys = Object.keys(sort);
|
||||
return [...list].sort((a, b) => {
|
||||
for (const k of keys) {
|
||||
const av = (a as any)[k];
|
||||
const bv = (b as any)[k];
|
||||
let cmp = 0;
|
||||
if (av === bv) cmp = 0;
|
||||
else if (av === undefined || av === null) cmp = -1;
|
||||
else if (bv === undefined || bv === null) cmp = 1;
|
||||
else if (typeof av === 'number' && typeof bv === 'number') cmp = av - bv;
|
||||
else cmp = String(av) < String(bv) ? -1 : 1;
|
||||
if (cmp !== 0) return cmp * (sort[k] === 1 ? 1 : -1);
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
|
||||
/** 条件查询 */
|
||||
async find(query?: MongoLikeQuery<T>): Promise<T[]> {
|
||||
const { filter, sort, skip = 0, limit } = query ?? {};
|
||||
const list = this.applySort(
|
||||
[...this.map.values()].filter((d) => this.matches(d, filter)),
|
||||
sort,
|
||||
);
|
||||
return limit !== undefined ? list.slice(skip, skip + limit) : list.slice(skip);
|
||||
}
|
||||
|
||||
/** 查单条 */
|
||||
async findOne(filter?: Filter): Promise<T | null> {
|
||||
const list = await this.find({ filter, limit: 1 });
|
||||
return list[0] ?? null;
|
||||
}
|
||||
|
||||
/** 按 id 查询 */
|
||||
async findById(id: string): Promise<T | null> {
|
||||
return this.map.get(id) ?? null;
|
||||
}
|
||||
|
||||
/** 插入(自动生成 id,若有 createdAt 自动填充) */
|
||||
async insertOne(doc: Partial<T>): Promise<T> {
|
||||
const id = (doc as any).id ?? this.storage.newId();
|
||||
const full = {
|
||||
...doc,
|
||||
id,
|
||||
createdAt: (doc as any).createdAt ?? Date.now(),
|
||||
} as unknown as T;
|
||||
this.map.set(id, full);
|
||||
this.schedulePersist();
|
||||
return full;
|
||||
}
|
||||
|
||||
/** 条件更新,返回更新后的文档 */
|
||||
async updateOne(filter: Filter, update: Partial<T>): Promise<T | null> {
|
||||
const found = [...this.map.values()].find((d) => this.matches(d, filter));
|
||||
if (!found) return null;
|
||||
const merged = { ...found, ...update, id: found.id };
|
||||
this.map.set(found.id, merged);
|
||||
this.schedulePersist();
|
||||
return merged;
|
||||
}
|
||||
|
||||
/** 按 id 更新 */
|
||||
async updateById(id: string, update: Partial<T>): Promise<T | null> {
|
||||
const found = this.map.get(id);
|
||||
if (!found) return null;
|
||||
const merged = { ...found, ...update, id };
|
||||
this.map.set(id, merged);
|
||||
this.schedulePersist();
|
||||
return merged;
|
||||
}
|
||||
|
||||
/** 条件删除 */
|
||||
async deleteOne(filter: Filter): Promise<boolean> {
|
||||
const found = [...this.map.values()].find((d) => this.matches(d, filter));
|
||||
if (!found) return false;
|
||||
this.map.delete(found.id);
|
||||
this.schedulePersist();
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 按 id 删除 */
|
||||
async deleteById(id: string): Promise<boolean> {
|
||||
const ok = this.map.delete(id);
|
||||
if (ok) this.schedulePersist();
|
||||
return ok;
|
||||
}
|
||||
|
||||
/** 计数 */
|
||||
async count(filter?: Filter): Promise<number> {
|
||||
return [...this.map.values()].filter((d) => this.matches(d, filter)).length;
|
||||
}
|
||||
|
||||
/** 全量替换(用于加载持久化数据) */
|
||||
loadAll(docs: T[]) {
|
||||
this.map.clear();
|
||||
for (const d of docs) this.map.set(d.id, d);
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class StorageService implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger(StorageService.name);
|
||||
private readonly collections = new Map<string, Collection<any>>();
|
||||
private readonly maps = new Map<string, Map<string, any>>();
|
||||
private persistTimer: NodeJS.Timeout | null = null;
|
||||
private dirty = false;
|
||||
|
||||
private get jsonFile(): string {
|
||||
return (
|
||||
process.env.STORAGE_FILE ||
|
||||
join(process.cwd(), 'data', 'db.json')
|
||||
);
|
||||
}
|
||||
|
||||
onModuleInit() {
|
||||
if (process.env.STORAGE_TYPE === 'memory') return;
|
||||
this.loadFromDisk();
|
||||
}
|
||||
|
||||
onModuleDestroy() {
|
||||
this.flushSync();
|
||||
}
|
||||
|
||||
/** 生成自增风格 id */
|
||||
newId(): string {
|
||||
return (
|
||||
Date.now().toString(36) + Math.random().toString(36).slice(2, 10)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取/创建一个集合。
|
||||
* 用法与 mongodb 类似:storage.collection<Message>('messages')
|
||||
*/
|
||||
collection<T extends { id: string } = any>(name: string): Collection<T> {
|
||||
let col = this.collections.get(name);
|
||||
if (!col) {
|
||||
const map = this.maps.get(name) ?? new Map<string, any>();
|
||||
this.maps.set(name, map);
|
||||
col = new Collection<T>(name, map, () => this.schedulePersist(), this);
|
||||
this.collections.set(name, col);
|
||||
}
|
||||
return col;
|
||||
}
|
||||
|
||||
private schedulePersist() {
|
||||
if (process.env.STORAGE_TYPE === 'memory') return;
|
||||
this.dirty = true;
|
||||
if (this.persistTimer) return;
|
||||
this.persistTimer = setTimeout(() => {
|
||||
this.persistTimer = null;
|
||||
this.flushSync();
|
||||
}, 200);
|
||||
}
|
||||
|
||||
private flushSync() {
|
||||
if (!this.dirty) return;
|
||||
try {
|
||||
const file = this.jsonFile;
|
||||
mkdirSync(dirname(file), { recursive: true });
|
||||
const dump: Record<string, any[]> = {};
|
||||
for (const [name, map] of this.maps) {
|
||||
dump[name] = [...map.values()];
|
||||
}
|
||||
writeFileSync(file, JSON.stringify(dump, null, 2), 'utf-8');
|
||||
this.dirty = false;
|
||||
this.logger.log(`storage persisted -> ${file}`);
|
||||
} catch (e) {
|
||||
this.logger.error('persist failed', e);
|
||||
}
|
||||
}
|
||||
|
||||
private loadFromDisk() {
|
||||
const file = this.jsonFile;
|
||||
if (!existsSync(file)) {
|
||||
this.logger.log('no persisted data, start fresh');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const dump = JSON.parse(readFileSync(file, 'utf-8')) as Record<
|
||||
string,
|
||||
any[]
|
||||
>;
|
||||
for (const [name, docs] of Object.entries(dump)) {
|
||||
const map = this.maps.get(name) ?? new Map<string, any>();
|
||||
this.maps.set(name, map);
|
||||
for (const d of docs) {
|
||||
if (d && d.id) map.set(d.id, d);
|
||||
}
|
||||
// 若集合已创建则加载进去
|
||||
const col = this.collections.get(name);
|
||||
if (col) col.loadAll([...map.values()]);
|
||||
}
|
||||
this.logger.log(`storage loaded from ${file}`);
|
||||
} catch (e) {
|
||||
this.logger.error('load failed', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/** 全局配置:后续接入 MongoDB/Redis 时只需修改这里 */
|
||||
export const CONFIG = {
|
||||
port: 3000,
|
||||
jwt: {
|
||||
secret: process.env.JWT_SECRET || 'chatoa-dev-secret-change-me',
|
||||
expiresIn: '7d',
|
||||
},
|
||||
storage: {
|
||||
// storage: 'memory' | 'json' —— 开发期使用 json(内存 + 文件持久化)
|
||||
type: (process.env.STORAGE_TYPE as 'memory' | 'json') || 'json',
|
||||
jsonFile: process.env.STORAGE_FILE || '',
|
||||
},
|
||||
redis: {
|
||||
// 预留:接入 Redis 时启用
|
||||
url: process.env.REDIS_URL || 'redis://localhost:6379',
|
||||
},
|
||||
mongo: {
|
||||
// 预留:接入 MongoDB 时启用
|
||||
uri: process.env.MONGO_URI || 'mongodb://localhost:27017/chatoa',
|
||||
},
|
||||
} as const;
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Body, Controller, Delete, Get, Param, Post, Query, UseGuards } from '@nestjs/common';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import { PublicUser } from '../auth/auth.service';
|
||||
import { ContactsService } from './contacts.service';
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('contacts')
|
||||
export class ContactsController {
|
||||
constructor(private readonly contacts: ContactsService) {}
|
||||
|
||||
@Get()
|
||||
list(@CurrentUser() me: PublicUser, @Query('keyword') keyword?: string) {
|
||||
return this.contacts.list(me.id, keyword);
|
||||
}
|
||||
|
||||
@Post()
|
||||
add(
|
||||
@CurrentUser() me: PublicUser,
|
||||
@Body() dto: { contactId: string; remark?: string },
|
||||
) {
|
||||
return this.contacts.add(me.id, dto.contactId, dto.remark);
|
||||
}
|
||||
|
||||
@Delete(':contactId')
|
||||
remove(@CurrentUser() me: PublicUser, @Param('contactId') contactId: string) {
|
||||
return this.contacts.remove(me.id, contactId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ContactsService } from './contacts.service';
|
||||
import { ContactsController } from './contacts.controller';
|
||||
|
||||
@Module({
|
||||
providers: [ContactsService],
|
||||
controllers: [ContactsController],
|
||||
})
|
||||
export class ContactsModule {}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { StorageService } from '../common/storage/storage.service';
|
||||
import { User } from '../auth/auth.service';
|
||||
|
||||
export interface Contact {
|
||||
id: string;
|
||||
ownerId: string;
|
||||
contactId: string;
|
||||
remark?: string;
|
||||
groupName?: string;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ContactsService {
|
||||
constructor(private readonly storage: StorageService) {}
|
||||
|
||||
private contacts() {
|
||||
return this.storage.collection<Contact>('contacts');
|
||||
}
|
||||
|
||||
private users() {
|
||||
return this.storage.collection<User>('users');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取联系人列表:开发期为「全部用户 - 自己」。
|
||||
* 后续接入真库后可改为按 ownerId 的关系表查询。
|
||||
*/
|
||||
async list(ownerId: string, keyword?: string) {
|
||||
const all = await this.users().find({ sort: { createdAt: 1 } });
|
||||
const kw = (keyword || '').trim().toLowerCase();
|
||||
const list = all
|
||||
.filter((u) => u.id !== ownerId)
|
||||
.filter((u) =>
|
||||
kw
|
||||
? u.username.toLowerCase().includes(kw) ||
|
||||
(u.nickname || '').toLowerCase().includes(kw)
|
||||
: true,
|
||||
)
|
||||
.map(({ passwordHash, ...rest }) => rest);
|
||||
return list;
|
||||
}
|
||||
|
||||
async add(ownerId: string, contactId: string, remark?: string) {
|
||||
const exists = await this.contacts().findOne({ ownerId, contactId });
|
||||
if (exists) return exists;
|
||||
return this.contacts().insertOne({ ownerId, contactId, remark });
|
||||
}
|
||||
|
||||
async remove(ownerId: string, contactId: string) {
|
||||
return this.contacts().deleteOne({ ownerId, contactId });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import { PublicUser } from '../auth/auth.service';
|
||||
import { FormsService } from './forms.service';
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('forms')
|
||||
export class FormsController {
|
||||
constructor(private readonly forms: FormsService) {}
|
||||
|
||||
// ===== 模板 =====
|
||||
@Get('templates')
|
||||
listTemplates(@CurrentUser() me: PublicUser) {
|
||||
return this.forms.listTemplates(me.id);
|
||||
}
|
||||
|
||||
@Get('templates/:id')
|
||||
getTemplate(@Param('id') id: string) {
|
||||
return this.forms.getTemplate(id);
|
||||
}
|
||||
|
||||
@Post('templates')
|
||||
createTemplate(@CurrentUser() me: PublicUser, @Body() dto: any) {
|
||||
return this.forms.createTemplate(me.id, dto);
|
||||
}
|
||||
|
||||
@Put('templates/:id')
|
||||
updateTemplate(
|
||||
@CurrentUser() me: PublicUser,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: any,
|
||||
) {
|
||||
return this.forms.updateTemplate(me.id, id, dto);
|
||||
}
|
||||
|
||||
@Delete('templates/:id')
|
||||
removeTemplate(@CurrentUser() me: PublicUser, @Param('id') id: string) {
|
||||
return this.forms.removeTemplate(me.id, id);
|
||||
}
|
||||
|
||||
// ===== 实例 =====
|
||||
@Get('instances')
|
||||
listInstances(
|
||||
@Query('templateId') templateId?: string,
|
||||
@Query('status') status?: string,
|
||||
) {
|
||||
return this.forms.listInstances(
|
||||
status ? { templateId, status: status as any } : { templateId },
|
||||
);
|
||||
}
|
||||
|
||||
@Post('instances')
|
||||
submit(@CurrentUser() me: PublicUser, @Body() dto: any) {
|
||||
return this.forms.submit(me.id, dto);
|
||||
}
|
||||
|
||||
@Patch('instances/:id')
|
||||
updateInstance(@Param('id') id: string, @Body() dto: any) {
|
||||
return this.forms.updateInstance(id, dto);
|
||||
}
|
||||
|
||||
@Delete('instances/:id')
|
||||
removeInstance(@Param('id') id: string) {
|
||||
return this.forms.removeInstance(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { FormsService } from './forms.service';
|
||||
import { FormsController } from './forms.controller';
|
||||
|
||||
@Module({
|
||||
providers: [FormsService],
|
||||
controllers: [FormsController],
|
||||
})
|
||||
export class FormsModule {}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { StorageService } from '../common/storage/storage.service';
|
||||
|
||||
/** 表单字段定义 */
|
||||
export interface FormField {
|
||||
name: string;
|
||||
label: string;
|
||||
type: 'text' | 'textarea' | 'number' | 'select' | 'radio' | 'date' | 'switch';
|
||||
required?: boolean;
|
||||
placeholder?: string;
|
||||
options?: { label: string; value: string }[];
|
||||
}
|
||||
|
||||
/** 表单模板(Json Schema 形式) */
|
||||
export interface FormTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
ownerId: string;
|
||||
fields: FormField[];
|
||||
createdAt: number;
|
||||
updatedAt?: number;
|
||||
}
|
||||
|
||||
/** 表单状态:pending 待处理 / processing 处理中 / done 已完成 */
|
||||
export type FormInstanceStatus = 'pending' | 'processing' | 'done';
|
||||
|
||||
/** 表单实例(已填写的表单) */
|
||||
export interface FormInstance {
|
||||
id: string;
|
||||
templateId: string;
|
||||
submitterId: string;
|
||||
data: Record<string, any>;
|
||||
submittedAt: number;
|
||||
status: FormInstanceStatus;
|
||||
/** 处理期限(时间戳),月历红点依据 */
|
||||
dueDate?: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class FormsService {
|
||||
constructor(private readonly storage: StorageService) {}
|
||||
|
||||
private templates() {
|
||||
return this.storage.collection<FormTemplate>('form_templates');
|
||||
}
|
||||
|
||||
private instances() {
|
||||
return this.storage.collection<FormInstance>('form_instances');
|
||||
}
|
||||
|
||||
// ===== 模板 =====
|
||||
|
||||
async listTemplates(ownerId: string) {
|
||||
return this.templates().find({
|
||||
filter: { $or: [{ ownerId }, { ownerId: '' }] } as any,
|
||||
sort: { createdAt: -1 },
|
||||
});
|
||||
}
|
||||
|
||||
async getTemplate(id: string) {
|
||||
const t = await this.templates().findById(id);
|
||||
if (!t) throw new NotFoundException('表单模板不存在');
|
||||
return t;
|
||||
}
|
||||
|
||||
async createTemplate(
|
||||
ownerId: string,
|
||||
dto: { name: string; description?: string; fields: FormField[] },
|
||||
) {
|
||||
if (!dto.name?.trim()) throw new Error('表单名称不能为空');
|
||||
if (!Array.isArray(dto.fields)) throw new Error('字段列表不合法');
|
||||
return this.templates().insertOne({
|
||||
name: dto.name.trim(),
|
||||
description: dto.description,
|
||||
ownerId,
|
||||
fields: dto.fields,
|
||||
});
|
||||
}
|
||||
|
||||
async updateTemplate(ownerId: string, id: string, dto: Partial<FormTemplate>) {
|
||||
const t = await this.templates().findById(id);
|
||||
if (!t || t.ownerId !== ownerId) throw new NotFoundException('模板不存在');
|
||||
return this.templates().updateById(id, { ...dto, updatedAt: Date.now() });
|
||||
}
|
||||
|
||||
async removeTemplate(ownerId: string, id: string) {
|
||||
const t = await this.templates().findById(id);
|
||||
if (!t || t.ownerId !== ownerId) throw new NotFoundException('模板不存在');
|
||||
await this.templates().deleteById(id);
|
||||
// 级联删除实例
|
||||
const list = await this.instances().find({ filter: { templateId: id } });
|
||||
for (const ins of list) await this.instances().deleteById(ins.id);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// ===== 实例 =====
|
||||
|
||||
async listInstances(options?: { templateId?: string; status?: FormInstanceStatus }) {
|
||||
const filter: Record<string, any> = {};
|
||||
if (options?.templateId) filter.templateId = options.templateId;
|
||||
if (options?.status) filter.status = options.status;
|
||||
return this.instances().find({
|
||||
filter: Object.keys(filter).length ? (filter as any) : undefined,
|
||||
sort: { submittedAt: -1 },
|
||||
});
|
||||
}
|
||||
|
||||
async submit(
|
||||
submitterId: string,
|
||||
dto: {
|
||||
templateId: string;
|
||||
data: Record<string, any>;
|
||||
status?: FormInstanceStatus;
|
||||
dueDate?: number;
|
||||
},
|
||||
) {
|
||||
const t = await this.templates().findById(dto.templateId);
|
||||
if (!t) throw new NotFoundException('表单模板不存在');
|
||||
return this.instances().insertOne({
|
||||
templateId: dto.templateId,
|
||||
submitterId,
|
||||
data: dto.data ?? {},
|
||||
status: dto.status ?? 'pending',
|
||||
dueDate: dto.dueDate,
|
||||
});
|
||||
}
|
||||
|
||||
async updateInstance(id: string, dto: Partial<FormInstance>) {
|
||||
const ins = await this.instances().findById(id);
|
||||
if (!ins) throw new NotFoundException('表单不存在');
|
||||
const patch: Partial<FormInstance> = {};
|
||||
if (dto.status !== undefined) patch.status = dto.status;
|
||||
if (dto.data !== undefined) patch.data = dto.data;
|
||||
if (dto.dueDate !== undefined) patch.dueDate = dto.dueDate;
|
||||
return this.instances().updateById(id, patch);
|
||||
}
|
||||
|
||||
async removeInstance(id: string) {
|
||||
return this.instances().deleteById(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import { PublicUser } from '../auth/auth.service';
|
||||
import { KnowledgeService } from './knowledge.service';
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('knowledge')
|
||||
export class KnowledgeController {
|
||||
constructor(private readonly knowledge: KnowledgeService) {}
|
||||
|
||||
@Get()
|
||||
list(@Query('keyword') keyword?: string, @Query('tag') tag?: string) {
|
||||
return this.knowledge.list(keyword, tag);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
detail(@Param('id') id: string) {
|
||||
return this.knowledge.get(id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(@CurrentUser() me: PublicUser, @Body() dto: any) {
|
||||
return this.knowledge.create(me.id, dto);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
update(
|
||||
@CurrentUser() me: PublicUser,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: any,
|
||||
) {
|
||||
return this.knowledge.update(me.id, id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
remove(@CurrentUser() me: PublicUser, @Param('id') id: string) {
|
||||
return this.knowledge.remove(me.id, id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { KnowledgeService } from './knowledge.service';
|
||||
import { KnowledgeController } from './knowledge.controller';
|
||||
|
||||
@Module({
|
||||
providers: [KnowledgeService],
|
||||
controllers: [KnowledgeController],
|
||||
})
|
||||
export class KnowledgeModule {}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { StorageService } from '../common/storage/storage.service';
|
||||
|
||||
export type KnowledgeNodeType = 'folder' | 'note';
|
||||
|
||||
export interface KnowledgeDoc {
|
||||
id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
tags: string[];
|
||||
ownerId: string;
|
||||
updatedAt: number;
|
||||
createdAt: number;
|
||||
/** folder=目录 / note=笔记 */
|
||||
type: KnowledgeNodeType;
|
||||
/** 父目录 id,根级为 null */
|
||||
parentId: string | null;
|
||||
}
|
||||
|
||||
interface CreateDocDto {
|
||||
title: string;
|
||||
type?: KnowledgeNodeType;
|
||||
parentId?: string | null;
|
||||
content?: string;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class KnowledgeService {
|
||||
constructor(private readonly storage: StorageService) {}
|
||||
|
||||
private docs() {
|
||||
return this.storage.collection<KnowledgeDoc>('knowledge_docs');
|
||||
}
|
||||
|
||||
async list(keyword?: string, tag?: string) {
|
||||
const kw = (keyword || '').trim().toLowerCase();
|
||||
let list = await this.docs().find({ sort: { updatedAt: -1 } });
|
||||
if (tag) list = list.filter((d) => d.tags.includes(tag));
|
||||
if (kw) {
|
||||
list = list.filter((d) => {
|
||||
if (d.type === 'folder') return d.title.toLowerCase().includes(kw);
|
||||
return (
|
||||
d.title.toLowerCase().includes(kw) ||
|
||||
d.content.toLowerCase().includes(kw) ||
|
||||
d.tags.some((t) => t.toLowerCase().includes(kw))
|
||||
);
|
||||
});
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
async get(id: string) {
|
||||
const doc = await this.docs().findById(id);
|
||||
if (!doc) throw new NotFoundException('文档不存在');
|
||||
return doc;
|
||||
}
|
||||
|
||||
private async ensureParent(parentId: string | null, ownerId: string) {
|
||||
if (!parentId) return;
|
||||
const parent = await this.docs().findById(parentId);
|
||||
if (!parent || parent.ownerId !== ownerId) {
|
||||
throw new NotFoundException('父目录不存在');
|
||||
}
|
||||
if (parent.type !== 'folder') {
|
||||
throw new Error('笔记下不能创建子节点,请在目录下创建');
|
||||
}
|
||||
}
|
||||
|
||||
async create(ownerId: string, dto: CreateDocDto) {
|
||||
const title = dto.title?.trim();
|
||||
if (!title) throw new Error('标题不能为空');
|
||||
const type = dto.type === 'note' ? 'note' : 'folder';
|
||||
const parentId = dto.parentId || null;
|
||||
await this.ensureParent(parentId, ownerId);
|
||||
const now = Date.now();
|
||||
return this.docs().insertOne({
|
||||
title,
|
||||
content: type === 'note' ? dto.content || '' : '',
|
||||
tags: dto.tags || [],
|
||||
ownerId,
|
||||
type,
|
||||
parentId,
|
||||
updatedAt: now,
|
||||
createdAt: now,
|
||||
});
|
||||
}
|
||||
|
||||
async update(ownerId: string, id: string, dto: Partial<KnowledgeDoc>) {
|
||||
const doc = await this.docs().findById(id);
|
||||
if (!doc || doc.ownerId !== ownerId) {
|
||||
throw new NotFoundException('文档不存在');
|
||||
}
|
||||
if (dto.parentId !== undefined) {
|
||||
if (dto.parentId === id) throw new Error('不能将目录移动到自身');
|
||||
await this.ensureParent(dto.parentId, ownerId);
|
||||
}
|
||||
return this.docs().updateById(id, { ...dto, updatedAt: Date.now() });
|
||||
}
|
||||
|
||||
async remove(ownerId: string, id: string) {
|
||||
const doc = await this.docs().findById(id);
|
||||
if (!doc || doc.ownerId !== ownerId) {
|
||||
throw new NotFoundException('文档不存在');
|
||||
}
|
||||
// 级联删除:收集全部子孙节点后一并删除
|
||||
const all = await this.docs().find();
|
||||
const toDelete = new Set<string>([id]);
|
||||
let grew = true;
|
||||
while (grew) {
|
||||
grew = false;
|
||||
for (const d of all) {
|
||||
if (d.parentId && toDelete.has(d.parentId) && !toDelete.has(d.id)) {
|
||||
toDelete.add(d.id);
|
||||
grew = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const delId of toDelete) {
|
||||
await this.docs().deleteById(delId);
|
||||
}
|
||||
return { deleted: toDelete.size };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { NestExpressApplication } from '@nestjs/platform-express';
|
||||
import { join } from 'path';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create<NestExpressApplication>(AppModule);
|
||||
app.setGlobalPrefix('api');
|
||||
app.enableCors({ origin: true, credentials: true });
|
||||
|
||||
// 上传文件静态目录(uploads 位于 server 根目录)
|
||||
const uploadsDir = join(process.cwd(), 'uploads');
|
||||
app.useStaticAssets(uploadsDir, { prefix: '/uploads/' });
|
||||
|
||||
const port = Number(process.env.PORT) || 3000;
|
||||
await app.listen(port);
|
||||
console.log(`[ChatOA] server running at http://localhost:${port}`);
|
||||
}
|
||||
bootstrap();
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { PresenceService } from './presence.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [PresenceService],
|
||||
exports: [PresenceService],
|
||||
})
|
||||
export class PresenceModule {}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
export interface Presence {
|
||||
userId: string;
|
||||
online: boolean;
|
||||
lastSeen: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 在线状态管理:开发期使用内存 Map(可分布式广播)。
|
||||
* 后续接入 Redis 时替换为 Redis Hash + Pub/Sub 实现,接口保持一致。
|
||||
*/
|
||||
@Injectable()
|
||||
export class PresenceService {
|
||||
private readonly map = new Map<string, Presence>();
|
||||
/** userId -> Set<socketId>,同一用户多端登录 */
|
||||
private readonly sockets = new Map<string, Set<string>>();
|
||||
|
||||
markOnline(userId: string, socketId: string) {
|
||||
const set = this.sockets.get(userId) ?? new Set<string>();
|
||||
set.add(socketId);
|
||||
this.sockets.set(userId, set);
|
||||
const p: Presence = { userId, online: true, lastSeen: Date.now() };
|
||||
this.map.set(userId, p);
|
||||
return p;
|
||||
}
|
||||
|
||||
markOffline(userId: string, socketId: string): Presence | null {
|
||||
const set = this.sockets.get(userId);
|
||||
if (set) {
|
||||
set.delete(socketId);
|
||||
if (set.size > 0) return this.map.get(userId) ?? null;
|
||||
this.sockets.delete(userId);
|
||||
}
|
||||
const p: Presence = { userId, online: false, lastSeen: Date.now() };
|
||||
this.map.set(userId, p);
|
||||
return p;
|
||||
}
|
||||
|
||||
get(userId: string): Presence {
|
||||
return this.map.get(userId) ?? { userId, online: false, lastSeen: 0 };
|
||||
}
|
||||
|
||||
getAll(): Presence[] {
|
||||
return [...this.map.values()];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import { PublicUser } from '../auth/auth.service';
|
||||
import { ScheduleService } from './schedule.service';
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('schedule')
|
||||
export class ScheduleController {
|
||||
constructor(private readonly schedule: ScheduleService) {}
|
||||
|
||||
@Get()
|
||||
list(
|
||||
@CurrentUser() me: PublicUser,
|
||||
@Query('start') start?: string,
|
||||
@Query('end') end?: string,
|
||||
) {
|
||||
return this.schedule.list(
|
||||
me.id,
|
||||
start ? Number(start) : undefined,
|
||||
end ? Number(end) : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(
|
||||
@CurrentUser() me: PublicUser,
|
||||
@Body() dto: any,
|
||||
) {
|
||||
return this.schedule.create(me.id, dto);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
update(
|
||||
@CurrentUser() me: PublicUser,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: any,
|
||||
) {
|
||||
return this.schedule.update(me.id, id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
remove(@CurrentUser() me: PublicUser, @Param('id') id: string) {
|
||||
return this.schedule.remove(me.id, id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ScheduleService } from './schedule.service';
|
||||
import { ScheduleController } from './schedule.controller';
|
||||
|
||||
@Module({
|
||||
providers: [ScheduleService],
|
||||
controllers: [ScheduleController],
|
||||
})
|
||||
export class ScheduleModule {}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { StorageService } from '../common/storage/storage.service';
|
||||
|
||||
export interface ScheduleItem {
|
||||
id: string;
|
||||
ownerId: string;
|
||||
title: string;
|
||||
/** 全天日程标记 */
|
||||
allDay?: boolean;
|
||||
startAt: number;
|
||||
endAt: number;
|
||||
location?: string;
|
||||
notes?: string;
|
||||
createdAt: number;
|
||||
updatedAt?: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ScheduleService {
|
||||
constructor(private readonly storage: StorageService) {}
|
||||
|
||||
private items() {
|
||||
return this.storage.collection<ScheduleItem>('schedule_items');
|
||||
}
|
||||
|
||||
/** 按时间范围查询自己的日程 */
|
||||
async list(ownerId: string, start?: number, end?: number) {
|
||||
const filter: Record<string, any> = { ownerId };
|
||||
if (start) filter.endAt = { $gte: start };
|
||||
if (end) filter.startAt = { $lte: end };
|
||||
return this.items().find({
|
||||
filter,
|
||||
sort: { startAt: 1 },
|
||||
});
|
||||
}
|
||||
|
||||
async create(
|
||||
ownerId: string,
|
||||
dto: Omit<ScheduleItem, 'id' | 'ownerId' | 'createdAt'>,
|
||||
) {
|
||||
if (!dto.title?.trim()) throw new Error('日程标题不能为空');
|
||||
if (!dto.startAt || !dto.endAt) throw new Error('缺少开始/结束时间');
|
||||
if (dto.endAt < dto.startAt) throw new Error('结束时间不能早于开始时间');
|
||||
return this.items().insertOne({ ...dto, ownerId });
|
||||
}
|
||||
|
||||
async update(ownerId: string, id: string, dto: Partial<ScheduleItem>) {
|
||||
const item = await this.items().findById(id);
|
||||
if (!item || item.ownerId !== ownerId) {
|
||||
throw new NotFoundException('日程不存在');
|
||||
}
|
||||
return this.items().updateById(id, { ...dto, updatedAt: Date.now() });
|
||||
}
|
||||
|
||||
async remove(ownerId: string, id: string) {
|
||||
const item = await this.items().findById(id);
|
||||
if (!item || item.ownerId !== ownerId) {
|
||||
throw new NotFoundException('日程不存在');
|
||||
}
|
||||
return this.items().deleteById(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { SeedService } from './seed.service';
|
||||
import { ChatModule } from '../chat/chat.module';
|
||||
|
||||
@Module({
|
||||
imports: [ChatModule],
|
||||
providers: [SeedService],
|
||||
})
|
||||
export class SeedModule {}
|
||||
@@ -0,0 +1,367 @@
|
||||
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import { StorageService } from '../common/storage/storage.service';
|
||||
import { User } from '../auth/auth.service';
|
||||
import { conversationKey, ChatService } from '../chat/chat.service';
|
||||
|
||||
interface SeedUserDef {
|
||||
username: string;
|
||||
nickname: string;
|
||||
department: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
const SEED_USERS: SeedUserDef[] = [
|
||||
{ username: 'demo', nickname: '演示用户', department: '产品部', title: '产品经理' },
|
||||
{ username: 'alice', nickname: '小艾', department: '行政部', title: '行政专员' },
|
||||
{ username: 'bob', nickname: '小波', department: '研发部', title: '前端工程师' },
|
||||
{ username: 'carol', nickname: '小卡', department: '财务部', title: '会计' },
|
||||
];
|
||||
|
||||
const SEED_PASSWORD = '123456';
|
||||
|
||||
@Injectable()
|
||||
export class SeedService implements OnModuleInit {
|
||||
private readonly logger = new Logger(SeedService.name);
|
||||
|
||||
constructor(
|
||||
private readonly storage: StorageService,
|
||||
private readonly chat: ChatService,
|
||||
) {}
|
||||
|
||||
async onModuleInit() {
|
||||
const users = this.storage.collection<User>('users');
|
||||
const count = await users.count();
|
||||
// 已存在数据则不重复初始化(保留用户修改/新增的数据)
|
||||
if (count > 0) {
|
||||
this.logger.log('seed skipped: users already exist');
|
||||
return;
|
||||
}
|
||||
this.logger.log('seeding demo data...');
|
||||
const hash = bcrypt.hashSync(SEED_PASSWORD, 10);
|
||||
const created: User[] = [];
|
||||
for (const def of SEED_USERS) {
|
||||
const u = await users.insertOne({
|
||||
username: def.username,
|
||||
passwordHash: hash,
|
||||
nickname: def.nickname,
|
||||
department: def.department,
|
||||
title: def.title,
|
||||
});
|
||||
created.push(u);
|
||||
}
|
||||
await this.seedChats(created);
|
||||
await this.seedSchedule(created);
|
||||
await this.seedForms(created);
|
||||
await this.seedKnowledge(created);
|
||||
this.logger.log('seed done');
|
||||
}
|
||||
|
||||
private async seedChats(users: User[]) {
|
||||
const demo = users.find((u) => u.username === 'demo')!;
|
||||
const others = users.filter((u) => u.username !== 'demo');
|
||||
const seedMessages: Array<{
|
||||
from: string;
|
||||
to: string;
|
||||
type: 'text' | 'image';
|
||||
content: string;
|
||||
}> = [];
|
||||
const convs: Array<{ a: string; b: string; updatedAt: number }> = [];
|
||||
|
||||
// 与 alice 的对话
|
||||
const alice = users.find((u) => u.username === 'alice')!;
|
||||
seedMessages.push(
|
||||
{ from: alice.id, to: demo.id, type: 'text', content: '早上好!今天 10 点开周会,别忘了哦~' },
|
||||
{ from: demo.id, to: alice.id, type: 'text', content: '收到,会议室订好了吗?' },
|
||||
{ from: alice.id, to: demo.id, type: 'text', content: '已订 3 楼小会议室,投影正常。' },
|
||||
);
|
||||
convs.push({ a: demo.id, b: alice.id, updatedAt: Date.now() - 3600_000 });
|
||||
|
||||
// 与 bob 的对话
|
||||
const bob = users.find((u) => u.username === 'bob')!;
|
||||
seedMessages.push(
|
||||
{ from: bob.id, to: demo.id, type: 'text', content: 'ChatOA 的消息模块联调完成,可以体验了 🎉' },
|
||||
{ from: demo.id, to: bob.id, type: 'text', content: '辛苦了!图片消息也测了吗?' },
|
||||
{ from: bob.id, to: demo.id, type: 'text', content: '测了,发送一张示意图你看看:' },
|
||||
);
|
||||
convs.push({ a: demo.id, b: bob.id, updatedAt: Date.now() - 10 * 60_000 });
|
||||
|
||||
for (const m of seedMessages) {
|
||||
await this.chat.sendMessage(m.from, {
|
||||
toUserId: m.to,
|
||||
type: m.type,
|
||||
content: m.content,
|
||||
});
|
||||
}
|
||||
// 修正会话更新时间,使列表顺序符合演示
|
||||
for (const c of convs) {
|
||||
const col = this.storage.collection('conversations');
|
||||
await col.updateById(conversationKey(c.a, c.b), { updatedAt: c.updatedAt });
|
||||
}
|
||||
void others;
|
||||
}
|
||||
|
||||
private async seedSchedule(users: User[]) {
|
||||
const demo = users.find((u) => u.username === 'demo')!;
|
||||
const schedule = this.storage.collection('schedule_items');
|
||||
const day = 24 * 3600_000;
|
||||
const now = Date.now();
|
||||
await schedule.insertOne({
|
||||
ownerId: demo.id,
|
||||
title: '周例会',
|
||||
allDay: false,
|
||||
startAt: now + 2 * 3600_000,
|
||||
endAt: now + 2.5 * 3600_000,
|
||||
location: '3 楼小会议室',
|
||||
notes: '各部门同步本周进展',
|
||||
});
|
||||
const todayStart = new Date();
|
||||
todayStart.setHours(0, 0, 0, 0);
|
||||
await schedule.insertOne({
|
||||
ownerId: demo.id,
|
||||
title: '产品评审会',
|
||||
allDay: false,
|
||||
startAt: todayStart.getTime() + 15 * 3600_000,
|
||||
endAt: todayStart.getTime() + 16 * 3600_000,
|
||||
location: '线上会议',
|
||||
});
|
||||
await schedule.insertOne({
|
||||
ownerId: demo.id,
|
||||
title: '提交季度报销',
|
||||
allDay: true,
|
||||
startAt: todayStart.getTime() + 2 * day,
|
||||
endAt: todayStart.getTime() + 2 * day + day - 1,
|
||||
notes: '记得整理发票',
|
||||
});
|
||||
}
|
||||
|
||||
private async seedForms(users: User[]) {
|
||||
const demo = users.find((u) => u.username === 'demo')!;
|
||||
const templates = this.storage.collection('form_templates');
|
||||
const instances = this.storage.collection('form_instances');
|
||||
const day = 24 * 3600_000;
|
||||
const today = Date.now();
|
||||
|
||||
// ===== 表单模板(表单库 = 可选的空白表单,可基于其填写) =====
|
||||
const leave = await templates.insertOne({
|
||||
name: '请假申请',
|
||||
description: 'OA 请假审批(Demo)',
|
||||
ownerId: demo.id,
|
||||
fields: [
|
||||
{ name: 'leaveType', label: '请假类型', type: 'select', required: true, options: [
|
||||
{ label: '事假', value: '事假' }, { label: '病假', value: '病假' },
|
||||
{ label: '年假', value: '年假' }, { label: '调休', value: '调休' },
|
||||
]},
|
||||
{ name: 'startDate', label: '开始日期', type: 'date', required: true },
|
||||
{ name: 'endDate', label: '结束日期', type: 'date', required: true },
|
||||
{ name: 'days', label: '请假天数', type: 'number', required: true },
|
||||
{ name: 'reason', label: '请假事由', type: 'textarea', required: true },
|
||||
],
|
||||
});
|
||||
const meeting = await templates.insertOne({
|
||||
name: '会议室预约',
|
||||
description: '预约会议室(Demo)',
|
||||
ownerId: demo.id,
|
||||
fields: [
|
||||
{ name: 'room', label: '会议室', type: 'select', required: true, options: [
|
||||
{ label: '1 号会议室(10 人)', value: '1' },
|
||||
{ label: '2 号会议室(20 人)', value: '2' },
|
||||
{ label: '3 号会议室(6 人)', value: '3' },
|
||||
]},
|
||||
{ name: 'date', label: '使用日期', type: 'date', required: true },
|
||||
{ name: 'purpose', label: '用途说明', type: 'textarea', required: true },
|
||||
{ name: 'needProjector', label: '需要投影仪', type: 'switch' },
|
||||
],
|
||||
});
|
||||
const office = await templates.insertOne({
|
||||
name: '办公室借用',
|
||||
description: '借用办公室 / 工位 / 洽谈室等场地(Demo)',
|
||||
ownerId: demo.id,
|
||||
fields: [
|
||||
{ name: 'place', label: '借用地点', type: 'select', required: true, options: [
|
||||
{ label: '独立办公室', value: '独立办公室' },
|
||||
{ label: '开放工位', value: '开放工位' },
|
||||
{ label: '洽谈室', value: '洽谈室' },
|
||||
{ label: '培训室', value: '培训室' },
|
||||
]},
|
||||
{ name: 'date', label: '借用日期', type: 'date', required: true },
|
||||
{ name: 'timeRange', label: '使用时段', type: 'select', required: true, options: [
|
||||
{ label: '上午', value: '上午' }, { label: '下午', value: '下午' },
|
||||
{ label: '全天', value: '全天' },
|
||||
]},
|
||||
{ name: 'seats', label: '使用人数', type: 'number', required: true },
|
||||
{ name: 'purpose', label: '借用用途', type: 'textarea', required: true },
|
||||
],
|
||||
});
|
||||
const car = await templates.insertOne({
|
||||
name: '公车借用',
|
||||
description: '申请使用公司公务车辆(Demo)',
|
||||
ownerId: demo.id,
|
||||
fields: [
|
||||
{ name: 'car', label: '车辆', type: 'select', required: true, options: [
|
||||
{ label: '商务车(7 座)', value: '商务车' },
|
||||
{ label: '轿车(5 座)', value: '轿车' },
|
||||
{ label: '厢式货车', value: '货车' },
|
||||
]},
|
||||
{ name: 'date', label: '使用日期', type: 'date', required: true },
|
||||
{ name: 'timeRange', label: '使用时段', type: 'select', required: true, options: [
|
||||
{ label: '上午', value: '上午' }, { label: '下午', value: '下午' },
|
||||
{ label: '全天', value: '全天' },
|
||||
]},
|
||||
{ name: 'destination', label: '目的地', type: 'text', required: true },
|
||||
{ name: 'passengers', label: '乘车人数', type: 'number', required: true },
|
||||
{ name: 'reason', label: '借车事由', type: 'textarea', required: true },
|
||||
],
|
||||
});
|
||||
|
||||
// ===== 演示实例(进行中 / 已完成,月历红点依据 dueDate) =====
|
||||
await instances.insertOne({
|
||||
templateId: leave.id,
|
||||
submitterId: demo.id,
|
||||
status: 'pending',
|
||||
dueDate: today + 4 * day,
|
||||
data: {
|
||||
leaveType: '年假',
|
||||
startDate: '2026-08-24',
|
||||
endDate: '2026-08-25',
|
||||
days: 2,
|
||||
reason: '家庭出行',
|
||||
},
|
||||
});
|
||||
await instances.insertOne({
|
||||
templateId: office.id,
|
||||
submitterId: demo.id,
|
||||
status: 'processing',
|
||||
dueDate: today + 1 * day,
|
||||
data: {
|
||||
place: '洽谈室',
|
||||
date: '2026-08-21',
|
||||
timeRange: '下午',
|
||||
seats: 6,
|
||||
purpose: '接待客户进行方案评审',
|
||||
},
|
||||
});
|
||||
await instances.insertOne({
|
||||
templateId: car.id,
|
||||
submitterId: demo.id,
|
||||
status: 'pending',
|
||||
dueDate: today + 2 * day,
|
||||
data: {
|
||||
car: '商务车',
|
||||
date: '2026-08-22',
|
||||
timeRange: '全天',
|
||||
destination: '机场',
|
||||
passengers: 4,
|
||||
reason: '接送外地来访客户',
|
||||
},
|
||||
});
|
||||
await instances.insertOne({
|
||||
templateId: meeting.id,
|
||||
submitterId: demo.id,
|
||||
status: 'done',
|
||||
data: {
|
||||
room: '2',
|
||||
date: '2026-08-19',
|
||||
purpose: '季度产品规划会',
|
||||
needProjector: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async seedKnowledge(users: User[]) {
|
||||
const demo = users.find((u) => u.username === 'demo')!;
|
||||
const docs = this.storage.collection('knowledge_docs');
|
||||
const now = Date.now();
|
||||
|
||||
// 根级目录
|
||||
const rootCompany = await docs.insertOne({
|
||||
title: '公司制度',
|
||||
content: '',
|
||||
tags: [],
|
||||
ownerId: demo.id,
|
||||
type: 'folder',
|
||||
parentId: null,
|
||||
updatedAt: now,
|
||||
});
|
||||
const rootTemplate = await docs.insertOne({
|
||||
title: '模板库',
|
||||
content: '',
|
||||
tags: [],
|
||||
ownerId: demo.id,
|
||||
type: 'folder',
|
||||
parentId: null,
|
||||
updatedAt: now - dayMs(1),
|
||||
});
|
||||
const rootProduct = await docs.insertOne({
|
||||
title: '产品文档',
|
||||
content: '',
|
||||
tags: [],
|
||||
ownerId: demo.id,
|
||||
type: 'folder',
|
||||
parentId: null,
|
||||
updatedAt: now - dayMs(2),
|
||||
});
|
||||
|
||||
// 二级目录(多级示例)
|
||||
const subFinance = await docs.insertOne({
|
||||
title: '财务规范',
|
||||
content: '',
|
||||
tags: [],
|
||||
ownerId: demo.id,
|
||||
type: 'folder',
|
||||
parentId: rootCompany.id,
|
||||
updatedAt: now - dayMs(3),
|
||||
});
|
||||
|
||||
// 笔记
|
||||
await docs.insertOne({
|
||||
title: 'ChatOA 项目说明',
|
||||
content: '基于 IM 的 OA 客户端工具,通过即时通讯实现对话、文件互传、表单签批。整体结构参考微信电脑端:第一列功能导航、第二列功能区、第三列主工作区。',
|
||||
tags: ['产品', '项目'],
|
||||
ownerId: demo.id,
|
||||
type: 'note',
|
||||
parentId: rootProduct.id,
|
||||
updatedAt: now,
|
||||
});
|
||||
await docs.insertOne({
|
||||
title: '报销流程指引',
|
||||
content: '1. 填写报销单并附发票照片;2. 部门负责人审批;3. 财务复核;4. 打款。\n发票需为增值税普通发票或电子发票,报销时限为费用发生后 30 天内。',
|
||||
tags: ['财务', '流程'],
|
||||
ownerId: demo.id,
|
||||
type: 'note',
|
||||
parentId: subFinance.id,
|
||||
updatedAt: now - dayMs(3),
|
||||
});
|
||||
await docs.insertOne({
|
||||
title: '考勤管理办法',
|
||||
content: '工作时间:周一至周五 9:00-18:00。\n- 迟到 30 分钟内按 1 次提醒\n- 月度满勤奖励 200 元\n- 请假需提前在表单模块提交「请假申请」',
|
||||
tags: ['人事', '制度'],
|
||||
ownerId: demo.id,
|
||||
type: 'note',
|
||||
parentId: rootCompany.id,
|
||||
updatedAt: now - dayMs(4),
|
||||
});
|
||||
await docs.insertOne({
|
||||
title: '周报模板',
|
||||
content: '本周完成:\n- 工作项 1\n- 工作项 2\n下周计划:\n- 计划项 1\n风险与问题:\n- 无',
|
||||
tags: ['模板'],
|
||||
ownerId: demo.id,
|
||||
type: 'note',
|
||||
parentId: rootTemplate.id,
|
||||
updatedAt: now - dayMs(7),
|
||||
});
|
||||
await docs.insertOne({
|
||||
title: '会议纪要模板',
|
||||
content: '会议主题:\n时间:\n参会人:\n结论:\n待办事项:\n- 事项 / 负责人 / 截止日期',
|
||||
tags: ['模板'],
|
||||
ownerId: demo.id,
|
||||
type: 'note',
|
||||
parentId: rootTemplate.id,
|
||||
updatedAt: now - dayMs(8),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function dayMs(n: number) {
|
||||
return n * 24 * 3600_000;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import { PublicUser } from '../auth/auth.service';
|
||||
import { UsersService } from './users.service';
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('users')
|
||||
export class UsersController {
|
||||
constructor(private readonly users: UsersService) {}
|
||||
|
||||
@Get()
|
||||
list(
|
||||
@CurrentUser() me: PublicUser,
|
||||
@Query('keyword') keyword?: string,
|
||||
) {
|
||||
return this.users.list(me.id, keyword);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
detail(@Param('id') id: string) {
|
||||
return this.users.getById(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { UsersService } from './users.service';
|
||||
import { UsersController } from './users.controller';
|
||||
|
||||
@Module({
|
||||
providers: [UsersService],
|
||||
controllers: [UsersController],
|
||||
exports: [UsersService],
|
||||
})
|
||||
export class UsersModule {}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { StorageService } from '../common/storage/storage.service';
|
||||
import { User } from '../auth/auth.service';
|
||||
|
||||
@Injectable()
|
||||
export class UsersService {
|
||||
constructor(private readonly storage: StorageService) {}
|
||||
|
||||
private users() {
|
||||
return this.storage.collection<User>('users');
|
||||
}
|
||||
|
||||
/** 用户列表(排除自己,支持关键词搜索) */
|
||||
async list(excludeId?: string, keyword?: string) {
|
||||
const kw = (keyword || '').trim().toLowerCase();
|
||||
let list = await this.users().find({ sort: { createdAt: 1 } });
|
||||
if (excludeId) list = list.filter((u) => u.id !== excludeId);
|
||||
if (kw) {
|
||||
list = list.filter(
|
||||
(u) =>
|
||||
u.username.toLowerCase().includes(kw) ||
|
||||
(u.nickname || '').toLowerCase().includes(kw),
|
||||
);
|
||||
}
|
||||
return list.map(({ passwordHash, ...rest }) => rest);
|
||||
}
|
||||
|
||||
async getById(id: string) {
|
||||
const user = await this.users().findById(id);
|
||||
if (!user) return null;
|
||||
const { passwordHash, ...rest } = user;
|
||||
return rest;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user