ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Java电子签章生成实战:从Graphics2D绘制到PDF/Word合成与避坑

Java电子签章生成实战:从Graphics2D绘制到PDF/Word合成与避坑 简介这是一套基于Spring Boot实现电子合同电子签章功能的Java项目资源面向需要为PDF合同增加数字签名与可视化签章的企业级开发人员适合具备Spring Boot基础并希望深入学习数字签名、PDF处理的开发者参考。压缩包约72KB共包含多个核心文件以Maven配置、Java源码、资源文件及打包产物为主其中源码目录覆盖了签章服务的核心业务逻辑资源目录内可配置证书、密钥与电子章图片整体目录结构清晰便于按模块理解。已有1248人学习浏览。项目完整展示了从接收签章请求到调用后端服务、结合数字签名算法如RSA/DSA与Bouncy Castle等加密库并利用iText或PDFBox在合同指定位置插入签章图形与签名数据结构的全过程。读者可从中掌握Spring Boot微服务搭建、PDF文档操作以及电子签章完整性验证的关键思路对实际业务中保障电子合同安全与合规具有直接借鉴意义。1. 电子合同里的 Java 电子签章先弄清楚生成的是什么电子合同项目里一旦出现“电子签章”这条需求很多 Java 工程师第一反应是把公章扫描件贴到 PDF 上。这么做出来的东西只能叫“贴图”不是电子签章。真正的电子签章至少包含两层一层是肉眼可见的印章图形公司名、专用章类型、防伪编码都要符合用章规范另一层是看不见的文档绑定关系合同内容被改一个字签章就必须能被验出失效。本文按这个标准从服务端生成签章图讲起落到 PDF 与 Word 合同的合成姿势和踩坑记录适合正在做合同管理系统、想自研签章模块的 Java 开发。2. 从业务需求到技术选型图像签章、数字签名与合规链路的分工2.1 电子签章和电子签名不是一回事别把名字搞混“电子签章”和“电子签名”在日常沟通里经常被混着说落地时却分工完全不同。电子签章是视觉层就是我们在合同落款处看到的那枚红章由圆形或方形轮廓、单位名称、印章类型、五角星、防伪码、序列号组成。它是给人看的解决“这个合同看起来是这家公司盖的章”这个识别问题。电子签名是算法层作用于文档内容本身先对合同文件计算哈希摘要再用签名方的私钥对这个摘要加密得到签名值。任何人改动合同哪怕只改一个空格重新计算的哈希就对不上签名自然失效。这一层解决“文档内容没被篡改”的验证问题。业务系统里要同时处理这两层而不是二选一。常见做法是先用 Java 在服务端把印章图像绘制出来合成到合同文件里让用户看到规范的用章效果同时调用数字签名 SDK 或 CA 机构提供的接口对合成后的文件做一次文档签名。这样视觉上有一枚章技术上有一个绑定文件内容的签名值审计时才经得起追问。只贴图不做签名的合同等于把章盖在了一张随时能被替换的白纸上。2.2 生成电子签章的三种 Java 路径与选型判断做签章生成模块通常会遇到三条路第一种是纯 Java 自研即用 Graphics2D 绘制印章图像再用 iText 或 PDFBox 把图合成到合同文件最后预留接口对接数字签名服务。这个路线适合企业内部合同管理系统以及想先跑通流程再升级合规能力的团队。优点是可控性高印章样式、防伪细节、生成速度都能自己调不依赖外部网络缺点是需要自己处理字体、透明通道、页面坐标这类图形学细节而且要额外接通 CA 做数字签名才能达到完整合规。第二种是直接对接 CA 机构的签章服务 API。签章图片、证书链、时间戳都由 CA 侧生成服务端只传递合同文件和用章人身份信息。这条路合规起点高适合对外签署、涉及多方纠纷责任的业务场景。代价是接口调用有网络依赖内网部署时还要考虑网关和证书管理合同文件频繁进出的场景要对吞吐量做压测。第三种是采购商业签章组件。这类组件通常把印章管理、审批流、签名服务打包在一起交付快项目急的话可以缩短开发周期。代价是定制能力弱公司想加一种“项目专用章”样式可能要等版本迭代而且按年授权的费用对中小团队不算低。三者的对比可以简化成一张表选型方向灵活度一次性成本合规支持集成周期适用场景纯 Java 自研 对接 CA高开发人力需自行对接2-4 周企业内部合同系统、原型验证CA 签章 API 对接中接口授权费开箱即用1-2 周对外签署、司法效力要求高商业签章组件低软件授权费完整1 周内快速交付、定制需求少我一般更推荐第一种作为起步。原因很直白即便后续要切换 CA 服务自研部分产出的印章图片、合成代码、落章规则依然可以复用被替换的只有“数字签名”这一层。Java 生态在这里非常成熟Graphics2D 是 Java 基础里就有的能力不需要额外引包PDF 和 Word 合成也有稳定可用的开源库。接下来几章就按这条路线完整展开。3. Java 服务端绘制签章图用 Graphics2D 画出可交付的圆章和方章3.1 圆章绘制外圈、文字弧线、五角星的完整实现圆章是最常见的合同专用章形态。先设定画布直径再依次绘制外圈、沿圆弧排列的单位名称、下弧的印章类型文字、居中五角星和防伪编码。下面这个生成器可以直接在服务端跑通最小闭环输出透明背景的 PNG 文件import javax.imageio.ImageIO; import java.awt.*; import java.awt.geom.*; import java.awt.image.BufferedImage; import java.io.File; public class SealGenerator { public static void main(String[] args) throws Exception { BufferedImage seal createCircularSeal( 300, // 画布直径 300px对应打印 25mm 左右 北京示例科技有限公司, // 单位全称沿上弧排列 合同专用章, // 印章类型沿下弧排列 A20260601); // 防伪序列号 ImageIO.write(seal, png, new File(/data/seal_contract.png)); } public static BufferedImage createCircularSeal(int diameter, String companyName, String sealType, String serialNo) { BufferedImage img new BufferedImage(diameter, diameter, BufferedImage.TYPE_INT_ARGB); Graphics2D g2d img.createGraphics(); g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); g2d.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON); int center diameter / 2; int lineWidth Math.max(2, diameter / 75); g2d.setColor(new Color(200, 0, 0, 255)); // 外圈 g2d.setStroke(new BasicStroke(lineWidth, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND)); g2d.draw(new Ellipse2D.Double(lineWidth / 2.0, lineWidth / 2.0, diameter - lineWidth, diameter - lineWidth)); // 上弧单位名称 Font nameFont new Font(Noto Sans CJK SC, Font.BOLD, diameter / 11); drawArcText(g2d, companyName, center, center, (int) (diameter * 0.33), 210, 120, nameFont, new Color(200, 0, 0, 255), true); // 下弧印章类型 Font typeFont new Font(Noto Sans CJK SC, Font.BOLD, diameter / 13); drawArcText(g2d, sealType, center, center, (int) (diameter * 0.38), 30, -120, typeFont, new Color(200, 0, 0, 255), false); // 中间五角星 Path2D.Double star createStar(center, center, diameter * 0.18, diameter * 0.07); g2d.fill(star); // 序列号横排 g2d.setFont(new Font(Noto Sans CJK SC, Font.PLAIN, diameter / 20)); FontMetrics fm g2d.getFontMetrics(); int textWidth fm.stringWidth(serialNo); g2d.drawString(serialNo, center - textWidth / 2, center fm.getHeight()); g2d.dispose(); return img; } }弧线文字是最容易翻车的部分单独拆开看逻辑。drawArcText 先把整段文字按 FontMetrics 量出每个字符宽度再把总弧长换算成总弧度然后逐字平移到圆上再旋转private static void drawArcText(Graphics2D g2d, String text, int centerX, int centerY, int radius, double startAngleDeg, double spanAngleDeg, Font font, Color color, boolean upper) { g2d.setFont(font); g2d.setColor(color); FontMetrics metrics g2d.getFontMetrics(font); double totalWidth 0; for (int i 0; i text.length(); i) { totalWidth metrics.charWidth(text.charAt(i)); } double step Math.toRadians(spanAngleDeg / totalWidth); double angle Math.toRadians(startAngleDeg); for (int i 0; i text.length(); i) { char ch text.charAt(i); double charWidth metrics.charWidth(ch); double charAngle charWidth * step; AffineTransform transform g2d.getTransform(); g2d.translate(centerX, centerY); g2d.rotate(angle charAngle / 2); g2d.translate(0, -radius); g2d.drawString(String.valueOf(ch), (float) (-charWidth / 2), 0); g2d.setTransform(transform); angle charAngle; } }这里有两个关键参数。第一个是 radius决定文字落在圆的哪个半径上单位名称一般取直径的 0.33 倍下弧文字取 0.38 倍这样上下弧不会打架第二个是 startAngleDeg 和 spanAngleDeg 的组合上弧从 210 度开始顺时针扫 120 度正好落在圆的上半部分居中下弧从 30 度开始扫 -120 度落位对称。每次旋转前用 g2d.getTransform() 保存现场画完单个字立即恢复否则角度会叠加出诡异效果。五角星用十边形路径绘制外顶点半径取直径的 0.18 倍内顶点取 0.07 倍这组比例在 300px 画布下视觉最舒服。private static Path2D.Double createStar(int centerX, int centerY, double outerR, double innerR) { Path2D.Double star new Path2D.Double(); for (int i 0; i 10; i) { double r (i % 2 0) ? outerR : innerR; double angle Math.toRadians(-90 i * 36); double x centerX r * Math.cos(angle); double y centerY r * Math.sin(angle); if (i 0) { star.moveTo(x, y); } else { star.lineTo(x, y); } } star.closePath(); return star; }3.2 方章、防伪纹理和透明 PNG 输出方章在公司内部业务章里很常见比如“项目专用章”“档案专用章”。绘制思路比圆章简单外框用双线矩形单位名称横排在上印章类型居中编号落在右下角。难的是让方章看起来不像用画图板拼出来的常用做法是外框线条加一点粗细变化内框用虚线区分区域private static BufferedImage createRectSeal(int width, int height, String line1, String line2, String serialNo) { BufferedImage img new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB); Graphics2D g2d img.createGraphics(); g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); g2d.setColor(new Color(200, 0, 0, 255)); int border Math.max(3, width / 40); g2d.setStroke(new BasicStroke(border)); g2d.draw(new Rectangle2D.Double(border / 2.0, border / 2.0, width - border, height - border)); g2d.setStroke(new BasicStroke(1.5f)); g2d.draw(new Rectangle2D.Double(border * 2, border * 2, width - border * 4, height - border * 4)); g2d.setFont(new Font(Noto Sans CJK SC, Font.BOLD, height / 5)); FontMetrics fm g2d.getFontMetrics(); g2d.drawString(line1, (width - fm.stringWidth(line1)) / 2, height * 4 / 10); g2d.setFont(new Font(Noto Sans CJK SC, Font.PLAIN, height / 8)); g2d.drawString(line2, (width - fm.stringWidth(line2)) / 2, height * 7 / 10); g2d.dispose(); return img; }防伪纹理方面我一般会加两类东西。一类是斜向微缩文字沿着印章内框边缘反复排一圈很小的字符串肉眼不容易察觉但扫描放大后能看到起到辨别真伪的作用另一类是可控噪点用固定随机种子在印章内部撒一些深度不同的红色像素点伪造者要完全复刻这些噪点位置成本很高。注意噪点密度不要超过画布面积的百分之二否则打印出来会脏。输出文件时务必保持 TYPE_INT_ARGB透明通道一旦被丢弃后续合成 PDF 会出现黑底这是后面避坑章节的第一条血泪经验。private static void addMicroText(Graphics2D g2d, int diameter, String text, int radius, int fontSize) { g2d.setFont(new Font(Noto Sans CJK SC, Font.PLAIN, fontSize)); int count diameter / fontSize * 4; for (int i 0; i count; i) { double angle Math.toRadians(360.0 * i / count); int x (int) (diameter / 2 radius * Math.cos(angle)); int y (int) (diameter / 2 radius * Math.sin(angle)); AffineTransform old g2d.getTransform(); g2d.translate(x, y); g2d.rotate(angle Math.PI / 2); g2d.drawString(text, 0, 0); g2d.setTransform(old); } }绘制完成后的输出环节有两个检查点。一是用 ImageIO.write 输出 PNG 后重新读回来校验 hasAlpha确认透明通道完好二是看生成的文件大小纯色印章 PNG 压缩后一般只有十几 KB如果出来几百 KB多半是抗锯齿叠加了异常噪点需要回去检查绘制过程中的透明度参数。4. 把签章合成进合同文件PDF 与 Word 两种合成姿势4.1 用 iText 把印章合入 PDF坐标、透明度与落款页定位合同文件在业务系统里大多是 PDF 形态。生成签章图之后最核心的合入逻辑是三步读出原 PDF、在目标页的画布上叠加印章图、写出新文件。下面这段方法可以直接复用入参是待盖章文件路径、签章图路径、目标页索引和印章落点坐标import com.itextpdf.text.Image; import com.itextpdf.text.pdf.PdfContentByte; import com.itextpdf.text.pdf.PdfReader; import com.itextpdf.text.pdf.PdfStamper; public static void stampPdf(String inputPath, String outputPath, String sealImagePath, int pageIndex, float xPt, float yPt, float targetDiameterPt) throws Exception { PdfReader reader new PdfReader(inputPath); PdfStamper stamper new PdfStamper(reader, new FileOutputStream(outputPath)); Image seal Image.getInstance(sealImagePath); seal.scaleToFit(targetDiameterPt, targetDiameterPt); seal.setAbsolutePosition(xPt, yPt); PdfContentByte canvas stamper.getOverContent(pageIndex); canvas.addImage(seal); stamper.close(); reader.close(); }调用时最需要注意的是单位换算。PDF 内部以点为长度单位1 点等于 1/72 英寸而印章图尺寸是像素不能直接拿像素值设坐标。常用做法是先定物理尺寸一枚直径 40mm 的合同章换算成点就是 40 * 72 / 25.4 ≈ 113 点。签名图的像素只需要保证分辨率足够300px 直径对应 40mm 时约 190 DPI打印清晰无需精确对应。坐标定位是另一个高频出错点。PDF 坐标系原点在页面左下角x 向右、y 向上而我们在网页预览图或 PDF 阅读器截图里量坐标时习惯从左上角开始。换算公式为 yPdf pageHeight - yFromTop - targetHeightxPdf 保持 xFromLeft 不变。如果不做这个转换章往往会整体往下偏出页面或在视觉上错位。页面高度可以通过 PdfReader.getPageSize(pageIndex).getHeight() 拿到。落款页的自动定位我一般先从最后一页开始逐页提取文本找包含“甲方”“乙方”“签署日期”这类关键词的页面作为盖章目标页import com.itextpdf.text.pdf.parser.PdfTextExtractor; public static int findSignPage(PdfReader reader) throws Exception { int total reader.getNumberOfPages(); for (int i total; i 1; i--) { String text PdfTextExtractor.getTextFromPage(reader, i); if (text.contains(甲方) text.contains(乙方)) { return i; } } return total; }这个方案有个边界要说明部分 PDF 的字体子集没有嵌入 ToUnicode 映射中文文本提取出来是乱码关键词匹配会落空代码就会退回盖最后一页。对这种文件我会把合同模板的落款页固定为倒数第二页直接在配置里指定不强依赖文本识别。4.2 Word 合同用 POI 合成XWPF 的图片插入与缩放一部分企业合同仍是 Word 文件流转服务端需要用 POI 把签章图写进 docx。POI 的 XWPF 组件只支持 docx老版 doc 要先做格式转换常见做法是部署环境里装 LibreOffice 无头模式批量转。插入图片的代码如下import org.apache.poi.util.Units; import org.apache.poi.xwpf.usermodel.*; public static void stampWord(String inputPath, String outputPath, String sealImagePath) throws Exception { try (XWPFDocument doc new XWPFDocument(new FileInputStream(inputPath)); InputStream in new FileInputStream(sealImagePath)) { XWPFParagraph lastPara doc.getParagraphs().get(doc.getParagraphs().size() - 1); XWPFRun run lastPara.addRun(); run.addPicture(in, Document.PICTURE_TYPE_PNG, seal.png, Units.toEMU(40), Units.toEMU(40)); doc.write(new FileOutputStream(outputPath)); } }XWPFRun.addPicture 的宽高参数以 EMU 为单位POI 的 Units.toEMU 可以直接把厘米转过去。这里传入的是 40表示印章直径 40 毫米打印尺寸和 PDF 场景保持一致。段落尾部追加图片的方式有个副作用签章会出现在最后一个段落之后如果 Word 模板最后一行是审批人签名文本章会盖到文字下方这时需要先手动选中落款段落在段落内新建 run 而不是直接在 doc.getParagraphs() 的最后一个元素上追加。多页 Word 合同盖骑缝章的思路和 PDF 一致需要先把章切成左右两半分别贴到相邻两页的边缘。图片插入后还要手动设置对齐方式否则章会跟随段落默认左对齐看起来不在页面中间。XWPFParagraph 的 setAlignment(ParagraphAlignment.CENTER) 可以解决这个问题。5. 电子签章生成避坑五个真实踩过的坑5.1 Linux 服务器上印章文字变成方块现象同一个生成服务开发机 Windows 上画出来的章文字清晰部署到 Linux 服务器后公司名称全部变成空心方块五角星正常。原因Graphics2D 绘制文本依赖系统注册的字体Windows 自带宋体、黑体JDK 能直接找到中文字形Linux 服务器通常是精简安装没有中文字体文件Font 对象回退到默认字体而默认字体不含中文字符的 glyph。解决把开源中文字体放进项目 resources 目录绘制前显式注册。我用的是 Noto Sans CJK部署包里固定带一个 otf/ttc 文件启动时注册一次Font font Font.createFont(Font.TRUETYPE_FONT, new File(/data/fonts/NotoSansCJK-Regular.ttc)); GraphicsEnvironment.getLocalGraphicsEnvironment().registerFont(font);注册之后所有绘制代码里的 new Font(Noto Sans CJK SC, ...) 才能在这台机器上按名称命中。不要动 JDK 安装目录里的字体目录线上环境升级 JDK 会把它重置项目自有目录最可控。5.2 印章图合成到 PDF 后背景变黑现象PNG 在图片查看器里正常透明背景干干净净合入 PDF 后印章区域出现一整块黑色或白色矩形把下方合同文字遮得严严实实。原因两个来源。一是生成印章时用了 BufferedImage.TYPE_INT_RGB根本没有 alpha 通道透明区域实际上是黑色填充二是用 PDFBox 的 JPEGFactory 加载 PNG有损压缩把透明信息丢掉了。解决生成端统一用 BufferedImage.TYPE_INT_ARGB保存用 ImageIO.write 输出 PNGPDF 合成用 iText 的 Image.getInstance 直接读 PNG它会保留颜色空间的透明掩码。写完代码后加一行断言if (!sealImage.getColorModel().hasAlpha()) { throw new IllegalStateException(印章图片缺失透明通道禁止合成); }5.3 签章位置对不齐坐标系的“轮子”转不过弯现象章按视觉坐标填了 x500、y300结果合成后印章偏到页面右侧边缘甚至一半在页面外面。原因页面坐标原点在左下角业务方给的坐标往往从左上角量起。y 方向没有换算印章会整体向上偏移误差在页面高度这个量级上肉眼一眼就能看出不对。解决先拿 PdfReader.getPageSize(pageIndex).getHeight() 取页面高度再套公式 yPdf pageHeight - yFromTop - targetHeightPt。这里 targetHeightPt 是印章直径换算成 PDF 点的值漏掉最后一项时印章底部会多出半个章身的高度差表现为章比预期位置整体偏上。加一个日志输出实际坐标联调时对照 PDF 打开后的视觉位置调一次就能固定。5.4 骑缝章盖不全、两半对不齐现象骑缝章预演时左页只有一小条红色边缘右页却出现大半个章合起来完全不是一枚完整的章。原因把整张印章图分别画到两页时没有做像素级切割只是把图片往页面外推了一半结果每页显示的部分宽度不一致。还要考虑 PDF 两页边缘如果有页边距差异偏差会被放大。解决先把整章图片从正中间竖直切开各自复制成独立的 BufferedImage再分别贴到左页右边缘和右页左边缘。切割时 getSubimage 返回的是原图的视图直接写文件会连带整张图需要先复制到新画布BufferedImage left new BufferedImage(half, height, BufferedImage.TYPE_INT_ARGB); left.getGraphics().drawImage(whole.getSubimage(0, 0, half, height), 0, 0, null);左半章 x 坐标设为 pageWidth - halfWidthPt右半章 x 设为 0y 保持一致视觉上才能拼回完整印章。这个技巧在多页合同场景里几乎是必经之路建议做成独立的工具方法。5.5 只贴图不签名合同改了一行字章依然纹丝不动现象合同正文被改了一个数字重新生成 PDF 后章还盖在原来的位置法务那边一眼看穿认为签章不具备防篡改能力。原因图像类电子签章本身不绑定文档内容。签章图是静态位图不管合同文字怎么变印章像素不受影响只有数字签名把文档哈希绑到签名值里内容一变签名即失效。解决把视觉签章和数字签名拆成两层。印章图负责展示和用章审批记录数字签名层负责内容完整性。自研阶段先用 PDF 签名字段占位把签名域画到落款位置附近后续对接 CA 机构的签名接口时直接填充。Java 侧如果自己实现签名逻辑可以用 BouncyCastle 的 CMSSignedData 做 detached 签名但证书链、时间戳、吊销验证这些环节很容易踩坑项目要求合规时优先评估集成成熟 CA 服务不要在这个位置省时间。6. 生成后的自检与验真让电子签章经得起审计签章生成不是“图能画出来、文件能打开”就结束了。交付前我会固定跑一轮自检第一层检查图片本身确认透明通道存在、直径与目标尺寸误差在一两个像素内、公司全称没有断笔画第二层检查合成后的合同文件用 PDFBox 把目标页渲染成图片统计红色像素占比验证章确实落在预期区域import org.apache.pdfbox.pdmodel.PDDocument; import org.apache.pdfbox.rendering.PDFRenderer; import javax.imageio.ImageIO; import java.awt.image.BufferedImage; import java.io.File; public static void verifyStamp(String pdfPath, int pageIndex) throws Exception { try (PDDocument doc PDDocument.load(new File(pdfPath))) { PDFRenderer renderer new PDFRenderer(doc); BufferedImage pageImage renderer.renderImage(pageIndex - 1, 72); int redPixels 0; int totalSamples 0; for (int y 0; y pageImage.getHeight(); y 3) { for (int x 0; x pageImage.getWidth(); x 3) { int rgb pageImage.getRGB(x, y); int r (rgb 16) 0xFF; int g (rgb 8) 0xFF; int b rgb 0xFF; if (r 140 g 90 b 90) { redPixels; } totalSamples; } } double ratio (double) redPixels / totalSamples; System.out.printf(印章区域红色像素占比: %.4f%n, ratio); } }红色像素占比是个粗粒度指标能发现章没盖上去、盖成黑块这类大问题发现不了文字错位。更细致的检查仍然要靠人眼把合成后的页面按 200% 放大看圆弧文字首尾是否闭合、五角星是否居中、微缩文字是否清晰可读。视觉检查最好固定由同一个人执行这活儿不需要多少技术含量但需要稳定的判断标准换人容易漏判。第三层检查是数字签名域是否预留、证书信息是否完整。如果项目还没有接 CA至少要确认 PDF 里存在空的签名占位域后续上线不必再改模板。对接第三方 CA 后验收时还要用阅读器打开签名属性看签名哈希值是否与文档匹配、证书链是否可信、时间戳是否在有效期内。这里的经验是尽早把“签字域 时间戳”作为生成流程的标配而不是等项目验收前再补。我现在做电子合同签章生成会把“画章”和“签文档”分成两个独立模块维护。画章模块只负责输出符合规范的印章位图签文档模块只负责合成坐标、签名域和后续 CA 对接。两个模块各自改版互不影响排查问题时也不用从头把图形代码读一遍。生成流程里必须留一份日志记录每份合同的版本号、用章时间、证书序列号审计时这份日志比印章图本身更能说明问题。这个习惯帮我避过不止一次“章是真的文件是改过的”这类扯皮希望帮到你。本文还有配套的精品资源点击获取
返回列表