退款5年可追溯:餐饮SaaS合规体系构建
退款5年可追溯:餐饮SaaS合规体系构建
做SaaS,功能做出来了只是第一步,合规做不好,随时可能翻车。餐饮行业涉及支付、用户隐私、食品安全,合规要求比普通SaaS高得多。
龙讯餐饮V2的合规体系,核心围绕五个字:可追溯、可审计、可证明。每一笔交易、每一次退款、每一条数据访问,都要经得起查。
系统定位:非金融中转方
这是最根本的合规前提。龙讯餐饮SaaS的定位是:
非金融中转方,仅调用官方支付API。
这意味着什么?
- 我们不碰钱——资金直达商户在支付平台的账户(mch_id),到账时间和提现规则取决于商户与支付平台的合约约定
- 我们不结算——不做资金清算,不做资金分发
- 我们不担保——不承担支付风险,不承担退款资金责任
这个定位决定了我们的合规边界:
| 维度 | 我们做的 | 我们不做的 |
|---|---|---|
| 支付 | 调用微信/支付宝SDK发起支付 | 不做资金中转 |
| 退款 | 调用SDK发起退款 | 不做退款资金垫付 |
| 对账 | 提供交易记录查询 | 不做资金清算 |
| 提现 | 展示支付平台的余额 | 不做提现通道 |
代码层面,我们的支付服务只做"调用"和"记录":
func (s *PaymentService) Pay(ctx context.Context, req *PayRequest) (*PayResponse, error) {
// 1. 记录支付请求
paymentLog := &PaymentLog{
TenantID: req.TenantID,
StoreID: req.StoreID,
OrderNo: req.OrderNo,
Amount: req.Amount,
Channel: req.Channel,
Status: "pending",
}
s.db.Create(paymentLog)
// 2. 调用官方支付SDK
resp, err := s.paymentClient.Pay(ctx, &sdk.PayRequest{
MchID: req.MchID,
Amount: req.Amount,
OrderNo: req.OrderNo,
AuthCode: req.AuthCode,
})
// 3. 记录支付结果
paymentLog.Status = resp.Status
paymentLog.TradeNo = resp.TradeNo
s.db.Save(paymentLog)
return &PayResponse{TradeNo: resp.TradeNo, Status: resp.Status}, err
}
资金流向:顾客 → 支付平台 → 商户在支付平台的账户(mch_id)。我们只是"传话人"。
证照验证门控支付
根据《网络餐饮服务食品安全监督管理办法》,入网餐饮服务提供者必须取得食品经营许可证。我们做了证照验证门控——没有上传有效证照的商户,不能开通支付功能。
type LicenseVerification struct {
ID int64 `gorm:"primaryKey"`
TenantID int64 `gorm:"uniqueIndex"`
LicenseNumber string // 许可证编号
LicenseImageURL string // 证照图片URL
Status string // pending / verified / rejected / expired
ReviewedBy *int64 // 审核人ID
ReviewedAt *time.Time // 审核时间
RejectReason string // 拒绝原因
CreatedAt time.Time
UpdatedAt time.Time
}
func (s *PaymentService) EnablePayment(ctx context.Context, tenantID int64) error {
// 检查证照是否有效
var license LicenseVerification
err := s.db.Where(
"tenant_id = ? AND status = ?",
tenantID, "verified",
).First(&license).Error
if err != nil {
return ErrLicenseRequired // 证照未验证或已过期
}
// 证照有效,开通支付
return s.enablePaymentChannel(ctx, tenantID)
}
这个门控是强制的——即使绕过前端直接调API,后端也会检查证照状态。证照过期后,支付功能自动关闭,直到商户更新证照。
个人信息保护法(PIPL)合规
《个人信息保护法》对个人信息处理提出了严格要求。餐饮SaaS涉及的个人信息包括:
| 信息类型 | 示例 | 敏感级别 |
|---|---|---|
| 会员手机号 | 138****1234 | 中 |
| 会员姓名 | 张* | 中 |
| 支付凭证 | 微信交易号 | 低 |
| 消费记录 | 2026-07-18 午餐 ¥89 | 低 |
| 人脸数据 | (我们不采集) | 高 |
数据最小化
我们只采集业务必需的信息,不做过度采集:
type Member struct {
ID int64 `gorm:"primaryKey"`
TenantID int64 `gorm:"index"`
Phone string // 加密存储
Name string // 可选,非必填
Level string // 会员等级
Points int64 // 积分
CreatedAt time.Time
// 不采集:身份证号、人脸、住址
}
数据脱敏
展示层做脱敏处理:
func MaskPhone(phone string) string {
if len(phone) != 11 {
return "***"
}
return phone[:3] + "****" + phone[7:]
}
func MaskName(name string) string {
runes := []rune(name)
if len(runes) <= 1 {
return "*"
}
return string(runes[0]) + strings.Repeat("*", len(runes)-1)
}
数据删除权
用户有权要求删除个人信息。我们提供了"软删除+匿名化"方案:
func (s *MemberService) DeleteMemberData(ctx context.Context, memberID int64) error {
tenantID := ctx.Value(CtxKeyTenantID).(int64)
// 匿名化个人信息
return s.db.Model(&Member{}).
Where("tenant_id = ? AND id = ?", tenantID, memberID).
Updates(map[string]interface{}{
"phone": "DELETED_" + strconv.FormatInt(memberID, 10),
"name": "已注销用户",
"status": "deleted",
}).Error
}
注意:消费记录不删除(合规要求保留),但关联的个人信息被匿名化。这样既满足了用户的删除权,又保留了交易记录的完整性。
数据保留策略
不同类型的数据,保留期限不同:
| 数据类型 | 保留期限 | 法律依据 | 过期处理 |
|---|---|---|---|
| 交易记录 | 5年 | 《电子商务法》 | 归档到冷存储 |
| 退款记录 | 5年 | 《电子商务法》 | 归档到冷存储 |
| 支付日志 | 5年 | 《非金融机构支付服务管理办法》 | 归档到冷存储 |
| 审计日志 | 5年 | 内部合规要求 | 归档到冷存储 |
| 会员信息 | 用户注销后30天 | 《个人信息保护法》 | 匿名化 |
| 操作日志 | 3年 | 内部合规要求 | 删除 |
自动归档任务
func (s *ArchiveService) ArchiveOldData(ctx context.Context) error {
cutoff := time.Now().AddDate(-5, 0, 0) // 5年前
// 1. 导出过期数据到CSV
var orders []Order
s.db.Where("created_at < ? AND status = 'completed'", cutoff).FindInBatches(&orders, 1000, func(tx *gorm.DB, batch int) error {
csvData := s.exportToCSV(orders)
s.objectStorage.Upload(ctx, fmt.Sprintf("archive/orders/%s/batch_%d.csv", cutoff.Format("20060102"), batch), csvData)
return nil
})
// 2. 验证归档完整性
// ... 校验逻辑
// 3. 删除已归档数据(保留摘要统计)
s.db.Where("created_at < ? AND archived = true", cutoff).Delete(&Order{})
return nil
}
哈希链审计日志
审计日志是合规体系的基石。但普通的审计日志有个问题:可以被篡改。数据库管理员可以直接UPDATE或DELETE日志记录。
我们的方案是哈希链(Hash Chain)——每条日志包含前一条日志的哈希值,形成一条不可篡改的链。
type AuditLog struct {
ID int64 `gorm:"primaryKey"`
TenantID int64 `gorm:"index"`
UserID int64
Action string // create_order / refund / modify_price / ...
TblName string // 操作的表名
RecordID int64 // 操作的记录ID
BeforeData *string `gorm:"type:jsonb"` // 变更前数据
AfterData *string `gorm:"type:jsonb"` // 变更后数据
IPAddress *string // 请求IP
UserAgent *string // 请求UA
PrevHash string `gorm:"column:prev_hash"` // 前一条日志的哈希
RecordHash string `gorm:"column:record_hash"` // 本条日志的哈希
CreatedAt time.Time `gorm:"index"`
}
哈希链构建
func (s *AuditService) Log(ctx context.Context, action string, tblName string, recordID int64, beforeData, afterData *string) error {
tenantID := ctx.Value(CtxKeyTenantID).(int64)
userID := ctx.Value(CtxKeyUserID).(int64)
// 获取前一条日志的哈希
var prevLog AuditLog
s.db.Where("tenant_id = ?", tenantID).Order("id DESC").First(&prevLog)
prevHash := ""
if prevLog.ID > 0 {
prevHash = prevLog.RecordHash
}
// 计算本条日志的哈希
log := &AuditLog{
TenantID: tenantID,
UserID: userID,
Action: action,
TblName: tblName,
RecordID: recordID,
BeforeData: beforeData,
AfterData: afterData,
PrevHash: prevHash,
CreatedAt: time.Now(),
}
hashInput := fmt.Sprintf("%d:%d:%s:%s:%d:%v:%v:%s:%d",
log.TenantID, log.UserID, log.Action, log.TblName,
log.RecordID, log.BeforeData, log.AfterData, log.PrevHash, log.CreatedAt.UnixNano())
log.RecordHash = fmt.Sprintf("%x", sha256.Sum256([]byte(hashInput)))
return s.db.Create(log).Error
}
哈希链验证
func (s *AuditService) VerifyChain(ctx context.Context, tenantID int64) (bool, []string) {
var logs []AuditLog
s.db.Where("tenant_id = ?", tenantID).Order("id ASC").Find(&logs)
var brokenLinks []string
prevHash := ""
for _, log := range logs {
// 检查prev_hash是否匹配
if log.PrevHash != prevHash {
brokenLinks = append(brokenLinks, fmt.Sprintf("日志ID %d:prev_hash不匹配", log.ID))
}
// 验证record_hash
hashInput := fmt.Sprintf("%d:%d:%s:%s:%d:%v:%v:%s:%d",
log.TenantID, log.UserID, log.Action, log.TblName,
log.RecordID, log.BeforeData, log.AfterData, log.PrevHash, log.CreatedAt.UnixNano())
expectedHash := fmt.Sprintf("%x", sha256.Sum256([]byte(hashInput)))
if log.RecordHash != expectedHash {
brokenLinks = append(brokenLinks, fmt.Sprintf("日志ID %d:record_hash被篡改", log.ID))
}
prevHash = log.RecordHash
}
return len(brokenLinks) == 0, brokenLinks
}
如果有人篡改了中间某条日志,从那条日志开始,后续所有日志的哈希链都会断裂。这种设计使得篡改成本极高——要改一条,就得改后面所有的。
审计日志的权限控制
-- 审计日志表:只有INSERT权限,没有UPDATE和DELETE
REVOKE UPDATE, DELETE ON audit_logs FROM app_role;
GRANT INSERT, SELECT ON audit_logs TO app_role;
数据库层面禁止修改和删除,配合哈希链验证,双重保障审计日志的不可篡改性。
退款追溯
退款记录是合规审计的重点。每一笔退款必须可追溯到:
- 谁发起的——操作人ID和角色
- 为什么退款——退款原因
- 退了多少——退款金额
- 退到哪里——原路退回的交易号
- 什么时候退的——精确到秒
type RefundLog struct {
ID int64 `gorm:"primaryKey"`
TenantID int64 `gorm:"index"`
OrderID int64 `gorm:"index"`
PaymentID *int64 `gorm:"index"`
RefundAmount Decimal // 退款金额(元)
RefundReason string // 退款原因
RefundType string // 退款类型
RefundItems *string `gorm:"type:text"` // 退款菜品明细
OperatorName *string // 操作人姓名
IsFullRefund bool // 是否全额退款
Status string // pending / success / failed
IdempotencyKey *string `gorm:"uniqueIndex"` // 幂等键
CreatedAt time.Time `gorm:"index"`
}
5年保留期内,任何一笔退款都可以被完整追溯。
小结
合规体系的核心要素:
| 要素 | 实现 | 价值 |
|---|---|---|
| 非金融定位 | 仅调用官方SDK | 合规边界清晰 |
| 证照门控 | 无证照不开支付 | 食品安全合规 |
| PIPL合规 | 最小化+脱敏+删除权 | 个人信息保护 |
| 数据保留 | 分级保留+自动归档 | 满足法定保留期 |
| 哈希链审计 | 不可篡改的审计日志 | 交易可追溯 |
| 退款追溯 | 5年完整记录 | 合规审计无忧 |
合规不是成本,是护城河。做好了合规,客户才敢把数据交给你。
下一篇,我们聊支付全链路——微信扫码→下单→支付→出票,30秒搞定。
系列导航:[龙讯餐饮SaaS架构实战]
← 上一篇:从1家店到100家店代码一行不用改:多门店架构设计 | 下一篇 →微信扫码→下单→支付→出票,30秒全链路
- 从单体到多租户:餐饮SaaS架构演进之路
- 100家店的数据如何互不偷看:多租户隔离深度解析
- 你的支付密钥我们连自己都看不到:AES-256-GCM加密实战
- 3秒出单200ms响应:餐饮收银系统性能密码
- 从1家店到100家店代码一行不用改:多门店架构设计
- 退款5年可追溯:餐饮SaaS合规体系构建
- 微信扫码→下单→支付→出票,30秒全链路
- Go + PostgreSQL + Redis:餐饮SaaS后端架构全景