CouchDB Design for POS application:系统架构设计咨询
结论
整体方案可行,但身份认证、数据库创建、授权、支付和业务安全控制不应直接交给 Angular。更稳妥的做法是:
- 每家公司使用一个 CouchDB database,以此隔离租户数据。不过,实施前需要评估数据库数量、备份、索引发布和运维成本。
- 如果 location 之间也有严格的隔离要求,建议采用“每个 location 一个业务库”,不要把 filtered replication 当作权限边界。
- PouchDB 和 CouchDB 可以共用大部分文档结构、Mango 查询与 CRUD 代码,但复制、权限管理和部分 API 仍要分别处理。
- CouchDB 适合事务数据同步和预定义聚合,不适合任意维度的复杂报表。复杂分析应交给专门的报表系统。
- 增加 server-side API 或 BFF,用于处理登录、授权、建库、计费、敏感业务规则和审计。
推荐架构如下:
Angular + PouchDB
│
│ HTTPS
▼
Backend / BFF
├── 身份认证与权限检查
├── 公司、门店和设备管理
├── CouchDB database provisioning
├── 支付、退款、发票等敏感操作
└── 报表任务与审计
│
▼
CouchDB
├── platform/license database
├── company_a database
├── company_b database
└── reporting pipeline
是否应该为每家公司创建独立 database
可以。database-per-tenant 模式的优点很明确:
- 公司之间的数据天然隔离。
- 可以按公司执行备份、恢复、停用或迁移。
- replication 和数据库权限更容易限定在单个公司内。
- 删除租户数据时,边界比较清楚。
相应的运维成本也不能忽略:
- 每个 database 都需要维护 design document 和 Mango index。
- 大量小数据库会让监控、备份和升级变得更复杂。
- 普通 CouchDB 查询无法直接完成跨公司统计。
- 新版本上线时,需要向所有租户数据库发布索引和设计文档。
数据库并非越多越好。应该根据预计的公司数量、单个公司的数据量和备份要求进行容量测试。
如果系统只有几百或几千家公司,而且每家公司的数据规模可控,这种模式通常比较直观。如果租户数量可能变得非常庞大,就应提前验证建库、索引部署、备份和故障恢复流程。
公司库还是门店库
选择哪种方式,取决于 location 是否属于真正的安全边界。
同一公司一个 database
所有 location 的文档都放在同一个公司库中,并在每个文档里保存 locationId:
{
"_id": "invoice:01JABC123",
"type": "invoice",
"companyId": "company_001",
"locationId": "location_shanghai",
"status": "completed",
"currency": "CNY",
"total": 12800,
"createdAt": "2026-09-01T10:30:00.000Z"
}
金额建议使用最小货币单位,例如分,以免出现浮点数误差。上例中的 12800 表示 128.00 CNY。
这种设计适用于以下情况:
- 公司管理员可以访问所有门店。
- location 隔离主要用于控制同步范围和界面权限,并非严格的安全边界。
- 公司内部需要生成跨门店视图。
每个 location 一个 database
如果门店账号绝对不能读取其他门店的数据,可以采用:
company_001_control
company_001_location_shanghai
company_001_location_beijing
公司级配置保存在 control database 中,销售、库存和付款记录则保存在各门店库中。总部报表可以由后端聚合,也可以把数据复制到专用 reporting database。
CouchDB 的数据库权限通常作用于整个 database。文档中的 locationId、Mango selector 和 replication filter 都不足以单独构成可靠的文档级读取权限。因此,需要强隔离时,更适合用数据库划定边界。
是否可以只同步当前 location
技术上可以。PouchDB 能在浏览器本地库和远程 CouchDB 之间进行双向 replication,并通过 selector 限定同步的文档:
import PouchDB from 'pouchdb-browser';
import PouchDBFind from 'pouchdb-find';
PouchDB.plugin(PouchDBFind);
const localDb = new PouchDB('pos-location-shanghai');
const remoteDb = new PouchDB(
'https://api.example.com/couch/company_001',
{
fetch(url, options) {
options.credentials = 'include';
return PouchDB.fetch(url, options);
}
}
);
const selector = {
locationId: 'location_shanghai'
};
const replication = localDb.sync(remoteDb, {
live: true,
retry: true,
selector
});
replication.on('error', error => {
console.error('Replication failed', error);
});
这里有三个问题需要注意。
第一,selector 只规定复制范围,不负责授权。如果用户能够直接访问整个公司 database,仍然可能绕过前端,查询其他 location 的文档。
第二,并非所有共享数据都只属于某个 location。例如,税率、商品目录、用户权限和公司配置可能需要同步到多个门店。可以为这些文档添加复制范围字段:
{
"_id": "product:10001",
"type": "product",
"replicationScopes": [
"location_shanghai",
"location_beijing"
]
}
对应的 selector 可以写成:
const selector = {
replicationScopes: {
$elemMatch: { $eq: 'location_shanghai' }
}
};
第三,必须测试文档删除、权限变更和门店调动后的实际行为。数据一旦复制到浏览器,不会因为用户权限被撤销就自动从设备上安全消失。用户退出登录、设备注销或权限变更时,需要清除或重新初始化本地数据库。
如果用户只是临时查看其他 location,建议通过后端 API 在线查询,不要直接向浏览器开放远程 CouchDB 的完整访问权限。
CouchDB 和 PouchDB 能否使用同一套代码
文档 CRUD 基本可以共用。PouchDB 既能打开本地数据库,也能把远程 CouchDB 作为数据库对象使用:
async function saveInvoice(db, invoice) {
return db.put(invoice);
}
async function loadInvoice(db, invoiceId) {
return db.get(invoiceId);
}
await saveInvoice(localDb, {
_id: crypto.randomUUID(),
type: 'invoice',
locationId: 'location_shanghai',
total: 12800,
createdAt: new Date().toISOString()
});
Mango 查询也可以采用相近的写法:
await localDb.createIndex({
index: {
fields: ['type', 'locationId', 'createdAt']
}
});
const result = await localDb.find({
selector: {
type: 'invoice',
locationId: 'location_shanghai',
createdAt: {
$gte: '2026-09-01T00:00:00.000Z',
$lt: '2026-09-02T00:00:00.000Z'
}
},
sort: [
{ type: 'asc' },
{ locationId: 'asc' },
{ createdAt: 'asc' }
]
});
实际项目中,最好封装一层 repository:
export class InvoiceRepository {
constructor(db) {
this.db = db;
}
async get(id) {
return this.db.get(id);
}
async save(invoice) {
return this.db.put(invoice);
}
async listByLocation(locationId, from, to) {
return this.db.find({
selector: {
type: 'invoice',
locationId,
createdAt: { $gte: from, $lt: to }
}
});
}
}
以下部分仍需分别处理:
- replication 和 checkpoint。
- CouchDB 管理 API。
- 数据库权限和用户会话。
- design document 的发布。
- PouchDB 插件和浏览器存储限制。
- 网络失败、冲突和本地数据库清理。
业务数据访问层可以共享,但不能假设两个产品的全部 API 和运行行为都相同。
MapReduce 和 Mango 能否生成复杂报表
Mango 适合条件筛选、排序和分页,但它不是完整的分析查询语言。CouchDB MapReduce view 更适合事先定义好的聚合,例如:
- 某门店的每日销售额。
- 某商品的每月销量。
- 按支付方式统计金额。
- 按税率汇总税额。
下面的示例按门店和日期汇总销售额:
function (doc) {
if (doc.type === 'invoice' && doc.status === 'completed') {
var day = doc.createdAt.slice(0, 10);
emit([doc.locationId, day], doc.total);
}
}
查询时使用内置的 _sum reduce。
MapReduce 不擅长临时切换维度、多数据集关联、窗口计算和交互式分析。复杂报表可以采用以下方式:
- 在 CouchDB 中保存不可变业务事件,再由后端生成汇总文档。
- 通过 changes feed 将数据同步到 PostgreSQL、ClickHouse 或数据仓库。
- 对规模较小的报表,由后端读取预聚合结果并做进一步计算。
- 将报表生成设计为异步任务,避免扫描生产数据库而阻塞收银业务。
不要把大量发票下载到浏览器,再计算公司级报表。这样会带来性能、流量和数据泄露问题。
是否需要 server-side framework
正式 POS 系统基本都需要后端。Angular 直接连接 CouchDB 只适合受控原型,因为用户可以检查、修改和重放浏览器中的代码与请求。
后端至少需要负责:
- 登录、注销、会话续期和密码策略。
- 公司、location、用户和设备的授权。
- 创建数据库和部署索引。
- 套餐、账单和订阅状态。
- 付款、退款、作废发票等敏感操作。
- 审计日志、限流和异常检测。
- 向客户端签发范围受限且可撤销的访问凭证。
- 跨数据库查询和复杂报表。
CouchDB 管理员账号或建库凭据绝不能放进 Angular bundle。
即使不采用第三方身份服务,也应该由后端实现完整的认证流程。密码必须使用成熟的密码哈希方案,同时处理会话失效、暴力破解防护、密码重置、MFA、CSRF、CORS 和 Cookie 安全属性。不要自行设计加密算法。
推荐的数据模型
使用 CouchDB 时,不需要把关系型数据库中的所有表机械地改成嵌套 JSON。文档应该按照一致性边界和读取方式来划分。
发票及其行项目通常适合放在同一个文档中:
{
"_id": "invoice:01JABC123",
"type": "invoice",
"companyId": "company_001",
"locationId": "location_shanghai",
"customerId": "customer_100",
"status": "completed",
"lines": [
{
"productId": "product_001",
"description": "Coffee",
"quantity": 2,
"unitPrice": 3200,
"lineTotal": 6400
}
],
"subtotal": 6400,
"tax": 0,
"total": 6400,
"createdAt": "2026-09-01T10:30:00.000Z",
"deviceId": "terminal_03"
}
付款建议保存为独立文档,因为一张发票可能对应多次付款、退款或失败记录:
{
"_id": "payment:01JABC456",
"type": "payment",
"invoiceId": "invoice:01JABC123",
"locationId": "location_shanghai",
"amount": 6400,
"method": "card",
"status": "captured",
"idempotencyKey": "terminal_03:payment:847293",
"createdAt": "2026-09-01T10:31:00.000Z"
}
商品、客户、库存调整、收银班次和审计事件也适合使用独立文档。不要创建一个持续增长、包含整家公司数据的巨大文档,否则每次修改都要重写整个文档,还会产生严重冲突。
Offline POS 最容易忽略的问题
冲突处理
PouchDB replication 采用最终一致性。两个终端离线修改同一文档时,可能出现 revision conflict。应用需要明确:
- 哪些文档允许修改。
- 哪些记录只能追加,不能覆盖。
- 冲突由客户端、后端还是人工处理。
- 冲突解决后如何保留审计轨迹。
发票、付款和库存流水更适合使用不可变事件或追加记录,避免多个终端反复修改同一份汇总文档。
编号生成
离线设备不能安全地依赖服务器生成连续编号。文档 _id 应使用 UUID、ULID 或包含设备标识的唯一值。
如果法律或财务要求发票号连续,可以在联网时为设备预分配号码段,或者先使用内部 ID,联网后再由服务端分配正式号码。具体做法需要根据当地税务规则确认。
库存一致性
离线终端无法得知其他终端刚刚完成的销售,因此不能保证实时库存永远不为负。业务上需要提前确定处理策略,例如:
- 离线时允许超卖,联网后发出告警。
- 为门店或终端预留库存。
- 禁止高风险商品离线销售。
- 使用追加式 movement 文档记录库存变化,再由服务端重新计算余额。
幂等性
客户端可能因超时而重复提交请求。付款、退款和订单确认必须携带稳定的 idempotencyKey。服务端发现重复请求时,应返回原结果,不能再次扣款。
本地数据安全
PouchDB 数据保存在浏览器存储中,还需要考虑:
- 共用收银设备上的用户切换。
- 设备丢失或被盗。
- 浏览器清除数据和存储配额。
- 本地数据是否需要额外加密。
- 权限撤销后如何删除离线数据。
- 设备时钟不准确会怎样影响报表和审计。
只有密钥管理同样可靠时,浏览器本地加密才有意义。固定密钥不能写在前端代码中。
建议的实施顺序
先打通一个 company、一个 location 和一个终端的完整流程,包括销售、付款、离线、重连和冲突测试。完成后,再逐步加入:
- company 和 location 的权限模型。
- 数据库自动创建、索引部署和版本迁移。
- 多终端同时离线时的冲突测试。
- 付款和退款的幂等控制。
- 设备注册、撤销和本地数据清理。
- 备份、按租户恢复和灾难恢复演练。
- changes feed 到报表数据库的数据管道。
- 审计日志、监控、告警和容量测试。
CouchDB replication 负责数据传输和最终一致性,后端负责信任边界与关键业务规则,PouchDB 负责离线体验。filtered replication、前端隐藏菜单和 Mango selector 都不能代替安全授权机制。
备注:内容仅供参考。
