退款5年可追溯:餐饮SaaS合规体系构建

退款5年可追溯:餐饮SaaS合规体系构建

做SaaS,功能做出来了只是第一步,合规做不好,随时可能翻车。餐饮行业涉及支付、用户隐私、食品安全,合规要求比普通SaaS高得多。

龙讯餐饮V2的合规体系,核心围绕五个字:可追溯、可审计、可证明。每一笔交易、每一次退款、每一条数据访问,都要经得起查。

系统定位:非金融中转方

这是最根本的合规前提。龙讯餐饮SaaS的定位是:

非金融中转方,仅调用官方支付API。

这意味着什么?

  1. 我们不碰钱——资金直达商户在支付平台的账户(mch_id),到账时间和提现规则取决于商户与支付平台的合约约定
  2. 我们不结算——不做资金清算,不做资金分发
  3. 我们不担保——不承担支付风险,不承担退款资金责任

这个定位决定了我们的合规边界:

维度 我们做的 我们不做的
支付 调用微信/支付宝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;

数据库层面禁止修改和删除,配合哈希链验证,双重保障审计日志的不可篡改性。

退款追溯

退款记录是合规审计的重点。每一笔退款必须可追溯到:

  1. 谁发起的——操作人ID和角色
  2. 为什么退款——退款原因
  3. 退了多少——退款金额
  4. 退到哪里——原路退回的交易号
  5. 什么时候退的——精确到秒
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秒全链路

  1. 从单体到多租户:餐饮SaaS架构演进之路
  2. 100家店的数据如何互不偷看:多租户隔离深度解析
  3. 你的支付密钥我们连自己都看不到:AES-256-GCM加密实战
  4. 3秒出单200ms响应:餐饮收银系统性能密码
  5. 从1家店到100家店代码一行不用改:多门店架构设计
  6. 退款5年可追溯:餐饮SaaS合规体系构建
  7. 微信扫码→下单→支付→出票,30秒全链路
  8. Go + PostgreSQL + Redis:餐饮SaaS后端架构全景

转载请注明来源,欢迎对文章中的引用来源进行考证,欢迎指出任何有错误或不够清晰的表达。可以在下面评论区评论,也可以邮件至 jaytp@qq.com

×

喜欢就点赞,疼爱就打赏