简介:一套基于前后端分离架构的银行流水数据分析系统毕设项目,面向计算机相关专业学生及数据分析入门者,为解决单一账户多笔资金流向处理与展示的人工筛查问题提供参考。压缩包共56个文件,以21个Go后端文件、12个Vue前端文件和10个TypeScript类型定义为主,另含配置、样式、文档等支持内容,总大小约1.96MB,目录结构便于按模块检索。后端集成Gin、Gorm、Viper、Logrus、Casbin与Excelize,覆盖Web服务、持久化、配置管理、日志、角色权限控制及表格解析;前端基于Vue与TypeScript构建。技术实现上,通过Golang反射创建结构体完成用户数据注入并做到数据库表级隔离,借助SQL语句上下错行比对识别交易数据不一致,实现异常数据标识。已有215人学习,适合用来理解银行流水分析场景、权限控制设计及前后端分离项目组织方式。
1. 银行流水数据分析系统到底在解决什么问题
刚接手这类“银行流水数据分析系统”的毕业设计时,最容易犯的错是先找数据、再搭界面,最后发现整个系统只是个“能打开 Excel 的图表工具”。实际上面试官和答辩老师真正关心的不是你会不会画折线图,而是面对一份几千行的银行流水,你能不能设计出“从原始交易记录到资金特征”的完整分析链路。银行流水数据虽然字段少,但脏数据多、金额语义含糊、时间跨度长,直接套用通用 BI 工具往往得不到稳定结论。这套系统的核心价值在于三件事:把不同银行导出的流水文件统一成标准结构,把收入、支出、结余、往来对手方等特征算得可靠,再让分析结果可验证、可解释。适合的人群不只是准备毕业设计的学生,还包括需要做流水尽调、个人信贷辅助判断或企业资金分析的开发者和数据工程师。
2. 先把银行流水数据结构化:从 XLSX、CSV 到标准交易表
2.1 不同银行的导出文件差异比想象中大
“银行流水”不是一种标准格式,而是同一类业务数据的多个变体。常见的下载渠道分三种:网银页面导出、手机银行分享、柜台打印后扫描识别;常见文件后缀有.xlsx、.xls、.csv,偶尔还有 PDF。字段名更是各行其是,同一含义有“交易时间”、“记账日期”、“入账日期”三种写法,收入和支出有的银行分成两列,有的合并成一列再用“收/支”标记表示,有的干脆用正负数区分。这是做流水数据相关系统时必须直面的事实,毕设答辩时这部分最容易被提问。
我一般会在系统里设计一个“文件解析适配层”,而不是在业务代码里到处写if bank == 'ICBC'。适配层的输出是一张标准交易表,字段固定为:
| 字段名 | 类型 | 说明 |
|---|---|---|
| tx_date | date | 交易日期,精确到日即可 |
| tx_time | varchar | 交易时间,允许为空,尽量保留原始串 |
| tx_type | varchar | 枚举值:income / expense / transfer |
| amount | decimal(14,2) | 金额绝对值,正数 |
| balance | decimal(14,2) | 该笔交易后的账户余额,可能为空 |
| counterparty | varchar | 交易对手方名称或对方账号 |
| remark | varchar | 摘要或附言 |
| raw_line_hash | char(32) | 原始行的 MD5,用于去重和溯源 |
tx_type不要用中文直接入库,统一转成英文枚举值,后面算指标时能少写一堆case when。amount与tx_type搭配,含义是清晰的:income 的 amount 增加余额,expense 的 amount 减少余额。raw_line_hash很多人忽略,但它是去重和定位脏数据的关键键。
2.2 用 Pandas 完成字段对齐与类型修正
即使不做复杂的数据治理,至少要做格式层清洗。下面这段代码处理的是最常见的“一行内同时有收支方向和金额”的文件:
import pandas as pd import hashlib def normalize_bill(raw_df: pd.DataFrame, col_map: dict) -> pd.DataFrame: df = raw_df.rename(columns=col_map) # 1. 统一日期格式,部分文件里日期是 2024/1/5 或 20240105 df['tx_date'] = pd.to_datetime(df['tx_date'], errors='coerce').dt.date # 2. 交易类型标准化:优先从收支方向列读取 if 'direction' in df.columns: df['tx_type'] = df['direction'].map({'收入': 'income', '支出': 'expense'}) elif 'amount' in df.columns and 'balance' in df.columns: # 部分文件用正负金额表示收支 df['tx_type'] = df['amount'].apply(lambda x: 'income' if x > 0 else 'expense') df['amount'] = df['amount'].abs() # 3. 处理金额里的逗号、人民币符号、全角空格 df['amount'] = ( df['amount'].astype(str) .str.replace(',', '', regex=False) .str.replace('¥', '', regex=False) .str.replace(' ', '', regex=False) ) df['amount'] = pd.to_numeric(df['amount'], errors='coerce') # 4. 对手方缺失时取备注折半截断,便于后续文本聚合 df['counterparty'] = df['counterparty'].fillna(df['remark'].str[:12]) # 5. 生成行指纹,保证同一笔交易从不同文件导入时能识别 hash_input = ( df['tx_date'].astype(str) + df['amount'].astype(str) + df['remark'].fillna('') ) df['raw_line_hash'] = hash_input.map(lambda x: hashlib.md5(x.encode()).hexdigest()) return df[['tx_date', 'tx_time', 'tx_type', 'amount', 'balance', 'counterparty', 'remark', 'raw_line_hash']]参数说明:col_map是原始列名到标准列名的映射,调用前先打印raw_df.head()确认采样值,再把{'交易日期': 'tx_date', '金额': 'amount'}这样的映射传进来;errors='coerce'保证解析失败的日期不会中断批量导入,而是置空;金额清洗里的regex=False是为了避免把逗号误认为正则元字符,全角空格的处理建议先encode('utf-8')再替换,细节不多但真能省事。
2.3 导入后必须做六项质量校验
结构统一只是第一步,质检是把关分析结论可信度的唯一手段。常见做法是写一个validate_flow(df)函数,返回校验报告,而不是直接抛异常。
2.3.1 校验内容与判定口径
| 校验项 | 判定口径 |
|---|---|
| 日期连续性 | 当月日期缺失大于 3 天则告警 |
| 余额自洽性 | 当余额字段非空时,计算前一余额 + 本笔变动是否等于本笔余额 |
| 金额异常 | amount 小于等于 0 且 tx_type 非 transfer 时判定为异常 |
| 重复交易 | raw_line_hash 出现次数大于等于 2,且金额超 1000 时标记 |
| 对手方缺失率 | counterparty 空值占比超过 30% 时提示降低文本分析价值 |
| 时间字段一致性 | tx_time 为空不能忽略,需统计占比,占比超 50% 则放弃时间维分析 |
我见过太多毕设项目直接跳过余额自洽校验,最后画出的资金曲线前后矛盾,答辩时被追问“为什么余额对不上”。其实这个校验不复杂,用 shift 就能完成。在上下文中加入:
report = {} df = df.sort_values(['tx_date', 'tx_time']) # 计算期望余额:上一笔余额 + 本笔变动,再与实际余额比较 prev_balance = df['balance'].shift(1) delta = df['amount'] * df['tx_type'].map({'income': 1, 'expense': -1, 'transfer': 0}) expected = prev_balance + delta mismatch_mask = df['balance'].notna() & prev_balance.notna() & (abs(expected - df['balance']) > 0.01) report['balance_mismatch_ratio'] = mismatch_mask.mean()需要注意这段代码的前提是流水中每笔交易的余额都完整,且没有任何间隙。多数银行流水满足这一点,但跨月导出时可能出现月初第一笔余额就是上月末值,shift 判断会报错,所以报告里给出的是“不匹配比例”,而不是直接断言数据有误。
3. 核心分析模块:收支、往来对手方与账户特征提取
3.1 收入支出统计要选用“期间口径”而不是简单汇总
银行流水分析系统里最常用的指标是月收入、月支出、月结余,但直接groupby('月份')['amount'].sum()会忽略转入转出的语义。实际操作中,我把交易分成三类:收入类(工资、报销、转账收入)、支出类(消费、取现、转账支出)、内部调拨类(同名账户互转)。在上一章的标准表里,transfer已经被单独标记,没标记的可以在分析层做二次判断。
def monthly_summary(df: pd.DataFrame) -> pd.DataFrame: df['month'] = df['tx_date'].astype(str).str[:7] income = df[df['tx_type'] == 'income'].groupby('month')['amount'].sum() expense = df[df['tx_type'] == 'expense'].groupby('month')['amount'].sum() transfer = df[df['tx_type'] == 'transfer'].groupby('month')['amount'].sum() result = pd.DataFrame({'income': income, 'expense': expense, 'transfer': transfer}) result['net_saving'] = result['income'] - result['expense'] # 月结余不应包含内部调拨,否则资金规模会被重复计算 result['balance_eop'] = df.groupby('month')['balance'].last() return result.fillna(0)参数说明:str[:7]是把“2024-03-15”截断成“2024-03”,避免to_period带来的时区问题;balance_eop取每月最后一笔交易的余额,而不是把余额加总,这个口径在财务报表里叫“期末时点数”,在毕设文档里写清楚这一笔,能直接体现对业务的理解。另一个要说明的是“net_saving 不包含 transfer”,很多同学把同名账户互转的金额加进净收入,导致分析结果出现“月收入 80 万”的失真情况。
3.2 交易对手方聚合要解决“同一实体多个名字”的问题
对手方字段是流水分析里文本噪声最大的部分。同一家“支付宝”在流水里可能出现“支付宝(中国)网络技术有限公司”“支付宝-转账”“余额宝-转出”等多种写法。如果简单分组,排名前十的“往来对手”会碎片化。常见做法是维护一个关键词归一化字典,按优先级做包含匹配。
counterparty_rules = [ (r'支付宝|余额宝|Alipay', 'Alipay'), (r'微信|WeChat|腾讯', 'WeChat'), (r'工资|代发|薪酬', '代发工资'), (r'POS|消费|商户', 'POS消费'), ] df['counterparty_norm'] = df['counterparty'].astype(str) for pattern, label in counterparty_rules: mask = df['counterparty_norm'].str.contains(pattern, regex=True, na=False) df.loc[mask, 'counterparty_norm'] = label # 对手方净流入 top10,正数表示净收入,负数表示净支出 peer_summary = ( df.groupby('counterparty_norm') .apply(lambda x: x.loc[x['tx_type'] == 'income', 'amount'].sum() - x.loc[x['tx_type'] == 'expense', 'amount'].sum(), include_groups=False) .sort_values(ascending=False) .head(10) )注意include_groups=False是 pandas 2.x 处理 groupby 后 apply 时避免分组列混入运算的写法,老版本会报警告,但结果一致。这个模块完成后,还需要输出一个“往来对手方交易频次”表,频次比金额更能反映资金往来是否稳定。
3.3 账户画像特征的设计要服务后续的异常识别
画像特征不要一次算太多,挑可解释性强的来。我常用的是下面这组特征,每个特征都有明确的业务含义:
- 收入集中度:最大来源对手方金额占比,正常工资卡通常大于 70%,异常账户往往分散。
- 夜间交易占比:晚 22 点到次日 6 点的支出笔数占比,越高说明交易行为越偏离常规。
- 整数交易扎堆:金额为整数(无角分)的笔数占比,赌博、套现场景通常显著偏高。
- 快进快出频次:单笔大额收入在 3 日内被转出的次数,这个特征对识别过渡账户很有价值。
- 月结余波动系数:月度净结余的标准差除以均值,波动接近 1 的账户资金结构不稳定。
feature_df = pd.DataFrame(index=[account_id]) feature_df['income_concentration'] = ( df[df['tx_type'] == 'income']['amount'].max() / df[df['tx_type'] == 'income']['amount'].sum() ) feature_df['night_tx_ratio'] = ( df['tx_time'].fillna('12:00:00').str[:2].astype(int).between(22, 23).sum() + df['tx_time'].fillna('12:00:00').str[:2].astype(int).between(0, 6).sum() ) / len(df)tx_time缺失时填充成12:00:00是因为它既不算夜间也不影响日间比例。这类细节在答辩的代码走查环节非常加分,是一眼能看出你处理过真实数据、而不是只说听见过的。
4. 用规则引擎实现流水异常检测,再叠加一个可视化看板
4.1 为什么规则引擎比直接上机器学习更适合毕设
银行流水数据分析系统里最容易“为了 AI 而 AI”的模块是异常检测。做毕设时,直接用孤立森林或异常编码器(AutoEncoder)跑一遍数据,会面临两个短期无法补齐的短板:一是训练数据没有标签,无法评估召回率和误报率;二是答辩时被问到“为什么把某笔交易标成异常”,模型给不出业务能理解的解释。常见的可靠路径是把流水异常识别做成一棵“规则决策表”,每一条规则都有触发条件和风险等级,后续想升级模型,也可以把规则输出当作特征拼进去。
规则设计遵循一个原则:任何单条规则都不要同时依赖金额和频率之外的高维条件,否则难以调参。
| 规则编号 | 触发条件 | 风险等级 |
|---|---|---|
| R01 | 单笔支出 > 账户三个月平均支出的 8 倍 | 中 |
| R02 | 单日累计转入,转出前 5 个交易日内转入金额之和的 90% 被转出 | 高 |
| R03 | 月收入笔数超过 30 且对手方数超过 10 个,且存在整笔金额精确到元 | 中 |
| R04 | 同日频繁拆分:同一天内对同一对手方交易超过 4 笔,金额逐笔递增 | 高 |
| R05 | 夜间 23 点至凌晨 2 点发生 3 笔以上 > 2000 元的支出 | 低 |
4.2 规则引擎的 Python 落地
def apply_rules(df: pd.DataFrame) -> pd.DataFrame: df = df.copy() df['alert_level'] = 'low' df['alert_rules'] = '' # R01:单笔大额支出 avg_expense = df[df['tx_type'] == 'expense']['amount'].mean() r01_mask = (df['tx_type'] == 'expense') & (df['amount'] > 8 * avg_expense) df.loc[r01_mask, 'alert_level'] = df.loc[r01_mask, 'alert_level'].where( df.loc[r01_mask, 'alert_level'] != 'high', 'high' ) df.loc[r01_mask, 'alert_level'] = 'medium' df.loc[r01_mask, 'alert_rules'] += 'R01;' # R02:快进快出,按收入后 3 天窗口扫描 income_dates = df[df['tx_type'] == 'income']['tx_date'].unique() for dt in income_dates: window_df = df[df['tx_date'] <= dt + pd.Timedelta(days=3)] recent_in = window_df[window_df['tx_type'] == 'income']['amount'].sum() recent_out = window_df[window_df['tx_type'] == 'expense']['amount'].sum() if recent_in > 0 and recent_out / recent_in > 0.9: df.loc[df['tx_date'] == dt, 'alert_level'] = 'high' df.loc[df['tx_date'] == dt, 'alert_rules'] += 'R02;' return df[df['alert_level'] != 'low']这里逐日扫描的性能在分析几万行流水时是可接受的,但如果审计场景要处理百万行,建议把窗口计算改成向量化的 rolling 值而不是 for 循环。提示:规则模块的输入输出要标准化,输出数据里alert_rules字段用分隔符拼装,便于看板端直接把规则名渲染成中文标签。
4.3 看板选型:优先考虑能放进答辩演示环境的应用
流水分析系统的可视化部分会占据毕设演示一半的时间,所以选型标准不是功能多少,而是“能不能在本地快速启动、离线可用、方便截图”。用 Django/Vue 做全套前端会花太多时间在接口联调上,用 Jupyter Notebook 又显得工程性不足。常见的可靠选择是 Streamlit,它天然支持 DataFrame 操作,几分钟就能把指标模块包成可交互页面。
import streamlit as st st.set_page_config(page_title="银行流水数据分析系统", layout="wide") uploaded_file = st.file_uploader("上传银行流水文件", type=["xlsx", "csv"]) if uploaded_file is not None: raw_df = read_raw(uploaded_file) stdf = normalize_bill(raw_df, col_map) stdf = apply_rules(stdf) col1, col2, col3 = st.columns(3) col1.metric("总收入", f"{stdf[stdf['tx_type']=='income']['amount'].sum():,.2f} 元") col2.metric("总支出", f"{stdf[stdf['tx_type']=='expense']['amount'].sum():,.2f} 元") col3.metric("告警交易笔数", len(stdf[stdf['alert_level'] != 'low'])) st.line_chart(monthly_summary(stdf)[['income', 'expense']])在 Streamlit 的metric组件里,金额格式化用了:,.2f,小数位会稳定到两位,不会出现科学计数法。st.line_chart会自动对索引排序,所以传入的数据要保证月份升序,前面的monthly_summary()已经按月份字符串排序了,这一点可以直接依赖。这类看板后续如果要升级成企业应用,再迁移到 FastAPI + React 也不迟,迁移成本主要集中在解析适配层,而分析逻辑都是纯函数,这点在文档里提前说明会更有说服力。
5. 让毕设更像生产系统的三个细节
流水分析系统在演示时能跑通不代表交付完整,还有三个细节值得在答辩前补上,它们在项目文档里能各占一个二级标题,工作量并不大,但能显著拉开和同类毕设的差距。
第一个细节是增量导入。真实场景下,用户会定期追加流水文件,而不是每次全量导入。做法是维护一张import_batch表,记录每次导入的文件名、文件哈希、导入时间、覆盖日期区间。再次上传时先比对文件哈希,相同则不处理;若哈希不同但日期区间重叠,则提示“存在数据变更,是否覆盖”。这个逻辑有 30 行代码就能完成,但能让系统从“一次性的脚本”升级为“可持续使用的小工具”。
第二个细节是分析结果的字典表。很多人在页面直接把counterparty_norm或alert_rules的枚举值展示出来,用户看不懂“R02;R05”是什么意思。给系统加一张metric_dict表,存放指标编码、名称、口径说明、参考阈值,看板端用st.dataframe的column_config关联字典做中文映射。这会逼着你把每个指标的计算口径写清楚,答辩评委追问“净结余怎么算的”时,你能直接拿出定义文档。
第三个细节是数据脱敏展示。如果系统里还包含客户姓名、完整银行账号这类字段,哪怕只是毕设,也应该在展示层打码。简单处理是只显示前四位后两位:138****4201,并在导出报表时提供脱敏开关。核心模块的代码示例是保留原始明细的,但对外演示时一定不要开“完整显示”权限。加上这个开关后的效果,是在合规层面把系统从“能用”变成了“敢给别人用”。
本文还有配套的精品资源,点击获取