3个坑避不开?淘宝店铺公告栏实战项目从零搭建
版本升级后 API 全变了,这是很多前端和全栈工程师在接手旧项目时的噩梦。
尤其是涉及淘宝开放平台(TOP)对接的【淘宝店铺公告栏】功能,老接口弃用,新接口鉴权复杂,文档更新滞后,导致大量【实战项目】在重构时陷入停滞。
今天不讲虚的,直接带你从零搭建一个符合最新规范的店铺公告栏系统。
项目目标与痛点拆解
我们要做的不是一个简单的静态页面,而是一个能够自动同步、支持富文本编辑、具备权限控制的后端驱动系统。
核心痛点在于:淘宝官方接口对频率限制严格,且返回数据结构在不同版本间差异巨大。
传统做法是直接调用 API 存库,但一旦 API 变动,整个数据层崩塌。
我们的目标是实现数据隔离层,将淘宝原始数据清洗后存入本地数据库,前端只读取本地数据,彻底解耦对第三方接口的依赖。
同时,系统需支持管理员后台配置,允许店铺运营人员自定义公告的展示逻辑,如置顶、有效期、关联商品等。
目录结构设计
一个清晰的目录结构是【实战项目】可维护性的基石。
我们采用 Vue3 + TypeScript + NestJS 的经典前后端分离架构。
前端负责交互展示,后端负责数据清洗与缓存策略。
src/
├── client/ # 前端 Vue3 代码
│ ├── views/
│ │ ├── AnnouncementList.vue # 公告列表页
│ │ ├── Editor.vue # 富文本编辑器
│ │ └── Dashboard.vue # 数据看板
│ ├── api/
│ │ └── announcement.ts # 接口封装
│ └── utils/
│ └── formatter.ts # 数据格式化
├── server/ # 后端 NestJS 代码
│ ├── modules/
│ │ ├── announcement/
│ │ │ ├── announcement.controller.ts
│ │ │ ├── announcement.service.ts
│ │ │ ├── announcement.model.ts
│ │ │ └── dto/
│ │ │ └── create-announcement.dto.ts
│ │ └── taobao/
│ │ ├── taobao.service.ts # 核心:淘宝接口封装
│ │ └── taobao.gateway.ts # 网关:处理 Token 刷新
│ └── common/
│ ├── interceptors/
│ └── filters/
└── shared/ # 前后端共享类型定义└── types/└── announcement.type.ts
关键点:将 taobao 模块独立出来,是为了方便后续扩展其他淘宝接口,如商品详情、订单查询等,保持单一职责原则。
核心代码实现:后端数据清洗
这是整个【淘宝店铺公告栏】项目的核心。
很多开发者直接在前端请求淘宝接口,这是绝对错误的。
淘宝接口有签名算法(MD5/SHA256),且每次请求消耗配额,必须放在后端处理。
以下是 taobao.service.ts 的核心逻辑:
import { Injectable, Logger } from '@nestjs/common';
import { HttpService } from '@nestjs/axios';
import { firstValueFrom } from 'rxjs';
import * as crypto from 'crypto';@Injectable()
export class TaobaoService {private readonly logger = new Logger(TaobaoService.name);private readonly appKey = process.env.TAOBAO_APP_KEY;private readonly appSecret = process.env.TAOBAO_APP_SECRET;private readonly baseUrl = 'https://eco.taobao.com/router/rest';constructor(private readonly httpService: HttpService) {}/*** 获取店铺公告列表* 注意:这里模拟了淘宝接口的调用逻辑* 实际开发中需根据官方最新文档调整参数*/async getAnnouncements(pageSize: number, currentPage: number) {try {// 1. 构造请求参数const params = {method: 'taobao.shop.get', // 假设的接口方法,需替换为实际有效接口app_key: this.appKey,session: '', // 如果是授权接口,这里需要 session keytimestamp: new Date().toISOString(),format: 'json',v: '2.0', // 版本 2.0page_size: pageSize,page_num: currentPage,};// 2. 计算签名const sign = this.generateSign(params, this.appSecret);params.sign = sign;// 3. 发送请求const url = this.baseUrl;const response = await firstValueFrom(this.httpService.post(url, params, {headers: { 'Content-Type': 'application/x-www-form-urlencoded' },}));// 4. 数据清洗与映射return this.mapResponseData(response.data);} catch (error) {this.logger.error('Failed to fetch announcements from Taobao', error);throw new Error('Taobao API Error');}}/*** 生成淘宝 API 签名* 依据淘宝开放平台开发者文档规范*/private generateSign(params: Record<string, any>, secret: string): string {// 按 ASCII 码排序参数名const sortedKeys = Object.keys(params).sort();let signStr = '';for (const key of sortedKeys) {if (params[key] !== undefined && params[key] !== null) {signStr += key + params[key];}}// 拼接密钥signStr += secret;// MD5 加密并转大写const md5 = crypto.createHash('md5').update(signStr, 'utf8').digest('hex');return md5.toUpperCase();}/*** 将淘宝返回的原始数据映射为本地数据库模型* 这一步至关重要,屏蔽了上游数据结构的变动*/private mapResponseData(rawData: any) {if (!rawData || !rawData.shop_get_response) {return { list: [], total: 0 };}const announcements = rawData.shop_get_response.announcements?.announcement || [];const mappedList = announcements.map((item: any) => ({id: item.id,title: item.title,content: item.content,publishTime: new Date(item.gmt_create),status: item.status === '1' ? 'active' : 'inactive',// 扩展字段:本地自定义localId: item.id, // 用于关联本地 ID}));return {list: mappedList,total: rawData.shop_get_response.total_count,};}
}
逐行解析:
- 签名算法:这是最容易被坑的地方。淘宝要求参数按 ASCII 码排序,且空值不参与签名。很多新手在这里报错
isv.invalid-parameter,90% 是因为排序或空值处理不对。 - 数据映射:
mapResponseData方法将淘宝的gmt_create字符串转为标准 Date 对象,并将状态码转为语义化字符串。这样前端不需要关心淘宝的状态码定义。 - 错误处理:捕获异常并记录日志,避免一个接口失败导致整个服务崩溃。
核心代码实现:前端展示与交互
前端负责将清洗后的数据渲染成用户友好的界面。
我们使用 Vue3 Composition API 结合 TypeScript 确保类型安全。
<template><div class="announcement-container"><h2>店铺公告</h2><div v-if="loading" class="loading">加载中...</div><div v-else-if="announcements.length === 0" class="empty">暂无公告</div><div v-else class="announcement-list"><div v-for="item in announcements" :key="item.id" class="announcement-item":class="{ 'is-pinned': item.isPinned }"><div class="header"><span class="title">{{ item.title }}</span><span class="time">{{ formatDate(item.publishTime) }}</span></div><!-- 使用 v-html 渲染富文本,需确保内容安全 --><div class="content" v-html="item.content"></div></div></div><div class="pagination"><button @click="loadMore" :disabled="loading">加载更多</button></div></div>
</template><script setup lang="ts">
import { ref, onMounted } from 'vue';
import { getAnnouncements } from '@/api/announcement';
import { formatDate } from '@/utils/formatter';interface Announcement {id: string;title: string;content: string;publishTime: Date;isPinned: boolean;
}const announcements = ref<Announcement[]>([]);
const loading = ref(false);
const currentPage = ref(1);const loadAnnouncements = async () => {loading.value = true;try {const res = await getAnnouncements(currentPage.value, 10);if (currentPage.value === 1) {announcements.value = res.data.list;} else {announcements.value = [...announcements.value, ...res.data.list];}} catch (error) {console.error('Failed to load announcements', error);} finally {loading.value = false;}
};const loadMore = () => {currentPage.value++;loadAnnouncements();
};onMounted(() => {loadAnnouncements();
});
</script><style scoped>
.announcement-item {border-bottom: 1px solid #eee;padding: 15px;
}
.is-pinned {background-color: #fffbe6;
}
.title {font-weight: bold;font-size: 16px;
}
.time {color: #999;font-size: 12px;
}
</style>
关键细节:
- XSS 防护:虽然我们在后端做了清洗,但前端使用
v-html时仍需警惕。建议引入DOMPurify库对内容进行二次过滤。 - 无限滚动:这里简单实现了“加载更多”,实际项目中可替换为 Intersection Observer 实现无限滚动体验。
- TypeScript 接口:定义明确的
Announcement接口,确保前后端数据结构一致,减少运行时错误。
运行与测试:模拟淘宝环境
在本地开发环境中,直接调用淘宝接口需要真实的 AppKey 和 Session,这对开发者不友好。
我们需要搭建一个 Mock 环境。
使用 msw (Mock Service Worker) 或 nock 来拦截 HTTP 请求。
// test/taobao.service.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { TaobaoService } from '../src/server/modules/taobao/taobao.service';
import { HttpService } from '@nestjs/axios';
import { of } from 'rxjs';
import { AxiosResponse } from 'axios';describe('TaobaoService', () => {let service: TaobaoService;let httpService: HttpService;beforeEach(async () => {const module: TestingModule = await Test.createTestingModule({providers: [TaobaoService,{provide: HttpService,useValue: {post: () => of({data: {shop_get_response: {announcements: {announcement: [{id: '1',title: '测试公告',content: '<p>Hello World</p>',gmt_create: '2023-10-27 10:00:00',status: '1',},],},total_count: 1,},},} as AxiosResponse),},},],}).compile();service = module.get<TaobaoService>(TaobaoService);httpService = module.get<HttpService>(HttpService);});it('should map taobao data correctly', async () => {const result = await service.getAnnouncements(10, 1);expect(result.list.length).toBe(1);expect(result.list[0].title).toBe('测试公告');expect(result.list[0].publishTime).toBeInstanceOf(Date);});
});
测试要点:
- 数据映射测试:确保
gmt_create字符串正确转换为 Date 对象。 - 签名算法测试:单独编写单元测试验证
generateSign方法,使用已知输入输出对比,确保 MD5 计算无误。 - 异常处理测试:模拟网络超时或 API 返回错误码,验证服务是否能正确抛出异常或返回默认值。
优化扩展与避坑指南
在【实战项目】中,性能和安全是永恒的话题。
1. 缓存策略
淘宝接口调用成本高,频繁请求会被封禁。
建议在 Redis 中缓存公告数据,设置 TTL(过期时间)为 5 分钟。
// 在 TaobaoService 中增加缓存逻辑
async getAnnouncementsCached(pageSize: number, currentPage: number) {const cacheKey = `tb_ann_${pageSize}_${currentPage}`;const cachedData = await this.redis.get(cacheKey);if (cachedData) {return JSON.parse(cachedData);}const data = await this.getAnnouncements(pageSize, currentPage);await this.redis.setex(cacheKey, 300, JSON.stringify(data)); // 缓存 5 分钟return data;
}
2. 富文本安全
淘宝返回的公告内容可能包含恶意脚本。
务必在后端或前端使用 DOMPurify 进行清洗。
import DOMPurify from 'dompurify';const cleanContent = (content: string) => {return DOMPurify.sanitize(content, {ALLOWED_TAGS: ['p', 'br', 'span', 'b', 'i', 'u'],ALLOWED_ATTR: ['style', 'class'],});
};
3. 接口版本兼容
淘宝 API 版本迭代快,建议在代码中定义版本常量,并预留降级逻辑。
如果 v2.0 接口失败,可尝试回退到 v1.0(如果仍可用),或返回静态兜底数据。
小结
搭建【淘宝店铺公告栏】这个【实战项目】,看似简单,实则涵盖了接口封装、数据清洗、安全过滤、缓存优化等多个全栈技术点。
核心在于解耦:将第三方接口隔离在 Service 层,通过数据映射屏蔽上游变动,这样即使淘宝 API 再次升级,你也只需要修改 mapResponseData 方法,而无需重构整个系统。
记住,代码不仅要能跑,更要能维护。
这个知识点你面试被问过吗?留言说说