项目源码路径:https://github.com/Syske/learning-dome-code.git
前言
最近应客户需求,需要实现电子签章功能,公章部分用的时金格科技的接口,个人人签字需要自己实现,公章部分我们就不说了,商业接口做的都比较成熟,也有示例代码,所以今天着重说的就是个人签字部分。
参照公章部分的实现方式,同时也参考了很多博客1,查了很多资料,也搞清楚了电子签章的基本流程:
因为我要实现的功能很简单就是个人签章,而且我的签名是手写的,所以创建签名部分就省略了,核心部分就是确定签名坐标和签名,确定坐标部分我根据自己查找的资料,实现了根据关键字确定坐标,因为确定坐标很麻烦,也不够灵活。
对于创建签名我有一个思路,可以将创建签名作为一个远程服务部署,然后远程调用,然后检验,返回签名;当然你也可以通过这种方式生成公章,但是因为没有经过公正机构认证,这种方式生成的公章其实是不具法律效应的,好了下面直接上代码吧。
创建签名密钥
这个密钥在pdf签名的时候会校验
package io.github.syske.common.util;
import org.bouncycastle.asn1.ASN1ObjectIdentifier;
import org.bouncycastle.asn1.ASN1Primitive;
import org.bouncycastle.asn1.x500.X500Name;
import org.bouncycastle.asn1.x509.*;
import org.bouncycastle.cert.X509CertificateHolder;
import org.bouncycastle.cert.X509v3CertificateBuilder;
import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import org.bouncycastle.operator.ContentSigner;
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
import java.io.*;
import java.math.BigInteger;
import java.security.*;
import java.security.cert.Certificate;
import java.security.cert.CertificateFactory;
import java.security.cert.X509Certificate;
import java.text.SimpleDateFormat;
import java.util.*;
import java.security.cert.Certificate;
/**
* @program: dpf-sign-test
* @description: 签名工具类
* @author: syske
* @create: 2019-12-04 09:02
*/
public class Pkcs {
private static KeyPair getKey() throws NoSuchAlgorithmException {
KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA",
new BouncyCastleProvider());
generator.initialize(1024);
// 证书中的密钥 公钥和私钥
KeyPair keyPair = generator.generateKeyPair();
return keyPair;
}
/**
* @param password
* 密码
* @param issuerStr 颁发机构信息
*
* @param subjectStr 使用者信息
*
* @param certificateCRL 颁发地址
*
* @return
*/
public static Map<String, byte[]> createCert(String password,
String issuerStr, String subjectStr, String certificateCRL) {
Map<String, byte[]> result = new HashMap<String, byte[]>();
ByteArrayOutputStream out = null;
try {
// 生成JKS证书
// KeyStore keyStore = KeyStore.getInstance("JKS");
// 标志生成PKCS12证书
KeyStore keyStore = KeyStore.getInstance("PKCS12",
new BouncyCastleProvider());
keyStore.load(null, null);
KeyPair keyPair = getKey();
// issuer与 subject相同的证书就是CA证书
Certificate cert = generateCertificateV3(issuerStr, subjectStr,
keyPair, result, certificateCRL, null);
// cretkey随便写,标识别名
keyStore.setKeyEntry("cretkey", keyPair.getPrivate(),
password.toCharArray(), new Certificate[] { cert });
out = new ByteArrayOutputStream();
cert.verify(keyPair.getPublic());
keyStore.store(out, password.toCharArray());
byte[] keyStoreData = out.toByteArray();
result.put("keyStoreData", keyStoreData);
return result;
} catch (Exception e) {
e.printStackTrace();
} finally {
if (out != null) {
try {
out.close();
} catch (IOException e) {
}
}
}
return result;
}
/**
* @param issuerStr
* @param subjectStr
* @param keyPair
* @param result
* @param certificateCRL
* @param extensions
* @return
*/
public static Certificate generateCertificateV3(String issuerStr,
String subjectStr, KeyPair keyPair, Map<String, byte[]> result,
String certificateCRL, List<Extension> extensions) {
ByteArrayInputStream bout = null;
X509Certificate cert = null;
try {
PublicKey publicKey = keyPair.getPublic();
PrivateKey privateKey = keyPair.getPrivate();
Date notBefore = new Date();
Calendar rightNow = Calendar.getInstance();
rightNow.setTime(notBefore);
// 日期加1年
rightNow.add(Calendar.YEAR, 1);
Date notAfter = rightNow.getTime();
// 证书序列号
BigInteger serial = BigInteger.probablePrime(256, new Random());
X509v3CertificateBuilder builder = new JcaX509v3CertificateBuilder(
new X500Name(issuerStr), serial, notBefore, notAfter,
new X500Name(subjectStr), publicKey);
JcaContentSignerBuilder jBuilder = new JcaContentSignerBuilder(
"SHA1withRSA");
SecureRandom secureRandom = new SecureRandom();
jBuilder.setSecureRandom(secureRandom);
ContentSigner singer = jBuilder.setProvider(
new BouncyCastleProvider()).build(privateKey);
// 分发点
ASN1ObjectIdentifier cRLDistributionPoints = new ASN1ObjectIdentifier(
"2.5.29.31");
GeneralName generalName = new GeneralName(
GeneralName.uniformResourceIdentifier, certificateCRL);
GeneralNames seneralNames = new GeneralNames(generalName);
DistributionPointName distributionPoint = new DistributionPointName(
seneralNames);
DistributionPoint[] points = new DistributionPoint[1];
points[0] = new DistributionPoint(distributionPoint, null, null);
CRLDistPoint cRLDistPoint = new CRLDistPoint(points);
builder.addExtension(cRLDistributionPoints, true, cRLDistPoint);
// 用途
ASN1ObjectIdentifier keyUsage = new ASN1ObjectIdentifier(
"2.5.29.15");
// | KeyUsage.nonRepudiation | KeyUsage.keyCertSign
builder.addExtension(keyUsage, true, new KeyUsage(
KeyUsage.digitalSignature | KeyUsage.keyEncipherment));
// 基本限制 X509Extension.java
ASN1ObjectIdentifier basicConstraints = new ASN1ObjectIdentifier(
"2.5.29.19");
builder.addExtension(basicConstraints, true, new BasicConstraints(
true));
// privKey:使用自己的私钥进行签名,CA证书
if (extensions != null)
for (Extension ext : extensions) {
builder.addExtension(
new ASN1ObjectIdentifier(ext.getOid()),
ext.isCritical(),
ASN1Primitive.fromByteArray(ext.getValue()));
}
X509CertificateHolder holder = builder.build(singer);
CertificateFactory cf = CertificateFactory.getInstance("X.509");
bout = new ByteArrayInputStream(holder.toASN1Structure()
.getEncoded());
cert = (X509Certificate) cf.generateCertificate(bout);
byte[] certBuf = holder.getEncoded();
SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd");
// 证书数据
result.put("certificateData", certBuf);
//公钥
result.put("publicKey", publicKey.getEncoded());
//私钥
result.put("privateKey", privateKey.getEncoded());
//证书有效开始时间
result.put("notBefore", format.format(notBefore).getBytes("utf-8"));
//证书有效结束时间
result.put("notAfter", format.format(notAfter).getBytes("utf-8"));
} catch (Exception e) {
e.printStackTrace();
} finally {
if (bout != null) {
try {
bout.close();
} catch (IOException e) {
}
}
}
return cert;
}
public static void main(String[] args) throws Exception{
// CN: 名字与姓氏 OU : 组织单位名称
// O :组织名称 L : 城市或区域名称 E : 电子邮件
// ST: 州或省份名称 C: 单位的两字母国家代码
String issuerStr = "CN=电子签名,OU=github,O=github,C=CN,L=西安,ST=陕西";
String subjectStr = "CN=电子签名,OU=github,O=github,C=CN,L=西安,ST=陕西";
String certificateCRL = "https://syske.github.io/";
Map<String, byte[]> result = createCert("123456", issuerStr, subjectStr, certificateCRL);
FileOutputStream outPutStream = new FileOutputStream("D:/keystore.p12"); // ca.jks
outPutStream.write(result.get("keyStoreData"));
outPutStream.close();
FileOutputStream fos = new FileOutputStream(new File("D:/keystore.cer"));
fos.write(result.get("certificateData"));
fos.flush();
fos.close();
}
}
package io.github.syske.common.util;
/**
* @program: dpf-sign-test
* @description:
* @author: syske
* @create: 2019-12-04 09:00
*/
public class Extension {
private String oid;
private boolean critical;
private byte[] value;
public String getOid() {
return oid;
}
public byte[] getValue() {
return value;
}
public boolean isCritical() {
return critical;
}
}
生成签名
生成的签名是png图片
package io.github.syske.common.util;
/**
* @program: dpf-sign-test
* @description:
* @author: syske
* @create: 2019-12-03 22:50
*/
import java.awt.Color;
import java.awt.Font;
import java.awt.FontMetrics;
import java.awt.Graphics2D;
import java.awt.RenderingHints;
import java.awt.image.BufferedImage;
import java.io.FileOutputStream;
import java.io.IOException;
import sun.font.FontDesignMetrics;
import com.sun.image.codec.jpeg.JPEGCodec;
import com.sun.image.codec.jpeg.JPEGEncodeParam;
import com.sun.image.codec.jpeg.JPEGImageEncoder;
public class SignImage {
/**
* @param doctorName String 医生名字
* @param hospitalName String 医生名称
* @param date String 签名日期
* 图片高度
* @param jpgname String jpg图片名
* @return
*/
public static boolean createSignTextImg(
String doctorName, //
String hospitalName, //
String date,
String jpgname) {
int width = 255;
int height = 100;
FileOutputStream out = null;
//背景色
Color bgcolor = Color.WHITE;
//字色
Color fontcolor = Color.RED;
Font doctorNameFont = new Font(null, Font.BOLD, 20);
Font othorTextFont = new Font(null, Font.BOLD, 18);
try { // 宽度 高度
BufferedImage bimage = new BufferedImage(width, height,
BufferedImage.TYPE_INT_RGB);
Graphics2D g = bimage.createGraphics();
g.setColor(bgcolor); // 背景色
g.fillRect(0, 0, width, height); // 画一个矩形
g.setRenderingHint(RenderingHints.KEY_ANTIALIASING,
RenderingHints.VALUE_ANTIALIAS_ON); // 去除锯齿(当设置的字体过大的时候,会出现锯齿)
g.setColor(Color.RED);
g.fillRect(0, 0, 8, height);
g.fillRect(0, 0, width, 8);
g.fillRect(0, height - 8, width, height);
g.fillRect(width - 8, 0, width, height);
g.setColor(fontcolor); // 字的颜色
g.setFont(doctorNameFont); // 字体字形字号
FontMetrics fm = FontDesignMetrics.getMetrics(doctorNameFont);
int font1_Hight = fm.getHeight();
int strWidth = fm.stringWidth(doctorName);
int y = 35;
int x = (width - strWidth) / 2;
g.drawString(doctorName, x, y); // 在指定坐标除添加文字
g.setFont(othorTextFont); // 字体字形字号
fm = FontDesignMetrics.getMetrics(othorTextFont);
int font2_Hight = fm.getHeight();
strWidth = fm.stringWidth(hospitalName);
x = (width - strWidth) / 2;
g.drawString(hospitalName, x, y + font1_Hight); // 在指定坐标除添加文字
strWidth = fm.stringWidth(date);
x = (width - strWidth) / 2;
g.drawString(date, x, y + font1_Hight + font2_Hight); // 在指定坐标除添加文字
g.dispose();
out = new FileOutputStream(jpgname); // 指定输出文件
JPEGImageEncoder encoder = JPEGCodec.createJPEGEncoder(out);
JPEGEncodeParam param = encoder.getDefaultJPEGEncodeParam(bimage);
param.setQuality(50f, true);
encoder.encode(bimage, param); // 存盘
out.flush();
return true;
} catch (Exception e) {
return false;
} finally {
if (out != null) {
try {
out.close();
} catch (IOException e) {
}
}
}
}
public static void main(String[] args) {
createSignTextImg("华佗", "在线医院", "2018.01.01", "sign.jpg");
}
}
签名实现
这里需要注意的是,签名入参中的页码,如果是最后一页,页码传null
package io.github.syske.common.util;
import com.itextpdf.awt.geom.Rectangle2D;
import com.itextpdf.text.Image;
import com.itextpdf.text.Rectangle;
import com.itextpdf.text.pdf.PdfReader;
import com.itextpdf.text.pdf.PdfSignatureAppearance;
import com.itextpdf.text.pdf.PdfSignatureAppearance.RenderingMode;
import com.itextpdf.text.pdf.PdfStamper;
import com.itextpdf.text.pdf.parser.ImageRenderInfo;
import com.itextpdf.text.pdf.parser.PdfReaderContentParser;
import com.itextpdf.text.pdf.parser.RenderListener;
import com.itextpdf.text.pdf.parser.TextRenderInfo;
import com.itextpdf.text.pdf.security.*;
import com.itextpdf.text.pdf.security.MakeSignature.CryptoStandard;
import org.apache.log4j.Logger;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import java.io.*;
import java.security.KeyStore;
import java.security.KeyStoreException;
import java.security.PrivateKey;
import java.security.Security;
import java.security.cert.Certificate;
import java.util.UUID;
/**
* @program: SignPdf
* @description:
* @author: syske
* @create: 2019-12-03 22:54
*/
public class SignPdf {
private static Logger logger = Logger.getLogger(SignPdf.class);
private static final String PASSWORD = "123456"; // 秘钥密码
private static final String KEY_STORE_PATH = "d:\\keystore.p12"; // 秘钥文件路径
private SignPdf() {
}
/**
* 图片签章,指定签名坐标位置
*
* @param signPdfSrc
* 签名的PDF文件
* @param signedPdfOutFile
* 签名后的的PDF文件
* @param signImage
* 签名图片完整路径
* @param x
* 以左下角为原点x坐标值
* @param y
* 以左下角为原点Y坐标值
* @param numberOfPages
* 签名页码,如果是最后一页则传null
* @param pageStyle
* 页面布局,横向或者纵向
* @throws Exception
*/
public static void sign(String signPdfSrc, String signedPdfOutFile,
String signImage, Float x, Float y, Integer numberOfPages,
PageStyle pageStyle) throws Exception {
sign(signPdfSrc, signedPdfOutFile, signImage, x, y, null,
numberOfPages, pageStyle);
}
/**
* 图片签章,指定关键字
*
* @param signPdfSrc
* 签名的PDF文件
* @param signedPdfFile
* 签名后的的PDF文件
* @param signImage
* 签名图片完整路径
* @param keyWords
* 关键字
* @param numberOfPages
* 签名页码,如果是最后一页则传null
* @param pageStyle
* 页面布局,横向或者纵向
*/
public static void sign(String signPdfSrc, String signedPdfFile,
String signImage, String keyWords, Integer numberOfPages,
PageStyle pageStyle) throws Exception {
sign(signPdfSrc, signedPdfFile, signImage, null, null, keyWords,
numberOfPages, pageStyle);
}
/**
* 私人签章
*
* @param signPdfSrc
* 签名的PDF文件
* @param signedPdfOutFile
* 签名后的的PDF文件
* @param signImage
* 签名图片完整路径
* @param x
* 以左下角为原点x坐标
* @param y
* 以左下角为原点y坐标
* @param keyWords
* 关键字
* @param numberOfPages
* 签名页码,如果是最后一页则传null
* @param pageStyle
* 页面布局,横向或者纵向
* @return
*/
public static void sign(String signPdfSrc, String signedPdfOutFile,
String signImage, Float x, Float y, String keyWords,
Integer numberOfPages, PageStyle pageStyle) throws Exception {
File signPdfSrcFile = new File(signPdfSrc);
PdfReader reader = null;
ByteArrayOutputStream signPDFData = null;
PdfStamper stp = null;
FileInputStream fos = null;
FileOutputStream pdfOutputStream = null;
try {
BouncyCastleProvider provider = new BouncyCastleProvider();
Security.addProvider(provider);
KeyStore ks = KeyStore.getInstance("PKCS12",
new BouncyCastleProvider());
fos = new FileInputStream(KEY_STORE_PATH);
// 私钥密码 为Pkcs生成证书是的私钥密码 123456
ks.load(fos, PASSWORD.toCharArray());
String alias = ks.aliases().nextElement();
PrivateKey key = (PrivateKey) ks.getKey(alias,
PASSWORD.toCharArray());
Certificate[] chain = ks.getCertificateChain(alias);
reader = new PdfReader(signPdfSrc); // 也可以输入流的方式构建
signPDFData = new ByteArrayOutputStream();
numberOfPages = numberOfPages == null ? reader.getNumberOfPages()
: 0;
// 临时pdf文件
File temp = new File(signPdfSrcFile.getParent(),
System.currentTimeMillis() + ".pdf");
stp = PdfStamper.createSignature(reader, signPDFData, '\0', temp,
true);
stp.setFullCompression();
PdfSignatureAppearance sap = stp.getSignatureAppearance();
sap.setReason("数字签名,不可改变");
// 使用png格式透明图片
Image image = Image.getInstance(signImage);
sap.setImageScale(0);
sap.setSignatureGraphic(image);
sap.setRenderingMode(RenderingMode.GRAPHIC);
float llx = 0f;
float lly = 0f;
float signImageWidth = image.getWidth();
float signImageHeight = image.getHeight();
float signImageHeightSocale = 85 / signImageWidth * signImageHeight;
if (keyWords != null && !keyWords.isEmpty()) {
KeyWordInfo keyWordInfo = getKeyWordLocation(numberOfPages,
keyWords, reader);
Rectangle pageSize = reader.getPageSize(numberOfPages);
float width = pageSize.getWidth();
if (PageStyle.PAGE_STYLE_LANDSCAPE.equals(pageStyle)) {
llx = keyWordInfo.getY() + (float) keyWordInfo.getHeight();
lly = width - keyWordInfo.getX() - signImageHeightSocale
/ 2;
} else if (PageStyle.PAGE_STYLE_PORTRAIT.equals(pageStyle)) {
llx = keyWordInfo.getX() + (float) keyWordInfo.getWidth();
lly = keyWordInfo.getY() - signImageHeightSocale / 2;
}
} else if (x != null && y != null) {
llx = x;
lly = y;
} else {
throw new Exception("坐标和关键字不能同时为空!");
}
float urx = llx + 85;
float ury = lly + signImageHeightSocale;
// 是对应x轴和y轴坐标
sap.setVisibleSignature(new Rectangle(llx, lly, urx, ury),
numberOfPages,
UUID.randomUUID().toString().replaceAll("-", ""));
stp.getWriter().setCompressionLevel(5);
ExternalDigest digest = new BouncyCastleDigest();
ExternalSignature signature = new PrivateKeySignature(key,
DigestAlgorithms.SHA512, provider.getName());
MakeSignature.signDetached(sap, digest, signature, chain, null,
null, null, 0, CryptoStandard.CADES);
stp.close();
reader.close();
pdfOutputStream = new FileOutputStream(signedPdfOutFile);
pdfOutputStream.write(signPDFData.toByteArray());
pdfOutputStream.close();
} catch (KeyStoreException e) {
logger.error("签名验证失败", e);
throw new Exception("签名验证失败", e);
} catch (FileNotFoundException e) {
logger.error("文件未找到", e);
throw new Exception("文件未找到", e);
} catch (IOException e) {
logger.error("IO异常", e);
throw new Exception("IO异常", e);
} catch (Exception e) {
logger.error("签章失败", e);
throw new Exception("签章失败", e);
} finally {
if (signPDFData != null) {
try {
signPDFData.close();
} catch (IOException e) {
logger.error("资源关闭失败", e);
throw new Exception("资源关闭失败", e);
}
}
if (fos != null) {
try {
fos.close();
} catch (IOException e) {
logger.error("资源关闭失败", e);
throw new Exception("资源关闭失败", e);
}
}
if (pdfOutputStream != null) {
try {
pdfOutputStream.close();
} catch (IOException e) {
logger.error("资源关闭失败", e);
throw new Exception("资源关闭失败", e);
}
}
}
}
/**
* 查找关键字定位
*
* @param numberOfPages
* @param keyWords
* 关键字
* @param reader
* @return
* @throws IOException
*/
private static KeyWordInfo getKeyWordLocation(Integer numberOfPages,
final String keyWords, PdfReader reader) throws IOException {
PdfReaderContentParser pdfReaderContentParser = new PdfReaderContentParser(
reader);
final KeyWordInfo keyWordInfo = new KeyWordInfo();
pdfReaderContentParser.processContent(numberOfPages,
new RenderListener() {
@Override
public void renderText(TextRenderInfo textRenderInfo) {
String text = textRenderInfo.getText(); // 整页内容
if (null != text && text.contains(keyWords)) {
Rectangle2D.Float boundingRectange = textRenderInfo
.getBaseline().getBoundingRectange();
float leftY = (float) boundingRectange.getMinY() - 1;
float rightY = (float) boundingRectange.getMaxY() + 1;
logger.debug(boundingRectange.x + "--"
+ boundingRectange.y + "---");
keyWordInfo.setHeight(rightY - leftY);
keyWordInfo.setWidth((rightY - leftY)
* keyWords.length());
keyWordInfo.setX(boundingRectange.x);
keyWordInfo.setY(boundingRectange.y);
}
}
@Override
public void renderImage(ImageRenderInfo arg0) {}
@Override
public void endTextBlock() {}
@Override
public void beginTextBlock() {}
});
return keyWordInfo;
}
private static class KeyWordInfo {
private float x;
private float y;
private double width;
private double height;
public float getX() {
return x;
}
public void setX(float x) {
this.x = x;
}
public float getY() {
return y;
}
public void setY(float y) {
this.y = y;
}
public double getWidth() {
return width;
}
public void setWidth(double width) {
this.width = width;
}
public double getHeight() {
return height;
}
public void setHeight(double height) {
this.height = height;
}
}
enum PageStyle {
PAGE_STYLE_LANDSCAPE, // 横向
PAGE_STYLE_PORTRAIT // 纵向
}
public static PageStyle getPageStyle_LANDSCAPE() {
return PageStyle.PAGE_STYLE_LANDSCAPE;
}
public static PageStyle getPageStyle_PORTRAIT() {
return PageStyle.PAGE_STYLE_PORTRAIT;
}
public static void main(String[] args) throws Exception {
sign("E:\\test2_sgin.pdf",//
"D:\\signed-35.pdf",
"E:\\workSpeace\\pansky\\other_files\\电子签章\\png\\sign_0000_1_yxz.png",
null, null, "负责人", null, PageStyle.PAGE_STYLE_LANDSCAPE);
// read();
// test();
}
}
项目pom依赖
差点都忘记了??
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>io.github.syske</groupId>
<artifactId>dpf-sign-test</artifactId>
<version>1.0-SNAPSHOT</version>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<source>6</source>
<target>6</target>
</configuration>
</plugin>
</plugins>
</build>
<dependencies>
<dependency>
<groupId>com.itextpdf</groupId>
<artifactId>itextpdf</artifactId>
<version>5.5.13</version>
</dependency>
<!-- https://mvnrepository.com/artifact/com.itextpdf/itext-asian -->
<dependency>
<groupId>com.itextpdf</groupId>
<artifactId>itext-asian</artifactId>
<version>5.2.0</version>
</dependency>
<dependency>
<groupId>org.bouncycastle</groupId>
<artifactId>bcpkix-jdk15on</artifactId>
<version>1.64</version>
</dependency>
<!-- https://mvnrepository.com/artifact/org.bouncycastle/bcpkix-jdk15on -->
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-log4j12</artifactId>
<version>1.7.25</version>
</dependency>
</dependencies>
</project>
总结
因为本身业务不复杂,所以也没有过多的说明,但以上代码实现部分还有很多需要改进的地方,比如:
1、查找关键字的时候,只获取了一次坐标,但是同一页关键字可能存在多个,如果想在相同关键字的地方都盖章,是不能实现的,如果你正好有这样的需求,你可以动手自己改造;
2、和第一个类似,没有实现全文档签名
项目源码路径:https://github.com/Syske/learning-dome-code.git
原文地址:https://www.cnblogs.com/caoleiCoding/p/12148017.html
时间: 2024-11-05 22:38:01