
简介WADE开发文档是一份面向Java Web开发者的框架学习资料聚焦WADE框架的入门与版本演进适合需要快速理解框架结构、搭建项目或进行旧系统改造的技术人员。文档围绕新建项目、配置说明、核心组件与开发改造展开既讲解quickstart生成空项目的整体思路也梳理项目名称、目录、WADELIB包路径及编译信息等配置项的作用并按版本归纳升级后的建议用法与兼容注意点涉及缓存集群共享、输入框回车控制、数据格式统一等实践要点。压缩包内共1个doc文件约3.24MB以单一文档形式集中呈现便于检索和离线查阅。该资料已有973人学习可作为框架查阅手册和版本迁移参考帮助读者建立WADE开发的知识框架理解配置与组件之间的关系并为项目搭建、功能调整和升级排查提供线索。1. 接手一套跑了多年的 Java Web 老系统页面里全是 jwcid 绑定接手一套跑了多年的 Java Web 系统页面不是 Vue 也不是 JSP 自由发挥而是 HTML 模板加jwcidwade:PageRedirect这类组件绑定构建靠 ant数据层自己封装了一层 DAO连分页和序列都要走框架约定。这时候最缺的不是架构课而是一份能照着敲的 WADE 开发文档新建工程怎么改名、老版本哪些写法要迁移、增删改查的页面类和 Bean 怎么分工、Excel 批量导入的配置放哪。这份文档覆盖的正是这些落地环节从 quickstart 生成空工程开始一路讲到批量导入的错误回写。适合两类人要在这套框架上做二次开发的以及从 2.5x 老版本往上升、需要逐条对照改造点的。它不教你 Java 语法教的是这套框架的约定和边界。2. 新建项目quickstart 的改名链路与 ant 的两个构建目标新建工程这件事看着只是复制一份模板实际动手过的都知道坑集中在「名字」和「路径」两个地方。名字改不干净打包出来的 war 还叫 quickstart部署到容器里和别人的应用撞上下文路径没对齐编译时找不到 wadelib报一堆看起来像代码错误的类缺失。这一章把这条链路拆成可复现的三步。2.1 build.properties 里真正要动的几行quickstart 下载下来之后第一件事不是点编译而是打开build/build.properties。这个文件是整个构建的输入源项目名、目录结构、依赖包位置、编译分支全从这里读。原始配置大致长这样# 编译时的分支代码开发期用 common 公用分支 # 打包部署时切成对应的分支标识 PROVINCE_NAME common # 项目名称产出物jar / war都以这个名称为准 PROJECT_NAME wade-quickstart # 项目根目录.. 表示当前目录的上级 PROJECT_HOME .. # wadelib 依赖包所在目录 WADELIB_HOME ../../../wadelib/wadelib # 各关键目录的展开路径后面 build.xml 全靠这几个变量 build ${PROJECT_HOME}/build config ${PROJECT_HOME}/etc source ${PROJECT_HOME}/src classes ${PROJECT_HOME}/web/WEB-INF/classes lib ${WADELIB_HOME}/web/WEB-INF/lib web ${PROJECT_HOME}/web # 源码编码老工程基本都是 GB2312别随手改成 UTF-8 APPLANG GB2312参数含义和改动建议如下参数作用改动要点PROVINCE_NAME编译时选取的分支代码开发期保持common出包时切到目标分支改错会编译到别人的定制代码PROJECT_NAME产物命名必须改成新项目名否则 war 包名和上下文路径都是旧的PROJECT_HOME工程根一般不动除非工程目录层级和模板不一致WADELIB_HOME框架依赖包目录最容易出错的一项见 2.3APPLANG源码编码老工程是 GB2312改成 UTF-8 会导致中文乱码且编译期不报错PROVINCE_NAME这一项值得多说一句。它本质上是编译期的条件开关同一份源码在不同分支下编译出不同产物。开发阶段用common能保证所有人拿到一致的基线等到测试打包再切换。我见过有人图省事直接在开发机上切成目标分支结果本地能跑、提交上去别人编译报错排查半天发现是配置没进版本库对照。2.2 OLD_NAME / NEW_NAME 三元组与残留清理改完基础配置接下来是改名。框架的做法是在build.properties末尾再追加六个变量用三组大小写形态描述「旧名 → 新名」的映射# 原系统的三种命名形态 OLD_NAME1 quickstart # 全小写出现在包名、路径、配置里 OLD_NAME2 Quickstart # 首字母大写出现在类名、页面名里 OLD_NAME3 QUICKSTART # 全大写出现在常量、环境变量里 # 新系统的三种命名形态必须与上面一一对应 NEW_NAME1 custmgr NEW_NAME2 Custmgr NEW_NAME3 CUSTMGR然后在 build 目录下执行生成命令cd build # 按 OLD_NAME / NEW_NAME 三元组扫描并替换生成一个全新的空工程 ant newproject # 开发期默认目标编译 部署改动后直接跑这个 ant all # 打包目标产出可发布的 war依赖 ant-contrib.jar ant allwarant newproject干的事情并不神秘就是按三个大小写形态在整个工程目录里做全量字符串替换包括目录名、文件名、包路径、配置文件内容。所以三组变量必须写全、写对少写一组就会出现「类名改了但包路径没改」这种半成品状态编译能过运行时报找不到页面。替换完成后还有一步手工清理很多人会漏打开page.application和sidebar.html把 quickstart 自带的一批示例页面名称配置和菜单项删掉。这两处是模板留下的演示入口不删的话新工程一登录就挂着一堆examples.basic.*的菜单点进去全是示例数据交付时很难看。提示ant newproject只做字符串替换不做语义判断。如果新项目名恰好是旧名的子串比如 old 叫cust、new 叫custmgr替换顺序会出问题建议新名和旧名在词根上完全无关。2.3 all 与 allwar两个目标、一个 jar 依赖、一条路径构建目标有两个用途完全不同别混着用# 开发期编译源码、拷贝资源、部署到本地容器改动即时生效 ant all # 发布期编译 打 war 包产物在 dist 目录下 # 需要 ant-contrib.jar 支持jar 放到 ant 的 lib 目录或 build 目录下 ant allwarant allwar依赖ant-contrib.jar这个包不在 JDK 和 ant 的默认发行版里缺了会直接报 taskdef 找不到。常规做法是把 jar 丢进 ant 安装目录的 lib 下或者按项目约定放到 build 目录并在build.xml里用taskdef显式声明路径。第二种更干净不污染本机的 ant 环境换机器也不用重新配。路径这块是新手最容易翻车的地方。WADELIB_HOME ../../../wadelib/wadelib这个相对路径的默认前提是wadelib 目录和应用目录在同一级。目录结构大概是这样workspace/ ├── wadelib/ │ └── wadelib/ │ ├── build/ │ └── web/WEB-INF/lib/ └── custmgr/ ├── build/ ├── etc/ ├── src/ └── web/一旦 wadelib 被挪到别处光改WADELIB_HOME还不够因为build.xml里引入build_wadelib.xml的那一行也是写死的相对路径!-- build/build.xml 中引入框架构建脚本的位置 -- import file../../wadelib/build/build_wadelib.xml/这两处必须同时改。只改 properties 不改 import现象是编译前期一切正常跑到某个 copy 任务时突然报文件不存在错误信息指向的却是一个你没写过的目录——这就是典型的配置分叉。注意PROJECT_HOME ..是相对 build 目录而言的。如果你在工程根目录下直接敲ant工作目录不对所有相对路径都会错位。养成习惯所有 ant 命令都在build目录里执行。3. 版本改造2.50 到 2.59 那些「还能跑但别再用」的写法框架升级最难受的不是报错是不报错。旧写法照样能跑页面照样出数据直到某个浏览器版本、某个集群环境、某次并发操作把它掀翻。WADE 文档里「开发改造」这一节列的就是这类问题不是不能用是不建议再用并且给了明确的替代写法。这一章按版本把改造点归成四类方便对照自己的工程。版本改造点影响面2.50初始脚本由init()改为页面载入自动调用所有页面2.50DOM 取值统一走getElement/hidden/disabled所有交互页面2.50linkSubmit换成wade:PageSubmit组件所有表单提交2.52Buttom.html/Buttom.java更名为Bottom.*页面骨架2.53客户端统一DataMap/DatasetListsetAjaxDataset下发数据所有 ajax 局部刷新2.55ehcache 支持集群共享需同步 quickstart 配置多节点部署2.56输入框回车动作统一用enterAction参数表单类组件2.59JDK_VERSION1.4|1.5指定编译目标构建配置3.1 初始脚本init()退场initScript上场老写法是在每个页面底部手写一段脚本调用!-- 旧写法每个页面都得手动挂一段漏了就静默不执行 -- scriptinit();/script新版本把这件事收进框架页面载入时自动调用一个按页面名生成的初始化方法比如页面名为examples.basic.VipcustEdit就调initVipcustEdit()。也可以自定义方法名通过wade:Head的initScript参数指定。如果页面没写对应的初始化方法框架直接忽略不报错。这个改动看着小实际影响挺大。以前页面多、脚本散谁漏写一行谁自己查现在统一入口好处是规范和可测坏处是「不报错」——方法名拼错一个字母页面静默跳过初始化界面上看不出任何异常只有某些默认值没填上。我的做法是新建页面时先把initXxx方法写出来打个日志跑通再补业务逻辑。3.2 DOM 操作getElement/hidden/disabled替代getElementById这条是血泪经验。老代码里到处是这种写法// 不推荐把组件当成裸 DOM 处理 document.getElementById(vipName).disabled true;问题在于框架里的很多「输入框」并不是一个 DOM 节点。日期组件是一个文本框加一个图标下拉表格是一组嵌套结构。你直接操作getElementById拿到的那个节点结果只禁用了文本框图标还能点弹窗照样弹出来用户看到的按钮是灰的但功能是活的最难受。统一改成框架提供的方法// 推荐由框架负责把组件当成整体处理 disabled(vipName); // 禁用整个组件 hidden(vipName); // 隐藏整个组件 var el getElement(vipName); // 取组件对象 var list getElements(vipName); // 取组件集合这几个方法的语义是「操作组件」而不是「操作节点」内部会处理组件的复合结构。迁移时不能简单全局替换因为getElement返回的对象和原生节点在属性访问上有差异比如取值的写法不一样。我的做法是按页面迁移迁一个测一个重点测日期、金额、下拉表格这三类复合组件。3.3 提交与跳转PageSubmit换掉linkSubmit客户端数据格式统一linkSubmit(page, listener, params)这个老方法在不同浏览器下存在无法完成跳转的问题框架后来用组件替代!-- 新写法用组件声明提交动作浏览器差异由框架兜底 -- a jwcidwade:PageSubmit value删除 listenerognl:listeners.deleteVipcust descVIP 客户资料 classe_bLinkDelete onclickreturn queryBox(this, vips);/listener指向页面类里的方法desc是操作描述会在二次确认框和操作日志里用到onclick里挂的是前端确认返回 false 就中断提交。这套组合比手写linkSubmit稳也更好维护。2.53 之后客户端数据格式统一成DataMap和DatasetList和服务器端保持一致配合setAjaxDataset把服务端数据直接下发到客户端客户端拿到DatasetList直接操作省掉一次格式转换。建议把「单一字段或少量局部刷新」的 ajax 都改成这种模式性能和可控性都更好涉及复杂业务计算的部分还是走服务端别为了统一而统一。3.4 集群缓存与编译版本ehcache 增量合并、JDK_VERSION显式声明2.55 之前的缓存是单机缓存集群环境下各节点各存一份A 节点改了数据 B 节点看不到用户刷新几次页面才「碰巧」看到新值这类问题排查起来极其费劲。新版本支持集群共享缓存做法是把 quickstart 2.35 及以上版本的etc/common/ehcache.xml增量合并过来加上自己的 cache 配置不要整份覆盖——覆盖会把本地已有的自定义 cache 干掉。!-- etc/common/ehcache.xml 增量片段示意 -- cache namevipCache maxElementsInMemory2000 eternalfalse timeToIdleSeconds300 timeToLiveSeconds600 overflowToDiskfalse/2.59 之后编译版本要在build.properties里显式声明# 指定编译目标 JDK 版本取值 1.4 或 1.5 # 声明后可以在 1.4 运行环境下正常部署 JDK_VERSION 1.4这一项解决的是「开发机用高版本 JDK 编译、生产环境跑低版本 JRE」的兼容问题。不声明的话编译出来的 class 文件版本偏高部署时直接UnsupportedClassVersionError错误信息还算明确但如果只是部分类由高版本编译器产出问题会变得很隐蔽。2.56 还补了个小参数所有输入框类组件弹出窗口、金额输入、文本输入、下拉表格都支持enterActionalert(xxx)用来控制回车动作。以前回车默认触发提交用户在一个多字段表单里敲回车经常误提交。现在按需声明就行input jwcidwade:TextField valueognl:info.custName enterActionreturn false;/2.52 的改名看起来最无聊但最容易漏web/Buttom.html、view/Buttom.java要改成Bottom.html、Bottom.javapage.application里的配置同步改。漏改的现象是底部页面直接报错而且报错信息未必指向这个文件容易往别处查。4. 数据访问save与update的语义差别以及主键绑定的并发校验增删改查这块框架把职责分得很清楚页面类负责取参数、调 Bean、写提示、控制跳转Bean 负责组装数据、调 DAO、返回结果。DAO 是框架封装的屏蔽了 JDBC 细节但带来一个新的问题——同一个方法名在语义上有差别用错不报错只是数据不对。这一章按四个动作拆开讲。4.1 新增序列、上下文取值与文本化写入页面的 HTML 用PageRedirect组件跳转到编辑页页面类接收提交后调 Bean// 页面类接收表单提交交给 Bean 处理 public void createVipcust(IRequestCycle cycle) throws Exception { PageData pd getPageData(); ExamplesBean bean new ExamplesBean(); // 把页面数据整体交给 Bean返回主键 String vip_id bean.createVipcust(pd, pd.getData()); // 把主键传递给下一个页面 pd.setTransfer(VIP_ID, vip_id); // 操作提示 后续动作导航不设置动作则关闭当前页面回主页 redirectToMsg(VIP 客户资料新增成功, new String[] { getPageName(), getPageName() }, new String[] { null, queryVipcust }, new String[] { 继续新增, 查看详情 }); }Bean 里做的是补全字段/** * 创建客户资料 * param pd 页面上下文用来取登录员工、归属部门、业务区等 * param data 页面提交上来的数据集 * return 生成的主键 */ public String createVipcust(PageData pd, IData data) throws Exception { ExamplesDAO dao new ExamplesDAO(pd); // 从数据库序列取主键不要用时间戳拼接 String vip_id dao.getSequence(SEQ_VIP_ID); // 部分字段来自另一组页面字段先覆盖再入库 data.put(USER_ID, data.get(USECUST_ID)); data.put(CUST_ID, data.get(USECUST_ID)); data.put(CUST_NAME, data.get(USECUST_NAME)); data.put(VIP_ID, vip_id); // 状态类字段一律走常量别在页面里写死字符串 data.put(REMOVE_TAG, ExampleFactory.VIP_REMOVETAG_VALID); data.put(NET_TYPE_CODE, ExampleFactory.VIP_NETTYPE_DEFAULT); // 从上下文取登录信息比从页面参数取可靠 data.put(IN_STAFF_ID, pd.getContext().getStaffId()); data.put(IN_DEPART_ID, pd.getContext().getDeptId()); data.put(CITY_CODE, pd.getContext().getCityId()); data.put(EPARCHY_CODE, pd.getContext().getEpachyId()); // 时间统一用系统时间函数格式为 yyyy-MM-dd HH:mm:ss 的字符串 data.put(JOIN_DATE, common.getSysTime()); data.put(IN_DATE, common.getSysTime()); dao.insert(TF_F_CUST_VIP, data); return vip_id; }两个细节要强调。第一所有数据以文本方式进入数据集包括时间直接写成yyyy-MM-dd HH:mm:ss的字符串就行不要自己转Date再塞回去框架层会处理。第二IN_STAFF_ID这类归属字段从pd.getContext()取不要从页面参数取——页面参数是可以被改的归属信息被篡改后数据统计会全线偏移而且这种错误在功能测试里完全看不出来。4.2 修改为什么必须带REMOVE_TAG一起去save修改的坑不在语法在并发。同一个页面开两次或者两个人同时编辑同一条记录A 先提交B 后提交时基于的是过期快照更糟的情况是 A 已经把记录注销了B 还在改。框架给的解法是更新时把「能识别记录当前状态」的列一起带上作为更新条件。// 页面类更新前先判断记录状态是否还允许修改 public void updateVipcust(IRequestCycle cycle) throws Exception { PageData pd getPageData(); String vip_id pd.getParameter(VIP_ID); String remove_tag pd.getParameter(REMOVE_TAG); ExamplesBean bean new ExamplesBean(); // values 里的列值会和数据库当前值比对不匹配则更新失败 if (!bean.updateVipcust(pd, pd.getData(), new String[] { vip_id, remove_tag })) { common.error(修改失败信息在此操作前已经被操作过); return; } pd.setTransfer(VIP_ID); redirectToMsg(VIP 客户资料修改成功, ...); }Bean 里对应两种写法区别在于条件从哪里来// 写法一条件值由调用方传入适合页面已经把关键列取出来的场景 public boolean updateVipcust(PageData pd, IData data, String[] values) throws Exception { ExamplesDAO dao new ExamplesDAO(pd); return dao.save(TF_F_CUST_VIP, data, new String[] { VIP_ID, REMOVE_TAG }, values); } // 写法二条件值从 data 里取适合 data 本身就包含主键和状态列的场景 public boolean updateVipcust(PageData pd, IData data) throws Exception { ExamplesDAO dao new ExamplesDAO(pd); return dao.save(TF_F_CUST_VIP, data, new String[] { VIP_ID, REMOVE_TAG }); }第三个参数是条件列名第四个参数可选是对应的值。不传这个参数时框架按表结构的主键更新。然后是save和update的差别这是这一节最该记住的方法行为适用场景update用 data 里的字段整行覆盖data 里没给的字段会被清空data 包含全部字段追求效率save先查出原记录用 data 覆盖后再更新只改部分字段只改一两个字段却用了update结果是把没传的字段全刷成空值。这种 bug 不会报错界面上看起来「保存成功」等下次打开详情页才发现一堆字段空了。血泪经验拿不准就用save性能差异在业务页面上基本感知不到。4.3 删除与查看绑定列 状态校验别只认主键删除和查看都涉及「按什么条件定位记录」。默认按主键但业务上经常需要附加状态条件。// 删除同样把状态列带上已注销的记录不允许再删 public boolean deleteVipcust(PageData pd, String vip_id, String remove_tag) throws Exception { ExamplesDAO dao new ExamplesDAO(pd); IData data new DataMap(); data.put(VIP_ID, vip_id); data.put(REMOVE_TAG, remove_tag); // 按 VIP_ID REMOVE_TAG 定位并删除条件不匹配则删除 0 行 return dao.delete(TF_F_CUST_VIP, data, new String[] { VIP_ID, REMOVE_TAG }); }查看走queryByPK同样可以指定绑定列// 查看按主键 状态列查唯一记录查不到返回 null public IData queryVipcust(PageData pd, String vip_id) throws Exception { ExamplesDAO dao new ExamplesDAO(pd); IData data new DataMap(); data.put(VIP_ID, vip_id); return dao.queryByPK(TF_F_CUST_VIP, data); // 默认按主键 // 需要附加条件时dao.queryByPK(TF_F_CUST_VIP, data, new String[] { VIP_ID, REMOVE_TAG }); }页面类里拿到 null 要主动报错不能静默IData info bean.queryVipcust(pd, vip_id); if (info null) { common.error(您要查看的记录已不存在); } setInfo(info);「记录不存在」这种情况在多人协作的系统里很常见用户点开列表里的某一行时别人正好删掉了。不提示的话页面会渲染成一片空白用户以为系统坏了。4.4 批量dataset直插、批量删与executeBatch批量操作有三种粒度选错了要么慢要么写不出来。第一种批量新增把整个数据集一次性插进去// 循环补全每行字段最后一次批量插入 public void createVipcusts(PageData pd, IDataset dataset) throws Exception { ExamplesDAO dao new ExamplesDAO(pd); for (int i 0; i dataset.size(); i) { IData data (IData) dataset.get(i); data.put(USER_ID, data.get(USECUST_ID)); data.put(CUST_ID, data.get(USECUST_ID)); data.put(CUST_NAME, data.get(USECUST_NAME)); data.put(VIP_ID, dao.getSequence(SEQ_VIP_ID)); data.put(REMOVE_TAG, ExampleFactory.VIP_REMOVETAG_VALID); data.put(IN_STAFF_ID, pd.getContext().getStaffId()); data.put(JOIN_DATE, common.getSysTime()); data.put(IN_DATE, common.getSysTime()); } dao.insert(TF_F_CUST_VIP, dataset); // 一次性批量插入 }第二种批量删除把主键列表转成数据集// 页面传上来的是 String[]转成 DatasetList 后批量删 public void deleteVipcusts(PageData pd, String[] vips) throws Exception { ExamplesDAO dao new ExamplesDAO(pd); IDataset dataset new DatasetList(); for (int i 0; i vips.length; i) { IData data new DataMap(); data.put(VIP_ID, vips[i]); dataset.add(data); } dao.delete(TF_F_CUST_VIP, dataset); }第三种通用增删改覆盖不了的场景手写批量 SQL// 每行更新的字段不同用 executeBatch 按顺序绑定参数 public void batchUpdateVipcusts(IData param) throws Exception { String[] vip_ids common.getValues(param.get(VIP_ID)); String[] cust_names common.getValues(param.get(CUST_NAME)); String[] cust_types common.getValues(param.get(CUST_TYPE)); String sql update TF_F_VIP_CUST set CUST_NAME ?, CUST_TYPE ? where VIP_ID ?; Parameter[] params new Parameter[vip_ids.length]; for (int i 0; i params.length; i) { params[i] new Parameter(); params[i].add(cust_names[i]); params[i].add(cust_types[i]); params[i].add(vip_ids[i]); // 顺序必须和 SQL 里的 ? 一一对应 } executeBatch(sql, params); }executeBatch的坑在参数顺序。SQL 里三个占位符Parameter里 add 的顺序就必须是三个字段的顺序错一位不会报错只会把名字写进类型字段。另外批量操作要控制单批数量几百条一批比较稳一次几千条容易把事务日志撑爆。5. 排查五个高频翻车现场这一章按「现象 → 原因 → 解决」记录五个实际踩过的坑都是那种看起来像代码问题、实际是配置或约定问题的类型。5.1 构建期ant allwar报 taskdef 找不到现象ant all一切正常切到ant allwar立刻报错提示某个 task 未定义错误位置在build.xml中间某行。原因allwar用到了 ant 之外的扩展任务需要ant-contrib.jar支持而这个包不在 ant 默认发行版里。开发机上曾经配过换机器或者重装 ant 之后就丢了。解决把ant-contrib.jar放到 build 目录在build.xml顶部用taskdef显式声明 classpath而不是丢进 ant 安装目录的 lib。后者换机器就失效而且新同事第一次拉代码必然翻车。声明时用相对路径跟工程一起走版本库。5.2 页面期底部页面报错、菜单一片空白现象新工程建好登录后页面能出来但底部区域报错或者侧边栏菜单全是 quickstart 的示例项。原因两个独立问题撞一起了。底部报错是 2.52 的改名改造点没做——Buttom.html、Buttom.java以及page.application里的配置都还是旧拼写菜单问题是sidebar.html里 quickstart 保留的菜单配置没删。解决先全局搜Buttom注意是拼写错误的那个把文件名、类名、配置项一起改再打开sidebar.html和page.application把examples.basic.*相关的菜单和页面配置清干净。建议建完工程先跑一遍空首页确认骨架正常再写业务。5.3 数据期保存成功但字段被清空现象编辑页只改了一个字段点保存提示成功重新打开详情页发现其他字段全空了。原因用了update而不是save。update是整行覆盖data 里没给的字段会被写成空值。页面表单往往只提交了部分字段剩下的自然就没了。解决只改部分字段的场景一律用save它会先查原记录再覆盖。如果确认 data 包含全部字段、要用update提效率就在页面上加一个断言性检查把必填字段列出来核对一遍再提交。改完之后回到列表页抽查两三条别只看当前这条。5.4 导入期文件读不到、导入后日期变了样现象Excel 导入点了没反应后台取不到文件流或者导入成功但导出的失败文件里日期从2007-03-01变成了2007-03-01 00:00。原因前者是表单没设enctypemultipart/form-data服务端拿不到文件流后者是错误回写配置里给列指定了type3和format导出时按格式做了转换丢了原始输入形态。解决表单必须加enctypemultipart/form-datainput typefile nameFILE_PATH/的 name 要和后台pd.getUploadFile(FILE_PATH)一致。错误回写配置里所有列统一设type1不做任何格式转换另外加一列IMPORT_ERROR承载具体错误信息。用户拿到失败文件是为了改完再传一次原始形态必须保住。5.5 并发期两个人同时操作后提交的覆盖了前一个现象A 修改了一条记录并保存B 在更早打开的页面上也保存了同一条A 的修改消失或者 A 已删除B 还能提交成功。原因更新和删除只按主键定位没有校验记录当前状态属于典型的丢失更新。解决把状态列如REMOVE_TAG作为附加条件一起带进save/delete条件不匹配时返回 false页面类里common.error提示「信息在此操作前已经被操作过」。这个方案不是严格的行锁但在业务上够用代价小。要求强一致的场景再考虑版本号字段每次更新 version1 并作为条件。注意这五条里5.1 和 5.2 属于「建工程时一次性解决」5.3 到 5.5 属于「写业务时每次都要想一下」。前两个忘了只是麻烦后三个忘了会丢数据。6. Excel 导入两套 XML 的分工与type1的保真技巧导入功能要配两份 XML很多人第一次做会只配一份然后在「失败数据怎么给用户」这一步卡住。分工是这样的import/xxx.xml定义导入模板的列、类型、长度、是否必填负责把 Excel 解析成数据集并做格式校验export/xxximport.xml定义失败数据的回写格式负责把校验不通过的行重新生成一个 Excel 给用户下载。两份配置的列要对得上但类型策略完全相反——导入配置可以严格回写配置必须宽松。!-- import/examples/basic/VipcustList.xml导入模板定义 -- sheet desc客户资料 header isshowtrue height300 cell nameUSECUST_ID desc客户标识 type2 align1 width4000 nullableno maxsize16/ cell nameUSECUST_NAME desc客户名称 type1 align1 width6000 nullableno maxsize100/ /header /sheet!-- export/examples/basic/VipcustImport.xml失败数据回写定义 -- sheet name客户资料 desc客户资料导入失败列表 header isshowtrue height300 !-- 全部列强制 type1不做任何格式转换保住用户原始输入 -- cell nameUSECUST_ID desc客户标识 type1 align1 width4000/ cell nameUSECUST_NAME desc客户名称 type1 align1 width6000/ !-- 专门一列承载错误原因 -- cell nameIMPORT_ERROR desc导入错误 type1 align1 width10000/ /header /sheettype的取值是 1 字符串、2 数字、3 时间。回写配置里如果给日期列设了type3加formatyyyy-MM-dd HH:mm用户原本填的2007-03-01会被导出成2007-03-01 00:00看起来只是补了个时间实际破坏了「原样返回」的承诺——用户拿回去改完再导入格式校验可能就过不了。这个细节我在项目里返工过一次后来所有回写配置一律type1。页面侧还有两个必查项。表单必须enctypemultipart/form-data否则pd.getUploadFile(FILE_PATH).getStream()拿不到流模板下载链接的file_path指向templet/下的模板文件file_name是用户看到的下载名两个参数别写反。业务校验比如客户标识是否已存在、状态是否允许必须自己写在 Bean 里框架只做格式校验不做业务判断。我现在的固定习惯是导入功能写完先造三份数据跑一遍——全对的、格式错的、业务冲突的确认成功条数、失败条数、失败文件里的错误列都对得上再交给测试。少这一步后面补数据的成本远高于当时多花的那二十分钟。希望帮到你。本文还有配套的精品资源点击获取