简介:围绕基金交易平台数据库设计展开的Oracle项目实战文档,面向数据库课程设计、Oracle入门进阶及金融业务建模学习者,帮助读者从需求描述推进到表结构与外键关系落地。内容以招商银行某分行开放式基金交易平台为背景,梳理后台基金管理、客户账户管理、交易审核,以及前台信息查询、委托交易、基金账户和理财账户管理等模块,并给出基金公司、基金、活期账户、理财账户、基金账户、购买基金、交易等数据表的字段、类型、主外键约束与状态位设计,同时包含相关技术工具、阶段划分和项目总结,便于对照完成课程设计或项目复盘。压缩包内共1个doc文件,约245KB,适合按章节阅读并摘录表结构。已有453人学习,可作为金融类数据库建模的参考样例,重点体会多账户体系与交易审核流程中的实体关系设计。
1. 七张表串成的基金交易平台:这套 Oracle 项目实战能跑通什么
想找一套能把 PL/SQL 学到的东西全串起来的 Oracle 项目,课程设计里那套“图书管理”往往撑不住场面——表太少,业务没有状态流转,写完增删改查就没事干了。这套开放式基金交易平台的库不一样:基金公司、基金、活期账户、理财账户、基金账户、购买合同、交易流水七张表首尾相接,一笔买入要穿过账户余额、冻结资金、审核状态三道关,正好把序列、触发器、程序包、存储过程、外键约束一次性用上。它适合刚学完 PL/SQL 块、需要一个完整闭环练手的人,也适合想复习“数据库侧做业务校验”这种老派做法的从业者——现在很多系统把这些校验全推到 Java 层了,可一旦碰上批量脚本、数据订正、跨系统对接,还是得回到库里写。
2. 建库与建表:表空间、配额和七张表的外键顺序怎么排
这一章是整个项目的底座。底座没搭好,后面的序列、触发器、程序包全是空中楼阁。我带人做这类项目时,第一阶段允许花 60 分钟,其中至少 20 分钟应该耗在建库和建表上,而不是急着写存储过程。
2.1 表空间与用户:先把配额给够,再谈建表
第一步是建表空间。原文给的路径是d:\funddb_file.dbf,这是典型的 Windows 单机环境写法。如果你在 Linux 或者容器里跑,直接照抄这条语句大概率会翻车,报 ORA-01119 或者 ORA-27040,因为 Oracle 后台进程根本找不到 D 盘。稳妥做法是先把目录建好、属主设成 oracle,再指定绝对路径。
-- 表空间:50M 起步,开启自动扩展,避免后续插测试数据把文件撑爆 CREATE TABLESPACE fund DATAFILE '/u01/app/oracle/oradata/ORCL/funddb_file.dbf' SIZE 50M AUTOEXTEND ON NEXT 10M MAXSIZE 2G EXTENT MANAGEMENT LOCAL SEGMENT SPACE MANAGEMENT AUTO;SIZE 50M是初始大小,AUTOEXTEND ON NEXT 10M表示空间不够时每次扩 10M,MAXSIZE 2G是上限,防止某条失控的 INSERT 把磁盘写满。EXTENT MANAGEMENT LOCAL和SEGMENT SPACE MANAGEMENT AUTO在 10g 之后是默认值,显式写出来主要是为了和老教材里的环境保持一致,省得对照检查时来回猜。
建用户这一步有个坑几乎每届都有人踩。很多人照着教材写GRANT CONNECT, RESOURCE TO test_user就以为完事了,结果第一次CREATE TABLE就报 ORA-01950:无权限在表空间中创建对象。原因是 11g 之后 RESOURCE 角色不再隐含 UNLIMITED TABLESPACE 权限,而 DEFAULT TABLESPACE 只决定“不写表空间名的时候数据落到哪”,并不等于给了配额。
-- 建用户时直接把配额带上,比事后 ALTER USER 补更省事 CREATE USER test_user IDENTIFIED BY test123456 DEFAULT TABLESPACE fund QUOTA UNLIMITED ON fund; -- CONNECT/RESOURCE 给的是连接和建对象的基础权限 GRANT CONNECT, RESOURCE TO test_user; -- 视图、序列、存储过程、触发器这几类对象权限单独授,不同版本角色包含范围有差异 GRANT CREATE VIEW, CREATE SEQUENCE, CREATE PROCEDURE, CREATE TRIGGER TO test_user;提示:如果你在 12c 及以上版本的多租户环境里操作,必须先
ALTER SESSION SET CONTAINER=你的PDB切到可插拔库,否则用户建在 CDB 根容器里,业务表却找不到,排查起来非常费劲。
2.2 七张表的依赖顺序:从 FundCompany 到 Trade
七张表不是随便什么顺序都能建。外键要求父表先存在,所以建表顺序必须按依赖关系排。我在纸上画过一遍,顺序是这样的:
| 建表顺序 | 表名 | 依赖的父表 | 说明 |
|---|---|---|---|
| 1 | FundCompany | 无 | 基金公司,所有业务的源头 |
| 2 | Fund | FundCompany | 基金产品挂在公司下面 |
| 3 | CurrentAccount | 无 | 活期账户,客户资金入口 |
| 4 | FinancingAccount | CurrentAccount | 一个活期账户对应一个理财账户 |
| 5 | FundAccount | FinancingAccount、FundCompany | 同一理财账户在同一公司只能开一个 |
| 6 | FundBuy | FinancingAccount、Fund | 购买合同,记录申购份额 |
| 7 | Trade | FundBuy、FinancingAccount、Fund、FundAccount | 交易流水,购买/赎回/撤单 |
前四张表没什么争议。真正需要动脑子的是 FundAccount 和 Trade。FundAccount 上有两个外键:FinancingAccount指向理财账户,CompanyID指向基金公司,这两个字段合起来应该加唯一约束,因为业务要求“一个理财账号对同一个公司只能开一个基金账户”。这条规则放在数据库层比放在应用层可靠得多,后面第 4 章还会展开。
Trade 表的外键是这套设计里最别扭的一处。原文把PactNo定义成引用 FundBuy 表PactNo的外键,但赎回交易根本没有对应的购买合同,撤单虽然针对某笔购买,语义上又不一样。如果严格按外键约束来,插入一条赎回记录时PactNo填什么都会违反约束。我的处理是把PactNo定义成可空外键:购买和撤单时填原合同号,赎回时置 NULL,同时在外键上不加ON DELETE CASCADE,避免删除合同时把交易流水一起带走。
-- 建表顺序:先公司,再基金,接着账户,最后交易 CREATE TABLE FundCompany ( CompanyId VARCHAR2(20) CONSTRAINT pk_fundcompany PRIMARY KEY, Name VARCHAR2(30) NOT NULL, Content CLOB, Money NUMBER(10,2) CONSTRAINT ck_company_money CHECK (Money >= 0), State NUMBER(1,0) DEFAULT 0 CONSTRAINT ck_company_state CHECK (State IN (0,1)) ); CREATE TABLE Fund ( FundNo VARCHAR2(20) CONSTRAINT pk_fund PRIMARY KEY, CompanyId VARCHAR2(20) CONSTRAINT fk_fund_company REFERENCES FundCompany(CompanyId), FundName VARCHAR2(20) NOT NULL, Price NUMBER(10,2) CONSTRAINT ck_fund_price CHECK (Price > 0), FundType NUMBER(1,0) CONSTRAINT ck_fund_type CHECK (FundType IN (1,2)), Invest NUMBER(1,0) CONSTRAINT ck_fund_invest CHECK (Invest IN (1,2,3,4)), BuyLimit NUMBER(5,0) CONSTRAINT ck_fund_limit CHECK (BuyLimit >= 0), IsChange NUMBER(1,0) DEFAULT 0 CONSTRAINT ck_fund_change CHECK (IsChange IN (0,1)), YearRate NUMBER(6,5) CONSTRAINT ck_fund_rate CHECK (YearRate >= 0 AND YearRate <= 1), ApplyDate DATE DEFAULT SYSDATE, State NUMBER(1,0) DEFAULT 0 CONSTRAINT ck_fund_state CHECK (State IN (0,1)) ); CREATE TABLE CurrentAccount ( CurrentAccount VARCHAR2(20) CONSTRAINT pk_current PRIMARY KEY, CurrentPassword VARCHAR2(20) NOT NULL, DepositSum NUMBER(10,2) DEFAULT 0 CHECK (DepositSum >= 0), CardType NUMBER(1,0) CHECK (CardType IN (1,2,3)), CardNo VARCHAR2(20), Name VARCHAR2(10) NOT NULL, Address VARCHAR2(100), Phone VARCHAR2(20), Sex NUMBER(1,0) CHECK (Sex IN (1,2)), OpenAccDate DATE DEFAULT SYSDATE, State NUMBER(1,0) DEFAULT 0 CHECK (State IN (0,1)) ); CREATE TABLE FinancingAccount ( FinancingAccount VARCHAR2(20) CONSTRAINT pk_financing PRIMARY KEY, FinancePassWord VARCHAR2(20) NOT NULL, MoneyType NUMBER(1,0) DEFAULT 1 CHECK (MoneyType IN (1,2,3)), AccountBalance NUMBER(10,2) DEFAULT 0 CHECK (AccountBalance >= 0), EnableBalance NUMBER(10,2) DEFAULT 0 CHECK (EnableBalance >= 0), CongealFund NUMBER(10,2) DEFAULT 0 CHECK (CongealFund >= 0), State NUMBER(1,0) DEFAULT 0 CHECK (State IN (0,1)), CurrentAccount VARCHAR2(20) REFERENCES CurrentAccount(CurrentAccount), -- 一个活期账户只能开一个理财账户 CONSTRAINT uk_financing_current UNIQUE (CurrentAccount) ); CREATE TABLE FundAccount ( FundAccount VARCHAR2(20) CONSTRAINT pk_fundaccount PRIMARY KEY, FinancingAccount VARCHAR2(20) REFERENCES FinancingAccount(FinancingAccount), CompanyID VARCHAR2(20) REFERENCES FundCompany(CompanyId), CardType NUMBER(1,0), CardNo VARCHAR2(20), Name VARCHAR2(10), Sex NUMBER(1,0), Address VARCHAR2(100), Phone VARCHAR2(20), PostNum VARCHAR2(10), Email VARCHAR2(30), CreateDate DATE DEFAULT SYSDATE, CongealState NUMBER(1,0) DEFAULT 0 CHECK (CongealState IN (0,1)), -- 同一理财账户在同一公司只能有一个基金账户 CONSTRAINT uk_fundacc_fin_company UNIQUE (FinancingAccount, CompanyID) ); CREATE TABLE FundBuy ( PactNo VARCHAR2(20) CONSTRAINT pk_fundbuy PRIMARY KEY, FinancingAccount VARCHAR2(20) REFERENCES FinancingAccount(FinancingAccount), FundNO VARCHAR2(20) REFERENCES Fund(FundNo), Fundname VARCHAR2(20), Fundnumber NUMBER(5,0) CHECK (Fundnumber > 0), BuyDate DATE DEFAULT SYSDATE, State NUMBER(1,0) DEFAULT 0 CHECK (State IN (0,1)) ); CREATE TABLE Trade ( PactNo VARCHAR2(20) REFERENCES FundBuy(PactNo), -- 赎回时允许为空 FinancingAccount VARCHAR2(20) REFERENCES FinancingAccount(FinancingAccount), FundNo VARCHAR2(20) REFERENCES Fund(FundNo), FundName VARCHAR2(20), DealType NUMBER(1,0) CHECK (DealType IN (1,2,3)), FundQuotient NUMBER(10,0) CHECK (FundQuotient > 0), BargainPrice NUMBER(10,2), DealMoney NUMBER(10,2), FundAccount VARCHAR2(20) REFERENCES FundAccount(FundAccount), DealDate DATE DEFAULT SYSDATE, Status NUMBER(1,0) DEFAULT 0 CHECK (Status IN (0,1)) );有一处我改了原文的参数:FundQuotient原文给的是NUMBER(5,0),最多五位整数,一只净值两块的基金买到三万份就溢出了。这里直接放到NUMBER(10,0),DealMoney保持NUMBER(10,2)足够覆盖常规场景。这种字段长度在练习数据上完全看不出来,等你要做压力测试、批量插十万条的时候才会炸,属于典型的血泪经验。
2.3 约束与默认值:把业务规则往前挪一层
建表语句里那些 CHECK 不是装饰。YearRate NUMBER(6,5) CHECK (YearRate >= 0 AND YearRate <= 1)这一条,把“年利率必须是 0 到 1 之间的小数”从应用层搬到了数据库层。你可能会问,应用层校验一遍不就行了?问题在于这套系统有多个入口——银行窗口、网上交易、后台批量导入,每个入口的校验代码各写各的,迟早有一个漏掉。数据库约束是最后一道闸门,谁也绕不过去。
默认值也一样。State DEFAULT 0、BuyDate DEFAULT SYSDATE、ApplyDate DEFAULT SYSDATE这些看起来不起眼,但在插入语句里省掉一个字段就少一个出错点。我见过有人在存储过程里手写State => 0,结果某个分支忘了传,插入 NULL,后面查询WHERE State = 0永远查不到这条记录,排查了半天才发现是空值不参与等值比较。
外键的删除策略要提前想清楚。这套库里的交易流水和合同记录都属于“发生了就不能删”的数据,所以外键一律不加ON DELETE CASCADE。代价是删测试数据时必须按倒序来:先删 Trade,再删 FundBuy,然后 FundAccount、FinancingAccount、CurrentAccount、Fund、FundCompany。这个顺序忘了,就会撞 ORA-02292。
3. 六种业务编号:序列加触发器把主键生成从应用层拿回数据库
主键怎么生成,是这套项目里最能体现“数据库侧思维”的部分。现在新项目基本用 UUID 或者雪花算法在应用层生成,但这套库要求的是带业务前缀的可读编号:基金公司K加 5 位数字、基金代码V加 6 位、活期和理财账号各 13 位纯数字、基金账户L加 5 位、合同号Z加 6 位。这种格式用序列加触发器最顺手。
3.1 六类编号的格式与序列参数
六个序列的差异主要在起始值、最大值和是否循环。带前缀的编号从 1 开始,靠LPAD补零到固定位数;13 位纯数字账号如果从 1 开始,要补 12 个零,LPAD一样能处理。关键是MAXVALUE必须卡死,比如 5 位数字最大是 99999,超过就说明业务量已经突破设计容量,应该报错而不是静默循环。
-- 带前缀的编号:序列只管数字部分,前缀在触发器里拼 CREATE SEQUENCE seq_company START WITH 1 INCREMENT BY 1 MAXVALUE 99999 NOCYCLE CACHE 20; CREATE SEQUENCE seq_fund START WITH 1 INCREMENT BY 1 MAXVALUE 999999 NOCYCLE CACHE 20; CREATE SEQUENCE seq_fundacc START WITH 1 INCREMENT BY 1 MAXVALUE 99999 NOCYCLE CACHE 20; CREATE SEQUENCE seq_pact START WITH 1 INCREMENT BY 1 MAXVALUE 999999 NOCYCLE CACHE 20; -- 13 位纯数字账号:同样从 1 开始,靠 LPAD 补到 13 位 CREATE SEQUENCE seq_current START WITH 1 INCREMENT BY 1 MAXVALUE 9999999999999 NOCYCLE CACHE 20; CREATE SEQUENCE seq_finance START WITH 1 INCREMENT BY 1 MAXVALUE 9999999999999 NOCYCLE CACHE 20;CACHE 20的意思是每次从数据字典里预取 20 个值放到内存,减少争用。代价是数据库异常关闭时,内存里没来得及用的号码会丢失,编号出现跳号。对于账号类编号,跳号完全可接受;如果业务方要求绝对连续,就得改成NOCACHE,性能会掉一截,还要接受序列争用带来的等待。NOCYCLE是一定要加的,否则到达 MAXVALUE 之后从 1 重新开始,直接撞主键冲突。
3.2 触发器写法:BEFORE INSERT 改 :NEW 的三个细节
触发器的核心逻辑是:插入前判断主键是否为空,为空就取序列值拼上前缀赋给:NEW.字段。这里有三个细节容易出错,我逐个说。
第一,必须是BEFORE INSERT ... FOR EACH ROW。AFTER触发器改不了:NEW,行已经写进去了;不加FOR EACH ROW就是语句级触发器,:NEW根本不存在。
第二,在 11g 的 PL/SQL 里,你不能写SELECT seq_company.NEXTVAL INTO :NEW.CompanyId FROM DUAL,:NEW是伪记录,不能作为 INTO 的目标,编译直接报错。正确写法是表达式赋值::NEW.CompanyId := 'K' || LPAD(...)。
第三,IF :NEW.CompanyId IS NULL这一层判断别省。有了它,外部导入历史数据时可以自己指定编号,触发器不插手;没有它,任何一次带编号的插入都会被覆盖成新序列值,数据迁移时能把人逼疯。
-- 基金公司编号:K + 5 位数字 CREATE OR REPLACE TRIGGER trg_company_id BEFORE INSERT ON FundCompany FOR EACH ROW BEGIN IF :NEW.CompanyId IS NULL THEN :NEW.CompanyId := 'K' || LPAD(TO_CHAR(seq_company.NEXTVAL), 5, '0'); END IF; END; / -- 基金代码:V + 6 位数字 CREATE OR REPLACE TRIGGER trg_fund_no BEFORE INSERT ON Fund FOR EACH ROW BEGIN IF :NEW.FundNo IS NULL THEN :NEW.FundNo := 'V' || LPAD(TO_CHAR(seq_fund.NEXTVAL), 6, '0'); END IF; END; / -- 活期账号:13 位纯数字,不拼前缀 CREATE OR REPLACE TRIGGER trg_current_acc BEFORE INSERT ON CurrentAccount FOR EACH ROW BEGIN IF :NEW.CurrentAccount IS NULL THEN :NEW.CurrentAccount := LPAD(TO_CHAR(seq_current.NEXTVAL), 13, '0'); END IF; END; /理财账号、基金账户、合同号的触发器结构完全一样,只是序列名、目标字段和前缀不同,照着改就行。六个触发器建完之后,所有 INSERT 语句里的主键字段都可以传 NULL,由数据库负责生成。
这里有一个隐藏的搭配问题:合同号触发器挂在 FundBuy 上,可 Trade 表也要写 PactNo。如果先插 FundBuy 再插 Trade,Trade 需要拿到刚刚生成的合同号。办法是在 INSERT 语句后面加RETURNING PactNo INTO 变量,BEFORE INSERT 触发器对:NEW的修改会反映到 RETURNING 的结果里。这一招在第 4 章的购买过程里会用到。
3.3 程序包封装:FundManager_pack 的接口设计
把基金管理模块的七个过程塞进一个包里,而不是散落在七个独立存储过程里,好处是接口清晰、依赖管理方便、重新编译时整包一起走。包的规范部分只声明签名,包体放实现,这是 Oracle 的标准做法。
CREATE OR REPLACE PACKAGE FundManager_pack AS -- 新增基金公司 PROCEDURE add_company(p_name IN VARCHAR2, p_content IN CLOB, p_money IN NUMBER); -- 在指定公司下新增基金 PROCEDURE add_fund(p_company_id IN VARCHAR2, p_fund_name IN VARCHAR2, p_price IN NUMBER, p_fund_type IN NUMBER, p_invest IN NUMBER, p_buy_limit IN NUMBER, p_is_change IN NUMBER, p_year_rate IN NUMBER); -- 冻结/解冻基金公司,p_state 传 0 正常、1 冻结 PROCEDURE set_company_state(p_company_id IN VARCHAR2, p_state IN NUMBER); -- 冻结/解冻基金 PROCEDURE set_fund_state(p_fund_no IN VARCHAR2, p_state IN NUMBER); -- 按公司编号查询,结果集用游标带出 PROCEDURE query_company(p_company_id IN VARCHAR2, p_cursor OUT SYS_REFCURSOR); PROCEDURE query_fund_by_company(p_company_id IN VARCHAR2, p_cursor OUT SYS_REFCURSOR); PROCEDURE query_fund_by_no(p_fund_no IN VARCHAR2, p_cursor OUT SYS_REFCURSOR); END FundManager_pack; /包体里最关键的是add_fund。它必须先查基金公司的状态,公司被冻结了就不允许新增产品,否则会出现“公司停业了还在发新基金”这种业务上说不通的数据。异常处理用NO_DATA_FOUND兜住“公司编号不存在”的情况,统一抛RAISE_APPLICATION_ERROR,错误码从 -20000 开始自己编号,前端拿到负数错误码就知道是业务异常而不是数据库故障。
CREATE OR REPLACE PACKAGE BODY FundManager_pack AS PROCEDURE add_company(p_name IN VARCHAR2, p_content IN CLOB, p_money IN NUMBER) IS BEGIN -- CompanyId 传 NULL,交给 trg_company_id 生成 INSERT INTO FundCompany(CompanyId, Name, Content, Money, State) VALUES (NULL, p_name, p_content, p_money, 0); END add_company; PROCEDURE add_fund(p_company_id IN VARCHAR2, p_fund_name IN VARCHAR2, p_price IN NUMBER, p_fund_type IN NUMBER, p_invest IN NUMBER, p_buy_limit IN NUMBER, p_is_change IN NUMBER, p_year_rate IN NUMBER) IS v_state FundCompany.State%TYPE; BEGIN SELECT State INTO v_state FROM FundCompany WHERE CompanyId = p_company_id; IF v_state = 1 THEN RAISE_APPLICATION_ERROR(-20001, '基金公司已冻结,不允许新增基金'); END IF; INSERT INTO Fund(FundNo, CompanyId, FundName, Price, FundType, Invest, BuyLimit, IsChange, YearRate, ApplyDate, State) VALUES (NULL, p_company_id, p_fund_name, p_price, p_fund_type, p_invest, p_buy_limit, p_is_change, p_year_rate, SYSDATE, 0); EXCEPTION WHEN NO_DATA_FOUND THEN RAISE_APPLICATION_ERROR(-20002, '基金公司不存在:' || p_company_id); END add_fund; PROCEDURE set_company_state(p_company_id IN VARCHAR2, p_state IN NUMBER) IS BEGIN IF p_state NOT IN (0, 1) THEN RAISE_APPLICATION_ERROR(-20003, '状态值只能是 0 或 1'); END IF; UPDATE FundCompany SET State = p_state WHERE CompanyId = p_company_id; IF SQL%ROWCOUNT = 0 THEN RAISE_APPLICATION_ERROR(-20004, '基金公司不存在'); END IF; END set_company_state; -- 其余过程实现省略,结构与上面一致:校验 -> DML -> 行数确认 END FundManager_pack; /%TYPE声明变量类型是个好习惯,字段长度改了不用回来改代码。SQL%ROWCOUNT = 0的判断也别省,UPDATE 一个不存在的编号不会报错,只会静默影响零行,接口那边以为成功了,实际什么都没改。
4. 账户与委托交易:冻结资金、手续费和审核状态机怎么写
到这一章,七张表、六个序列、六个触发器、基金管理包都已经就位。真正让这套库有价值的是业务校验:转账要验密码和余额,开户要验唯一性,买基金要验冻结状态和可用资金,审核和撤单要把冻结资金挪对位置。这些逻辑如果只写在 Java 里,数据库就退化成了一个存储桶;写进存储过程,才能保证任何入口调用的结果一致。
4.1 活期转理财:四个校验缺一不可
按需求,活期转理财要依次判断:活期密码是否正确、活期账户是否被冻结、活期余额是否足够、理财账户是否被冻结。四个判断的顺序有讲究——先查密码再查余额,可以避免“密码错了但提示余额不足”这种误导性信息;状态判断放在金额操作之前,避免对一个冻结账户做了扣款又被回滚。
CREATE OR REPLACE PROCEDURE transfer_to_financing( p_current_acc IN VARCHAR2, p_fin_acc IN VARCHAR2, p_amount IN NUMBER, p_password IN VARCHAR2 ) IS v_pwd CurrentAccount.CurrentPassword%TYPE; v_state CurrentAccount.State%TYPE; v_balance CurrentAccount.DepositSum%TYPE; v_fstate FinancingAccount.State%TYPE; BEGIN IF p_amount <= 0 THEN RAISE_APPLICATION_ERROR(-20010, '转账金额必须大于 0'); END IF; -- FOR UPDATE 锁住活期账户行,防止并发转账把余额扣成负数 SELECT CurrentPassword, State, DepositSum INTO v_pwd, v_state, v_balance FROM CurrentAccount WHERE CurrentAccount = p_current_acc FOR UPDATE; IF v_state = 1 THEN RAISE_APPLICATION_ERROR(-20011, '活期账户已冻结,不可转账'); END IF; IF v_pwd <> p_password THEN RAISE_APPLICATION_ERROR(-20012, '活期账户密码错误'); END IF; IF v_balance < p_amount THEN RAISE_APPLICATION_ERROR(-20013, '活期账户余额不足'); END IF; SELECT State INTO v_fstate FROM FinancingAccount WHERE FinancingAccount = p_fin_acc FOR UPDATE; IF v_fstate = 1 THEN RAISE_APPLICATION_ERROR(-20014, '理财账户已冻结,不可转入'); END IF; -- 活期扣款;理财端总金额和可用余额同时增加,冻结资金不动 UPDATE CurrentAccount SET DepositSum = DepositSum - p_amount WHERE CurrentAccount = p_current_acc; UPDATE FinancingAccount SET AccountBalance = AccountBalance + p_amount, EnableBalance = EnableBalance + p_amount WHERE FinancingAccount = p_fin_acc; EXCEPTION WHEN NO_DATA_FOUND THEN RAISE_APPLICATION_ERROR(-20015, '活期或理财账户不存在,请检查账号'); END; /两个FOR UPDATE顺序不能颠倒。如果先锁理财再锁活期,而另一个会话的理财转活期过程是先锁理财再锁活期,两个会话就可能互相等待形成死锁。约定一个全局加锁顺序——这里统一按“先活期、后理财”——是避免死锁最省事的办法。
理财转活期的逻辑对称,差别在于校验对象换成理财账户密码和EnableBalance,注意是可用余额而不是总金额,冻结中的资金不能转出去。至于“冻结活期时必须同时冻结理财,启用活期时不能启用理财”这条规则,不要写在转账过程里,应该单独放在账户冻结过程里实现,转账只负责读状态。
4.2 基金账户开户与唯一性
基金账户开户有两条硬规则:编号不能重复,一个理财账号对同一家公司只能开一个基金账户。第一条靠主键加序列触发器解决,第二条在 2.2 节已经用UNIQUE (FinancingAccount, CompanyID)处理了。但光有约束不够,用户看到 ORA-00001 这种原始报错根本不知道发生了什么,所以存储过程里要先查一次,给出人话提示。
CREATE OR REPLACE PROCEDURE open_fund_account( p_fin_acc IN VARCHAR2, p_company_id IN VARCHAR2, p_name IN VARCHAR2, p_card_type IN NUMBER, p_card_no IN VARCHAR2 ) IS v_cnt NUMBER; v_state FinancingAccount.State%TYPE; BEGIN -- 理财账户必须存在且未冻结 SELECT State INTO v_state FROM FinancingAccount WHERE FinancingAccount = p_fin_acc; IF v_state = 1 THEN RAISE_APPLICATION_ERROR(-20040, '理财账户已冻结,不能开户'); END IF; -- 同一公司下不允许重复开户 SELECT COUNT(*) INTO v_cnt FROM FundAccount WHERE FinancingAccount = p_fin_acc AND CompanyID = p_company_id; IF v_cnt > 0 THEN RAISE_APPLICATION_ERROR(-20041, '该理财账户在此基金公司已有基金账户'); END IF; -- FundAccount 传 NULL,交给触发器生成 L 开头的编号 INSERT INTO FundAccount(FundAccount, FinancingAccount, CompanyID, CardType, CardNo, Name, CreateDate, CongealState) VALUES (NULL, p_fin_acc, p_company_id, p_card_type, p_card_no, p_name, SYSDATE, 0); EXCEPTION WHEN NO_DATA_FOUND THEN RAISE_APPLICATION_ERROR(-20042, '理财账户不存在'); END; /这里的“先查后插”在并发场景下仍然可能被绕过:两个会话同时查到 0 条,然后同时插入,最后还是靠唯一约束拦住其中一个。所以约束和预检查一个都不能少——约束保证正确性,预检查保证错误信息可读。
4.3 买入、审核、撤单的状态机
委托交易是整套库的核心。一笔买入要经历三个状态:下单时 FundBuy.State = 0、Trade.Status = 0;审核通过后都变成 1;未审核前撤单,则释放冻结资金、不扣手续费。资金怎么动,必须一开始就定死,否则写到一半改口径,前面测过的数据全得重来。
我采用的规则是:买入时EnableBalance减去“份额×净值 + 手续费”,CongealFund增加同样的金额,AccountBalance保持不变;审核通过时AccountBalance减去这笔金额,CongealFund减回去,EnableBalance不动;撤单时把CongealFund里的钱原路退回EnableBalance。这样任何时刻都满足AccountBalance = EnableBalance + CongealFund,第 6 章的对账 SQL 就是靠这条恒等式。
CREATE OR REPLACE PROCEDURE buy_fund( p_fund_acc IN VARCHAR2, p_fund_no IN VARCHAR2, p_shares IN NUMBER ) IS v_price Fund.Price%TYPE; v_limit Fund.BuyLimit%TYPE; v_fund_state Fund.State%TYPE; v_fund_name Fund.FundName%TYPE; v_company Fund.CompanyId%TYPE; v_comp_state FundCompany.State%TYPE; v_acc_state FundAccount.CongealState%TYPE; v_fin_acc FundAccount.FinancingAccount%TYPE; v_enable FinancingAccount.EnableBalance%TYPE; v_amount NUMBER(10,2); v_fee NUMBER(10,2); v_pact_no FundBuy.PactNo%TYPE; BEGIN -- 一次性把基金、公司状态取出来,减少往返 SELECT f.Price, f.BuyLimit, f.State, f.FundName, f.CompanyId, c.State INTO v_price, v_limit, v_fund_state, v_fund_name, v_company, v_comp_state FROM Fund f JOIN FundCompany c ON c.CompanyId = f.CompanyId WHERE f.FundNo = p_fund_no; IF v_fund_state = 1 THEN RAISE_APPLICATION_ERROR(-20020, '该基金已冻结,暂停申购'); END IF; IF v_comp_state = 1 THEN RAISE_APPLICATION_ERROR(-20021, '基金公司已冻结,暂停申购'); END IF; IF p_shares < v_limit THEN RAISE_APPLICATION_ERROR(-20022, '购买份额低于下限 ' || v_limit); END IF; SELECT FinancingAccount, CongealState INTO v_fin_acc, v_acc_state FROM FundAccount WHERE FundAccount = p_fund_acc; IF v_acc_state = 1 THEN RAISE_APPLICATION_ERROR(-20023, '基金账户已冻结,不能交易'); END IF; -- 手续费率 0.75%,按成交金额计算后四舍五入到分 v_amount := v_price * p_shares; v_fee := ROUND(v_amount * 0.0075, 2); SELECT EnableBalance INTO v_enable FROM FinancingAccount WHERE FinancingAccount = v_fin_acc FOR UPDATE; IF v_enable < v_amount + v_fee THEN RAISE_APPLICATION_ERROR(-20024, '可用余额不足,需要 ' || (v_amount + v_fee)); END IF; -- 资金从可用转冻结,总金额暂不动 UPDATE FinancingAccount SET EnableBalance = EnableBalance - (v_amount + v_fee), CongealFund = CongealFund + (v_amount + v_fee) WHERE FinancingAccount = v_fin_acc; -- RETURNING 拿回触发器生成的合同号,供 Trade 使用 INSERT INTO FundBuy(PactNo, FinancingAccount, FundNO, Fundname, Fundnumber, BuyDate, State) VALUES (NULL, v_fin_acc, p_fund_no, v_fund_name, p_shares, SYSDATE, 0) RETURNING PactNo INTO v_pact_no; INSERT INTO Trade(PactNo, FinancingAccount, FundNo, FundName, DealType, FundQuotient, BargainPrice, DealMoney, FundAccount, DealDate, Status) VALUES (v_pact_no, v_fin_acc, p_fund_no, v_fund_name, 1, p_shares, v_price, v_amount, p_fund_acc, SYSDATE, 0); END; /审核过程只做三件事:把合同和交易状态改成已审核、把冻结资金真正扣掉、把AccountBalance减下去。这里最容易出错的地方是重复审核——同一个合同号点两次“通过”,资金被扣两次。解决办法是在过程开头查状态,已经不是 0 就直接抛异常。
CREATE OR REPLACE PROCEDURE audit_buy(p_pact_no IN VARCHAR2) IS v_state FundBuy.State%TYPE; v_fin FundBuy.FinancingAccount%TYPE; v_amount NUMBER(10,2); BEGIN SELECT State, FinancingAccount INTO v_state, v_fin FROM FundBuy WHERE PactNo = p_pact_no FOR UPDATE; IF v_state = 1 THEN RAISE_APPLICATION_ERROR(-20030, '该合同已审核,不能重复操作'); END IF; -- 冻结金额 = 成交金额 + 手续费,与买入时口径保持一致 SELECT DealMoney + ROUND(DealMoney * 0.0075, 2) INTO v_amount FROM Trade WHERE PactNo = p_pact_no AND DealType = 1; UPDATE FinancingAccount SET AccountBalance = AccountBalance - v_amount, CongealFund = CongealFund - v_amount WHERE FinancingAccount = v_fin; UPDATE FundBuy SET State = 1 WHERE PactNo = p_pact_no; UPDATE Trade SET Status = 1 WHERE PactNo = p_pact_no AND DealType = <p> <a href="https://download.csdn.net/download/shaodequansxx/5722187" style="color:#ec7500;font-size:14px;"> 本文还有配套的精品资源,点击获取 </a> <img alt="menu-r.4af5f7ec.gif" src="https://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif" style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;"> </p>