feat: 初始化 ChatOA 企业协作 OA 系统

This commit is contained in:
2026-08-20 19:36:07 +08:00
commit 6d7a1f6e63
90 changed files with 20067 additions and 0 deletions
+2461
View File
File diff suppressed because it is too large Load Diff
+35
View File
@@ -0,0 +1,35 @@
{
"name": "chatoa-server",
"version": "0.1.0",
"description": "ChatOA IM server (NestJS + TypeScript + Socket.IO)",
"private": true,
"scripts": {
"start": "ts-node -r tsconfig-paths/register src/main.ts",
"start:dev": "nodemon --watch src -e ts --exec \"ts-node -r tsconfig-paths/register src/main.ts\"",
"build": "tsc -p tsconfig.json",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@nestjs/common": "^11.0.0",
"@nestjs/core": "^11.0.0",
"@nestjs/platform-express": "^11.0.0",
"@nestjs/platform-socket.io": "^11.0.0",
"@nestjs/websockets": "^11.0.0",
"bcryptjs": "^2.4.3",
"jsonwebtoken": "^9.0.2",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"socket.io": "^4.8.0"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/express": "^5.0.0",
"@types/jsonwebtoken": "^9.0.7",
"@types/multer": "^1.4.12",
"@types/node": "^22.10.0",
"nodemon": "^3.1.7",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.6.0"
}
}
+27
View File
@@ -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 {}
+33
View File
@@ -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 };
}
}
+11
View File
@@ -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 {}
+93
View File
@@ -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;
},
);
+23
View File
@@ -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;
}
}
+82
View File
@@ -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 };
}
}
+85
View File
@@ -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 };
}
}
+11
View File
@@ -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 {}
+153
View File
@@ -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 });
}
}
+40
View File
@@ -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);
}
}
}
+21
View File
@@ -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);
}
}
+9
View File
@@ -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 {}
+54
View File
@@ -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 });
}
}
+78
View File
@@ -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);
}
}
+9
View File
@@ -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 {}
+142
View File
@@ -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);
}
}
+9
View File
@@ -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 {}
+124
View File
@@ -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 };
}
}
+19
View File
@@ -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();
+9
View File
@@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { PresenceService } from './presence.service';
@Global()
@Module({
providers: [PresenceService],
exports: [PresenceService],
})
export class PresenceModule {}
+47
View File
@@ -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);
}
}
+9
View File
@@ -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 {}
+62
View File
@@ -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);
}
}
+9
View File
@@ -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 {}
+367
View File
@@ -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;
}
+24
View File
@@ -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);
}
}
+10
View File
@@ -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 {}
+34
View File
@@ -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;
}
}
+25
View File
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"module": "commonjs",
"target": "ES2022",
"lib": ["ES2022"],
"moduleResolution": "node",
"declaration": false,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strict": true,
"strictPropertyInitialization": false,
"noImplicitAny": false,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}