PAYATHON 2026

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 和一个终端的完整流程,包括销售、付款、离线、重连和冲突测试。完成后,再逐步加入:

  1. company 和 location 的权限模型。
  2. 数据库自动创建、索引部署和版本迁移。
  3. 多终端同时离线时的冲突测试。
  4. 付款和退款的幂等控制。
  5. 设备注册、撤销和本地数据清理。
  6. 备份、按租户恢复和灾难恢复演练。
  7. changes feed 到报表数据库的数据管道。
  8. 审计日志、监控、告警和容量测试。

CouchDB replication 负责数据传输和最终一致性,后端负责信任边界与关键业务规则,PouchDB 负责离线体验。filtered replication、前端隐藏菜单和 Mango selector 都不能代替安全授权机制。

备注:内容仅供参考。