Search K
Appearance
🍵 欢迎来到技术茶馆 🍵
这里是一个分享技术、交流学习的地方
技术札记 | 茶馆周刊 | 工具书签 | 作品展示
让我们一起品茗技术,共同成长
Appearance
大家好,我是技术茶馆的博主。今天想和大家分享一个我最近一直在开发的项目——矩阵魔方(xhs-publisher),一个专为小红书内容创作者打造的全栈 Web 应用。
这个项目从最初的一个简单想法,到现在已经发展成为一个集内容创作、AI 辅助、数据洞察、账号管理于一体的完整解决方案。在这篇文章中,我想和大家分享整个开发历程中的技术选型、架构设计、遇到的挑战以及解决方案。
作为一名内容创作者,我深知在小红书上发布内容的痛点:
基于这些痛点,我决定开发一个网页端的小红书发布助手,让内容创作变得更高效、更智能。
经过深思熟虑,我选择了以下技术栈:
Vue 3 + Vite
├── Element Plus # UI 组件库
├── TailwindCSS # 原子化 CSS
├── Vue Router 4 # 路由管理
├── Axios # HTTP 客户端
├── @icon-park/vue-next # 图标库
└── qrcode.vue # 二维码生成选择理由:
NestJS 10
├── Prisma # 现代化 ORM
├── Passport + JWT # 认证授权
├── BullMQ + Redis # 任务队列
├── Swagger # API 文档
├── 阿里云 OSS # 文件存储
└── 火山引擎 OpenAPI # AI 集成选择理由:
xhs-publisher/
├── client/ # Vue 3 前端
│ ├── src/
│ │ ├── views/ # 14 个页面组件
│ │ ├── components/ # 5 个通用组件
│ │ ├── admin/ # 管理后台(7 个文件)
│ │ ├── router/ # 路由配置
│ │ ├── utils/ # 工具函数
│ │ └── composables/ # 组合式函数
│ └── package.json
│
└── server/ # NestJS 后端
├── src/
│ ├── modules/ # 12 个功能模块
│ │ ├── ai-core/ # AI 核心模块
│ │ ├── auth/ # 认证授权
│ │ ├── user/ # 用户管理
│ │ ├── finance/ # 财务系统
│ │ ├── admin/ # 管理后台
│ │ ├── publish/ # 发布模块
│ │ ├── creation/ # 内容创作
│ │ ├── insights/ # 数据洞察
│ │ ├── hot-materials/# 热点素材
│ │ ├── xhs/ # 小红书 API
│ │ ├── oss/ # 对象存储
│ │ └── crawler/ # 爬虫模块
│ ├── common/ # 公共模块
│ ├── config/ # 配置管理
│ └── main.ts # 应用入口
├── prisma/ # 数据库 Schema
└── package.json这种前后端分离 + 模块化的架构设计,让项目具有良好的可维护性和可扩展性。
这是整个项目最核心的功能,也是技术难度最高的部分。
小红书官方并没有提供开放的发布 API,我们只能通过以下方式实现:
前端页面:
Publisher.vue - 桌面端发布器MobilePublish.vue - 移动端适配页面ContentEditor.vue - 富文本编辑器PublishButton.vue - 一键发布按钮SchemeSelector.vue - Scheme 选择器后端模块:
publish/ - 处理发布流程xhs/ - 小红书 API 集成完整流程:
关键代码片段:
// 初始化小红书 JSSDK
const initXhsSDK = async () => {
const { signature, timestamp, nonceStr } = await getSignature();
window.xhs.config({
signature,
timestamp,
nonceStr,
jsApiList: ["shareToNote"],
});
window.xhs.ready(() => {
console.log("小红书 SDK 初始化成功");
});
};
// 发布内容
const publishToXhs = () => {
window.xhs.shareToNote({
title: noteTitle.value,
content: noteContent.value,
images: uploadedImages.value,
success: () => {
ElMessage.success("发布成功!");
},
fail: (err) => {
// 降级方案:使用 URL Scheme
const scheme = `xhsdiscover://note/publish?title=${encodeURIComponent(
noteTitle.value
)}`;
window.location.href = scheme;
},
});
};这是项目的一大亮点,也是我投入精力最多的部分。
| 功能 | 页面 | 说明 |
|---|---|---|
| AI 文案生成 | Copywriting.vue | 根据关键词生成完整文案 |
| 一键生成 | OneClickGenerate.vue | 一键生成标题+正文+标签 |
| 人设风格 | PersonaStyle.vue | 定制专属创作风格 |
| 选题策划 | TopicPlanning.vue | AI 辅助选题和内容规划 |
后端 AI 核心模块:
// ai-core/ai.service.ts
@Injectable()
export class AiService {
constructor(
@InjectQueue("ai-tasks") private aiQueue: Queue,
private readonly configService: ConfigService
) {}
async generateContent(
prompt: string,
model: "deepseek" | "qwen"
): Promise<string> {
// 添加到任务队列,避免阻塞主线程
const job = await this.aiQueue.add("generate", {
prompt,
model,
timestamp: Date.now(),
});
return job.finished();
}
// DeepSeek 集成
private async callDeepSeek(prompt: string): Promise<string> {
const client = new OpenAI({
apiKey: this.configService.get("DEEPSEEK_API_KEY"),
baseURL: "https://api.deepseek.com/v1",
});
const response = await client.chat.completions.create({
model: "deepseek-chat",
messages: [{ role: "user", content: prompt }],
stream: false,
});
return response.choices[0].message.content;
}
// 通义千问集成
private async callQwen(prompt: string): Promise<string> {
// 使用火山引擎 OpenAPI
const client = new VolcengineOpenAPI({
accessKeyId: this.configService.get("VOLCENGINE_ACCESS_KEY"),
secretAccessKey: this.configService.get("VOLCENGINE_SECRET_KEY"),
});
// 调用通义千问 API
// ...
}
}前端调用示例:
<script setup>
import { ref } from "vue";
import { generateContent } from "@/api/ai";
const keyword = ref("");
const generatedContent = ref("");
const loading = ref(false);
const handleGenerate = async () => {
loading.value = true;
try {
const result = await generateContent({
keyword: keyword.value,
model: "deepseek",
type: "copywriting",
});
generatedContent.value = result.content;
} catch (error) {
ElMessage.error("生成失败,请重试");
} finally {
loading.value = false;
}
};
</script>
<template>
<div class="ai-generator">
<el-input
v-model="keyword"
placeholder="输入关键词,AI 为你生成文案"
@keyup.enter="handleGenerate"
/>
<el-button type="primary" :loading="loading" @click="handleGenerate">
生成文案
</el-button>
<div v-if="generatedContent" class="result">
{{ generatedContent }}
</div>
</div>
</template>技术方案:
// hot-materials/crawler.service.ts
@Injectable()
export class CrawlerService {
@Cron("0 */2 * * *") // 每 2 小时执行一次
async crawlHotTopics() {
// 抓取小红书热榜
const topics = await this.fetchXhsHotTopics();
// 存储到数据库
await this.prisma.hotTopic.createMany({
data: topics.map((topic) => ({
title: topic.title,
heat: topic.heat,
category: topic.category,
crawledAt: new Date(),
})),
});
}
private async fetchXhsHotTopics() {
// 使用 Puppeteer 或 API 抓取
// ...
}
}前端展示:
<!-- HotMaterials.vue -->
<template>
<div class="hot-materials">
<el-card v-for="topic in hotTopics" :key="topic.id">
<div class="topic-header">
<span class="topic-title">{{ topic.title }}</span>
<el-tag type="danger">🔥 {{ formatHeat(topic.heat) }}</el-tag>
</div>
<div class="topic-meta">
<span>{{ topic.category }}</span>
<span>{{ formatTime(topic.crawledAt) }}</span>
</div>
<el-button size="small" @click="useThisTopic(topic)">
使用此话题
</el-button>
</el-card>
</div>
</template>提供以下数据分析:
功能特性:
数据库设计:
// prisma/schema.prisma
model Account {
id String @id @default(cuid())
userId String
platform String // 'xiaohongshu'
nickname String
avatar String?
accessToken String?
refreshToken String?
expiresAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id])
posts Post[]
@@index([userId])
}
model Post {
id String @id @default(cuid())
accountId String
title String
content String @db.Text
images Json
status String // 'draft' | 'published' | 'scheduled'
publishedAt DateTime?
createdAt DateTime @default(now())
account Account @relation(fields: [accountId], references: [id])
@@index([accountId])
}使用 Passport + JWT 实现:
// auth/jwt.strategy.ts
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(
private configService: ConfigService,
private userService: UserService
) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKey: configService.get("JWT_SECRET"),
});
}
async validate(payload: any) {
const user = await this.userService.findById(payload.sub);
if (!user) {
throw new UnauthorizedException();
}
return user;
}
}充值流程:
兑换码系统:
// finance/redemption.service.ts
@Injectable()
export class RedemptionService {
async createCode(amount: number, count: number): Promise<string[]> {
const codes = [];
for (let i = 0; i < count; i++) {
const code = this.generateCode();
await this.prisma.redemptionCode.create({
data: {
code,
amount,
status: "unused",
},
});
codes.push(code);
}
return codes;
}
async redeemCode(userId: string, code: string): Promise<boolean> {
const redemption = await this.prisma.redemptionCode.findUnique({
where: { code },
});
if (!redemption || redemption.status !== "unused") {
throw new BadRequestException("兑换码无效或已使用");
}
// 更新用户余额
await this.userService.addBalance(userId, redemption.amount);
// 标记兑换码已使用
await this.prisma.redemptionCode.update({
where: { code },
data: { status: "used", usedBy: userId, usedAt: new Date() },
});
return true;
}
private generateCode(): string {
// 生成 16 位随机兑换码
return randomBytes(8).toString("hex").toUpperCase();
}
}目标:验证核心功能可行性
完成内容:
技术难点:
经验总结:
MVP 阶段最重要的是快速验证想法,不要过度设计。我最初花了很多时间纠结架构,后来发现先把核心功能跑通才是关键。
目标:从 Express 迁移到 NestJS,建立可扩展架构
完成内容:
为什么选择 NestJS?
| 对比项 | Express | NestJS |
|---|---|---|
| 类型安全 | ❌ 需要额外配置 | ✅ 原生 TypeScript |
| 依赖注入 | ❌ 需要第三方库 | ✅ 内置 DI 容器 |
| 模块化 | ❌ 需要手动组织 | ✅ 模块系统 |
| API 文档 | ❌ 需要手写 | ✅ Swagger 自动生成 |
| 测试支持 | ⚠️ 需要配置 | ✅ 内置测试工具 |
架构优势:
目标:引入 AI 辅助创作能力
完成内容:
技术选型:
为什么选择 DeepSeek?
AI 调用优化:
// 使用任务队列避免阻塞
@Processor("ai-tasks")
export class AiProcessor {
@Process("generate")
async handleGenerate(job: Job) {
const { prompt, model } = job.data;
// 更新进度
await job.progress(10);
const result = await this.aiService.generate(prompt, model);
await job.progress(100);
return result;
}
}目标:提供数据洞察和运营工具
完成内容:
爬虫实现:
// 使用 Puppeteer 抓取小红书热榜
async crawlHotTopics() {
const browser = await puppeteer.launch({ headless: true });
const page = await browser.newPage();
await page.goto('https://www.xiaohongshu.com/explore');
const topics = await page.evaluate(() => {
const items = document.querySelectorAll('.hot-topic-item');
return Array.from(items).map(item => ({
title: item.querySelector('.title').textContent,
heat: item.querySelector('.heat').textContent,
category: item.querySelector('.category').textContent,
}));
});
await browser.close();
return topics;
}目标:提升用户体验和系统稳定性
计划内容:
问题:
解决方案:
const getSignature = async () => {
const response = await axios.post("/api/xhs/signature", {
url: window.location.href,
});
return response.data;
};const publishWithFallback = () => {
try {
// 尝试使用 JSSDK
window.xhs.shareToNote({...});
} catch (error) {
// 降级到 URL Scheme
const scheme = `xhsdiscover://note/publish?...`;
window.location.href = scheme;
}
};<el-alert
v-if="publishError"
type="warning"
title="自动发布失败"
description="请点击下方按钮手动跳转到小红书 App"
show-icon
/>问题:
解决方案:
// 将 AI 任务放入队列
const job = await this.aiQueue.add("generate", {
prompt,
model,
});
// 返回任务 ID
return { jobId: job.id };@Sse('ai/stream/:jobId')
async streamAiResult(@Param('jobId') jobId: string) {
return new Observable(observer => {
const interval = setInterval(async () => {
const job = await this.aiQueue.getJob(jobId);
const progress = await job.progress();
observer.next({ data: { progress } });
if (await job.isCompleted()) {
const result = await job.returnvalue;
observer.next({ data: { result } });
observer.complete();
clearInterval(interval);
}
}, 500);
});
}<script setup>
const eventSource = new EventSource(`/api/ai/stream/${jobId}`);
eventSource.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.progress) {
progress.value = data.progress;
}
if (data.result) {
generatedContent.value = data.result;
eventSource.close();
}
};
</script>
<template>
<el-progress :percentage="progress" />
</template>问题:
解决方案:
import { createCipheriv, createDecipheriv } from "crypto";
@Injectable()
export class EncryptionService {
private readonly algorithm = "aes-256-cbc";
private readonly key = this.configService.get("ENCRYPTION_KEY");
encrypt(text: string): string {
const iv = randomBytes(16);
const cipher = createCipheriv(this.algorithm, this.key, iv);
const encrypted = Buffer.concat([cipher.update(text), cipher.final()]);
return `${iv.toString("hex")}:${encrypted.toString("hex")}`;
}
decrypt(text: string): string {
const [ivHex, encryptedHex] = text.split(":");
const iv = Buffer.from(ivHex, "hex");
const encrypted = Buffer.from(encryptedHex, "hex");
const decipher = createDecipheriv(this.algorithm, this.key, iv);
const decrypted = Buffer.concat([
decipher.update(encrypted),
decipher.final(),
]);
return decrypted.toString();
}
}@Injectable()
export class AccountService {
async getValidToken(accountId: string): Promise<string> {
const account = await this.prisma.account.findUnique({
where: { id: accountId },
});
// 检查 Token 是否过期
if (account.expiresAt < new Date()) {
// 刷新 Token
const newToken = await this.refreshToken(account.refreshToken);
// 更新数据库
await this.prisma.account.update({
where: { id: accountId },
data: {
accessToken: this.encryptionService.encrypt(newToken.accessToken),
expiresAt: newToken.expiresAt,
},
});
return newToken.accessToken;
}
return this.encryptionService.decrypt(account.accessToken);
}
}// 使用 Row-Level Security
@Injectable()
export class AccountGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest();
const user = request.user;
const accountId = request.params.accountId;
// 验证账号归属
return this.accountService.belongsToUser(accountId, user.id);
}
}问题:
解决方案:
@Cron('0 */2 * * *') // 每 2 小时执行
async crawlHotTopics() {
const latestTopics = await this.fetchXhsHotTopics();
// 只更新变化的数据
for (const topic of latestTopics) {
await this.prisma.hotTopic.upsert({
where: { topicId: topic.id },
update: { heat: topic.heat, updatedAt: new Date() },
create: { ...topic },
});
}
}@Injectable()
export class HotMaterialsService {
async getHotTopics(): Promise<HotTopic[]> {
// 先查缓存
const cached = await this.redis.get("hot_topics");
if (cached) {
return JSON.parse(cached);
}
// 查数据库
const topics = await this.prisma.hotTopic.findMany({
orderBy: { heat: "desc" },
take: 50,
});
// 写入缓存(5 分钟过期)
await this.redis.setex("hot_topics", 300, JSON.stringify(topics));
return topics;
}
}// 使用代理池
const proxyList = [
"http://proxy1.example.com:8080",
"http://proxy2.example.com:8080",
// ...
];
const randomProxy = proxyList[Math.floor(Math.random() * proxyList.length)];
const browser = await puppeteer.launch({
args: [`--proxy-server=${randomProxy}`],
});
// 添加随机延迟
await page.waitForTimeout(Math.random() * 3000 + 2000);矩阵魔方这个项目从最初的一个想法,到现在已经发展成为一个功能完善的全栈应用。在这个过程中,我学到了很多:
这个项目还在持续开发中,未来我会继续分享更多的技术细节和实战经验。如果你对这个项目感兴趣,或者有任何问题和建议,欢迎在评论区留言交流!
记住:好的产品是迭代出来的,不是一蹴而就的。 让我们一起在技术的道路上不断探索,创造更多有价值的产品。
项目状态:🚧 积极开发中
当前版本:v1.0.0
最后更新:2025-12-16
如果这篇文章对你有帮助,欢迎点赞、收藏、分享!关注我,获取更多全栈开发和 AI 应用的实战经验。