Compare commits
15 Commits
3b11fe0d31
...
7887a8d3be
| Author | SHA1 | Date | |
|---|---|---|---|
| 7887a8d3be | |||
| e10d225063 | |||
| e381679165 | |||
| 0020df5d5e | |||
| 3ad8cc3a56 | |||
| 97528ce2c5 | |||
| 2871141d4c | |||
| ea32aeb819 | |||
| 07c8a0f99d | |||
| 4eaac38484 | |||
| c55666b481 | |||
| 88bec71cf8 | |||
| 026be99393 | |||
| 17dc9b9dba | |||
| 3910c0da9f |
46
.github/workflows/backend-ci.yml
vendored
Normal file
46
.github/workflows/backend-ci.yml
vendored
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
name: backend-ci
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ["main"]
|
||||||
|
paths:
|
||||||
|
- "Fabric.Backend.Center/**"
|
||||||
|
- "Fabric.Backend.Guild/**"
|
||||||
|
- ".github/workflows/backend-ci.yml"
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- "Fabric.Backend.Center/**"
|
||||||
|
- "Fabric.Backend.Guild/**"
|
||||||
|
- ".github/workflows/backend-ci.yml"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
verify:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
service:
|
||||||
|
- Fabric.Backend.Center
|
||||||
|
- Fabric.Backend.Guild
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: ${{ matrix.service }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: ${{ matrix.service }}/package-lock.json
|
||||||
|
|
||||||
|
- name: Install
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Lint
|
||||||
|
run: npm run lint
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: npm run build
|
||||||
10
.gitmodules
vendored
10
.gitmodules
vendored
@@ -2,9 +2,13 @@
|
|||||||
path = Fabric.OpenclawPlugin
|
path = Fabric.OpenclawPlugin
|
||||||
url = https://git.hangman-lab.top/nav/Fabric.OpenclawPlugin.git
|
url = https://git.hangman-lab.top/nav/Fabric.OpenclawPlugin.git
|
||||||
branch = main
|
branch = main
|
||||||
[submodule "Fabric.Backend"]
|
[submodule "Fabric.Backend.Center"]
|
||||||
path = Fabric.Backend
|
path = Fabric.Backend.Center
|
||||||
url = https://git.hangman-lab.top/nav/Fabric.Backend.git
|
url = https://git.hangman-lab.top/nav/Fabric.Backend.Center.git
|
||||||
|
branch = main
|
||||||
|
[submodule "Fabric.Backend.Guild"]
|
||||||
|
path = Fabric.Backend.Guild
|
||||||
|
url = https://git.hangman-lab.top/nav/Fabric.Backend.Guild.git
|
||||||
branch = main
|
branch = main
|
||||||
[submodule "Fabric.Frontend"]
|
[submodule "Fabric.Frontend"]
|
||||||
path = Fabric.Frontend
|
path = Fabric.Frontend
|
||||||
|
|||||||
2
.prettierignore
Normal file
2
.prettierignore
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
**/dist
|
||||||
|
**/node_modules
|
||||||
6
.prettierrc
Normal file
6
.prettierrc
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"singleQuote": true,
|
||||||
|
"semi": true,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"printWidth": 100
|
||||||
|
}
|
||||||
Submodule Fabric.Backend deleted from 08b9424648
20
Fabric.Backend.Center/.env.example
Normal file
20
Fabric.Backend.Center/.env.example
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# Server
|
||||||
|
PORT=7001
|
||||||
|
|
||||||
|
# MySQL
|
||||||
|
DB_HOST=mysql-center
|
||||||
|
DB_PORT=3306
|
||||||
|
DB_USER=fabric
|
||||||
|
DB_PASSWORD=fabric
|
||||||
|
DB_NAME=fabric_center
|
||||||
|
DB_SYNC=true
|
||||||
|
DB_LOGGING=false
|
||||||
|
|
||||||
|
# Auth (to be used in auth module)
|
||||||
|
JWT_ACCESS_SECRET=change-me-access
|
||||||
|
JWT_REFRESH_SECRET=change-me-refresh
|
||||||
|
JWT_ACCESS_EXPIRES_IN=15m
|
||||||
|
JWT_REFRESH_EXPIRES_IN=30d
|
||||||
|
|
||||||
|
# Center <-> Guild handshake
|
||||||
|
CENTER_SHARED_SECRET=change-me-center-secret
|
||||||
2
Fabric.Backend.Center/.gitignore
vendored
Normal file
2
Fabric.Backend.Center/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
19
Fabric.Backend.Center/Dockerfile
Normal file
19
Fabric.Backend.Center/Dockerfile
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
FROM node:22-alpine AS deps
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
FROM node:22-alpine AS build
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM node:22-alpine AS runtime
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci --omit=dev
|
||||||
|
COPY --from=build /app/dist ./dist
|
||||||
|
EXPOSE 7001
|
||||||
|
CMD ["node", "dist/main.js"]
|
||||||
14
Fabric.Backend.Center/README.md
Normal file
14
Fabric.Backend.Center/README.md
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
# Fabric.Backend.Center
|
||||||
|
|
||||||
|
Identity Hub service for Fabric.
|
||||||
|
|
||||||
|
## Scope (MVP)
|
||||||
|
- User register/login
|
||||||
|
- Session/token management
|
||||||
|
- Guild Node registration + shared-secret handshake
|
||||||
|
- Center-level audit logs
|
||||||
|
|
||||||
|
## Next
|
||||||
|
- API skeleton (NestJS)
|
||||||
|
- Auth module
|
||||||
|
- Guild node registry module
|
||||||
28
Fabric.Backend.Center/eslint.config.mjs
Normal file
28
Fabric.Backend.Center/eslint.config.mjs
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import tsParser from '@typescript-eslint/parser';
|
||||||
|
import tsPlugin from '@typescript-eslint/eslint-plugin';
|
||||||
|
|
||||||
|
export default [
|
||||||
|
{
|
||||||
|
ignores: ['dist/**', 'node_modules/**'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ['src/**/*.ts'],
|
||||||
|
languageOptions: {
|
||||||
|
parser: tsParser,
|
||||||
|
parserOptions: {
|
||||||
|
ecmaVersion: 'latest',
|
||||||
|
sourceType: 'module',
|
||||||
|
},
|
||||||
|
globals: {
|
||||||
|
console: 'readonly',
|
||||||
|
process: 'readonly',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
'@typescript-eslint': tsPlugin,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
...tsPlugin.configs.recommended.rules,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
4417
Fabric.Backend.Center/package-lock.json
generated
Normal file
4417
Fabric.Backend.Center/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
41
Fabric.Backend.Center/package.json
Normal file
41
Fabric.Backend.Center/package.json
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"name": "fabric-backend-center",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "Fabric Identity Hub (Center service)",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc -p tsconfig.build.json",
|
||||||
|
"start": "node dist/main.js",
|
||||||
|
"start:dev": "ts-node src/main.ts",
|
||||||
|
"lint": "eslint 'src/**/*.ts'",
|
||||||
|
"lint:fix": "eslint 'src/**/*.ts' --fix",
|
||||||
|
"format": "prettier --write 'src/**/*.ts'"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@nestjs/common": "^10.4.8",
|
||||||
|
"@nestjs/core": "^10.4.8",
|
||||||
|
"@nestjs/platform-express": "^10.4.8",
|
||||||
|
"@nestjs/typeorm": "^11.0.1",
|
||||||
|
"bcryptjs": "^3.0.3",
|
||||||
|
"class-transformer": "^0.5.1",
|
||||||
|
"class-validator": "^0.15.1",
|
||||||
|
"jsonwebtoken": "^9.0.3",
|
||||||
|
"mysql2": "^3.22.3",
|
||||||
|
"reflect-metadata": "^0.2.2",
|
||||||
|
"rxjs": "^7.8.1",
|
||||||
|
"typeorm": "^0.3.29"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^10.0.1",
|
||||||
|
"@types/bcryptjs": "^2.4.6",
|
||||||
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
|
"@types/node": "^22.10.1",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^8.59.3",
|
||||||
|
"@typescript-eslint/parser": "^8.59.3",
|
||||||
|
"eslint": "^10.3.0",
|
||||||
|
"eslint-config-prettier": "^10.1.8",
|
||||||
|
"prettier": "^3.8.3",
|
||||||
|
"ts-node": "^10.9.2",
|
||||||
|
"typescript": "^5.7.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
12
Fabric.Backend.Center/src/app.module.ts
Normal file
12
Fabric.Backend.Center/src/app.module.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { buildTypeOrmConfig } from './database.config';
|
||||||
|
import { HealthController } from './common/health.controller';
|
||||||
|
import { AuthModule } from './auth/auth.module';
|
||||||
|
import { NodesModule } from './nodes/nodes.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forRoot(buildTypeOrmConfig()), AuthModule, NodesModule],
|
||||||
|
controllers: [HealthController],
|
||||||
|
})
|
||||||
|
export class AppModule {}
|
||||||
31
Fabric.Backend.Center/src/auth/auth.controller.ts
Normal file
31
Fabric.Backend.Center/src/auth/auth.controller.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { Body, Controller, Post } from '@nestjs/common';
|
||||||
|
import { AuthService } from './auth.service';
|
||||||
|
import { RegisterDto } from './dto.register.dto';
|
||||||
|
import { LoginDto } from './dto.login.dto';
|
||||||
|
import { RefreshDto } from './dto.refresh.dto';
|
||||||
|
import { LogoutDto } from './dto.logout.dto';
|
||||||
|
|
||||||
|
@Controller('auth')
|
||||||
|
export class AuthController {
|
||||||
|
constructor(private readonly authService: AuthService) {}
|
||||||
|
|
||||||
|
@Post('register')
|
||||||
|
register(@Body() body: RegisterDto) {
|
||||||
|
return this.authService.register(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('login')
|
||||||
|
login(@Body() body: LoginDto) {
|
||||||
|
return this.authService.login(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('refresh')
|
||||||
|
refresh(@Body() body: RefreshDto) {
|
||||||
|
return this.authService.refresh(body.refreshToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('logout')
|
||||||
|
logout(@Body() body: LogoutDto) {
|
||||||
|
return this.authService.logout(body.refreshToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
12
Fabric.Backend.Center/src/auth/auth.module.ts
Normal file
12
Fabric.Backend.Center/src/auth/auth.module.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { AuthController } from './auth.controller';
|
||||||
|
import { AuthService } from './auth.service';
|
||||||
|
import { User } from '../entities/user.entity';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forFeature([User])],
|
||||||
|
controllers: [AuthController],
|
||||||
|
providers: [AuthService],
|
||||||
|
})
|
||||||
|
export class AuthModule {}
|
||||||
140
Fabric.Backend.Center/src/auth/auth.service.ts
Normal file
140
Fabric.Backend.Center/src/auth/auth.service.ts
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
import {
|
||||||
|
ConflictException,
|
||||||
|
Injectable,
|
||||||
|
UnauthorizedException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import * as bcrypt from 'bcryptjs';
|
||||||
|
import * as jwt from 'jsonwebtoken';
|
||||||
|
import { User } from '../entities/user.entity';
|
||||||
|
import { RegisterDto } from './dto.register.dto';
|
||||||
|
import { LoginDto } from './dto.login.dto';
|
||||||
|
|
||||||
|
function parseDurationToSeconds(input: string, fallbackSeconds: number): number {
|
||||||
|
const raw = input.trim();
|
||||||
|
if (/^\d+$/.test(raw)) return Number(raw);
|
||||||
|
|
||||||
|
const m = raw.match(/^(\d+)([smhd])$/i);
|
||||||
|
if (!m) return fallbackSeconds;
|
||||||
|
|
||||||
|
const value = Number(m[1]);
|
||||||
|
const unit = m[2].toLowerCase();
|
||||||
|
if (unit === 's') return value;
|
||||||
|
if (unit === 'm') return value * 60;
|
||||||
|
if (unit === 'h') return value * 3600;
|
||||||
|
if (unit === 'd') return value * 86400;
|
||||||
|
return fallbackSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
function signAccessToken(userId: string, email: string): string {
|
||||||
|
const secret = process.env.JWT_ACCESS_SECRET as string;
|
||||||
|
const expiresIn = parseDurationToSeconds(process.env.JWT_ACCESS_EXPIRES_IN ?? '15m', 900);
|
||||||
|
return jwt.sign({ sub: userId, email }, secret, { expiresIn });
|
||||||
|
}
|
||||||
|
|
||||||
|
function signRefreshToken(userId: string, email: string): string {
|
||||||
|
const secret = process.env.JWT_REFRESH_SECRET as string;
|
||||||
|
const expiresIn = parseDurationToSeconds(process.env.JWT_REFRESH_EXPIRES_IN ?? '30d', 2592000);
|
||||||
|
return jwt.sign({ sub: userId, email, typ: 'refresh' }, secret, { expiresIn });
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AuthService {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(User)
|
||||||
|
private readonly userRepo: Repository<User>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async register(input: RegisterDto) {
|
||||||
|
const exists = await this.userRepo.findOne({ where: { email: input.email } });
|
||||||
|
if (exists) {
|
||||||
|
throw new ConflictException('email already exists');
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordHash = await bcrypt.hash(input.password, 10);
|
||||||
|
const user = this.userRepo.create({
|
||||||
|
email: input.email,
|
||||||
|
passwordHash,
|
||||||
|
refreshTokenHash: null,
|
||||||
|
});
|
||||||
|
const saved = await this.userRepo.save(user);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: saved.id,
|
||||||
|
email: saved.email,
|
||||||
|
createdAt: saved.createdAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async login(input: LoginDto) {
|
||||||
|
const user = await this.userRepo.findOne({ where: { email: input.email } });
|
||||||
|
if (!user) throw new UnauthorizedException('invalid credentials');
|
||||||
|
|
||||||
|
const ok = await bcrypt.compare(input.password, user.passwordHash);
|
||||||
|
if (!ok) throw new UnauthorizedException('invalid credentials');
|
||||||
|
|
||||||
|
const accessToken = signAccessToken(user.id, user.email);
|
||||||
|
const refreshToken = signRefreshToken(user.id, user.email);
|
||||||
|
user.refreshTokenHash = await bcrypt.hash(refreshToken, 10);
|
||||||
|
await this.userRepo.save(user);
|
||||||
|
|
||||||
|
return {
|
||||||
|
accessToken,
|
||||||
|
refreshToken,
|
||||||
|
tokenType: 'Bearer',
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async refresh(refreshToken: string) {
|
||||||
|
let payload: jwt.JwtPayload;
|
||||||
|
try {
|
||||||
|
payload = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET as string) as jwt.JwtPayload;
|
||||||
|
} catch {
|
||||||
|
throw new UnauthorizedException('invalid refresh token');
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = String(payload.sub ?? '');
|
||||||
|
const user = await this.userRepo.findOne({ where: { id: userId } });
|
||||||
|
if (!user || !user.refreshTokenHash) {
|
||||||
|
throw new UnauthorizedException('invalid refresh token');
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenOk = await bcrypt.compare(refreshToken, user.refreshTokenHash);
|
||||||
|
if (!tokenOk) throw new UnauthorizedException('invalid refresh token');
|
||||||
|
|
||||||
|
const newAccessToken = signAccessToken(user.id, user.email);
|
||||||
|
const newRefreshToken = signRefreshToken(user.id, user.email);
|
||||||
|
user.refreshTokenHash = await bcrypt.hash(newRefreshToken, 10);
|
||||||
|
await this.userRepo.save(user);
|
||||||
|
|
||||||
|
return {
|
||||||
|
accessToken: newAccessToken,
|
||||||
|
refreshToken: newRefreshToken,
|
||||||
|
tokenType: 'Bearer',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async logout(refreshToken: string) {
|
||||||
|
let payload: jwt.JwtPayload;
|
||||||
|
try {
|
||||||
|
payload = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET as string) as jwt.JwtPayload;
|
||||||
|
} catch {
|
||||||
|
return { status: 'ok' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = String(payload.sub ?? '');
|
||||||
|
if (!userId) return { status: 'ok' };
|
||||||
|
|
||||||
|
const user = await this.userRepo.findOne({ where: { id: userId } });
|
||||||
|
if (!user) return { status: 'ok' };
|
||||||
|
|
||||||
|
user.refreshTokenHash = null;
|
||||||
|
await this.userRepo.save(user);
|
||||||
|
return { status: 'ok' };
|
||||||
|
}
|
||||||
|
}
|
||||||
10
Fabric.Backend.Center/src/auth/dto.login.dto.ts
Normal file
10
Fabric.Backend.Center/src/auth/dto.login.dto.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { IsEmail, IsString, MinLength } from 'class-validator';
|
||||||
|
|
||||||
|
export class LoginDto {
|
||||||
|
@IsEmail()
|
||||||
|
email!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(8)
|
||||||
|
password!: string;
|
||||||
|
}
|
||||||
7
Fabric.Backend.Center/src/auth/dto.logout.dto.ts
Normal file
7
Fabric.Backend.Center/src/auth/dto.logout.dto.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { IsString, MinLength } from 'class-validator';
|
||||||
|
|
||||||
|
export class LogoutDto {
|
||||||
|
@IsString()
|
||||||
|
@MinLength(16)
|
||||||
|
refreshToken!: string;
|
||||||
|
}
|
||||||
7
Fabric.Backend.Center/src/auth/dto.refresh.dto.ts
Normal file
7
Fabric.Backend.Center/src/auth/dto.refresh.dto.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { IsString, MinLength } from 'class-validator';
|
||||||
|
|
||||||
|
export class RefreshDto {
|
||||||
|
@IsString()
|
||||||
|
@MinLength(16)
|
||||||
|
refreshToken!: string;
|
||||||
|
}
|
||||||
10
Fabric.Backend.Center/src/auth/dto.register.dto.ts
Normal file
10
Fabric.Backend.Center/src/auth/dto.register.dto.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { IsEmail, IsString, MinLength } from 'class-validator';
|
||||||
|
|
||||||
|
export class RegisterDto {
|
||||||
|
@IsEmail()
|
||||||
|
email!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(8)
|
||||||
|
password!: string;
|
||||||
|
}
|
||||||
9
Fabric.Backend.Center/src/common/health.controller.ts
Normal file
9
Fabric.Backend.Center/src/common/health.controller.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { Controller, Get } from '@nestjs/common';
|
||||||
|
|
||||||
|
@Controller('healthz')
|
||||||
|
export class HealthController {
|
||||||
|
@Get()
|
||||||
|
get() {
|
||||||
|
return { ok: true, service: 'center' };
|
||||||
|
}
|
||||||
|
}
|
||||||
15
Fabric.Backend.Center/src/database.config.ts
Normal file
15
Fabric.Backend.Center/src/database.config.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { TypeOrmModuleOptions } from '@nestjs/typeorm';
|
||||||
|
import { User } from './entities/user.entity';
|
||||||
|
import { GuildNode } from './entities/guild-node.entity';
|
||||||
|
|
||||||
|
export const buildTypeOrmConfig = (): TypeOrmModuleOptions => ({
|
||||||
|
type: 'mysql',
|
||||||
|
host: process.env.DB_HOST ?? 'mysql-center',
|
||||||
|
port: Number(process.env.DB_PORT ?? 3306),
|
||||||
|
username: process.env.DB_USER ?? 'fabric',
|
||||||
|
password: process.env.DB_PASSWORD ?? 'fabric',
|
||||||
|
database: process.env.DB_NAME ?? 'fabric_center',
|
||||||
|
entities: [User, GuildNode],
|
||||||
|
synchronize: (process.env.DB_SYNC ?? 'true') === 'true',
|
||||||
|
logging: (process.env.DB_LOGGING ?? 'false') === 'true',
|
||||||
|
});
|
||||||
26
Fabric.Backend.Center/src/entities/guild-node.entity.ts
Normal file
26
Fabric.Backend.Center/src/entities/guild-node.entity.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn } from 'typeorm';
|
||||||
|
|
||||||
|
@Entity('guild_nodes')
|
||||||
|
export class GuildNode {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@Column({ unique: true })
|
||||||
|
nodeId!: string;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
endpoint!: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'enum',
|
||||||
|
enum: ['active', 'offline', 'revoked'],
|
||||||
|
default: 'active',
|
||||||
|
})
|
||||||
|
status!: 'active' | 'offline' | 'revoked';
|
||||||
|
|
||||||
|
@CreateDateColumn()
|
||||||
|
createdAt!: Date;
|
||||||
|
}
|
||||||
19
Fabric.Backend.Center/src/entities/user.entity.ts
Normal file
19
Fabric.Backend.Center/src/entities/user.entity.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn } from 'typeorm';
|
||||||
|
|
||||||
|
@Entity('users')
|
||||||
|
export class User {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@Column({ unique: true })
|
||||||
|
email!: string;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
passwordHash!: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 255, nullable: true })
|
||||||
|
refreshTokenHash!: string | null;
|
||||||
|
|
||||||
|
@CreateDateColumn()
|
||||||
|
createdAt!: Date;
|
||||||
|
}
|
||||||
42
Fabric.Backend.Center/src/main.ts
Normal file
42
Fabric.Backend.Center/src/main.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import 'reflect-metadata';
|
||||||
|
import { ValidationPipe } from '@nestjs/common';
|
||||||
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
import { AppModule } from './app.module';
|
||||||
|
|
||||||
|
function requireEnv(name: string): string {
|
||||||
|
const value = process.env[name];
|
||||||
|
if (!value || value.trim() === '') {
|
||||||
|
throw new Error(`Missing required env: ${name}`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateEnv(): void {
|
||||||
|
requireEnv('DB_HOST');
|
||||||
|
requireEnv('DB_PORT');
|
||||||
|
requireEnv('DB_USER');
|
||||||
|
requireEnv('DB_PASSWORD');
|
||||||
|
requireEnv('DB_NAME');
|
||||||
|
requireEnv('CENTER_SHARED_SECRET');
|
||||||
|
requireEnv('JWT_ACCESS_SECRET');
|
||||||
|
requireEnv('JWT_REFRESH_SECRET');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bootstrap() {
|
||||||
|
validateEnv();
|
||||||
|
|
||||||
|
const app = await NestFactory.create(AppModule);
|
||||||
|
app.setGlobalPrefix('api');
|
||||||
|
app.useGlobalPipes(
|
||||||
|
new ValidationPipe({
|
||||||
|
whitelist: true,
|
||||||
|
forbidNonWhitelisted: true,
|
||||||
|
transform: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const port = process.env.PORT ? Number(process.env.PORT) : 7001;
|
||||||
|
await app.listen(port);
|
||||||
|
console.log(`Fabric.Backend.Center listening on :${port}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
void bootstrap();
|
||||||
18
Fabric.Backend.Center/src/nodes/dto.register-node.dto.ts
Normal file
18
Fabric.Backend.Center/src/nodes/dto.register-node.dto.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { IsString, IsUrl, MinLength } from 'class-validator';
|
||||||
|
|
||||||
|
export class RegisterNodeDto {
|
||||||
|
@IsString()
|
||||||
|
@MinLength(3)
|
||||||
|
nodeId!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(2)
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@IsUrl({ require_tld: false })
|
||||||
|
endpoint!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(8)
|
||||||
|
sharedSecret!: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { IsIn } from 'class-validator';
|
||||||
|
|
||||||
|
export class UpdateNodeStatusDto {
|
||||||
|
@IsIn(['active', 'offline', 'revoked'])
|
||||||
|
status!: 'active' | 'offline' | 'revoked';
|
||||||
|
}
|
||||||
111
Fabric.Backend.Center/src/nodes/nodes.controller.ts
Normal file
111
Fabric.Backend.Center/src/nodes/nodes.controller.ts
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
ConflictException,
|
||||||
|
Controller,
|
||||||
|
DefaultValuePipe,
|
||||||
|
ForbiddenException,
|
||||||
|
Get,
|
||||||
|
NotFoundException,
|
||||||
|
Param,
|
||||||
|
ParseIntPipe,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { GuildNode } from '../entities/guild-node.entity';
|
||||||
|
import { RegisterNodeDto } from './dto.register-node.dto';
|
||||||
|
import { UpdateNodeStatusDto } from './dto.update-node-status.dto';
|
||||||
|
|
||||||
|
@Controller('nodes')
|
||||||
|
export class NodesController {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(GuildNode)
|
||||||
|
private readonly nodeRepo: Repository<GuildNode>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Post('register')
|
||||||
|
async register(@Body() body: RegisterNodeDto) {
|
||||||
|
if (body.sharedSecret !== process.env.CENTER_SHARED_SECRET) {
|
||||||
|
throw new ForbiddenException('invalid shared secret');
|
||||||
|
}
|
||||||
|
|
||||||
|
const existedByNodeId = await this.nodeRepo.findOne({
|
||||||
|
where: { nodeId: body.nodeId },
|
||||||
|
});
|
||||||
|
if (existedByNodeId) {
|
||||||
|
throw new ConflictException('nodeId already exists');
|
||||||
|
}
|
||||||
|
|
||||||
|
const existedByEndpoint = await this.nodeRepo.findOne({
|
||||||
|
where: { endpoint: body.endpoint },
|
||||||
|
});
|
||||||
|
if (existedByEndpoint) {
|
||||||
|
throw new ConflictException('endpoint already exists');
|
||||||
|
}
|
||||||
|
|
||||||
|
const node = this.nodeRepo.create({
|
||||||
|
nodeId: body.nodeId,
|
||||||
|
name: body.name,
|
||||||
|
endpoint: body.endpoint,
|
||||||
|
status: 'active',
|
||||||
|
});
|
||||||
|
const saved = await this.nodeRepo.save(node);
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 'accepted',
|
||||||
|
node: {
|
||||||
|
id: saved.id,
|
||||||
|
nodeId: saved.nodeId,
|
||||||
|
name: saved.name,
|
||||||
|
endpoint: saved.endpoint,
|
||||||
|
status: saved.status,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':nodeId/status')
|
||||||
|
async updateStatus(
|
||||||
|
@Param('nodeId') nodeId: string,
|
||||||
|
@Body() body: UpdateNodeStatusDto,
|
||||||
|
) {
|
||||||
|
const node = await this.nodeRepo.findOne({ where: { nodeId } });
|
||||||
|
if (!node) {
|
||||||
|
throw new NotFoundException('node not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
node.status = body.status;
|
||||||
|
const saved = await this.nodeRepo.save(node);
|
||||||
|
return {
|
||||||
|
id: saved.id,
|
||||||
|
nodeId: saved.nodeId,
|
||||||
|
name: saved.name,
|
||||||
|
endpoint: saved.endpoint,
|
||||||
|
status: saved.status,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
async list(
|
||||||
|
@Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
|
||||||
|
@Query('pageSize', new DefaultValuePipe(20), ParseIntPipe) pageSize: number,
|
||||||
|
) {
|
||||||
|
const safePage = page < 1 ? 1 : page;
|
||||||
|
const safePageSize = pageSize < 1 ? 20 : Math.min(pageSize, 100);
|
||||||
|
|
||||||
|
const [items, total] = await this.nodeRepo.findAndCount({
|
||||||
|
order: { createdAt: 'DESC' },
|
||||||
|
skip: (safePage - 1) * safePageSize,
|
||||||
|
take: safePageSize,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
page: safePage,
|
||||||
|
pageSize: safePageSize,
|
||||||
|
total,
|
||||||
|
totalPages: Math.max(1, Math.ceil(total / safePageSize)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
10
Fabric.Backend.Center/src/nodes/nodes.module.ts
Normal file
10
Fabric.Backend.Center/src/nodes/nodes.module.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { NodesController } from './nodes.controller';
|
||||||
|
import { GuildNode } from '../entities/guild-node.entity';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forFeature([GuildNode])],
|
||||||
|
controllers: [NodesController],
|
||||||
|
})
|
||||||
|
export class NodesModule {}
|
||||||
7
Fabric.Backend.Center/tsconfig.build.json
Normal file
7
Fabric.Backend.Center/tsconfig.build.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"declaration": false
|
||||||
|
},
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
15
Fabric.Backend.Center/tsconfig.json
Normal file
15
Fabric.Backend.Center/tsconfig.json
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "commonjs",
|
||||||
|
"target": "es2020",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"emitDecoratorMetadata": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"outDir": "./dist",
|
||||||
|
"baseUrl": "./",
|
||||||
|
"skipLibCheck": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"]
|
||||||
|
}
|
||||||
20
Fabric.Backend.Guild/.env.example
Normal file
20
Fabric.Backend.Guild/.env.example
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# Server
|
||||||
|
PORT=7002
|
||||||
|
|
||||||
|
# MySQL
|
||||||
|
DB_HOST=mysql-guild
|
||||||
|
DB_PORT=3306
|
||||||
|
DB_USER=fabric
|
||||||
|
DB_PASSWORD=fabric
|
||||||
|
DB_NAME=fabric_guild
|
||||||
|
DB_SYNC=true
|
||||||
|
DB_LOGGING=false
|
||||||
|
|
||||||
|
# Guild identity
|
||||||
|
GUILD_NODE_ID=guild-node-1
|
||||||
|
GUILD_NODE_NAME=Guild Node 1
|
||||||
|
GUILD_PUBLIC_ENDPOINT=http://localhost:7002
|
||||||
|
|
||||||
|
# Center handshake
|
||||||
|
CENTER_BASE_URL=http://backend-center:7001
|
||||||
|
CENTER_SHARED_SECRET=change-me-center-secret
|
||||||
2
Fabric.Backend.Guild/.gitignore
vendored
Normal file
2
Fabric.Backend.Guild/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
19
Fabric.Backend.Guild/Dockerfile
Normal file
19
Fabric.Backend.Guild/Dockerfile
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
FROM node:22-alpine AS deps
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
FROM node:22-alpine AS build
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM node:22-alpine AS runtime
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci --omit=dev
|
||||||
|
COPY --from=build /app/dist ./dist
|
||||||
|
EXPOSE 7002
|
||||||
|
CMD ["node", "dist/main.js"]
|
||||||
15
Fabric.Backend.Guild/README.md
Normal file
15
Fabric.Backend.Guild/README.md
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# Fabric.Backend.Guild
|
||||||
|
|
||||||
|
Guild Node service for Fabric.
|
||||||
|
|
||||||
|
## Scope (MVP)
|
||||||
|
- Workspace/Guild/Channel/DM
|
||||||
|
- Message create/edit/delete/reply/@mention
|
||||||
|
- Per-channel/DM seq ordering + gap backfill API
|
||||||
|
- Webhook/Bot integration surface
|
||||||
|
- Guild-level audit logs
|
||||||
|
|
||||||
|
## Next
|
||||||
|
- API skeleton (NestJS)
|
||||||
|
- Chat domain models
|
||||||
|
- Seq allocator and range query endpoints
|
||||||
28
Fabric.Backend.Guild/eslint.config.mjs
Normal file
28
Fabric.Backend.Guild/eslint.config.mjs
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import tsParser from '@typescript-eslint/parser';
|
||||||
|
import tsPlugin from '@typescript-eslint/eslint-plugin';
|
||||||
|
|
||||||
|
export default [
|
||||||
|
{
|
||||||
|
ignores: ['dist/**', 'node_modules/**'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ['src/**/*.ts'],
|
||||||
|
languageOptions: {
|
||||||
|
parser: tsParser,
|
||||||
|
parserOptions: {
|
||||||
|
ecmaVersion: 'latest',
|
||||||
|
sourceType: 'module',
|
||||||
|
},
|
||||||
|
globals: {
|
||||||
|
console: 'readonly',
|
||||||
|
process: 'readonly',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
'@typescript-eslint': tsPlugin,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
...tsPlugin.configs.recommended.rules,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
4234
Fabric.Backend.Guild/package-lock.json
generated
Normal file
4234
Fabric.Backend.Guild/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
35
Fabric.Backend.Guild/package.json
Normal file
35
Fabric.Backend.Guild/package.json
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"name": "fabric-backend-guild",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "Fabric Guild Node service",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc -p tsconfig.build.json",
|
||||||
|
"start": "node dist/main.js",
|
||||||
|
"start:dev": "ts-node src/main.ts",
|
||||||
|
"lint": "eslint 'src/**/*.ts'",
|
||||||
|
"lint:fix": "eslint 'src/**/*.ts' --fix",
|
||||||
|
"format": "prettier --write 'src/**/*.ts'"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@nestjs/common": "^10.4.8",
|
||||||
|
"@nestjs/core": "^10.4.8",
|
||||||
|
"@nestjs/platform-express": "^10.4.8",
|
||||||
|
"@nestjs/typeorm": "^11.0.1",
|
||||||
|
"mysql2": "^3.22.3",
|
||||||
|
"reflect-metadata": "^0.2.2",
|
||||||
|
"rxjs": "^7.8.1",
|
||||||
|
"typeorm": "^0.3.29"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^10.0.1",
|
||||||
|
"@types/node": "^22.10.1",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^8.59.3",
|
||||||
|
"@typescript-eslint/parser": "^8.59.3",
|
||||||
|
"eslint": "^10.3.0",
|
||||||
|
"eslint-config-prettier": "^10.1.8",
|
||||||
|
"prettier": "^3.8.3",
|
||||||
|
"ts-node": "^10.9.2",
|
||||||
|
"typescript": "^5.7.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
13
Fabric.Backend.Guild/src/app.module.ts
Normal file
13
Fabric.Backend.Guild/src/app.module.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { buildTypeOrmConfig } from './database.config';
|
||||||
|
import { HealthController } from './common/health.controller';
|
||||||
|
import { GuildsModule } from './guilds/guilds.module';
|
||||||
|
import { ChannelsModule } from './channels/channels.module';
|
||||||
|
import { MessagingModule } from './messaging/messaging.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forRoot(buildTypeOrmConfig()), GuildsModule, ChannelsModule, MessagingModule],
|
||||||
|
controllers: [HealthController],
|
||||||
|
})
|
||||||
|
export class AppModule {}
|
||||||
9
Fabric.Backend.Guild/src/channels/channels.controller.ts
Normal file
9
Fabric.Backend.Guild/src/channels/channels.controller.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { Body, Controller, Post } from '@nestjs/common';
|
||||||
|
|
||||||
|
@Controller('channels')
|
||||||
|
export class ChannelsController {
|
||||||
|
@Post()
|
||||||
|
create(@Body() body: Record<string, unknown>) {
|
||||||
|
return { status: 'todo', action: 'create-channel', received: body };
|
||||||
|
}
|
||||||
|
}
|
||||||
7
Fabric.Backend.Guild/src/channels/channels.module.ts
Normal file
7
Fabric.Backend.Guild/src/channels/channels.module.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ChannelsController } from './channels.controller';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [ChannelsController],
|
||||||
|
})
|
||||||
|
export class ChannelsModule {}
|
||||||
9
Fabric.Backend.Guild/src/common/health.controller.ts
Normal file
9
Fabric.Backend.Guild/src/common/health.controller.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { Controller, Get } from '@nestjs/common';
|
||||||
|
|
||||||
|
@Controller('healthz')
|
||||||
|
export class HealthController {
|
||||||
|
@Get()
|
||||||
|
get() {
|
||||||
|
return { ok: true, service: 'guild' };
|
||||||
|
}
|
||||||
|
}
|
||||||
16
Fabric.Backend.Guild/src/database.config.ts
Normal file
16
Fabric.Backend.Guild/src/database.config.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { TypeOrmModuleOptions } from '@nestjs/typeorm';
|
||||||
|
import { Guild } from './entities/guild.entity';
|
||||||
|
import { Channel } from './entities/channel.entity';
|
||||||
|
import { Message } from './entities/message.entity';
|
||||||
|
|
||||||
|
export const buildTypeOrmConfig = (): TypeOrmModuleOptions => ({
|
||||||
|
type: 'mysql',
|
||||||
|
host: process.env.DB_HOST ?? 'mysql-guild',
|
||||||
|
port: Number(process.env.DB_PORT ?? 3306),
|
||||||
|
username: process.env.DB_USER ?? 'fabric',
|
||||||
|
password: process.env.DB_PASSWORD ?? 'fabric',
|
||||||
|
database: process.env.DB_NAME ?? 'fabric_guild',
|
||||||
|
entities: [Guild, Channel, Message],
|
||||||
|
synchronize: (process.env.DB_SYNC ?? 'true') === 'true',
|
||||||
|
logging: (process.env.DB_LOGGING ?? 'false') === 'true',
|
||||||
|
});
|
||||||
20
Fabric.Backend.Guild/src/entities/channel.entity.ts
Normal file
20
Fabric.Backend.Guild/src/entities/channel.entity.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { Column, CreateDateColumn, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
|
||||||
|
|
||||||
|
@Entity('channels')
|
||||||
|
export class Channel {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
guildId!: string;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ default: 0 })
|
||||||
|
lastSeq!: number;
|
||||||
|
|
||||||
|
@CreateDateColumn()
|
||||||
|
createdAt!: Date;
|
||||||
|
}
|
||||||
13
Fabric.Backend.Guild/src/entities/guild.entity.ts
Normal file
13
Fabric.Backend.Guild/src/entities/guild.entity.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn } from 'typeorm';
|
||||||
|
|
||||||
|
@Entity('guilds')
|
||||||
|
export class Guild {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@CreateDateColumn()
|
||||||
|
createdAt!: Date;
|
||||||
|
}
|
||||||
21
Fabric.Backend.Guild/src/entities/message.entity.ts
Normal file
21
Fabric.Backend.Guild/src/entities/message.entity.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { Column, CreateDateColumn, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
|
||||||
|
|
||||||
|
@Entity('messages')
|
||||||
|
@Index(['channelId', 'seq'], { unique: true })
|
||||||
|
export class Message {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column()
|
||||||
|
channelId!: string;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
seq!: number;
|
||||||
|
|
||||||
|
@Column({ type: 'text' })
|
||||||
|
content!: string;
|
||||||
|
|
||||||
|
@CreateDateColumn()
|
||||||
|
createdAt!: Date;
|
||||||
|
}
|
||||||
9
Fabric.Backend.Guild/src/guilds/guilds.controller.ts
Normal file
9
Fabric.Backend.Guild/src/guilds/guilds.controller.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { Body, Controller, Post } from '@nestjs/common';
|
||||||
|
|
||||||
|
@Controller('guilds')
|
||||||
|
export class GuildsController {
|
||||||
|
@Post()
|
||||||
|
create(@Body() body: Record<string, unknown>) {
|
||||||
|
return { status: 'todo', action: 'create-guild', received: body };
|
||||||
|
}
|
||||||
|
}
|
||||||
7
Fabric.Backend.Guild/src/guilds/guilds.module.ts
Normal file
7
Fabric.Backend.Guild/src/guilds/guilds.module.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { GuildsController } from './guilds.controller';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [GuildsController],
|
||||||
|
})
|
||||||
|
export class GuildsModule {}
|
||||||
13
Fabric.Backend.Guild/src/main.ts
Normal file
13
Fabric.Backend.Guild/src/main.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import 'reflect-metadata';
|
||||||
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
import { AppModule } from './app.module';
|
||||||
|
|
||||||
|
async function bootstrap() {
|
||||||
|
const app = await NestFactory.create(AppModule);
|
||||||
|
app.setGlobalPrefix('api');
|
||||||
|
const port = process.env.PORT ? Number(process.env.PORT) : 7002;
|
||||||
|
await app.listen(port);
|
||||||
|
console.log(`Fabric.Backend.Guild listening on :${port}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
void bootstrap();
|
||||||
62
Fabric.Backend.Guild/src/messaging/messaging.controller.ts
Normal file
62
Fabric.Backend.Guild/src/messaging/messaging.controller.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { Body, Controller, Delete, Get, Param, Patch, Post, Query } from '@nestjs/common';
|
||||||
|
|
||||||
|
type Message = {
|
||||||
|
messageId: string;
|
||||||
|
seq: number;
|
||||||
|
content: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
@Controller('channels/:id/messages')
|
||||||
|
export class MessagingController {
|
||||||
|
private seqByChannel = new Map<string, number>();
|
||||||
|
private messagesByChannel = new Map<string, Message[]>();
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
create(@Param('id') channelId: string, @Body() body: { content?: string; messageId?: string }) {
|
||||||
|
const next = (this.seqByChannel.get(channelId) ?? 0) + 1;
|
||||||
|
this.seqByChannel.set(channelId, next);
|
||||||
|
|
||||||
|
const message: Message = {
|
||||||
|
messageId: body.messageId ?? `m-${channelId}-${next}`,
|
||||||
|
seq: next,
|
||||||
|
content: body.content ?? '',
|
||||||
|
};
|
||||||
|
|
||||||
|
const arr = this.messagesByChannel.get(channelId) ?? [];
|
||||||
|
arr.push(message);
|
||||||
|
this.messagesByChannel.set(channelId, arr);
|
||||||
|
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':messageId')
|
||||||
|
edit(@Param('id') channelId: string, @Param('messageId') messageId: string, @Body() body: { content?: string }) {
|
||||||
|
const arr = this.messagesByChannel.get(channelId) ?? [];
|
||||||
|
const item = arr.find((m) => m.messageId === messageId);
|
||||||
|
if (!item) return { status: 'not_found' };
|
||||||
|
item.content = body.content ?? item.content;
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':messageId')
|
||||||
|
remove(@Param('id') channelId: string, @Param('messageId') messageId: string) {
|
||||||
|
const arr = this.messagesByChannel.get(channelId) ?? [];
|
||||||
|
const next = arr.filter((m) => m.messageId !== messageId);
|
||||||
|
this.messagesByChannel.set(channelId, next);
|
||||||
|
return { status: 'deleted', messageId };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
listBySeq(
|
||||||
|
@Param('id') channelId: string,
|
||||||
|
@Query('seq_from') seqFrom?: string,
|
||||||
|
@Query('seq_to') seqTo?: string,
|
||||||
|
) {
|
||||||
|
const from = seqFrom ? Number(seqFrom) : 1;
|
||||||
|
const to = seqTo ? Number(seqTo) : Number.MAX_SAFE_INTEGER;
|
||||||
|
const arr = this.messagesByChannel.get(channelId) ?? [];
|
||||||
|
return {
|
||||||
|
items: arr.filter((m) => m.seq >= from && m.seq <= to),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
7
Fabric.Backend.Guild/src/messaging/messaging.module.ts
Normal file
7
Fabric.Backend.Guild/src/messaging/messaging.module.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { MessagingController } from './messaging.controller';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [MessagingController],
|
||||||
|
})
|
||||||
|
export class MessagingModule {}
|
||||||
7
Fabric.Backend.Guild/tsconfig.build.json
Normal file
7
Fabric.Backend.Guild/tsconfig.build.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"declaration": false
|
||||||
|
},
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
15
Fabric.Backend.Guild/tsconfig.json
Normal file
15
Fabric.Backend.Guild/tsconfig.json
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "commonjs",
|
||||||
|
"target": "es2020",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"emitDecoratorMetadata": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"outDir": "./dist",
|
||||||
|
"baseUrl": "./",
|
||||||
|
"skipLibCheck": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"]
|
||||||
|
}
|
||||||
18
PLAN.md
18
PLAN.md
@@ -55,11 +55,15 @@
|
|||||||
- Guild 服务注册时需证明持有中心服务 secret(共享密钥握手)
|
- Guild 服务注册时需证明持有中心服务 secret(共享密钥握手)
|
||||||
|
|
||||||
## 5. 模块拆分(对应仓库)
|
## 5. 模块拆分(对应仓库)
|
||||||
- `Fabric.Backend`
|
- `Fabric.Backend.Center`
|
||||||
- Auth/Workspace(Guild)
|
- 用户身份与登录(Identity Hub)
|
||||||
- Chat Core(Channel、DM、消息;不含 Thread)
|
- Guild Node 注册与鉴权(共享密钥握手)
|
||||||
|
- 中心级配置与审计
|
||||||
|
- `Fabric.Backend.Guild`
|
||||||
|
- Workspace/Guild/Channel/DM
|
||||||
|
- Chat Core(消息、回复、编辑、删除、@;不含 Thread)
|
||||||
- Integration Surface(Webhook、Bot Token、扩展回调)
|
- Integration Surface(Webhook、Bot Token、扩展回调)
|
||||||
- Permission & Audit
|
- Guild 级权限与审计
|
||||||
- `Fabric.Frontend`
|
- `Fabric.Frontend`
|
||||||
- 工作区/频道 UI
|
- 工作区/频道 UI
|
||||||
- 消息流、输入框、回复/编辑/删除/@
|
- 消息流、输入框、回复/编辑/删除/@
|
||||||
@@ -81,7 +85,8 @@
|
|||||||
- 架构图、数据模型、接口草案
|
- 架构图、数据模型、接口草案
|
||||||
|
|
||||||
### Week 2:基础业务 API
|
### Week 2:基础业务 API
|
||||||
- 登录注册、工作区、频道、消息 REST API
|
- Center:登录注册、Guild Node 注册鉴权 API
|
||||||
|
- Guild:工作区、频道、消息 REST API
|
||||||
- 基础前端页面(频道列表 + 消息流)
|
- 基础前端页面(频道列表 + 消息流)
|
||||||
|
|
||||||
### Week 3:实时通信
|
### Week 3:实时通信
|
||||||
@@ -108,7 +113,8 @@
|
|||||||
- `Fabric`(主仓库)
|
- `Fabric`(主仓库)
|
||||||
- 挂载子模块:
|
- 挂载子模块:
|
||||||
- `Fabric.OpenclawPlugin`
|
- `Fabric.OpenclawPlugin`
|
||||||
- `Fabric.Backend`
|
- `Fabric.Backend.Center`
|
||||||
|
- `Fabric.Backend.Guild`
|
||||||
- `Fabric.Frontend`
|
- `Fabric.Frontend`
|
||||||
- `Fabric.Desktop`
|
- `Fabric.Desktop`
|
||||||
- `Fabric.Android`
|
- `Fabric.Android`
|
||||||
|
|||||||
86
docker-compose.yml
Normal file
86
docker-compose.yml
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
services:
|
||||||
|
mysql-center:
|
||||||
|
image: mysql:8.4
|
||||||
|
container_name: fabric-mysql-center
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
MYSQL_ROOT_PASSWORD: root
|
||||||
|
MYSQL_DATABASE: fabric_center
|
||||||
|
MYSQL_USER: fabric
|
||||||
|
MYSQL_PASSWORD: fabric
|
||||||
|
command: ["--default-authentication-plugin=mysql_native_password"]
|
||||||
|
ports:
|
||||||
|
- "3307:3306"
|
||||||
|
volumes:
|
||||||
|
- mysql_center_data:/var/lib/mysql
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-uroot", "-proot"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 12
|
||||||
|
|
||||||
|
mysql-guild:
|
||||||
|
image: mysql:8.4
|
||||||
|
container_name: fabric-mysql-guild
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
MYSQL_ROOT_PASSWORD: root
|
||||||
|
MYSQL_DATABASE: fabric_guild
|
||||||
|
MYSQL_USER: fabric
|
||||||
|
MYSQL_PASSWORD: fabric
|
||||||
|
command: ["--default-authentication-plugin=mysql_native_password"]
|
||||||
|
ports:
|
||||||
|
- "3308:3306"
|
||||||
|
volumes:
|
||||||
|
- mysql_guild_data:/var/lib/mysql
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-uroot", "-proot"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 12
|
||||||
|
|
||||||
|
backend-center:
|
||||||
|
build:
|
||||||
|
context: ./Fabric.Backend.Center
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: fabric-backend-center
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
mysql-center:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
PORT: 7001
|
||||||
|
DB_HOST: mysql-center
|
||||||
|
DB_PORT: 3306
|
||||||
|
DB_USER: fabric
|
||||||
|
DB_PASSWORD: fabric
|
||||||
|
DB_NAME: fabric_center
|
||||||
|
DB_SYNC: "true"
|
||||||
|
DB_LOGGING: "false"
|
||||||
|
ports:
|
||||||
|
- "7001:7001"
|
||||||
|
|
||||||
|
backend-guild:
|
||||||
|
build:
|
||||||
|
context: ./Fabric.Backend.Guild
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: fabric-backend-guild
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
mysql-guild:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
PORT: 7002
|
||||||
|
DB_HOST: mysql-guild
|
||||||
|
DB_PORT: 3306
|
||||||
|
DB_USER: fabric
|
||||||
|
DB_PASSWORD: fabric
|
||||||
|
DB_NAME: fabric_guild
|
||||||
|
DB_SYNC: "true"
|
||||||
|
DB_LOGGING: "false"
|
||||||
|
ports:
|
||||||
|
- "7002:7002"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
mysql_center_data:
|
||||||
|
mysql_guild_data:
|
||||||
120
docs/TODO-backend-center-guild.md
Normal file
120
docs/TODO-backend-center-guild.md
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
# TODO - Backend Center/Guild 开发任务分解
|
||||||
|
|
||||||
|
## 0. 基础工程(本周)
|
||||||
|
- [x] Backend 拆分:Center / Guild
|
||||||
|
- [x] NestJS skeleton 初始化(Center/Guild)
|
||||||
|
- [x] MySQL + TypeORM 接入
|
||||||
|
- [x] Dockerfile(Center/Guild)
|
||||||
|
- [x] docker-compose(center/guild + mysql)
|
||||||
|
- [x] 增加 `.env.example`(Center/Guild)
|
||||||
|
- [x] 增加统一 lint/format 配置(eslint + prettier)
|
||||||
|
- [x] 增加基础 CI(build + lint)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Fabric.Backend.Center(Identity Hub)
|
||||||
|
|
||||||
|
### 1.1 Auth
|
||||||
|
- [x] 用户注册(email/password)
|
||||||
|
- [x] 用户登录(JWT access + refresh)
|
||||||
|
- [x] token 刷新
|
||||||
|
- [x] 登出(refresh token 失效)
|
||||||
|
- [x] 密码哈希(bcrypt/argon2)
|
||||||
|
- [x] DTO + 参数校验 + 错误码规范
|
||||||
|
|
||||||
|
### 1.2 Guild Node 注册与握手
|
||||||
|
- [x] `POST /nodes/register` shared-secret 校验
|
||||||
|
- [x] node 唯一性校验(nodeId/endpoint)
|
||||||
|
- [x] node 状态模型(active/offline/revoked)
|
||||||
|
- [x] `GET /nodes` 列表 + 分页
|
||||||
|
- [ ] node 心跳接口(可选)
|
||||||
|
|
||||||
|
### 1.3 Center 运维能力
|
||||||
|
- [ ] 审计日志(auth/node 关键操作)
|
||||||
|
- [ ] 健康检查深化(DB ready)
|
||||||
|
- [x] 配置校验(启动时必填项检查)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Fabric.Backend.Guild(Guild Node)
|
||||||
|
|
||||||
|
### 2.1 领域模型
|
||||||
|
- [ ] Guild/Channel/DM 实体补全
|
||||||
|
- [ ] Member/Role 基础模型(即使 MVP 权限全开,也先留结构)
|
||||||
|
- [ ] 索引设计(channel_id + seq, created_at 等)
|
||||||
|
|
||||||
|
### 2.2 消息主链路
|
||||||
|
- [ ] 发送消息(content/reply/mentions/attachments 元数据)
|
||||||
|
- [ ] 编辑消息(可编辑窗口策略先简化)
|
||||||
|
- [ ] 删除消息(软删 vs 硬删,先定策略)
|
||||||
|
- [ ] `GET messages` 分页(seq 区间 + limit)
|
||||||
|
- [ ] seq 分配改为 DB 原子方案(避免并发冲突)
|
||||||
|
|
||||||
|
### 2.3 一致性与回补
|
||||||
|
- [ ] 回补接口:`seq_from/seq_to`
|
||||||
|
- [ ] 断片检测辅助响应字段(next_expected_seq 等)
|
||||||
|
- [ ] 幂等键支持(写接口)
|
||||||
|
|
||||||
|
### 2.4 实时通信(MVP 后半)
|
||||||
|
- [ ] WebSocket 网关接入
|
||||||
|
- [ ] message.created/updated/deleted 事件广播
|
||||||
|
- [ ] 在线状态 + typing 事件
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Center ↔ Guild 协议层
|
||||||
|
- [ ] 鉴权方案定稿(node token / HMAC)
|
||||||
|
- [ ] 注册握手协议文档化
|
||||||
|
- [ ] 错误码与重试策略统一
|
||||||
|
- [ ] 版本协商(`X-Fabric-Version`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 插件与扩展面(为 OpenclawPlugin 准备)
|
||||||
|
- [ ] Webhook 事件信封落地(event_id/event_type/occurred_at/data)
|
||||||
|
- [ ] HMAC 签名与重放防护
|
||||||
|
- [ ] 出站重试队列(指数退避)
|
||||||
|
- [ ] Bot Token 入站调用鉴权
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 测试与质量门禁
|
||||||
|
|
||||||
|
### 5.1 自动化测试
|
||||||
|
- [ ] 单元测试(auth/service/message/seq)
|
||||||
|
- [ ] 集成测试(MySQL + API)
|
||||||
|
- [ ] 合约测试(Center-Guild 协议)
|
||||||
|
|
||||||
|
### 5.2 质量门禁
|
||||||
|
- [ ] lint/typecheck/build 全绿
|
||||||
|
- [ ] API 文档(OpenAPI/Swagger)
|
||||||
|
- [ ] 关键链路压测(发送/拉取/回补)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 部署与运维
|
||||||
|
- [ ] `docker-compose.prod.yml`(去掉 `DB_SYNC=true`)
|
||||||
|
- [ ] DB migration 机制(TypeORM migration)
|
||||||
|
- [ ] 结构化日志 + request id
|
||||||
|
- [ ] 基础监控指标(QPS、延迟、错误率)
|
||||||
|
- [ ] 备份与恢复流程文档
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 推荐执行顺序(建议)
|
||||||
|
1. Center Auth 完整闭环
|
||||||
|
2. Guild 消息链路 + DB 原子 seq
|
||||||
|
3. Center-Guild 握手协议固定
|
||||||
|
4. WebSocket 实时层
|
||||||
|
5. 插件扩展面 + 回调重试
|
||||||
|
6. 测试、压测、发布文档
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Definition of Done(MVP)
|
||||||
|
- [ ] 用户可注册登录
|
||||||
|
- [ ] Guild/Channel/DM 可创建并发消息
|
||||||
|
- [ ] 消息 seq 连续可回补
|
||||||
|
- [ ] WebSocket 可实时收发
|
||||||
|
- [ ] 插件可通过 Bot Token 写入消息并接收 webhook
|
||||||
|
- [ ] docker-compose 一键部署可用
|
||||||
42
docs/backend-split-mvp.md
Normal file
42
docs/backend-split-mvp.md
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# Backend Split MVP 开发清单
|
||||||
|
|
||||||
|
## Fabric.Backend.Center
|
||||||
|
|
||||||
|
### Phase 1
|
||||||
|
- [ ] 初始化 NestJS 项目骨架
|
||||||
|
- [ ] Auth 模块(register/login/refresh/logout)
|
||||||
|
- [ ] Guild Node 注册接口(shared-secret handshake)
|
||||||
|
- [ ] Node 列表与状态查询接口
|
||||||
|
|
||||||
|
### 建议 API
|
||||||
|
- `POST /auth/register`
|
||||||
|
- `POST /auth/login`
|
||||||
|
- `POST /auth/refresh`
|
||||||
|
- `POST /nodes/register`
|
||||||
|
- `GET /nodes`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fabric.Backend.Guild
|
||||||
|
|
||||||
|
### Phase 1
|
||||||
|
- [ ] 初始化 NestJS 项目骨架
|
||||||
|
- [ ] Guild/Channel/DM 数据模型
|
||||||
|
- [ ] 消息发送/编辑/删除接口
|
||||||
|
- [ ] 每 Channel/DM 的 `seq` 分配器
|
||||||
|
- [ ] 按 `seq` 区间回补接口
|
||||||
|
|
||||||
|
### 建议 API
|
||||||
|
- `POST /guilds`
|
||||||
|
- `POST /channels`
|
||||||
|
- `POST /channels/:id/messages`
|
||||||
|
- `PATCH /channels/:id/messages/:messageId`
|
||||||
|
- `DELETE /channels/:id/messages/:messageId`
|
||||||
|
- `GET /channels/:id/messages?seq_from=&seq_to=`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 集成顺序建议
|
||||||
|
1. 先完成 Center 登录 + Node 注册
|
||||||
|
2. 再完成 Guild 消息主链路(create + list + seq)
|
||||||
|
3. 最后接入 WebSocket 与插件事件
|
||||||
Reference in New Issue
Block a user