PAYATHON 2026

如何设计可维护的银行交易分类 fallback pipeline?

支付阿杰

结论

采用 chain/fallback pipeline 是合理的,尤其适合不同银行字段完整度不一的场景。银行类别、MCC、商户别名、关键词以及后续的 ML 模型都可以拆成独立策略,避免 service 最终变成难以维护的大型 if/else。

但 classifier 最好不要只返回 Optional<TransactionCategory>。实际系统往往还需要记录:

  • 分类依据;
  • 命中的规则;
  • 结果的可信度;
  • 是否存在冲突;
  • 使用的规则或模型版本。

另外,“第一个命中即结束”只适合优先级明确、误判率可控的分类器。银行类别未必总比 MCC 或商户规则可靠,所以执行顺序应根据数据质量配置,不能把它写死为业务常识。

先把标准化与分类分开

银行原始行解析、文本标准化和类别判断是三个不同的职责:

Bank row
    ↓
Bank-specific parser
    ↓
ParsedTransaction
    ↓
Normalization / feature extraction
    ↓
Classification pipeline
    ↓
ClassificationResult

ParsedTransaction 应尽量保留解析后字段的原始含义。不要为了方便匹配而直接覆盖 merchantName 或 description,可以另外生成分类上下文:

public record ClassificationContext(
    ParsedTransaction transaction,
    String normalizedMerchant,
    String normalizedDescription,
    Set<String> merchantTokens,
    String sourceBank
) {}

标准化组件可以统一处理:

  • 大小写及 Unicode;
  • 多余的空白和标点;
  • 银行固定前缀;
  • 脱敏卡号;
  • 支付参考号;
  • 可识别的日期和地点后缀。

去噪需要有针对性,不能简单删除所有数字。门牌号、商户分店号、账单编号,甚至部分交易类型都可能包含有效数字。

返回可解释的分类结果

相比 Optional<TransactionCategory>,更适合的返回值是带有证据的候选结果:

public enum ClassificationSource {
    USER_OVERRIDE,
    BANK_CATEGORY,
    MCC,
    MERCHANT_ALIAS,
    KEYWORD_RULE,
    MACHINE_LEARNING
}

public record ClassificationCandidate(
    TransactionCategory category,
    ClassificationSource source,
    String ruleId,
    double confidence,
    String explanation
) {}

classifier 接口仍然可以很简单:

public interface TransactionClassifier {
    Optional<ClassificationCandidate> classify(
        ClassificationContext context
    );
}

最终结果还可以保存 pipeline 和规则版本:

public record ClassificationResult(
    TransactionCategory category,
    ClassificationSource source,
    String ruleId,
    double confidence,
    String explanation,
    String rulesetVersion
) {}

这些信息不只是为了方便排查。后续加入人工修正、规则灰度发布、历史数据重算和 ML 训练时,同样会用到它们。

如何组织 fallback chain

如果各 classifier 的优先级已经明确约定,可以按顺序执行并采用第一个有效结果:

public final class TransactionClassificationService {

    private final List<TransactionClassifier> classifiers;
    private final String rulesetVersion;

    public TransactionClassificationService(
        List<TransactionClassifier> classifiers,
        String rulesetVersion
    ) {
        this.classifiers = List.copyOf(classifiers);
        this.rulesetVersion = rulesetVersion;
    }

    public ClassificationResult classify(ClassificationContext context) {
        return classifiers.stream()
            .map(classifier -> classifier.classify(context))
            .flatMap(Optional::stream)
            .findFirst()
            .map(candidate -> new ClassificationResult(
                candidate.category(),
                candidate.source(),
                candidate.ruleId(),
                candidate.confidence(),
                candidate.explanation(),
                rulesetVersion
            ))
            .orElse(new ClassificationResult(
                TransactionCategory.UNKNOWN,
                null,
                null,
                0.0,
                "No classifier produced an acceptable result",
                rulesetVersion
            ));
    }
}

在 Spring 中,可以显式构造执行顺序,也可以通过 @Order 管理 classifier 顺序。不过,分类优先级本身属于业务规则,显式配置通常比依赖组件扫描顺序更容易审查。

初始顺序可以设为:

  1. 用户或人工确认的覆盖规则;
  2. 银行及账户范围内的高精度规则;
  3. MCC 映射;
  4. 商户别名;
  5. 关键词或交易类型规则;
  6. 达到置信度阈值的 ML 结果;
  7. UNKNOWN。

这个顺序只是起点,不能直接套用到所有银行。某家银行的 bankCategoryName 可能稳定可靠,另一家银行却可能大量使用语义模糊的类别。因此,映射至少要包含 sourceBank,必要时还要区分账户类型和数据格式版本。

第一命中与候选评分如何选择

第一命中模型简单且容易预测,适合以下情况:

  • 规则已经按精度严格排序;
  • 高优先级规则的误判率在可接受范围内;
  • classifier 能在证据不足时不返回结果;
  • 规则之间很少冲突。

如果多个来源经常给出不同结论,可以让所有 classifier 都返回候选项,再交给统一的决策组件:

public interface TransactionClassifier {
    List<ClassificationCandidate> classify(
        ClassificationContext context
    );
}

聚合器可以同时考虑来源优先级、置信度、规则特异性和冲突情况。例如,精确的商户别名通常应排在宽泛的 description 关键词之前。

不要默认所有来源的 confidence 都是可以直接比较的概率。手工规则的 0.9 与模型输出的 0.9 未必含义相同。若要统一排序,就需要定义评分语义,并使用真实标注数据进行校准。否则,采用”来源优先级 + 来源内部评分”通常更稳妥。

映射数据放在哪里

数据库、配置文件和代码各有用途,不必三选一。

适合放在代码中的内容

稳定的领域结构和算法适合留在代码中,例如:

  • TransactionCategory;
  • classifier 接口;
  • 标准化算法;
  • 候选结果的决策流程;
  • 规则校验逻辑。

经常变化的商户名单不适合写成大量 switch 或 if。

适合放在配置文件中的内容

如果映射规模不大,由开发人员维护,并且希望通过代码审查和部署发布,可以使用 YAML、JSON 或 CSV。例如:

mccMappings:
  "5411": GROCERIES
  "4111": TRANSPORT
  "5812": RESTAURANTS

merchantAliases:
  - id: merchant-ah
    canonicalName: ALBERT_HEIJN
    aliases:
      - AH
      - ALBERT HEIJN
      - AH TO GO
    category: GROCERIES

配置文件简单、可以纳入版本控制,也容易回滚。但修改通常需要经过发布流程,不适合大量在线编辑或按租户维护。

适合放在数据库中的内容

出现以下需求时,更适合使用数据库:

  • 运营人员需要直接维护规则;
  • 映射必须在不发布应用的情况下更新;
  • 规则因银行、国家、账户或租户而异;
  • 需要设置生效和失效时间、优先级及审批状态;
  • 需要审计、版本、回滚或灰度发布;
  • 规则数量较大。

表结构不应只有”字符串到类别”两个字段,至少可以考虑:

rule_id
rule_type
source_bank
match_value
match_mode
category
priority
enabled
valid_from
valid_to
ruleset_version
created_by
updated_at

其中 match_mode 可以区分:

  • EXACT;
  • PREFIX;
  • TOKEN;
  • REGEX;
  • MCC_EXACT;
  • BANK_CATEGORY_EXACT。

精确匹配、别名匹配和正则匹配需要明确区分。否则,规则增多后很难判断交易为什么会命中某条规则。

实践中常采用混合方案:内置的基础 MCC 映射放在版本化文件中,变化较频繁的商户别名和人工覆盖规则则存入数据库。

商户别名不要只做字符串替换

AH、Albert Heijn 和 AH TO GO 可以指向同一个 canonical merchant,但别名规则必须考虑匹配边界。

例如,直接使用 description.contains("AH") 很容易误匹配无关单词。对于短别名,尤其应该采用:

  • 标准化后的 merchant 字段,而不是任意 description;
  • 完整字段匹配;
  • token 边界匹配;
  • 银行或国家范围限制;
  • 更高的最小匹配长度。

商户识别和类别映射也可以拆开:

Raw merchant text
    ↓
Canonical merchant: ALBERT_HEIJN
    ↓
Merchant category: GROCERIES

这样,每个别名只负责解析到 canonical merchant,类别规则不必为同一商户的每个别名重复维护。

同一商户也未必始终属于同一类别,大型电商平台、商场、支付聚合商和钱包类描述尤其如此。遇到这类商户时,强行分配固定类别可能比返回 UNKNOWN 风险更高。

银行类别和 MCC 也需要上下文

银行提供的 category 通常属于外部分类体系。即使名称相同,也不能认为它与内部类别的语义完全一致。映射时建议使用组合键:

(sourceBank, bankCategoryName) → internalCategory

而不是只使用:

bankCategoryName → internalCategory

MCC 同样不是绝对事实。它通常描述受理商户的类型,不一定代表某笔交易的实际用途,而且可能缺失、错误或范围过宽。MCC 映射可以作为较强的证据,但仍要保存来源和原始值。

交易方向也应该纳入上下文。相同的描述可能对应付款、退款、收入或转账,至少要保留:

  • 金额正负或 debit/credit 方向;
  • 交易类型;
  • 是否冲正;
  • 是否退款;
  • 是否为账户间转账。

否则,Transfer 之类的关键词很容易把工资、外部汇款和本人账户之间的转账混在一起。

后续加入 ML 不需要改写流程

只要 ML classifier 遵循同一个接口,就可以把它作为低优先级策略加入现有 pipeline:

public final class MachineLearningClassifier
        implements TransactionClassifier {

    private static final double ACCEPTANCE_THRESHOLD = 0.85;

    @Override
    public Optional<ClassificationCandidate> classify(
        ClassificationContext context
    ) {
        ModelPrediction prediction = predict(context);

        if (prediction.confidence() < ACCEPTANCE_THRESHOLD) {
            return Optional.empty();
        }

        return Optional.of(new ClassificationCandidate(
            prediction.category(),
            ClassificationSource.MACHINE_LEARNING,
            prediction.modelVersion(),
            prediction.confidence(),
            "Prediction accepted above configured threshold"
        ));
    }

    private ModelPrediction predict(ClassificationContext context) {
        throw new UnsupportedOperationException("Model integration omitted");
    }
}

这里的 0.85 只是结构示例,并非通用推荐值。实际阈值要根据独立验证数据、各类别的误判成本以及置信度校准结果确定。

从一开始,ML 就应该遵守以下约束:

  • 允许拒绝分类并回退到 UNKNOWN;
  • 保存模型版本和输入特征版本;
  • 分开存储预测结果与最终人工修正;
  • 防止未经确认的历史预测再次被当作训练标签;
  • 按时间切分训练集和验证集,避免同一交易或近似重复交易发生泄漏;
  • 分类别评估,而不只看整体 accuracy。

例如,大量 GROCERIES 样本可能让整体 accuracy 看起来不错,但系统仍可能几乎无法识别 RENT 或 TRANSFER。

应尽早处理的常见陷阱

把缺失值和空字符串混为一谈

不同解析器可能分别产生 null、"" 或只包含空格的字符串。应在边界层统一缺失值的语义,否则各 classifier 的行为会不一致。

过度信任来源顺序

字段存在不代表字段可靠。classifier 只有在证据足够时才应返回候选,不能看到非空字段就强制给出类别。

关键词规则过宽

rent、gas、care 等词可能出现在商户名、地点或参考文本中。规则需要明确作用字段、词边界、排除条件和优先级。

没有负向规则

只支持”出现某词就归类”通常不够。例如,一条规则可能需要表达:

包含 "NS"
且 merchant 字段精确匹配
但不包含特定退款或账户验证标记

规则模型应允许表达必要的排除条件,不能依靠不断调整执行顺序来掩盖冲突。

丢失原始数据

标准化结果和原始字段都要保留。否则,规则发生误判时无法复现,标准化算法升级后也不能重新计算。

处理卡号、账户号或付款参考号时,还要遵守数据最小化原则,只保留分类和审计确实需要的信息,并对日志中的相关数据进行脱敏。

没有版本信息

规则变化后,同一笔交易在不同时间可能得到不同结果。系统应保存:

  • 分类时间;
  • ruleset version;
  • classifier source;
  • rule ID 或 model version;
  • 是否经过人工覆盖。

有了这些信息,才能解释历史结果,并判断是否需要批量重新分类。

把人工修正覆盖掉

人工确认的结果通常应具有最高优先级。重新运行 pipeline 时,不能静默覆盖用户修正。更稳妥的做法是分别保存:

predictedCategory
confirmedCategory
classificationStatus

最终展示类别可以优先使用 confirmedCategory。

只测试单个 classifier

除了为每个 classifier 编写单元测试,还需要准备 pipeline 级别的回归样本。测试数据应覆盖:

  • 字段缺失;
  • 来源冲突;
  • 大小写和 Unicode 差异;
  • 退款和冲正;
  • 正负金额;
  • 短商户别名;
  • 噪声文本;
  • 完全未知的商户;
  • 新规则是否意外改变旧结果。

可以维护一组版本化的”黄金交易样本”,每次修改标准化逻辑或分类规则时都运行回归测试。

推荐的落地方式

可以先实现一个简单但信息完整的版本:

  1. 保留银行原始字段,并生成统一的 ParsedTransaction。
  2. 使用独立组件生成 ClassificationContext。
  3. 让 classifier 返回包含来源、规则 ID 和可信度的候选结果。
  4. 初期采用显式排序的 first-match pipeline。
  5. 将基础 MCC 映射放入版本化配置,把频繁变化的商户规则和人工规则放入数据库。
  6. 保存最终结果、命中证据和 ruleset version。
  7. 无法可靠判断的交易返回 UNKNOWN。
  8. 积累经过人工确认的标签后,再接入能够拒绝预测的 ML classifier。
  9. 规则冲突逐渐增多后,再把 first-match 升级为候选聚合,不必一开始就构建复杂的评分引擎。

这个方案整体可行。系统能否长期维护,不仅取决于是否把 classifier 拆成多个类,还取决于是否把标准化、规则数据、优先级、解释信息、人工覆盖和版本管理建模为明确的一等概念。

备注:内容仅供参考。