优化后端框架

This commit is contained in:
BBIT-Kai
2026-05-07 10:25:02 +08:00
parent c932419c73
commit f7a27d99e1
73 changed files with 1742 additions and 1596 deletions
+328
View File
@@ -0,0 +1,328 @@
# Codex 三期工程执行文档
适用范围:基于当前已经完成的通用后台底座,继续建设“农产品收购发票开票平台”业务闭环。
使用方式:建议你后续给 Codex 下任务时,一次只下发一个“任务包”,不要跨太多模块,这样实现时间更短、回归范围更可控。
## 1. 业务功能规划
### 1.1 新增业务菜单
当前系统管理、日志管理等菜单已经具备,后续只新增业务菜单,不重复建设已有后台功能。
建议新增一级菜单:
- `发票业务`
建议在 `发票业务` 下新增 4 个菜单:
- `待开票列表`
- 用于查看业务系统返回的待开票数据、筛选、勾选、发起开票
- `开票任务`
- 用于查看开票状态、失败原因、票号信息、重试操作
- `认证中心`
- 用于处理票通认证状态、短信认证、扫码认证
- `票通账号配置`
- 用于维护公司与票通账号、默认开票参数、基础映射关系
说明:
- `开票结果` 不建议单独再做一个菜单,直接并入 `开票任务`
- `票通回调``主动查询补偿``业务系统回写` 不需要前端菜单,做后端能力即可
### 1.2 建议目录规划
为了适配当前项目结构,建议继续沿用现有 `modules` / `features` 风格,不要一开始就做大规模重构。
后端建议新增目录:
```text
server/src/main/kotlin/com/bbit/platform/
database/invoice/
InvoiceJobTable.kt
InvoiceJobItemTable.kt
TaxAccountTable.kt
InvoiceSyncLogTable.kt
integration/biz/
BizSystemClient.kt
BizInvoiceDtos.kt
integration/piaotong/
PiaotongClient.kt
PiaotongCrypto.kt
PiaotongDtos.kt
modules/invoice/candidate/
InvoiceCandidateModule.kt
modules/invoice/job/
InvoiceJobModule.kt
modules/invoice/auth/
InvoiceAuthModule.kt
modules/invoice/settings/
TaxAccountModule.kt
modules/invoice/callback/
PiaotongCallbackModule.kt
modules/invoice/shared/
InvoiceStatus.kt
InvoiceServices.kt
```
前端建议新增目录:
```text
web/src/
api/invoice/
candidates.ts
jobs.ts
auth.ts
settings.ts
types/invoice/
candidate.ts
job.ts
auth.ts
settings.ts
features/invoice/
candidates/index.vue
jobs/index.vue
auth-center/index.vue
settings/index.vue
components/invoice/
InvoiceConfirmDialog.vue
InvoiceAuthDialog.vue
InvoiceJobStatusTag.vue
```
### 1.3 模块职责划分
建议后续只围绕 4 个业务模块推进:
- `待开票模块`
- 对接业务系统待开票列表
- 支持筛选、勾选、金额汇总、发起开票
- `开票任务模块`
- 负责本地任务记录、状态流转、重试、详情查询
- `认证模块`
- 负责认证状态查询、短信认证、扫码认证、认证后继续开票
- `账号配置模块`
- 负责公司与票通账号、默认参数、启停状态维护
Codex 执行建议:
- 不要把“票通对接、业务系统对接、前端页面、状态补偿”揉成一个任务
- 最适合的粒度是“一个模块的一条主链路”
- 每次任务尽量能做到“改完即可本地验证一个页面或一个接口”
## 2. 需要准备的内容
这一章只写你这边需要准备的内容。票通接口文档已经在项目 `doc/` 目录里,后续不需要你再重复整理一遍。
### 2.1 你自己的业务系统接口资料
这是最关键的一部分,建议你提前准备并确认下面这些接口或数据来源:
- `当前登录人/公司上下文接口`
- 至少要能拿到 `userId``companyId`、公司名称、销方税号
- `待开票列表接口`
- 至少要能返回待开票主数据、金额、税率、商品信息、购销双方信息、唯一业务单号
- `开票前写库/锁单接口`
- 发起开票前锁定业务数据,避免重复开票
- `开票结果回写接口`
- 回写成功/失败状态、发票号码、数电发票号码、失败原因、票通流水号
- `可选:解锁或撤销接口`
- 如果开票失败后需要解除业务锁定,最好也提前明确
每个接口至少准备 5 类信息:
- 请求地址和调用方式
- 鉴权方式
- 请求参数说明
- 返回字段说明
- 一份真实示例报文
### 2.2 业务规则说明
Codex 能写代码,但业务规则如果不明确,后面很容易返工。建议你提前确认这些规则:
- 一条待开票数据是否对应一张发票,还是允许合并开票
- 发票商品行从哪里来,是业务系统直接给,还是平台侧组装
- 税率、税收分类编码、商品名称是否有默认规则
- 哪些字段缺失时不能发起开票
- 开票失败后,业务系统状态如何处理
- 同一业务单据的重复开票判断依据是什么
- 一个公司是否可能配置多个票通账号
### 2.3 联调和测试资源
建议你至少准备一套可联调的数据环境:
- 业务系统测试地址
- 可用的测试公司
- 至少 1 个可用测试账号
- 3 组待开票测试数据
- 正常成功
- 需要认证
- 明确失败
最好再补两类样例:
- 一份真实的待开票列表返回数据
- 一份真实的开票结果回写样例
### 2.4 环境和配置准备
建议你提前准备好下面这些配置:
- 后端数据库连接信息
- Redis 连接信息
- 前端、后端联调地址
- 业务系统的接口鉴权配置
- 票通测试账号、密钥、平台编码
- 能接收回调的测试域名或内网穿透地址
说明:
- 票通接口定义已经在 `doc/` 中,重点是确认账号和环境真实可用
- 如果业务系统接口还没稳定,建议先给一版 mock 或示例 JSON,Codex 可以先按样例落代码
## 3. 具体的三期工程执行内容
### 总体执行原则
三期都建议按“任务包”推进。每个任务包尽量满足下面三个条件:
- 改动范围集中,不跨太多目录
- 能在一次对话里做完并验证
- 做完后能形成明确可见结果
建议你后续给 Codex 下指令时,也按下面的任务包来发,不要一次塞完整三期。
### 第一期:业务骨架和数据接入
目标:先把“业务菜单、业务数据接入、基础配置、任务落库”搭起来,不急着一次打通所有票通细节。
一期任务包建议:
1. `新增业务菜单和前端页面骨架`
- 新增发票业务菜单
- 新建 4 个业务页面空壳
- 接通路由和权限码
2. `新增业务表和后端模块骨架`
- 新增开票任务、任务明细、票通账号等表
- 新增 invoice 相关后端模块目录和路由注册
3. `接入公司上下文和待开票列表`
- 打通当前用户所属公司信息
- 对接业务系统待开票列表接口
- 前端完成待开票列表查询展示
4. `完成票通账号配置页`
- 后端完成账号配置增删改查
- 前端完成配置页面
5. `完成本地开票任务创建骨架`
- 支持勾选数据创建本地任务
- 先把锁单、落库、状态初始化串起来
一期验收结果:
- 能看到新菜单
- 能查到待开票数据
- 能保存票通账号配置
- 能创建本地开票任务
### 第二期:开票主流程和认证流程
目标:把“发起开票 -> 遇到认证 -> 完成认证 -> 继续开票”这条主链路打通。
二期任务包建议:
1. `接入票通开票主接口`
- 完成票通报文组装、加密、签名
- 打通蓝字开票主调用
2. `补齐开票状态机和幂等控制`
- 固化 `invoiceReqSerialNo`
- 完成 `PENDING / PROCESSING / NEED_AUTH / SUCCESS / FAILED` 状态流转
3. `完成认证中心后端接口`
- 查询认证状态
- 短信验证码
- 短信登录
- 扫码二维码和扫码状态查询
4. `完成前端开票确认和认证交互`
- 开票确认弹窗
- 短信认证弹窗
- 扫码认证弹窗
- 认证完成后继续原任务
5. `完成开票任务列表和详情页`
- 展示任务状态、失败原因、票号信息
- 支持查看任务明细
二期验收结果:
- 能从待开票列表发起真实开票
- 遇到认证时可以完成短信或扫码认证
- 认证后可以继续原任务
- 能看到任务状态和开票结果
### 第三期:回调、补偿、稳定性收口
目标:把“结果同步、失败补偿、重试、运维可见性”补齐,让系统进入可持续使用状态。
三期任务包建议:
1. `完成票通回调接收`
- 新增回调接口
- 完成验签、去重、状态更新
2. `完成主动查询补偿`
- 定时查询处理中任务
- 补齐回调未达或超时场景
3. `完成业务系统结果回写和补偿`
- 开票成功/失败回写业务系统
- 回写失败时记录补偿状态并支持重试
4. `完成任务重试和异常处理`
- 支持失败任务重试
- 保证原幂等号复用
- 明确错误提示和异常留痕
5. `完成页面收口和交付文档`
- 页面细节优化
- 状态文案统一
- 补一份联调说明和部署说明
三期验收结果:
- 开票结果可以通过回调或主动查询最终落定
- 结果能稳定回写业务系统
- 失败任务可以重试
- 系统具备基本可运维性
### 给 Codex 的推荐下发方式
后续你可以直接按下面这种方式给 Codex 发任务:
- `按一期第 1 个任务包做,只做菜单、路由和 4 个页面骨架,不做接口`
- `按一期第 3 个任务包做,只打通待开票列表前后端,不碰票通`
- `按二期第 3 个任务包做,只做认证中心后端接口和页面交互`
- `按三期第 2 个任务包做,只做处理中任务的主动查询补偿`
这样做最适合 Codex
- 单次上下文更清晰
- 改动范围更集中
- 验证更容易
- 出问题时也更容易回退和继续推进
+38
View File
@@ -0,0 +1,38 @@
services:
postgres:
image: postgres:18.3
container_name: ticket-postgres
restart: unless-stopped
environment:
POSTGRES_DB: ticket
POSTGRES_USER: ticket
POSTGRES_PASSWORD: ticket_password
TZ: Asia/Shanghai
ports:
- "5432:5432"
volumes:
- platform_a_postgres_data:/var/lib/postgresql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U platform -d platform"]
interval: 10s
timeout: 5s
retries: 5
redis:
image: redis:8
container_name: ticket-redis
restart: unless-stopped
command: ["redis-server", "--requirepass", "ticket_password", "--appendonly", "yes"]
ports:
- "6379:6379"
volumes:
- platform_a_redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "-a", "ticket_password", "ping"]
interval: 10s
timeout: 5s
retries: 5
volumes:
platform_a_postgres_data:
platform_a_redis_data:
@@ -0,0 +1,66 @@
using System;
using System.Security.Cryptography;
using System.Text;
namespace ConsoleDemo
{
public class EncryptDes
{
/**
* aStrString 加密内容
* aStrKey 加密秘钥
*/
public static String Encrypt3Des(String aStrString, String aStrKey, CipherMode mode = CipherMode.ECB, String iv = "12345678")
{
try
{
var des = new TripleDESCryptoServiceProvider
{
Key = Encoding.UTF8.GetBytes(aStrKey),
Mode = mode
};
if (mode == CipherMode.CBC)
{
des.IV = Encoding.UTF8.GetBytes(iv);
}
var desEncrypt = des.CreateEncryptor();
byte[] buffer = Encoding.UTF8.GetBytes(aStrString);
return Convert.ToBase64String(desEncrypt.TransformFinalBlock(buffer, 0, buffer.Length));
}
catch (Exception e)
{
return string.Empty;
}
}
public static string Decrypt3Des(string aStrString, string aStrKey, CipherMode mode = CipherMode.ECB, string iv = "12345678")
{
try
{
var des = new TripleDESCryptoServiceProvider
{
Key = Encoding.UTF8.GetBytes(aStrKey),
Mode = mode,
Padding = PaddingMode.PKCS7
};
if (mode == CipherMode.CBC)
{
des.IV = Encoding.UTF8.GetBytes(iv);
}
var desDecrypt = des.CreateDecryptor();
var result = "";
byte[] buffer = Convert.FromBase64String(aStrString);
result = Encoding.UTF8.GetString(desDecrypt.TransformFinalBlock(buffer, 0, buffer.Length));
return result;
}
catch (Exception e)
{
return string.Empty;
}
}
}
}
@@ -0,0 +1,37 @@
using System.IO;
using System.Net;
using System.Text;
namespace ConsoleDemo
{
public class PostJson
{
/**
* Json的请求头 post请求地址
*/
public static string Post4Json(string url,string buildRequest)
{
string result = "";
HttpWebRequest request =(HttpWebRequest) WebRequest.Create(url);
request.Method = "POST";
request.Timeout = 5000;
request.ContentType = "application/json";
byte[] byte4builde = Encoding.UTF8.GetBytes(buildRequest);
request.ContentLength = byte4builde.Length;
using (Stream reqStream=request.GetRequestStream())
{
reqStream.Write(byte4builde,0,byte4builde.Length);
reqStream.Close();
}
HttpWebResponse response = (HttpWebResponse) request.GetResponse();
Stream stream = response.GetResponseStream();
//获得响应内容
using (StreamReader reader=new StreamReader(stream,Encoding.UTF8))
{
result = reader.ReadToEnd();
}
return result;
}
}
}
@@ -0,0 +1,4 @@
// See https://aka.ms/new-console-template for more information
using ConsoleDemo;
Console.WriteLine("Hello, World!");
@@ -0,0 +1,123 @@
using System;
using System.Collections;
using System.Runtime.CompilerServices;
using System.Text;
namespace ConsoleDemo
{
public class PublicData
{
/**
* 公共报文组装
*/
public static string publicparam(string content,string platformCode ,
string platformAlias,string privateKey,string password)
{
StringBuilder sign =new StringBuilder();
string contentstr=EncryptDes.Encrypt3Des(content, password);
sign.Append("content="+contentstr+"&");
sign.Append("format=JSON&");
sign.Append("platformCode="+platformCode+"&");
string time = DateTime.Now.ToString("yyyyMMddHHmmss");
string serianlNo = platformAlias + time + GenerateCheckCode(8);
sign.Append("serialNo="+serianlNo+"&");
sign.Append("signType=RSA&");
string timestamp=DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss");
sign.Append("timestamp="+timestamp+"&");
sign.Append("version=1.0");
string rsasign = RSA.sign(sign.ToString(), privateKey);
Hashtable publictable=new Hashtable();
publictable.Add("sign",rsasign);
publictable.Add("format","JSON");
publictable.Add("platformCode",platformCode);
publictable.Add("serialNo",serianlNo);
publictable.Add("signType","RSA");
publictable.Add("timestamp",timestamp);
publictable.Add("version","1.0");
publictable.Add("content",contentstr);
return ToJson.Table2Json(publictable);
}
public static string disposeResponse(string str,string ptpublickey, string deskey)
{
Hashtable strtable=new Hashtable();
StringBuilder content=new StringBuilder();
Hashtable dispose=new Hashtable();
string sign = "";
string serialNo = "";
str=str.Replace("{","").Replace("}","").Replace("\"","").Replace(",","&").Replace(":","&");
string[] arraystr = str.Split('&');
for (int i = 0; i < arraystr.Length-1; i+=2)
{
if (arraystr[i]=="sign")
{
sign = arraystr[i + 1];
}
if (arraystr[i]=="serialNo")
{
serialNo=arraystr[i + 1];
}
strtable.Add(arraystr[i],arraystr[i+1]);
}
strtable.Remove("serialNo");
strtable.Remove("sign");
ArrayList ke = new ArrayList(strtable.Keys);
ke.Sort();
foreach (string tableEntry in ke)
{
content.Append(string.Format("{0}={1}&", tableEntry, strtable[tableEntry]));
}
content.Append("serialNo=" + serialNo);
bool res=RSA.verify(content.ToString(), sign, ptpublickey, "UTF-8");
Console.WriteLine(res);
if (res)
{
for (int i = 0; i < arraystr.Length - 1; i += 2)
{
if (arraystr[i] == "content")
{
arraystr[i+1]=EncryptDes.Decrypt3Des(arraystr[i+1],deskey);
}
dispose.Add(arraystr[i],arraystr[i+1]);
}
return ToJson.Table2Json(dispose);
}
else
{
return "验签失败";
}
}
/**
* 随机数生成
*/
private static string GenerateCheckCode(int codeCount)
{
int rep = 0;
string str = string.Empty;
long num2 = DateTime.Now.Ticks + rep;
rep++;
Random random = new Random(((int)(((ulong)num2) & 0xffffffffL)) | ((int)(num2 >> rep)));
for (int i = 0; i < codeCount; i++)
{
char ch;
int num = random.Next();
if ((num % 2) == 0)
{
ch = (char)(0x30 + ((ushort)(num % 10)));
}
else
{
ch = (char)(0x41 + ((ushort)(num % 0x1a)));
}
str = str + ch.ToString();
}
return str;
}
}
}
@@ -0,0 +1,502 @@
using System;
using System.Collections.Generic;
using System.Text;
using System.IO;
using System.Security.Cryptography;
namespace ConsoleDemo
{
internal class RSA
{
/**
* content 签名前的报文
* privateKey 私钥
* input_charset 编码格式 (以下默认UTF-8)
*/
public static string sign(string content, string privateKey)
{
byte[] Data = Encoding.GetEncoding("UTF-8").GetBytes(content);
RSACryptoServiceProvider rsa = DecodePemPrivateKey(privateKey);
SHA1 sh = new SHA1CryptoServiceProvider();
byte[] signData = rsa.SignData(Data, sh);
return Convert.ToBase64String(signData);
}
/// <summary>
/// 验签
/// </summary>
/// <param name="content">待验签字符串</param>
/// <param name="signedString">签名</param>
/// <param name="publicKey">公钥</param>
/// <param name="input_charset">编码格式</param>
/// <returns>true(通过)false(不通过)</returns>
public static bool verify(string content, string signedString, string publicKey, string input_charset)
{
bool result ;
byte[] Data = Encoding.GetEncoding(input_charset).GetBytes(content);
byte[] data = Convert.FromBase64String(signedString);
RSAParameters paraPub = ConvertFromPublicKey(publicKey);
RSACryptoServiceProvider rsaPub = new RSACryptoServiceProvider();
rsaPub.ImportParameters(paraPub);
SHA1 sh = new SHA1CryptoServiceProvider();
result = rsaPub.VerifyData(Data, sh, data);
return result;
}
/// <summary>
/// 加密
/// </summary>
/// <param name="resData">需要加密的字符串</param>
/// <param name="publicKey">公钥</param>
/// <param name="input_charset">编码格式</param>
/// <returns>明文</returns>
public static string encryptData(string resData, string publicKey, string input_charset)
{
byte[] DataToEncrypt = Encoding.ASCII.GetBytes(resData);
string result = encrypt(DataToEncrypt, publicKey, input_charset);
return result;
}
/// <summary>
/// 解密
/// </summary>
/// <param name="resData">加密字符串</param>
/// <param name="privateKey">私钥</param>
/// <param name="input_charset">编码格式</param>
/// <returns>明文</returns>
public static string decryptData(string resData, string privateKey, string input_charset)
{
byte[] DataToDecrypt = Convert.FromBase64String(resData);
string result = "";
for (int j = 0; j < DataToDecrypt.Length / 128; j++)
{
byte[] buf = new byte[128];
for (int i = 0; i < 128; i++)
{
buf[i] = DataToDecrypt[i + 128 * j];
}
result += decrypt(buf, privateKey, input_charset);
}
return result;
}
#region
private static string encrypt(byte[] data, string publicKey, string input_charset)
{
RSACryptoServiceProvider rsa = DecodePemPublicKey(publicKey);
SHA1 sh = new SHA1CryptoServiceProvider();
byte[] result = rsa.Encrypt(data, false);
return Convert.ToBase64String(result);
}
private static string decrypt(byte[] data, string privateKey, string input_charset)
{
string result = "";
RSACryptoServiceProvider rsa = DecodePemPrivateKey(privateKey);
SHA1 sh = new SHA1CryptoServiceProvider();
byte[] source = rsa.Decrypt(data, false);
char[] asciiChars = new char[Encoding.GetEncoding(input_charset).GetCharCount(source, 0, source.Length)];
Encoding.GetEncoding(input_charset).GetChars(source, 0, source.Length, asciiChars, 0);
result = new string(asciiChars);
//result = ASCIIEncoding.ASCII.GetString(source);
return result;
}
private static RSACryptoServiceProvider DecodePemPublicKey(String pemstr)
{
byte[] pkcs8publickkey;
pkcs8publickkey = Convert.FromBase64String(pemstr);
if (pkcs8publickkey != null)
{
RSACryptoServiceProvider rsa = DecodeRSAPublicKey(pkcs8publickkey);
return rsa;
}
else
return null;
}
private static RSACryptoServiceProvider DecodePemPrivateKey(String pemstr)
{
byte[] pkcs8privatekey;
pkcs8privatekey = Convert.FromBase64String(pemstr);
if (pkcs8privatekey != null)
{
RSACryptoServiceProvider rsa = DecodePrivateKeyInfo(pkcs8privatekey);
return rsa;
}
else
return null;
}
private static RSACryptoServiceProvider DecodePrivateKeyInfo(byte[] pkcs8)
{
byte[] SeqOID = { 0x30, 0x0D, 0x06, 0x09, 0x2A, 0x86, 0x48, 0x86, 0xF7, 0x0D, 0x01, 0x01, 0x01, 0x05, 0x00 };
byte[] seq = new byte[15];
MemoryStream mem = new MemoryStream(pkcs8);
int lenstream = (int)mem.Length;
BinaryReader binr = new BinaryReader(mem); //wrap Memory Stream with BinaryReader for easy reading
byte bt = 0;
ushort twobytes = 0;
try
{
twobytes = binr.ReadUInt16();
if (twobytes == 0x8130) //data read as little endian order (actual data order for Sequence is 30 81)
binr.ReadByte(); //advance 1 byte
else if (twobytes == 0x8230)
binr.ReadInt16(); //advance 2 bytes
else
return null;
bt = binr.ReadByte();
if (bt != 0x02)
return null;
twobytes = binr.ReadUInt16();
if (twobytes != 0x0001)
return null;
seq = binr.ReadBytes(15); //read the Sequence OID
if (!CompareBytearrays(seq, SeqOID)) //make sure Sequence for OID is correct
return null;
bt = binr.ReadByte();
if (bt != 0x04) //expect an Octet string
return null;
bt = binr.ReadByte(); //read next byte, or next 2 bytes is 0x81 or 0x82; otherwise bt is the byte count
if (bt == 0x81)
binr.ReadByte();
else
if (bt == 0x82)
binr.ReadUInt16();
//------ at this stage, the remaining sequence should be the RSA private key
byte[] rsaprivkey = binr.ReadBytes((int)(lenstream - mem.Position));
RSACryptoServiceProvider rsacsp = DecodeRSAPrivateKey(rsaprivkey);
return rsacsp;
}
catch (Exception)
{
return null;
}
finally { binr.Close(); }
}
private static bool CompareBytearrays(byte[] a, byte[] b)
{
if (a.Length != b.Length)
return false;
int i = 0;
foreach (byte c in a)
{
if (c != b[i])
return false;
i++;
}
return true;
}
private static RSACryptoServiceProvider DecodeRSAPublicKey(byte[] publickey)
{
// encoded OID sequence for PKCS #1 rsaEncryption szOID_RSA_RSA = "1.2.840.113549.1.1.1"
byte[] SeqOID = { 0x30, 0x0D, 0x06, 0x09, 0x2A, 0x86, 0x48, 0x86, 0xF7, 0x0D, 0x01, 0x01, 0x01, 0x05, 0x00 };
byte[] seq = new byte[15];
// --------- Set up stream to read the asn.1 encoded SubjectPublicKeyInfo blob ------
MemoryStream mem = new MemoryStream(publickey);
BinaryReader binr = new BinaryReader(mem); //wrap Memory Stream with BinaryReader for easy reading
byte bt = 0;
ushort twobytes = 0;
try
{
twobytes = binr.ReadUInt16();
if (twobytes == 0x8130) //data read as little endian order (actual data order for Sequence is 30 81)
binr.ReadByte(); //advance 1 byte
else if (twobytes == 0x8230)
binr.ReadInt16(); //advance 2 bytes
else
return null;
seq = binr.ReadBytes(15); //read the Sequence OID
if (!CompareBytearrays(seq, SeqOID)) //make sure Sequence for OID is correct
return null;
twobytes = binr.ReadUInt16();
if (twobytes == 0x8103) //data read as little endian order (actual data order for Bit String is 03 81)
binr.ReadByte(); //advance 1 byte
else if (twobytes == 0x8203)
binr.ReadInt16(); //advance 2 bytes
else
return null;
bt = binr.ReadByte();
if (bt != 0x00) //expect null byte next
return null;
twobytes = binr.ReadUInt16();
if (twobytes == 0x8130) //data read as little endian order (actual data order for Sequence is 30 81)
binr.ReadByte(); //advance 1 byte
else if (twobytes == 0x8230)
binr.ReadInt16(); //advance 2 bytes
else
return null;
twobytes = binr.ReadUInt16();
byte lowbyte = 0x00;
byte highbyte = 0x00;
if (twobytes == 0x8102) //data read as little endian order (actual data order for Integer is 02 81)
lowbyte = binr.ReadByte(); // read next bytes which is bytes in modulus
else if (twobytes == 0x8202)
{
highbyte = binr.ReadByte(); //advance 2 bytes
lowbyte = binr.ReadByte();
}
else
return null;
byte[] modint = { lowbyte, highbyte, 0x00, 0x00 }; //reverse byte order since asn.1 key uses big endian order
int modsize = BitConverter.ToInt32(modint, 0);
byte firstbyte = binr.ReadByte();
binr.BaseStream.Seek(-1, SeekOrigin.Current);
if (firstbyte == 0x00)
{ //if first byte (highest order) of modulus is zero, don't include it
binr.ReadByte(); //skip this null byte
modsize -= 1; //reduce modulus buffer size by 1
}
byte[] modulus = binr.ReadBytes(modsize); //read the modulus bytes
if (binr.ReadByte() != 0x02) //expect an Integer for the exponent data
return null;
int expbytes = (int)binr.ReadByte(); // should only need one byte for actual exponent data (for all useful values)
byte[] exponent = binr.ReadBytes(expbytes);
// ------- create RSACryptoServiceProvider instance and initialize with public key -----
RSACryptoServiceProvider RSA = new RSACryptoServiceProvider();
RSAParameters RSAKeyInfo = new RSAParameters();
RSAKeyInfo.Modulus = modulus;
RSAKeyInfo.Exponent = exponent;
RSA.ImportParameters(RSAKeyInfo);
return RSA;
}
catch (Exception)
{
return null;
}
finally { binr.Close(); }
}
private static RSACryptoServiceProvider DecodeRSAPrivateKey(byte[] privkey)
{
byte[] MODULUS, E, D, P, Q, DP, DQ, IQ;
// --------- Set up stream to decode the asn.1 encoded RSA private key ------
MemoryStream mem = new MemoryStream(privkey);
BinaryReader binr = new BinaryReader(mem); //wrap Memory Stream with BinaryReader for easy reading
byte bt = 0;
ushort twobytes = 0;
int elems = 0;
try
{
twobytes = binr.ReadUInt16();
if (twobytes == 0x8130) //data read as little endian order (actual data order for Sequence is 30 81)
binr.ReadByte(); //advance 1 byte
else if (twobytes == 0x8230)
binr.ReadInt16(); //advance 2 bytes
else
return null;
twobytes = binr.ReadUInt16();
if (twobytes != 0x0102) //version number
return null;
bt = binr.ReadByte();
if (bt != 0x00)
return null;
//------ all private key components are Integer sequences ----
elems = GetIntegerSize(binr);
MODULUS = binr.ReadBytes(elems);
elems = GetIntegerSize(binr);
E = binr.ReadBytes(elems);
elems = GetIntegerSize(binr);
D = binr.ReadBytes(elems);
elems = GetIntegerSize(binr);
P = binr.ReadBytes(elems);
elems = GetIntegerSize(binr);
Q = binr.ReadBytes(elems);
elems = GetIntegerSize(binr);
DP = binr.ReadBytes(elems);
elems = GetIntegerSize(binr);
DQ = binr.ReadBytes(elems);
elems = GetIntegerSize(binr);
IQ = binr.ReadBytes(elems);
// ------- create RSACryptoServiceProvider instance and initialize with public key -----
RSACryptoServiceProvider RSA = new RSACryptoServiceProvider();
RSAParameters RSAparams = new RSAParameters();
RSAparams.Modulus = MODULUS;
RSAparams.Exponent = E;
RSAparams.D = D;
RSAparams.P = P;
RSAparams.Q = Q;
RSAparams.DP = DP;
RSAparams.DQ = DQ;
RSAparams.InverseQ = IQ;
RSA.ImportParameters(RSAparams);
return RSA;
}
catch (Exception)
{
return null;
}
finally { binr.Close(); }
}
private static int GetIntegerSize(BinaryReader binr)
{
byte bt = 0;
byte lowbyte = 0x00;
byte highbyte = 0x00;
int count = 0;
bt = binr.ReadByte();
if (bt != 0x02) //expect integer
return 0;
bt = binr.ReadByte();
if (bt == 0x81)
count = binr.ReadByte(); // data size in next byte
else
if (bt == 0x82)
{
highbyte = binr.ReadByte(); // data size in next 2 bytes
lowbyte = binr.ReadByte();
byte[] modint = { lowbyte, highbyte, 0x00, 0x00 };
count = BitConverter.ToInt32(modint, 0);
}
else
{
count = bt; // we already have the data size
}
while (binr.ReadByte() == 0x00)
{ //remove high order zeros in data
count -= 1;
}
binr.BaseStream.Seek(-1, SeekOrigin.Current); //last ReadByte wasn't a removed zero, so back up a byte
return count;
}
#endregion
#region .net Pem
private static RSAParameters ConvertFromPublicKey(string pemFileConent)
{
byte[] keyData = Convert.FromBase64String(pemFileConent);
if (keyData.Length < 162)
{
throw new ArgumentException("pem file content is incorrect.");
}
byte[] pemModulus = new byte[128];
byte[] pemPublicExponent = new byte[3];
Array.Copy(keyData, 29, pemModulus, 0, 128);
Array.Copy(keyData, 159, pemPublicExponent, 0, 3);
RSAParameters para = new RSAParameters();
para.Modulus = pemModulus;
para.Exponent = pemPublicExponent;
return para;
}
private static RSAParameters ConvertFromPrivateKey(string pemFileConent)
{
byte[] keyData = Convert.FromBase64String(pemFileConent);
if (keyData.Length < 609)
{
throw new ArgumentException("pem file content is incorrect.");
}
int index = 11;
byte[] pemModulus = new byte[128];
Array.Copy(keyData, index, pemModulus, 0, 128);
index += 128;
index += 2;//141
byte[] pemPublicExponent = new byte[3];
Array.Copy(keyData, index, pemPublicExponent, 0, 3);
index += 3;
index += 4;//148
byte[] pemPrivateExponent = new byte[128];
Array.Copy(keyData, index, pemPrivateExponent, 0, 128);
index += 128;
index += ((int)keyData[index + 1] == 64 ? 2 : 3);//279
byte[] pemPrime1 = new byte[64];
Array.Copy(keyData, index, pemPrime1, 0, 64);
index += 64;
index += ((int)keyData[index + 1] == 64 ? 2 : 3);//346
byte[] pemPrime2 = new byte[64];
Array.Copy(keyData, index, pemPrime2, 0, 64);
index += 64;
index += ((int)keyData[index + 1] == 64 ? 2 : 3);//412/413
byte[] pemExponent1 = new byte[64];
Array.Copy(keyData, index, pemExponent1, 0, 64);
index += 64;
index += ((int)keyData[index + 1] == 64 ? 2 : 3);//479/480
byte[] pemExponent2 = new byte[64];
Array.Copy(keyData, index, pemExponent2, 0, 64);
index += 64;
index += ((int)keyData[index + 1] == 64 ? 2 : 3);//545/546
byte[] pemCoefficient = new byte[64];
Array.Copy(keyData, index, pemCoefficient, 0, 64);
RSAParameters para = new RSAParameters();
para.Modulus = pemModulus;
para.Exponent = pemPublicExponent;
para.D = pemPrivateExponent;
para.P = pemPrime1;
para.Q = pemPrime2;
para.DP = pemExponent1;
para.DQ = pemExponent2;
para.InverseQ = pemCoefficient;
return para;
}
#endregion
}
}
@@ -0,0 +1,277 @@
using System;
using System.Collections;
using System.Text;
namespace ConsoleDemo
{
public class StarDemo
{
//私钥(与发给票通的公钥为一对)
private static String privateKey =
"MIICdQIBADANBgkqhkiG9w0BAQEFAASCAl8wggJbAgEAAoGBAIVLAoolDaE7m5oMB1ZrILHkMXMF6qmC8I/FCejz4hwBcj59H3rbtcycBEmExOJTGwexFkNgRakhqM+3uP3VybWu1GBYNmqVzggWKKzThul9VPE3+OTMlxeG4H63RsCO1//J0MoUavXMMkL3txkZBO5EtTqek182eePOV8fC3ZxpAgMBAAECgYBp4Gg3BTGrZaa2mWFmspd41lK1E/kPBrRA7vltMfPj3P47RrYvp7/js/Xv0+d0AyFQXcjaYelTbCokPMJT1nJumb2A/Cqy3yGKX3Z6QibvByBlCKK29lZkw8WVRGFIzCIXhGKdqukXf8RyqfhInqHpZ9AoY2W60bbSP6EXj/rhNQJBAL76SmpQOrnCI8Xu75di0eXBN/bE9tKsf7AgMkpFRhaU8VLbvd27U9vRWqtu67RY3sOeRMh38JZBwAIS8tp5hgcCQQCyrOS6vfXIUxKoWyvGyMyhqoLsiAdnxBKHh8tMINo0ioCbU+jc2dgPDipL0ym5nhvg5fCXZC2rvkKUltLEqq4PAkAqBf9b932EpKCkjFgyUq9nRCYhaeP6JbUPN3Z5e1bZ3zpfBjV4ViE0zJOMB6NcEvYpy2jNR/8rwRoUGsFPq8//AkAklw18RJyJuqFugsUzPznQvad0IuNJV7jnsmJqo6ur6NUvef6NA7ugUalNv9+imINjChO8HRLRQfRGk6B0D/P3AkBt54UBMtFefOLXgUdilwLdCUSw4KpbuBPw+cyWlMjcXCkj4rHoeksekyBH1GrBJkLqDMRqtVQUubuFwSzBAtlc";
//票通公钥(票通提供)
private static String ptPublicKey =
"MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCJkx3HelhEm/U7jOCor29oHsIjCMSTyKbX5rpoAY8KDIs9mmr5Y9r+jvNJH8pK3u5gNnvleT6rQgJQW1mk0zHuPO00vy62tSA53fkSjtM+n0oC1Fkm4DRFd5qJgoP7uFQHR5OEffMjy2qIuxChY4Au0kq+6RruEgIttb7wUxy8TwIDAQAB";
//3DES秘钥(票通提供)
private static String password = "lsBnINDxtct8HZB7KCMyhWSJ";
//请更换请求平台简称(票通提供)
private static String platform_alias = "DEMK";
//请更换请求平台编码(票通提供)
private static String platform_code = "11111111";
public static void Main(string[] args)
{
Console.Write(PublicData.disposeResponse(Blue(), ptPublicKey, password));//蓝票接口
// testqueryInvoice();//查询接口
// testGetInvoiceRepertoryInfo();//库存接口
// testAuthWeChatCards();//插入微信卡包接口
// testTitleInfo();//查询抬头接口
// testGetPTBoxStatus();//查询设备状态
// testGetQrCodeByItems();//获取开票二维码和提取码
// testdeleteInvoiceQrCode();//作为二维码
// testInvoiceRed();//冲红接口
// testRegister();// 注册接口
//string content = "{\"code\":\"0000\",\"msg\":\"处理成功\",\"sign\":\"ZjAqLXwvEEgz2+jzP/+vUWGuvhBr4N4Gg/pLLOt90sMP160SC1RrkOy6b5p1CCx3y4QYRkbqq2NmkYXpAJX5BdkoXFYUO1hF4ufUvYPmIjQvKT9JMnXt1RV0EdNLliiEowJPjjXDSlTZdthIsTXdVirCkGohzLt3b/2YU9moAM8=\",\"serialNo\":\"CTXP20181206100927n5ObjiJM\",\"content\":\"q55jwSlpLhWV7cnEgNTvm+bswSXLiOPDbw8HvqR7SKhQDWJ/x1qlcJHAOB2lYHmQmefePoaVJ4abG7O9aJwIssDZsit2a2pqNeiCWqVmKhceLAsD/IV4DAlHmwZZhb9tqqco+HDHmZlqJy9pQv478OW0UDx/X0kTbIy4au5pZvJdODh4t31o5I2HrGm1HNcykyKMDpr5D1Mx2mYsjHm95OKBAzLPKMo+o1JrotnyjlS08CbbF6CF5OPZPB8tu88g0xl1u7/3kkjgc0KEmE+bQTEF6RoLqtQ9XRdfHf+tjzLpUcfS7j/nzPcHJnU3d1PGU0NsR+QNyHvI2cfo8HLlmnL5V7GDX+iSMNKMJ8vq7lWwcHvxZjyrHRzSpmxsQJXkQN4hungnNjiNGWzJZ8FssSLLkHw3VlQVJ8mz9sugsCn3Gr/muwUG46W7AsxUqM0Oo1JrotnyjlT6yPhLDxoIzumCiet4Hf02Gxfox417aZ6Jw+BGXo/B9KtKAIlQfkV8Zen4leGaPYo+6G+NPE2a7E+g3FRb571HMiwddiHpNVYzpc/pGTxna1JDIOODExKTJPCrI47HGZGbG7O9aJwIssDZsit2a2pqTWa8x1ePRf8eLAsD/IV4DAlHmwZZhb9tqqco+HDHmZlqJy9pQv478OW0UDx/X0kTbIy4au5pZvJdODh4t31o5JaXBuJBVtYBkyKMDpr5D1Mx2mYsjHm95F0rfY1FyxjYo1JrotnyjlS08CbbF6CF5MlEbxEcfrXqIRR3QbB604fgc0KEmE+bQTEF6RoLqtQ9XRdfHf+tjzILJodvesFM/gbYzWVOUKLKkw3+uglxfg0H9K+suDCPJFRGRT6xxFCnTglsJo/q9b0bF+jHjXtpnu9+bNNgN22dfgeGCjXTZ52gwQVlALiNUkaR5cdbKH8gRWCS9TRcrE1pnHLSywkCgr2LCkRS/wEd1863dwA7HMJuY8TDqXWlYC/kkUF84Oo8kyKMDpr5D1NA31vurQ5/BHbrS0QR43dycGeIzhqufLnEBxK01e2CYnK3sqwBwwBa6qhdLFlh9DTb7pjjR5w00A/i74mi2g8Fq91vU5nj5kkoL7fsH3ChjxJwiAQA8RwGPsTnkCcQSnzqj8s+uTYMPFzmWuWd2UYP\"}";
//PublicData.disposeResponse(content,ptPublicKey,password);
}
/**
* 注册接口
*/
public static string testRegister()
{
String url = "http://fpkj.testnw.vpiaotong.cn/tp/openapi/register.pt";
Hashtable map = new Hashtable();
map.Add("taxpayerNum", platform_alias + "0003242300000"); //销方纳税人识别号
map.Add("enterpriseName", "测试C#"); //销方企业名称
map.Add("legalPersonName", "AA"); //法人名称
map.Add("contactsName", "AA"); //联系人名称
map.Add("contactsEmail", "1121@qq.com"); //联系人邮箱
map.Add("contactsPhone", "15111111133"); //联系人手机号
map.Add("regionCode", "11"); //地区编码
map.Add("cityName", "海淀区"); //市(区)名
map.Add("enterpriseAddress", "地址"); //详细地址
// TODO 请修改为正确的图片Base64传
map.Add("taxRegistrationCertificate", "sdddddddddddddddddddd"); //证件图片base64
string content = ToJson.Table2Json(map);
String builderrequest =
PublicData.publicparam(content, platform_code, platform_alias, privateKey, password);
string response = PostJson.Post4Json(url, builderrequest);
Console.Write("最终返回:" + response);
return response;
}
/**
* 开具蓝票
*/
public static string Blue()
{
string url = "http://fpkj.testnw.vpiaotong.cn/tp/openapi/invoiceBlue.pt";
ArrayList itemList = new ArrayList();
Hashtable OuterMessage = new Hashtable();
OuterMessage.Add("taxpayerNum", "500102201007206608");
// TODO 请更换请求流水号前缀
OuterMessage.Add("invoiceReqSerialNo", "SCPTT538117842484711");
OuterMessage.Add("buyerName", "购买购买方名称购买方名称购买方名称");
OuterMessage.Add("buyerAddress", "购买方地址");
OuterMessage.Add("buyerTel", "1234-56789104");
OuterMessage.Add("sellerBankAccount", "123456789");
OuterMessage.Add("sellerAddress", "深圳市福田区沙头街道天安社区深南大道车是多少大所大所大多所大所大cdtuiolj");
OuterMessage.Add("sellerTel", "17603327743");
OuterMessage.Add("takerEmail", "767034475@qq.com");
OuterMessage.Add("drawerName", "");
OuterMessage.Add("casherName", "收款人Dd");
OuterMessage.Add("reviewerName", "复核人Bb");
OuterMessage.Add("takerName", "");
OuterMessage.Add("definedData", "测试数据1,测试数据2");
Hashtable InnerMessageOne = new Hashtable();
InnerMessageOne.Add("taxClassificationCode", "1010101020000000000"); //税收分类编码(可以按照Excel文档填写)
InnerMessageOne.Add("quantity", "1.00"); //数量
InnerMessageOne.Add("goodsName", "货物名称"); //货物名称
InnerMessageOne.Add("unitPrice", "5.64"); //单价
InnerMessageOne.Add("invoiceAmount", "5.64"); //金额
InnerMessageOne.Add("taxRateValue", "0.16"); //税率
InnerMessageOne.Add("includeTaxFlag", "0"); //含税标识
Hashtable InnerMessageTwo = new Hashtable();
InnerMessageTwo.Add("taxClassificationCode", "1010101020000000000"); //税收分类编码(可以按照Excel文档填写)
InnerMessageTwo.Add("quantity", "1.00"); //数量
InnerMessageTwo.Add("goodsName", "货物名称"); //货物名称
InnerMessageTwo.Add("unitPrice", "5.64"); //单价
InnerMessageTwo.Add("invoiceAmount", "5.64"); //金额
InnerMessageTwo.Add("taxRateValue", "0.16"); //税率
InnerMessageTwo.Add("includeTaxFlag", "0"); //含税标识
itemList.Add(InnerMessageOne);
itemList.Add(InnerMessageTwo);
OuterMessage.Add("itemList", itemList);
string content = ToJson.Table2Json(OuterMessage);
String builderrequest =
PublicData.publicparam(content, platform_code, platform_alias, privateKey, password);
string response = PostJson.Post4Json(url, builderrequest);
Console.WriteLine("最终返回:" + response);
return response;
}
/**
* 红票开具接口
*/
public static void testInvoiceRed()
{
String url = "http://fpkj.testnw.vpiaotong.cn/tp/openapi/invoiceRed.pt";
Hashtable map = new Hashtable();
map.Add("taxpayerNum", "110101201702071"); //销方税号(请于要冲红的蓝票税号一致)
// TODO 请更换请求流水号前缀
map.Add("invoiceReqSerialNo", platform_alias + "5678902275418903"); //发票流水号 (唯一, 与蓝票发票流水号不一致)
map.Add("invoiceCode", "150003529999"); //冲红发票的发票代码
map.Add("invoiceNo", "61033842"); //冲红发票的发票号码
map.Add("redReason", "冲红"); //冲红原因
map.Add("amount", "-65.70"); //冲红金额 (要与原发票的总金额一致)
string content = ToJson.Table2Json(map);
String builderrequest =
PublicData.publicparam(content, platform_code, platform_alias, privateKey, password);
string response = PostJson.Post4Json(url, builderrequest);
Console.Write("最终返回:" + response);
}
/**
* 开票二维码接口
*/
public static void testGetQrCodeByItems() {
String url = "http://fpkj.testnw.vpiaotong.cn/tp/openapi/getQrCodeByItems.pt";
Hashtable map = new Hashtable();
map.Add("taxpayerNum", "110101201705230001"); //销方纳税人识别号
map.Add("enterpriseName", "测试"); //销方企业名称
map.Add("tradeNo", platform_alias + "10002001");//订单号(唯一)
map.Add("tradeTime", "2017-06-26 09:15:54"); //交易时间
map.Add("invoiceAmount", "100"); //发票金额(含税)
map.Add("casherName", "收款人A"); //收款人姓名(校验规则: 中文/字母大小写/及其两者组合)
map.Add("reviewerName", "审核人A"); //审核人姓名(校验规则: 中文/字母大小写/及其两者组合)
map.Add("drawerName", "开票人A"); //开票人姓名(校验规则: 中文/字母大小写/及其两者组合)
map.Add("allowInvoiceCount", "1"); //允许开票张数(非必填 默认值:1)
// map.put("smsFlag", "false"); //是否发送短信 (非必填 默认值:false 测试环境不发送短信)
// map.put("expireTime", ""); //有效时间 (非必填 默认值:永久有效 填写格式 yyyy-MM-dd HH:mm:ss)
// map.put("email","XXXXX@XX.com"); //二维码发送邮箱地址(非必填)
//其他参数见接口文档
ArrayList list = new ArrayList();
Hashtable listMapOne = new Hashtable();
listMapOne.Add("itemName", "小麦"); //开票项目名
listMapOne.Add("taxRateValue", "0.16"); //税率
listMapOne.Add("taxClassificationCode", "1010101020000000000");//税收分类编码
listMapOne.Add("quantity", "1"); //数量
listMapOne.Add("unitPrice", "50"); //单价
listMapOne.Add("invoiceItemAmount", "50"); //金额
Hashtable listMapTwo = new Hashtable();
listMapTwo.Add("itemName", "大米");
listMapTwo.Add("taxRateValue", "0.16");
listMapTwo.Add("taxClassificationCode", "1010101020000000000");
listMapTwo.Add("quantity", "1");
listMapTwo.Add("unitPrice", "50");
listMapTwo.Add("invoiceItemAmount", "50");
list.Add(listMapOne);
list.Add(listMapTwo);
map.Add("itemList", list);
string content = ToJson.Table2Json(map);
String builderrequest=PublicData.publicparam(content,platform_code,platform_alias,privateKey,password);
string response= PostJson.Post4Json(url, builderrequest);
Console.Write("最终返回:"+response);
}
/**
* 作废二维码接口
*/
public static void testdeleteInvoiceQrCode()
{
String url = "http://fpkj.testnw.vpiaotong.cn/tp/openapi/deleteInvoiceQrCode.pt";
string content = "[{\"taxpayerNum\":\"110101201705230001\",\"enterpriseName\":\"测试\",\"tradeNo\":\"DEMO10002001\",\"tradeTime\":\"2017-06-26 09:15:54\",\"invoiceAmount\":\"100\"}]";
String builderrequest=PublicData.publicparam(content,platform_code,platform_alias,privateKey,password);
string response= PostJson.Post4Json(url, builderrequest);
Console.Write("最终返回:"+response);
}
/**
* 查询发票
*/
public static void testqueryInvoice()
{
String url = "http://fpkj.testnw.vpiaotong.cn/tp/openapi/queryInvoice.pt";
// String url = "http://fpkj.testnw.vpiaotong.cn/tp/openapi/queryInvoiceInfo.pt"; //查询发票票面全面信息地址
String content =
"{ \"taxpayerNum\": \"110101201702071\", \"invoiceReqSerialNo\": \"DEMO6678997514279636\"}";
String builderrequest =
PublicData.publicparam(content, platform_code, platform_alias, privateKey, password);
string response = PostJson.Post4Json(url, builderrequest);
Console.Write("最终返回:" + response);
}
/*
* 获取库存接口
*/
public static void testGetInvoiceRepertoryInfo()
{
String url = "http://fpkj.testnw.vpiaotong.cn/tp/openapi/getInvoiceRepertoryInfo.pt";
String content = "{\"taxpayerNum\":\"110101201702071\",\"enterpriseName\":\"电子票测试新1\"}";
String builderrequest =
PublicData.publicparam(content, platform_code, platform_alias, privateKey, password);
string response = PostJson.Post4Json(url, builderrequest);
Console.Write("最终返回:" + response);
}
/**
* 插入微信卡包接口
*/
public static void testAuthWeChatCards()
{
String url = "http://fpkj.testnw.vpiaotong.cn/tp/openapi/authWeChatCards.pt";
String content = "{\"taxpayerNum\":\"110101201705230001\",\"invoiceReqSerialNo\":\"GAGA0000000000000009\"}";
String builderrequest =
PublicData.publicparam(content, platform_code, platform_alias, privateKey, password);
string response = PostJson.Post4Json(url, builderrequest);
Console.Write("最终返回:" + response);
}
/**
* 查询发票抬头
*/
public static void testTitleInfo()
{
String url = "http://fpkj.testnw.vpiaotong.cn/tp/openapi/getInvoiceTitleInfo.pt";
String content = "{\"enterpriseName\":\"测试\"}";
String builderrequest =
PublicData.publicparam(content, platform_code, platform_alias, privateKey, password);
string response = PostJson.Post4Json(url, builderrequest);
Console.Write("最终返回:" + response);
}
/**
* 查询票通宝状态接口
*/
public static void testGetPTBoxStatus()
{
String url = "http://fpkj.testnw.vpiaotong.cn/tp/openapi/getPTBoxStatus.pt";
String content = "{\"taxpayerNum\":\"110101201702071\",\"enterpriseName\":\"电子票测试新1\"}";
String builderrequest =
PublicData.publicparam(content, platform_code, platform_alias, privateKey, password);
string response = PostJson.Post4Json(url, builderrequest);
Console.Write("最终返回:" + response);
}
}
}
@@ -0,0 +1,57 @@
using System;
using System.Collections;
using System.Runtime.CompilerServices;
using System.Text;
namespace ConsoleDemo
{
internal class ToJson
{
public static String Table2Json(Hashtable table)
{
StringBuilder jsonstr =new StringBuilder();
jsonstr.Append("{");
foreach (DictionaryEntry tableEntry in table)
{
if (tableEntry.Key=="itemList")
{
String liststr=list2json((ArrayList)tableEntry.Value);
jsonstr.Append(string.Format("\"{0}\":{1},", tableEntry.Key,liststr));
}
else
{
jsonstr.Append(string.Format("\"{0}\":\"{1}\",", tableEntry.Key, tableEntry.Value));
}
}
jsonstr.Append("}");
jsonstr.Remove(jsonstr.Length - 2, 1);
return jsonstr.ToString();
// Console.Write(jsonstr.ToString());
}
private static String list2json(ArrayList list)
{
StringBuilder jsonstr =new StringBuilder();
jsonstr.Append("[");
foreach (Hashtable valuelist in list)
{
jsonstr.Append(Table2Json(valuelist)) ;
jsonstr.Append(",");
}
jsonstr.Remove(jsonstr.Length - 1, 1);
jsonstr.Append("]");
return jsonstr.ToString();
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,206 @@
票通电子发票平台对接指引
V1.0
北京票通信息技术有限公司
2024 年 10 月 16 日
修订文档历史记录
日期
版本
2024-10-16
<1.0>
说明
编写初稿,包括对接文档及 sdk 获取,企业注册开通说明,
开票场景,冲红场景,数电账号管理、补充场景说明等内容
作者
饶森林
本文档基于票通电子发票平台提供的接口能力进行说明,为合作伙伴对接票通系统时提
供简要指引和参考。
一、对接前事项
票通提供对接服务,随时可启动对接。对接前合作伙伴或客户需提供一些简单的信息。
接入方名称:
接入方联系人及电话:
接入方产品类型:企业定制开发产品、企业私有化部署系统、SaaS 产品
接入方产品所属行业:餐饮收银、物业收银、停车收费、供热收费等等
接入方需要的开票场景:扫码开票、订单开票、小程序开票、支付开票等
接入方预计服务的企业数量:用于评估需要接入的接口范围
该信息可发给票通商务人员,或直接发到对接沟通群。
二、如何启动对接
票通商务人员确认需要提供对接服务时,建立微信群,拉入相关的人员,票通侧需拉入
产品人员、对接人员、服务或运营人员。客户侧视客户情况拉入相关人员,一般建议有商务、
产品和研发人员。
在微信群,票通对接人员发送标准接口文档《票通数电发票接口文档 3.X.X.pdf》和 SDK
工具包,目前 SDK 工具包支持 Java 和 C#两种开发语言。
对接所需的测试环境及参数信息:如平台编码,RSA 签名验签的证书,3des 加密秘钥,
可用于测试开票的税号等由票通对接人员提供。接入方系统开发完成,正式上线前,联系票
通对接人员提供正式环境的参数信息。
正式对接前,可先参考该文档,如有疑问可在微信群组织会议沟通。
三、企业注册开通
要通过接口开具发票,需要开票的企业先在票通平台完成入驻流程,完成入驻有以下几
种方法。(联调测试阶段,可使用票通对接人员提供测试税号)
1)客户在票通平台直接注册
客户可以在票通企业版平台申请注册(注册地址:https://fpkj.vpiaotong.com/register
也可以在票通集团版(票通集团版账号可联系票通商务人员获取)的机构管理功能中添加需
要入驻的企业。申请提交后,需要票通平台运营人员或有权限的代理商进行审核。该过程如
有问题可联系商务人员沟通。
2)接口注册
针对企业数量较多的平台,可通过票通接口完成注册,使用上述接口文档中的“2.2.注
册企业”提交注册信息。接口注册的企业,无登录密码,用户如需使用票通平台,可在票通
平台,通过注册时预留的手机号,获取短信验证码完成密码设置,使用设置后的密码即可登
录票通平台。
基础文档仅提供了提交注册接口,如需获取票通的审核状态,可通过查询接口或推送接
口获取审核状态,如需接口可联系票通对接人员提供。
3)可由票通代理商协助完成注册
联系票通代理商进行操作。
通过接口注册的企业,平台和企业的绑定关系自动生成,如果是使用票通产品功能完成
的注册或已经在票通完成入驻的企业,需要联系票通商务人员或运营人员完成接入方平台和
开票企业的绑定关系。
四、主要的开票场景
一般情况下,仅完成基础开票,对接少量的一至两个接口即可。根据开票场景不同,我
们分别介绍。
1)扫码开票
扫码开票是票通提供的特色能力,针对一些当面交易场景,企业的顾客消费完成后,现
场获取交易小票,可在小票后追加打印开票二维码,顾客可自行扫码,填写完成开票。
顾客侧操作如下图示意:
该过程中,二维码信息生成,可调用票通接口文档中提供的“2.16.获取开票二维码”
接入方系统传入交易相关信息,如商品、单价、数量、税率等,票通为该笔交易生成对应的
开票链接和二维码并同步返回给接入方,接入方系统展示或打印二维码到小票上。
用户扫码后,看到的开票界面是由票通提供,该界面集成了发票抬头模糊检索,获取微
信或支付宝抬头,默认记录上次开票的发票抬头等功能,可方便顾客快速填充抬头信息,并
支持填写邮箱地址和手机号,票通会自动发送邮件,已开通短信服务的企业票通也会自动发
送短信推送发票。票通开票 H5 支持扫描多个二维码合并开票,或将一个二维码拆分开票,
商户自行设置即可。
提交发票后,还可授权将发票插入微信发票卡包或支付宝发票管家,发票开具成功后,
票通自动推到发票到顾客的微信发票卡包或支付宝发票管家。
平台发票开具成功,会调用“2.13.推送发票主要信息”接口推送开具成功的发票,接入
方也可调用“2.18.查询二维码开票信息”主动查询二维码开票状态。
如果用户侧产生退货,未开票情况下可调用“2.17.批量作废开票二维码”直接作废开票
二维码。如果二维码已开票,可调用“2.10.快捷冲红数电发票(全额冲红)”冲红已开具发
票。
注意:2.10、2.13、2.17、2.18 为可选接口,如不对接接口,相关操作也可以通过票通
产品功能完成。
2)直接开票
直接开票接口是通用的开票能力,系统接入方组织好待开票信息后,直接调用“2.9.开
具蓝字数电发票”接口完成开票申请提交,票通服务会实时返回结果,并附带一个链接,通
过该链接可打开 H5 界面实时查看发票开具状态。
票通接收开票申请后,处理开票过程,开票成功或开票异常,都会通过“2.13.推送发票
主要信息”接口,推送相关的信息。
目前数电票开具,需要开票人保持电子税务局账号的登录状态,如果需要开票人员进行
登录认证或风险认证时,票通将会 2.13 接口返回 3999 的特定错误状态,该状态表明需要提
供开票人完成相关认证。数电账号的认证问题,会在后续第六章节详细说明。
注意:提交开票时,发票请求流水号字段,唯一代表一张发票,同一个发票请求流水号
不会重复开票,接入方系统遇到一些场景需重试开票时,如果为同一张发票,请不要变更发
票请求流水号,否则可能有导致重开发票的风险。
3)单据开票
票通集团版提供的单据管理的能力,支持拆分开票、合并开票、支持直接传入订单信息
后续补充发票抬头等进行开票。该接口并非基础能力接口,如有需要财务人员介入进行审核
或拆分合并开票的场景,可使用该套接口能力,联系票通商务人员,安排提供相关的接口文
档,该功能的使用需依赖票通集团版系统。
五、冲红场景
当顾客产生退货或发票开具错误时,票通提供了多种冲红操作能力,票通企业版、集团
版均提供有手工冲红和一键冲红的能力,接口能力也提供了快捷冲红(1 个接口)和全场景
冲红(需 4-8 个接口组合使用)
数电票冲红需要发起红字确认单,确认单申请通过后才可发起真正的冲红操作,流程相
对较长,接口较多,一般建议接入方系统使用快捷冲红接口即可,快捷冲红接口服务对冲红
逻辑进行了封装,由票通整合红字信单申请管理和冲红功能,有效减轻接入方系统的开发工
作量。
1)快捷冲红
快捷冲红时,如仅需全部冲红,可对接“2.10.快捷冲红数电发票(全额冲红)”,该接口
参数简单;如需支持部分冲红,可对接“2.37.快捷冲红数电发票(全额冲红、部分冲红)”,
该接口参数比较完整,支持场景更多,部分冲红和全额冲红都支持,如果需要进行部分冲红,
建议调用“2.36.初始化红字信息确认单”完成初始化,该初始化的目的是加载剩余可冲红的
发票信息,无需接入方系统自行管理剩余可冲红的商品信息。
2)全场景冲红管理
全场景冲红需对接“2.28.红字发票确认单申请”、“2.29.查看红字发票确认单”、“2.30.
开具红字数电发票”、“2.31.红字发票确认单审核”、“2.32.红字发票确认单撤销”、“2.33.红
字发票确认单查询(下载)”、“2.34.获取红字发票确认单查询(下载)结果”、
“2.36.初始化
红字信息确认单”接口,用于精细化管理红字发票确认单及冲红。
销方申请冲红管理流程如下:
发票冲红流程及事项:
数电发票冲红,均需发起红字发票确认单申请,
普通发票申请,如果对方未进行入账操作,则申请后无需确认,调用红字信息表查询接
口获取到已确认(或无需确认)状态后,可进行冲红。
专用发票申请,如果对方未入账未勾选,则申请后无需确认,其他情况需对方确认,对
方确认通过后,调用红字信息表查询接口获取已确认(或无需确认)状态,接下来可进行冲
红。
对方企业发起的红字信息表,可通过红字信息表查询(下载)接口获取红字信息表,对
需要审核的红字信息表,可通过红字信息表审核接口,进行拒绝或者通过。
数电发票(普通发票)可冲红对应的增值税普通发票,包括普通电子发票。
数电发票(增值税专用发票),可冲红对应的增值税专用发票,包括专用电子发票。
接口同时支持购方申请红字确认单,冲红动作需销方执行。
注:冲红接口为非必须对接的接口,有些开发资源紧张或财务人员可人工处理冲红的情
况下,可以不对接冲红接口。或者将冲红功能放入后续的迭代开发。
六、数电账号管理
数电发票的开具,需要用户先在票通平台完成电子税务局账号的登录和风险认证。对于
接入方系统期望在系统内完成认证过程的,可使用票通提供的接口能力。如接入方系统仅需
完成开票,不关注认证过程,可使用票通成熟的认证方式。可通过票通企业版、集团版、微
票通 APP、票通云小程序、票通云服务公众号都可完成认证。推荐使用票通云公众号,该方
式认证无需登录票通账号,可直接在公众号进行数电账号的绑定、认证和对未认证导致开
票失败的发票进行重开。
1)接口能力
票通提供了“2.3.数电账号登记”、“2.4.获取登录短信验证码”、“2.5.短信登录”、“2.6.
获取实名认证二维码”、
“2.7.查询实名认证二维码扫码状态”
“2.8.查询数电账号认证状态”、
“2.45.查询数电账号列表”
、“2.46.退出电子税局登录”等接口完成数电账号的认证。
这里支持几种场景。
1. 接入方系统仅需要数电账号信息,用于开票时指定开票人,可使用 2.45 接口获取
在票通维护好的数电账号及对应状态即可。在开票时传入开票人信息。
2. 接入方系统需进行是数电账号维护的,可调用 2.3 数电账号登记接口,该接口为实
时接口,可验证用户输入的账号密码是否正确。
3. 接入方系统如需完成认证,需首先通过 2.3 或 2.45 获取登录信息,然后调用 2.4 和
2.5 完成短信登录认证,使用 2.6、2.7 完成风险码扫码认证。如需查询各状态的登录或认证
状态,可通过 2.8 接口完成认证。
2)票通云公众号
票通云服务公众号提供了您和您的数电账号关联的两种方式,一种是通过在企业版或集
团版对应数电账号后的关注二维码,扫码关注后,建立用户和数电账号的绑定关系。一种是
直接在票通云公众号-数电认证功能,通过输入手机号或电子税务局的登录账号密码完成验
证,获取属于用户的数电账号列表进行绑定。
“票通云服务”公众号提供的能力:
1. 用户绑定的账号未登录或未认证,导致发票开票失败时,票通将会通过消息通知进
行提醒,用户可直接点击通知,进入到认证界面,完成相应的认证,认证完成后可直接选择
是否重开发票及重开发票的范围(当前失败的发票或 30 天内因为该错误导致失败的发票)
2. 可通过票通云服务-数电认证功能,主动完成登录认证或风险认证,该场景适用于
一些企业为提高顾客体验,要求企业员工按时认证的情况,数电认证功能,无论当前账号处
于何种状态,都可以随时进行再次认证。
3.支持退出登录,目前通过短信方式完成登录,可保持很长的登录状态。
注意:由于目前短信登录方式保持登录的有效期很长,在 PC 端登录进行审核或其他操
作时,可能会被票通平台挤掉,可提醒用户先在票通平台退出登录,然后在电子税务局 PC
端进行操作。退出登录操作可使用票通产品或公众号,也可以通过接口 2.46 完成。
七、补充场景
1)发票抬头获取接口
接入方系统如需自行开发 H5 或需要帮助用户补充抬头信息时,可使用发票抬头获取接
口,该接口非基础接口,请联系商务人员获取。
2)开票项目智能赋码接口
接入方系统如需管理大量的商品品目时,可通过智能赋码接口完成对商品的税收分类编
码设置。该接口非基础接口,请联系商务人员获取。
3SaaS 平台类对接工单接口
接入方系统如果服务的企业较多,需要完善的线上原因流程支持,可联系票通商务人员
和对接人员,提供工单的接口支持,工单接口包括套餐(订单)创建、企业绑定、套餐续费
等能力。
八、更多支持
票通平台提供了多种场景组合的接口能力,如需交流沟通,可在微信群直接沟通或组织
会议进行沟通。
+257
View File
@@ -0,0 +1,257 @@
# Codex 三期工程实施文档(通用票通优先)
## 1. 文档用途
这份文档不是详细设计书,而是一份可以直接拿去给 Codex 执行的实施文档。
默认路线:
- 先做 `通用票通开票模块`
- 暂不接你现有业务系统的待开票数据
- 后续再把你的业务系统接到这套票通能力上
适用前提:
- 当前项目前后端已经有通用后台底座
- 已有登录、权限、动态菜单、系统管理、日志管理
- 新增功能应尽量复用现有结构,不做大重构
## 2. 当前项目可直接复用的基础
Codex 在执行时,默认直接复用这些已有能力:
- 后端 Ktor 启动、鉴权、异常处理、日志、数据库初始化
- 后端 `modules/*` 的路由组织方式
- 后端 `SeedData` 的菜单、权限、字典初始化方式
- 前端登录、主布局、动态路由、权限按钮、页签体系
- 前端 `features/*` 页面组织方式
- 前端现有表格、表单、弹窗、查询页风格
本次新增内容尽量放到以下高层目录:
- `server/src/main/kotlin/com/bbit/platform/integration/piaotong`
- `server/src/main/kotlin/com/bbit/platform/modules/piaotong`
- `server/src/main/kotlin/com/bbit/platform/database/piaotong`
- `web/src/api/piaotong`
- `web/src/types/piaotong`
- `web/src/features/piaotong`
- `web/src/components/piaotong`
## 3. 全局实施原则
Codex 执行时,统一遵循下面原则:
- 不改现有系统管理模块的交互风格
- 不做全局重命名和大规模目录重构
- 每次只处理一个任务包,不跨太多模块
- 单个任务包尽量做到“改完即可本地验证一个页面或一组接口”
- 先打通票通主链路,再考虑业务系统适配
- 农产品收购发票从一开始就要纳入支持范围
新增菜单统一按这套来:
- 一级菜单:`票通开票`
- 二级菜单:
- `票通企业`
- `数电账号`
- `认证中心`
- `手工开票`
- `开票任务`
## 4. 三期工程总览
### 第一期目标
把票通模块骨架和基础查询能力搭起来,做到“能看企业、能看账号、能看认证状态、页面和接口结构稳定”。
### 第二期目标
把认证链路和手工开票主链路打通,做到“可以在系统里完成认证并发起真实开票”。
### 第三期目标
把推送、补偿、重试、稳定性补齐,做到“开票结果能稳定落定,模块具备持续使用能力”。
## 5. 第一期:票通底座与基础查询
### 5.1 本期目标
先把“票通开票”模块作为一个独立业务域立起来,完成最小骨架和只读能力,不急着一开始就打开发票提交。
### 5.2 本期应完成的内容
- 新增票通开票菜单和页面骨架
- 新增票通相关后端模块骨架
- 落票通配置、加密签名、HTTP 客户端基础能力
- 支持查询企业信息
- 支持查询企业开户行及账号信息
- 支持查询数电账号列表
- 支持查询数电账号认证状态
- 建立本地票通账号表和基础任务表
### 5.3 本期不要做的内容
- 不做真实开票提交
- 不做认证短信/扫码交互闭环
- 不做票通推送回调
- 不接你自己的业务系统
### 5.4 可直接给 Codex 的任务包
#### 任务包 1
新增 `票通开票` 一级菜单和 5 个二级菜单,补齐前端路由和页面骨架,页面先只保留基础查询区和占位内容,不接真实接口。
#### 任务包 2
在后端新增 `piaotong` 业务域目录和模块注册,补齐配置读取、公共报文封装、RSA/3DES 基础能力、票通 HTTP 客户端基础封装,先不接具体业务页面。
#### 任务包 3
实现 `票通企业` 相关接口和页面,支持按税号查询企业信息、查询企业开户行及账号信息,并把查询结果在前端页面展示出来。
#### 任务包 4
实现 `数电账号``认证状态` 的基础查询能力,支持查询数电账号列表、查看默认账号、查看认证建议和认证状态,并补齐本地账号表的基础读写。
### 5.5 本期验收标准
- 菜单、路由、页面已经可访问
- 后端已经形成独立的 `piaotong` 业务域
- 可以查询企业信息
- 可以查询企业开户行及账号信息
- 可以查询数电账号列表和认证状态
- 代码结构已经为后续认证和开票留出扩展位置
## 6. 第二期:认证中心与手工开票主链路
### 6.1 本期目标
把“账号认证 -> 手工录入 -> 发起开票 -> 查看任务状态”这条主链路打通。
### 6.2 本期应完成的内容
- 认证中心后端接口
- 短信验证码发送与短信登录
- 二维码认证获取与扫码状态查询
- 手工开票页面
- 普通蓝字数电票开票
- 农产品收购发票开票
- 本地开票任务记录
- 开票任务列表和详情页
- 手动查询开票结果
### 6.3 本期不要做的内容
- 不做票通推送回调
- 不做定时补偿任务
- 不接你的业务系统回写
### 6.4 可直接给 Codex 的任务包
#### 任务包 1
实现 `认证中心` 后端接口,支持查询认证状态、发送短信验证码、提交短信登录、获取实名认证二维码、查询二维码扫码状态、退出电子税局登录。
#### 任务包 2
实现 `认证中心` 前端页面和交互,支持短信认证流程和扫码认证流程,页面能清晰展示当前认证建议、二维码状态和短信状态。
#### 任务包 3
实现 `手工开票` 页面和后端开票接口,支持手工录入发票基础信息、购买方/销方信息、商品明细,并支持普通开票模式和农产品收购发票模式切换。
#### 任务包 4
实现本地开票任务创建、开票提交、手动状态查询、任务列表和详情页,让用户能看到发票请求流水号、状态、失败原因、票号等核心结果。
### 6.5 本期验收标准
- 用户可以在系统里完成短信认证或扫码认证
- 用户可以手工录入一张票并发起开票
- 农产品收购发票模式可用
- 能看到本地任务状态和票通返回结果
- 遇到 `3999` 时能进入认证流程,并可继续原任务
## 7. 第三期:推送、补偿与稳定性收口
### 7.1 本期目标
把结果同步和异常处理补齐,让模块从“可演示”进入“可稳定联调和可持续使用”状态。
### 7.2 本期应完成的内容
- 票通推送回调接收
- 验签、解密、幂等处理
- 主动查询补偿
- 失败任务重试
- 状态机收口
- 日志和异常信息收口
- 联调说明和部署说明
### 7.3 本期不要做的内容
- 仍然不接你的待开票业务系统
- 不把整套系统改造成完整业务平台
### 7.4 可直接给 Codex 的任务包
#### 任务包 1
实现票通发票推送回调接口,支持接收推送主要信息和全票面信息,完成验签、解密、幂等判断和本地任务状态更新。
#### 任务包 2
实现主动查询补偿机制,对处理中和待认证后的任务进行补偿查询,避免只依赖回调导致状态长期不落定。
#### 任务包 3
实现失败任务重试和状态机收口,保证同一张发票重试时复用原 `invoiceReqSerialNo`,并把任务页上的错误提示、状态标签、操作入口整理完整。
#### 任务包 4
整理联调说明、环境配置说明和回调部署说明,补齐必要的日志与调试信息,让后续对接你自己的业务系统时可以直接复用这套票通能力。
### 7.5 本期验收标准
- 开票结果可以通过推送或主动查询最终落定
- 重复推送不会造成重复更新
- 失败任务可以重试
- 任务状态流转稳定
- 模块已经具备后续接业务系统的基础
## 8. 给 Codex 的执行边界
后续给 Codex 发任务时,建议直接按“任务包”发,不要一次跨两期。
推荐下发方式:
- `按第一期任务包 1 做,只做菜单、路由和 5 个页面骨架,不接真实接口`
- `按第一期任务包 3 做,只做票通企业查询前后端,不动认证和开票`
- `按第二期任务包 2 做,只做认证中心前端交互,复用现有后端接口`
- `按第二期任务包 4 做,只做本地开票任务和任务列表详情`
- `按第三期任务包 2 做,只做主动查询补偿和状态落定`
不建议下发方式:
- 一次要求 Codex 同时做菜单、认证、开票、推送、补偿
- 一次要求 Codex 同时接票通和你的业务系统
- 一次要求 Codex 重构现有后台结构后再开发功能
## 9. 这份文档的最终建议
这三期的节奏,核心是先把“票通能力底座”做出来,再考虑“业务系统接入层”。
如果按当前项目状态来看,这样分期最合理:
- 第一期把结构立稳,先查得到
- 第二期把主链路跑通,先开得出
- 第三期把稳定性补齐,先用得住
后续如果你要再接自己的待开票业务系统,就在这三期完成后再新增第四阶段:
- 读取业务待开票数据
- 自动创建票通任务
- 结果回写业务系统
这样不会推翻前面的实现,Codex 也更容易持续推进。
+219
View File
@@ -0,0 +1,219 @@
# 农产品收购发票开票平台待开发功能梳理
## 1. 梳理结论
结合当前前端、后端代码和原 [系统设计.md](C:\Users\BBIT\Desktop\农产品收购发票开票平台\系统设计.md),目前项目已经完成的是“通用后台底座”,尚未进入“农产品收购发票开票业务闭环”开发阶段。
当前可以从后续范围中移除的内容,主要是:
- 平台账号登录、JWT 鉴权、当前用户信息获取
- 动态菜单、按钮权限、路由权限控制
- 用户、组织、角色、菜单、字典管理
- 操作日志、接口访问日志
- 基础工作台、后台布局、通用表格表单交互
因此,新文档不再把上述能力作为一期建设重点,后续应聚焦“业务系统对接 + 票通对接 + 开票状态闭环 + 业务页面”。
## 2. 当前已完成能力
### 2.1 后端已完成
- 认证基础:`/api/auth/login``/api/auth/logout``/api/auth/me`
- 权限体系:JWT、权限码校验、角色菜单绑定
- 系统管理接口:
- `/api/system/users`
- `/api/system/orgs`
- `/api/system/roles`
- `/api/system/menus`
- `/api/system/dicts`
- 日志查询接口:
- `/api/logs/operation`
- `/api/logs/api-access`
- 初始化能力:默认组织、管理员、菜单、字典种子数据
### 2.2 前端已完成
- 登录页、主布局、动态路由、页签导航
- 工作台首页
- 用户管理
- 组织管理
- 角色管理
- 菜单管理
- 字典管理
- 操作日志、接口日志
## 3. 当前未完成的核心业务能力
## 3.1 业务主线能力
以下是当前最需要补齐的一期闭环:
1. 获取当前用户所属公司上下文
2. 对接我方业务系统,查询待开票农产品收购数据
3. 选择待开票数据并发起蓝字数电发票开具
4. 对接票通认证流程,处理短信认证、扫码认证
5. 查询开票结果并回写我方业务系统
6. 接收票通回调,驱动本地状态更新
7. 支持失败重试、处理中补偿查询、审计留痕
目前以上 7 项在现有前后端代码中都还没有真正落地。
## 3.2 后端待开发清单
### 3.2.1 我方业务系统对接
需要新增业务系统集成模块,至少包括:
- 登录后获取当前用户 `companyId`、公司名称、纳税人识别号
- 根据 `companyId` 查询待开票列表
- 开票前写库/锁单
- 开票结果回写
- 回写失败后的补偿重试
### 3.2.2 票通集成能力
需要新增票通专用模块,至少包括:
- 票通基础配置管理
- 3DES 加密、RSA 签名、验签、解密
- `invoiceBlue.pt` 蓝字开票接口封装
- `getTaxBureauAccountAuthStatus.pt` 认证状态查询
- `sendLoginSmsCode.pt` 短信验证码发送
- `smsLogin.pt` 短信登录
- `getAuthenticationQrcode.pt` 实名认证二维码获取
- `queryAuthQrcodeScanStatus.pt` 二维码扫码状态查询
- `queryInvoice.pt` 发票结果主动查询
- 电子税局退出登录接口封装
### 3.2.3 开票任务与状态机
需要新增开票任务中心,而不是直接同步开票:
- `invoice_job``invoice_job_item``tax_account``audit_log` 等业务表
- `invoiceReqSerialNo` 幂等号生成与复用
- 本地状态流转:
- `PENDING`
- `PROCESSING`
- `NEED_AUTH`
- `SUCCESS`
- `FAILED`
- `BIZ_SYNC_FAILED`
- 认证后继续原任务,而不是重新生成任务
- 调用超时后转处理中,并通过主动查询补状态
### 3.2.4 对外业务接口
当前还缺少原设计中的业务接口:
- `GET /api/invoice-candidates`
- `POST /api/invoice-jobs`
- `GET /api/invoice-jobs/{jobId}`
- `POST /api/invoice-jobs/{jobId}/retry`
- `GET /api/piaotong/accounts/{account}/auth-status`
- `POST /api/piaotong/accounts/{account}/sms-code`
- `POST /api/piaotong/accounts/{account}/sms-login`
- `POST /api/piaotong/accounts/{account}/auth-qrcode`
- `GET /api/piaotong/accounts/{account}/auth-qrcode/{authId}`
- `POST /api/piaotong/accounts/{account}/logout`
- `POST /api/callbacks/piaotong/invoice`
### 3.2.5 后台任务与可靠性
还需要补齐后台可靠性能力:
- 定时查询 `PROCESSING` 任务
- 回调去重与乱序保护
- 开票结果与业务回写失败补偿
- 关键字段脱敏存档
- 面向票通/业务系统的错误码映射
## 3.3 前端待开发清单
### 3.3.1 业务页面
当前前端没有任何开票业务页面,需要新增:
- 待开票列表页
- 开票确认弹窗
- 票通认证弹窗
- 开票结果页/任务详情页
- 票通账号配置页
- 开票日志或任务追踪页
### 3.3.2 页面交互能力
需要补齐以下交互:
- 按公司查看待开票数据
- 勾选待开票单据并显示金额汇总
- 发起开票前的参数确认
- 根据 `operationProposed` 展示不同认证方式
- 认证完成后继续原开票任务
- 展示开票状态、失败原因、票号、数电发票号码
- 支持手动刷新、重试、查看明细
### 3.3.3 菜单与品牌调整
当前前端仍偏“通用管理平台”,还需要调整为业务平台形态:
- 新增发票业务菜单,而不是只有系统管理菜单
- 工作台改为展示开票业务统计,而不是菜单/权限数量
- 登录页、工作台、导航标题统一到“农产品收购发票开票平台”业务语境
## 4. 建议保留的一期范围
结合现状,建议把一期范围压缩成“最小可用闭环”:
1. 登录后拿到公司上下文
2. 查询待开票列表
3. 发起蓝字数电普票开具
4. 票通认证:短信 + 扫码
5. 开票状态查询
6. 开票结果回写我方业务系统
7. 票通回调接收
8. 失败重试与日志留痕
## 5. 建议暂缓到二期的内容
以下内容建议暂不纳入当前主线:
- 冲红流程
- 红字确认单申请/审核
- 发票文件下载
- 二维码开票扩展能力
- 微信/支付宝卡包
- 企业注册入驻
- 更复杂的经营分析报表
## 6. 推荐实施顺序
### 第一阶段:打通后端主链路
- 建业务系统客户端
- 建票通客户端与加密签名能力
- 落业务表结构
- 完成开票任务、状态机、回调、主动查询
### 第二阶段:补前端业务页面
- 待开票列表
- 发起开票
- 认证弹窗
- 结果页与重试
### 第三阶段:做稳定性和运维补齐
- 补偿任务
- 日志审计
- 配置维护页
- 错误提示和异常兜底
## 7. 最终结论
当前项目“底座已具备,业务未开工”的特征非常明确。接下来不需要继续投入系统管理模块,而应集中资源完成以下三块:
- 我方业务系统对接
- 票通开票与认证对接
- 开票任务闭环与前端业务页面
只要这三块完成,项目才会从“通用后台”真正进入“农产品收购发票开票平台”的可用状态。
Binary file not shown.
+513
View File
@@ -0,0 +1,513 @@
# 票通对接业务流程说明
本文基于以下资料整理:
- [系统设计.md](C:\Users\BBIT\Desktop\农产品收购发票开票平台\系统设计.md)
- [doc\票通数电平台对接指引v1.0(2).txt](C:\Users\BBIT\Desktop\农产品收购发票开票平台\doc\票通数电平台对接指引v1.0(2).txt)
- [doc\票通数电发票接口文档3.3.5.txt](C:\Users\BBIT\Desktop\农产品收购发票开票平台\doc\票通数电发票接口文档3.3.5.txt)
## 1. 你的角色本质是什么
结论:是的,你本质上是“接入方平台 / 第三方平台 / 中台”,帮助甲方企业对接票通,以甲方企业的名义完成开票。
但这里不是简单的 HTTP 转发,还包括以下职责:
- 维护“你的平台”和“开票企业”的绑定关系
- 维护或选择甲方企业可用的数电账号
- 组织开票报文并调用票通接口
- 处理登录认证、风险认证
- 接收票通推送或主动查询开票结果
- 再把开票结果回写给你自己的业务系统
从票通文档看,这个角色被明确定义为:
- `第三方平台` / `集团企业`
- `接入方系统`
关键依据:
- 对接指引说明“接入方系统开发完成后,由票通提供正式环境参数”
- 直接开票场景中,`系统接入方组织好待开票信息后,直接调用 2.9 开具蓝字数电发票`
- 企业通过接口注册后,会自动生成“平台和企业的绑定关系”;如果企业已在票通完成入驻,则需要联系票通侧建立“接入方平台和开票企业的绑定关系”
所以业务归属上:
- 开票主体是甲方企业
- 票通是开票服务平台
- 你是中间接入平台
## 2. 票通是否提供测试接口
结论:提供。
### 2.1 测试环境说明
票通对接指引明确写了:
- 测试环境和参数信息由票通对接人员提供
- 包括平台编码、RSA 证书、3DES 密钥、测试税号
- 正式上线前,再由票通提供正式环境参数
文档里的核心说明:
- 测试环境参数:平台编码、RSA 签名验签证书、3DES 密钥、测试税号
- 联调测试阶段,可直接使用票通对接人员提供的测试税号
### 2.2 测试地址风格
核心测试地址统一是:
```text
http://fpkj.testnw.vpiaotong.cn/tp/openapi/...
```
正式地址统一是:
```text
https://fpkj.vpiaotong.com/tp/openapi/...
```
### 2.3 你当前真正会用到的测试接口
- `register.pt`
- `registerUser.pt`
- `sendLoginSmsCode.pt`
- `smsLogin.pt`
- `getAuthenticationQrcode.pt`
- `queryAuthQrcodeScanStatus.pt`
- `getTaxBureauAccountAuthStatus.pt`
- `listTaxBureauAccount.pt`
- `invoiceBlue.pt`
- `queryInvoice.pt`
- `queryInvoiceInfo.pt`
- `logoutEtax.pt`
- `getEnterpriseInfo.pt`
- `queryEnterpriseBankInfo.pt`
注意:
- 推送接口 `2.13 / 2.14 / 2.48` 没有票通固定测试地址,因为这是“票通调用你提供的接口地址”
- 也就是说,回调 URL 不是你调用票通时传进去的,而是你提供给票通侧配置使用的
## 3. 开票结果“回写”到底分几层
这里要分清楚两层,不要混在一起。
### 3.1 第一层:票通把结果返回给你的平台
这是票通体系内的结果同步,主要有两种方式:
#### 方式 A:票通推送
票通文档定义了:
- `2.13 推送发票主要信息`
- `2.14 推送发票全票面信息`
调用关系写得很明确:
- `票通平台调用第三方平台`
- `接口地址:第三方平台提供`
这就是我们说的“回调”。
#### 方式 B:你的平台主动查询
票通文档定义了:
- `2.11 查询发票主要信息`
- `2.12 查询发票全票面信息`
也就是说,开票结果可以:
- 等票通推送过来
- 或者你主动轮询查询
最佳实践不是二选一,而是:
- 主用推送
- 查询作为补偿和兜底
### 3.2 第二层:你的平台再把结果写回你自己的业务系统
这个“开票结果回写接口”不是给票通用的。
它是你自己的业务系统接口,用来做这些事:
- 把原始业务单据状态改成“开票成功/失败/处理中”
- 回写发票号码、数电发票号码、开票日期
- 回写失败原因
- 记录票通流水号或发票请求流水号
所以:
- `票通 -> 你的平台`:票通文档里有
- `你的平台 -> 你的业务系统`:票通文档里没有,需要你自己定义
## 4. 回调是怎么和票通绑定的
从文档能确认两件事:
1. `2.13 / 2.14 / 2.48` 都是“票通平台调用第三方平台”
2. 接口地址不是你在开票请求里传递,而是“第三方平台提供”
因此可以得出一个明确结论:
- 回调地址不是按每张发票动态传的
- 而是你先提供给票通,由票通侧事先配置
文档没有给出“通过某个接口动态设置回调地址”的描述,所以这里应按“线下配置 / 对接阶段配置”理解。
实际落地时通常就是:
1. 你先准备一个公网可访问的回调地址
2. 提供给票通对接人员
3. 票通侧把这个地址配置成推送接收地址
4. 票通后续在开票成功、失败、认证异常等场景调用你的回调接口
对你来说要注意:
- 回调接口必须能公网访问
- 必须按票通公共报文做验签和解密
- 要做幂等,避免重复推送重复入库
- 不能只靠回调,最好仍保留主动查询兜底
## 5. 开票结果样例怎么理解
这里也分两种。
### 5.1 票通推送/查询给你的样例
这个在票通文档里是有的。
最有代表性的就是:
- `2.13 推送发票主要信息` 样例
- `2.11 查询发票主要信息` 样例
- `2.12 查询发票全票面信息` 样例
例如 `2.13` 的成功推送报文会包含:
- `taxpayerNum`
- `invoiceReqSerialNo`
- `invoiceType`
- `invoiceKind`
- `code`
- `msg`
- `tradeNo`
- `definedData`
- `invoiceCode`
- `invoiceNo`
- `electronicInvoiceNo`
- `invoiceDate`
- `noTaxAmount`
- `taxAmount`
- `invoicePdf`
- `invoiceXml`
- `downloadUrl`
其中:
- `code=0000` 表示成功
- `code=3999` 表示需要扫码或短信认证
- `code=9999` 表示开票失败
### 5.2 你写回甲方业务系统的样例
这个在票通文档里没有,因为这是你自己的内部系统接口。
你至少需要自己约定一份这样的回写报文:
```json
{
"bizOrderNo": "CG202604290001",
"companyId": "COMP_001",
"invoiceReqSerialNo": "DEMO202604290001234567",
"status": "SUCCESS",
"ptCode": "0000",
"ptMsg": "开具成功",
"invoiceCode": "123456789012",
"invoiceNo": "12345678",
"electronicInvoiceNo": "12345678901212345678",
"invoiceDate": "2026-04-29 14:20:31",
"noTaxAmount": "90.50",
"taxAmount": "11.50",
"downloadUrl": "https://...",
"definedData": "自定义数据"
}
```
失败样例建议至少保留:
```json
{
"bizOrderNo": "CG202604290001",
"companyId": "COMP_001",
"invoiceReqSerialNo": "DEMO202604290001234567",
"status": "FAILED",
"ptCode": "9999",
"ptMsg": "开票失败,失败原因见票通返回",
"failReason": "票通返回的失败描述"
}
```
## 6. 按业务流程顺序,你需要知道和会用到的接口
下面按实际开发顺序来列,不按文档章节号罗列。
### 6.1 安全机制和公共报文
所有接口调用前都要先满足:
- 公共报文使用 RSA 签名
- 业务报文使用 3DES 加密
- 编码统一 UTF-8
- 公共字段包括:
- `platformCode`
- `signType`
- `sign`
- `format`
- `timestamp`
- `version`
- `serialNo`
- `content`
注意事项:
- `content` 里放的是除公共参数外的全部业务参数
- RSA 签名结果要做 Base64 编码
- 业务 JSON 编码统一 UTF-8
### 6.2 企业是否已入驻票通
你需要先确认开票企业是否已经具备票通开票资格。
可用接口:
- `2.2 注册企业`
- `2.47 查询企业信息`
- `2.48 企业审核结果推送`
使用顺序建议:
1. 如果甲方企业尚未入驻,走 `2.2 注册企业`
2. 注册后用 `2.47 查询企业信息` 看审核状态
3. 如果票通侧支持,可接 `2.48 企业审核结果推送`
注意事项:
- 企业必须先完成入驻才能开票
- 如果企业不是通过接口注册,而是在票通侧已存在,需要和票通做“平台与企业绑定”
- `reviewStatus` 要关注:
- `0` 待审核
- `1` 审核通过
- `2` 审核不通过
- `3` 审核中
### 6.3 数电账号准备
如果甲方企业已经入驻,下一步是准备可以用于开票的数电账号。
可用接口:
- `2.3 数电账号登记`
- `2.45 查询数电账号列表`
- `2.8 查询数电账号认证状态`
- `2.46 退出电子税局登录`
使用顺序建议:
1. 先用 `2.45` 查询某税号下已经维护好的数电账号
2. 如果你需要平台内维护账号,再用 `2.3` 登记
3. 开票前用 `2.8` 检查认证状态
4. 如存在异常登录状态,再视情况用 `2.46` 退出后重登
注意事项:
- `2.3` 的密码和身份密码要用票通要求的 3DES 加密
- `2.45 / 2.8` 会返回:
- `operationProposed`
- `authStatus`
- `switchable`
- `wechatUserBindStatus`
- `operationProposed` 最重要:
- `0` 无需认证
- `1` 需扫码认证
- `2` 需扫码或短信认证
- `3` 需短信认证
### 6.4 开票前置检查
正式开票前,建议先做企业和票面数据检查。
建议用到:
- `2.47 查询企业信息`
- `2.49 查询企业开户行及账号`
- `2.45 查询数电账号列表`
- `2.8 查询数电账号认证状态`
注意事项:
- `2.49` 文档明确说明:可用于避免“企业没有维护开户行及账号导致开票失败”
- 如果票通平台和电子税局都没有维护开户行及账号,需要先提醒企业维护
- 查询电子税局开户行及账号时,要求数电账号已登录认证
### 6.5 发起开票
核心接口:
- `2.9 开具蓝字数电发票`
你的业务场景重点注意以下字段:
- `invoiceIssueKindCode`
- 农产品收购发票只能开数电普通票,建议按 `82`
- `specialInvoiceKind`
- 农产品收购发票必须传 `02`
- `invoiceReqSerialNo`
- 一张发票的唯一幂等号
- `account`
- 明确指定开票使用的数电账号
- `definedData`
- 建议传你自己的业务单号或关联标识,后续推送会原样带回
- `tradeNo`
- 可传业务订单号,不传时票通默认使用 `invoiceReqSerialNo`
农产品收购发票特别注意:
- 文档写明:`specialInvoiceKind=02` 时,只能开具数电票(普通发票)
- 文档还写明:`购方信息代表实际的销方信息,销方信息是实际的购方`
- `purchaseInvSellerIdType` 开具农产品收购发票时必填
- `buyerTaxpayerNum` 开具农产品收购发票时必填
注意事项:
- `invoiceReqSerialNo` 对同一张票重试时不能变
- 文档明确提示:如果变更同一张票的请求流水号,可能造成重开发票
- 返回里的 `qrCodePath` / `qrCode` 可用于打开 H5 实时查看开票状态
### 6.6 如果开票遇到认证
如果开票返回 `3999`,不要直接判成普通失败。
可用接口:
- `2.8 查询数电账号认证状态`
- `2.4 获取登录短信验证码`
- `2.5 短信登录`
- `2.6 获取实名认证二维码`
- `2.7 查询实名认证二维码扫码状态`
短信认证路径:
1.`2.8` 看建议
2. 如果允许短信认证,调 `2.4`
3. 再调 `2.5` 完成登录
扫码认证路径:
1.`2.6` 获取二维码
2. 展示二维码给用户
3. 轮询 `2.7` 查询扫码状态
4. 认证完成后,再继续原开票流程
注意事项:
- `2.4` 返回 `6666` 时,文档说“无需真正发送短信验证码,系统会自动登录成功”
- `2.6` 二维码一般 5 分钟左右过期
- `2.7` 文档特别提示:接口比较耗时,请调长超时时间
- `2.7``scanStatus`
- `1` 未扫码
- `2` 已扫码
- `3` 二维码已过期
### 6.7 获取开票结果
结果同步建议两条线都做。
#### 第一条:票通推送
- `2.13 推送发票主要信息`
- `2.14 推送发票全票面信息`
作用:
- 票通开票成功、失败、需要认证时,主动通知你
注意事项:
- 接口地址由你提供给票通配置
- 你返回的业务结果码:
- `0000` 接收成功
- `9999` 接收失败
#### 第二条:主动查询
- `2.11 查询发票主要信息`
- `2.12 查询发票全票面信息`
作用:
- 回调没到时兜底
- 开票中状态补偿查询
- 手动刷新状态
关键状态码:
- `0000` 开票成功
- `6666` 未开票
- `7777` 开票中
- `9999` 开票失败
- `3999` 需要扫码或短信认证
### 6.8 结果落库并回写你自己的业务系统
这一段票通不会帮你做,需要你自己做。
建议你的平台在收到 `2.13` 推送或 `2.11 / 2.12` 查询结果后:
1. 更新本地开票任务状态
2. 保存发票号码、数电发票号码、开票时间、失败原因
3. 再调用你自己的业务系统回写接口
建议你自己的回写接口至少要包含:
- 业务单号
- 公司标识
- 发票请求流水号
- 开票状态
- 票通状态码
- 票通状态描述
- 发票号码 / 数电发票号码
- 开票日期
- 金额 / 税额
- 下载地址或文件标识
## 7. 这份文档给你的最终结论
你现在最需要抓住的不是“票通有多少接口”,而是下面这条最小闭环:
1. 确认企业已入驻且已与平台绑定
2. 确认企业下有可用数电账号
3. 开票前检查认证状态和企业基础信息
4.`2.9` 发起农产品收购发票开具
5. 如遇 `3999`,走短信认证或扫码认证
6.`2.13 / 2.14` 收推送,同时用 `2.11 / 2.12` 做补偿查询
7. 最后把结果回写到你自己的业务系统
如果只按开发优先级排序,你最先应该落的接口就是:
- `2.45 查询数电账号列表`
- `2.8 查询数电账号认证状态`
- `2.9 开具蓝字数电发票`
- `2.11 查询发票主要信息`
- `2.13 推送发票主要信息`
- `2.4 / 2.5 / 2.6 / 2.7` 认证链路
对你这个项目最容易踩坑的点有 4 个:
- 农产品收购发票字段语义和普通蓝票不同,购销方字段有反转
- `invoiceReqSerialNo` 必须稳定复用,不能重试就换
- 回调地址不是请求里传的,而是提前配置给票通的
- “开票结果回写接口”不是票通接口,而是你自己的业务系统接口
+315
View File
@@ -0,0 +1,315 @@
# 农产品收购发票开票平台系统设计
## 1. 系统定位
本平台作为“业务系统”和“票通数电发票平台”之间的开票中台:
- 前端 Web:登录、查看待开票农产品收购数据、选择数据、发起开票、处理票通认证、查看开票结果。
- Ktor 后端:统一封装我方业务接口、票通接口、票通加密签名、开票状态机、结果回写、重试与审计。
- 我方业务系统:提供公司、待开票列表、开票前写库、开票结果回写等接口。
- 票通系统:提供数电账号认证、短信登录、实名认证二维码、蓝字发票开具、发票查询、结果推送等接口。
当前文档资料中,本业务主要对应票通“直接开票”场景:调用 `invoiceBlue.pt` 开具蓝字数电发票;开票结果通过票通推送或主动查询同步。票通要求业务报文使用 UTF-8,业务 JSON 先 3DES 加密,外层报文再按字段排序后用 RSA 签名。
## 2. 总体架构
```mermaid
flowchart LR
Web["Web 前端"]
Ktor["Ktor 后端"]
Biz["我方业务系统"]
DB["平台数据库"]
PT["票通 OpenAPI"]
Web -->|"登录/查询/开票/认证"| Ktor
Ktor -->|"公司、待开票、预写库、状态回写"| Biz
Ktor -->|"开票任务、状态、票通账号、审计日志"| DB
Ktor -->|"加密签名请求"| PT
PT -->|"推送结果 callback"| Ktor
Ktor -->|"状态更新"| Web
```
建议 Ktor 后端承担“防重复、状态机、签名加密、票通异常兼容”的职责,前端不要直接调用票通,也不要持有票通密钥。
## 3. 核心业务流程
### 3.1 平台登录与公司上下文
1. 用户登录 Web。
2. Ktor 调用我方登录或用户信息接口,获取 `userId``companyId`、公司名称、纳税人识别号 `taxpayerNum`
3. Ktor 建立平台会话/JWT,前端后续请求都带当前公司上下文。
### 3.2 查询待开票列表
1. 前端调用 `GET /api/invoice-candidates`
2. Ktor 按当前 `companyId` 调用我方“根据公司 id 查询待开票列表”接口。
3. Ktor 对列表做轻量规范化:金额、税率、商品名称、收购方/销售方信息、是否可开票、已锁定状态。
4. 前端展示并支持勾选。
### 3.3 发起开票
1. 前端选中待开票数据,调用 `POST /api/invoice-jobs`
2. Ktor 校验当前公司票通配置和数电账号状态。
3. 如果 `getTaxBureauAccountAuthStatus.pt` 返回需要认证:
- `operationProposed=1`:提示扫码认证;
- `operationProposed=2`:展示扫码和短信两种方式;
- `operationProposed=3`:提示短信登录;
- `operationProposed=0`:继续开票。
4. Ktor 调用我方“开票前写库”接口,锁定这些业务数据,并取得我方开票批次号或业务流水号。
5. Ktor 生成稳定且唯一的 `invoiceReqSerialNo`。同一张发票重试必须复用同一个流水号,避免重复开票。
6. Ktor 按票通字段组装 `invoiceBlue.pt` 业务报文,调用票通。
7. Ktor 记录票通同步返回结果:
- 成功受理或开票中:本地状态置为 `PROCESSING`
- 立即成功:置为 `SUCCESS`,回写我方系统;
- 失败:置为 `FAILED``NEED_AUTH`,回写我方系统。
### 3.4 认证流程
票通文档中认证不是简单登录态,包含登录认证和风险认证。推荐做成统一认证面板。
- 查询认证状态:`getTaxBureauAccountAuthStatus.pt`
- 获取短信验证码:`sendLoginSmsCode.pt`
- 短信登录:`smsLogin.pt`
- 获取实名认证二维码:`getAuthenticationQrcode.pt`
- 查询二维码扫码状态:`queryAuthQrcodeScanStatus.pt`
- 退出电子税局登录:`logout` 类接口,对应文档 2.46
前端行为:
- `operationProposed=1`:调用后端获取二维码,轮询扫码状态。
- `operationProposed=2`:同时展示“扫码认证”和“短信认证”。
- `operationProposed=3`:展示手机号/账号、验证码输入、发送验证码按钮。
- 认证完成后,用户点击“继续开票”,后端用原 `invoiceReqSerialNo` 继续或重试。
### 3.5 开票结果同步
建议同时支持两条链路:
1. 票通推送:实现 `POST /api/callbacks/piaotong/invoice`,接收票通推送发票主要信息或全票面信息。
2. 主动查询:后台任务定时调用 `queryInvoice.pt` 查询 `PROCESSING``NEED_AUTH_RETRYING` 的发票。
票通状态码映射:
| 票通 code | 含义 | 本地状态 |
| --- | --- | --- |
| `0000` | 开票成功 | `SUCCESS` |
| `6666` | 未开票 | `PENDING``PROCESSING` |
| `7777` | 开票中 | `PROCESSING` |
| `9999` | 开票失败 | `FAILED` |
| `3999` | 需要扫码或短信认证 | `NEED_AUTH` |
| `4999` | 红字确认单申请中 | 后续冲红扩展 |
| `5999` | 红字确认单审核中 | 后续冲红扩展 |
结果落库后,Ktor 调用我方“写状态”接口,把成功/失败、票号、数电发票号码、失败原因、票通流水号等回写。
## 4. 后端模块设计
建议 Ktor 后端按以下模块拆分:
```text
server/
src/main/kotlin/com/bbit/agriinvoice/
Application.kt
config/
AppConfig.kt
PiaotongConfig.kt
BizSystemConfig.kt
routes/
AuthRoutes.kt
InvoiceCandidateRoutes.kt
InvoiceJobRoutes.kt
PiaotongAuthRoutes.kt
PiaotongCallbackRoutes.kt
domain/
Company.kt
InvoiceCandidate.kt
InvoiceJob.kt
InvoiceStatus.kt
TaxAccount.kt
service/
LoginService.kt
InvoiceCandidateService.kt
InvoiceIssueService.kt
InvoiceStatusService.kt
PiaotongAuthService.kt
PiaotongCallbackService.kt
integration/
biz/
BizSystemClient.kt
BizDtos.kt
piaotong/
PiaotongClient.kt
PiaotongCrypto.kt
PiaotongDtos.kt
PiaotongEndpoints.kt
repository/
InvoiceJobRepository.kt
TaxAccountRepository.kt
AuditLogRepository.kt
```
关键服务职责:
- `PiaotongCrypto`:3DES 加解密、RSA 签名验签、外层报文构造、响应解密。
- `PiaotongClient`:封装票通 endpoint,所有票通接口只接受/返回业务 DTO。
- `InvoiceIssueService`:开票主流程、幂等控制、状态推进。
- `PiaotongAuthService`:数电账号状态查询、短信登录、二维码认证。
- `InvoiceStatusService`:票通状态映射、本地落库、我方业务系统状态回写。
## 5. Web 页面设计
### 5.1 页面
- 登录页:登录平台并进入公司上下文。
- 待开票列表页:筛选、勾选、查看金额合计、发起开票。
- 开票确认弹窗:展示本次开票单据、购买方/销售方、商品行、价税合计。
- 票通认证弹窗:根据后端返回展示扫码或短信认证。
- 开票结果页:展示处理中、成功、失败、需要认证,支持刷新和重试。
- 系统配置页:维护票通数电账号、开票员、税号、默认商品编码等。
### 5.2 前端状态
列表项建议展示:
- `未开票`
- `已锁定`
- `开票中`
- `待认证`
- `开票成功`
- `开票失败`
前端不要自己判断票通接口细节,只消费后端聚合后的 `status``actionRequired``authOptions``message`
## 6. Ktor 对外接口草案
```http
POST /api/auth/login
GET /api/me
GET /api/invoice-candidates
POST /api/invoice-jobs
GET /api/invoice-jobs/{jobId}
POST /api/invoice-jobs/{jobId}/retry
GET /api/piaotong/accounts/{account}/auth-status
POST /api/piaotong/accounts/{account}/sms-code
POST /api/piaotong/accounts/{account}/sms-login
POST /api/piaotong/accounts/{account}/auth-qrcode
GET /api/piaotong/accounts/{account}/auth-qrcode/{authId}
POST /api/piaotong/accounts/{account}/logout
POST /api/callbacks/piaotong/invoice
```
`POST /api/invoice-jobs` 请求示例:
```json
{
"candidateIds": ["A001", "A002"],
"taxAccount": "18900000000",
"invoiceKind": "82",
"buyer": {
"name": "购买方名称",
"taxpayerNum": "XX0000000000000000"
}
}
```
响应示例:
```json
{
"jobId": "job_20260424153000001",
"status": "NEED_AUTH",
"message": "当前数电账号需要短信或扫码认证",
"authOptions": ["QRCODE", "SMS"]
}
```
## 7. 数据库表建议
### 7.1 invoice_job
| 字段 | 说明 |
| --- | --- |
| id | 平台开票任务 ID |
| company_id | 公司 ID |
| taxpayer_num | 销方纳税人识别号 |
| invoice_req_serial_no | 票通发票请求流水号,唯一 |
| biz_batch_no | 我方系统开票前写库返回批次号 |
| tax_account | 数电账号 |
| invoice_kind | 发票种类,数电普票通常为 `82` |
| status | 本地状态 |
| pt_code | 票通状态码 |
| pt_message | 票通返回信息 |
| invoice_no | 发票号码 |
| all_ele_inv_no | 数电发票号码 |
| invoice_date | 开票日期 |
| total_amount | 合计金额 |
| total_tax | 合计税额 |
| raw_request | 脱敏后的票通业务请求 |
| raw_response | 脱敏后的票通响应 |
| created_at / updated_at | 时间 |
### 7.2 invoice_job_item
保存本次开票关联的我方待开票数据 ID、商品行、金额、税率、数量、税收分类编码。
### 7.3 tax_account
保存公司下可用数电账号、姓名、身份类型、手机号、最近认证状态。密码或密钥类数据必须加密保存,不能明文落库。
### 7.4 audit_log
保存登录、开票、认证、回写、回调、重试等操作审计。
## 8. 票通报文封装
参考现有 Java Demo,后端需要实现:
1. 业务 JSON 使用 UTF-8 序列化。
2. 使用票通提供的 3DES key 加密业务 JSON,得到外层 `content`
3. 外层字段包含:
- `platformCode`
- `signType=RSA`
- `format=JSON`
- `version=1.0`
- `content`
- `timestamp`
- `serialNo`
4. 对外层字段按 key 排序,拼成 `key=value&key=value`,忽略 null,使用 `SHA1WithRSA` 和我方私钥签名。
5. 响应先用票通公钥验签,再用 3DES 解密 `content`
配置项必须放在环境变量或配置中心:
```properties
PIAOTONG_BASE_URL=https://fpkj.vpiaotong.com/tp/openapi
PIAOTONG_PLATFORM_CODE=...
PIAOTONG_PLATFORM_ALIAS=...
PIAOTONG_3DES_KEY=...
PIAOTONG_PRIVATE_KEY=...
PIAOTONG_PUBLIC_KEY=...
```
## 9. 幂等与异常策略
- `invoiceReqSerialNo` 是开票幂等核心。同一张发票重试必须复用,不能重新生成。
- 调用我方“开票前写库”成功后,即使票通调用超时,也不能直接判失败,应进入 `PROCESSING` 并主动查询。
- 票通返回 `3999` 时,不应生成新发票任务,应把原任务置为 `NEED_AUTH`,认证后继续用原流水号重试或查询。
- 我方状态回写失败时,本地记录为 `BIZ_SYNC_FAILED`,后台补偿重试。
- 回调接口必须验签、去重、按状态版本更新,避免乱序回调覆盖成功状态。
## 10. 推荐一期范围
一期先做最小闭环:
1. 登录并获取公司 ID。
2. 查询待开票列表。
3. 选择数据并发起蓝字数电普票开具。
4. 票通短信登录和扫码认证。
5. 开票状态查询和结果回写。
6. 票通回调接收。
7. 开票日志和失败重试。
冲红、二维码开票、发票文件下载、微信/支付宝卡包、企业注册入驻可放到二期。
@@ -0,0 +1,571 @@
# 通用票通开票模块设计书
## 1. 设计目标
结论:可以,而且我认为这是一个很适合当前阶段的做法。
在不先对接你现有业务系统待开票数据的前提下,可以先建设一个“通用票通开票模块”,目标是:
- 先把票通接口、加密签名、认证流程、开票状态流转跑通
- 先验证票通联调、测试账号、测试税号、回调、查询这些关键链路
- 先形成一个独立可用的开票能力层
- 后续再把你自己的待开票数据、锁单、回写逻辑接到这个能力层上
也就是说,第一阶段把系统拆成两层:
- `通用票通开票层`
- `业务系统适配层`
当前先只做第一层。
## 2. 为什么适合先这样做
先做通用票通模块有 5 个好处:
- 把最大不确定性先消化掉:票通联调、认证、回调、状态码
- 不被你现有业务系统接口进度卡住
- 可以先做出可演示、可测试、可验收的开票闭环
- 后续接你自己的平台时,改动集中在“数据映射”和“回写适配”
- 更适合 Codex 分阶段落地,每次任务可以更聚焦
这条路线本质上是先做:
- `票通能力产品`
再做:
- `你自己业务平台的票通接入`
## 3. 系统定位
这个模块在一期不关心“待开票数据来自哪里”,只关心:
- 用户手工录入或粘贴开票数据
- 系统把数据转换成票通标准报文
- 系统调用票通完成开票
- 系统处理认证、查询、回调、重试
所以它不是“农产品收购发票业务平台完整体”,而是一个:
- `通用票通开票控制台`
它先支持:
- 企业和数电账号准备
- 手工发起蓝字开票
- 查看开票任务
- 处理短信/扫码认证
- 查看开票结果
后续再扩展:
- 对接你自己的待开票业务单据
- 自动锁单
- 自动结果回写
## 4. 一期范围
一期只做“纯票通能力闭环”,不做以下内容:
- 不对接你现有业务系统待开票列表
- 不做你自己的业务锁单/预写库
- 不做你自己的业务结果回写
- 不做复杂经营报表
- 不做红冲
一期只保留这些能力:
1. 企业信息查询
2. 数电账号维护/查询
3. 认证状态查询
4. 短信认证
5. 扫码认证
6. 手工录入开票信息
7. 发起蓝字开票
8. 查询开票状态
9. 接收票通推送
10. 查看开票任务与结果
## 5. 核心业务流程
### 5.1 初始化准备
1. 录入或查询开票企业税号
2. 查询企业是否已在票通开通
3. 查询企业下可用数电账号
4. 选择一个开票账号
5. 查询该账号当前认证状态
### 5.2 手工开票
1. 用户在页面上录入开票信息
2. 系统校验必填字段
3. 系统生成 `invoiceReqSerialNo`
4. 系统调用 `invoiceBlue.pt`
5. 记录本地开票任务
6. 返回初始结果
### 5.3 认证处理
如果票通返回需要认证:
1. 查询认证建议
2. 根据返回结果进入短信认证或扫码认证
3. 认证完成后继续原开票任务
4. 复用原 `invoiceReqSerialNo`
### 5.4 结果同步
结果同步同时走两条线:
- 票通推送
- 主动查询
处理原则:
- 推送优先
- 查询补偿
- 状态幂等更新
## 6. 功能模块设计
建议先做 4 个模块。
### 6.1 企业与账号模块
作用:
- 查询票通企业信息
- 查询企业开户行及账号信息
- 查询数电账号列表
- 维护本地默认开票账号
建议菜单:
- `票通企业`
- `数电账号`
### 6.2 认证中心模块
作用:
- 查询账号认证状态
- 发送短信验证码
- 提交短信登录
- 获取实名认证二维码
- 查询二维码扫码状态
- 执行退出电子税局登录
建议菜单:
- `认证中心`
### 6.3 通用开票模块
作用:
- 手工录入发票信息
- 支持普通蓝字数电票
- 支持农产品收购发票
- 生成标准开票任务
- 发起票通开票
建议菜单:
- `手工开票`
### 6.4 开票任务模块
作用:
- 查看任务状态
- 查看票通返回信息
- 查看发票号码、数电发票号码
- 查看失败原因
- 手动查询状态
- 重试失败任务
建议菜单:
- `开票任务`
## 7. 页面设计
建议新增 5 个页面。
### 7.1 票通企业页
展示内容:
- 企业税号
- 企业名称
- 审核状态
- 开通票种
- 服务状态
操作:
- 查询企业信息
- 查询开户行及账号信息
### 7.2 数电账号页
展示内容:
- 数电账号
- 姓名
- 身份类型
- 认证状态
- 登录状态
- 风险认证状态
- 是否绑定公众号
操作:
- 查询账号列表
- 登记账号
- 设为默认账号
### 7.3 认证中心页
展示内容:
- 当前认证建议
- 当前认证状态
- 短信验证码状态
- 二维码状态
操作:
- 查询认证状态
- 发送短信验证码
- 提交短信登录
- 获取扫码二维码
- 轮询扫码状态
- 退出登录
### 7.4 手工开票页
展示内容:
- 基本信息表单
- 购买方信息
- 销方信息
- 开票项目明细
- 农产品收购发票专用字段
操作:
- 保存草稿
- 发起开票
- 清空表单
### 7.5 开票任务页
展示内容:
- 发票请求流水号
- 企业税号
- 数电账号
- 发票种类
- 状态
- 失败原因
- 发票号码
- 数电发票号码
- 开票时间
操作:
- 查看详情
- 查询最新状态
- 重新发起查询
- 失败后重试
## 8. 后端设计
### 8.1 目录建议
```text
server/src/main/kotlin/com/bbit/platform/
database/piaotong/
PtInvoiceJobTable.kt
PtInvoiceJobItemTable.kt
PtTaxAccountTable.kt
PtCallbackLogTable.kt
integration/piaotong/
PiaotongClient.kt
PiaotongCrypto.kt
PiaotongDtos.kt
PiaotongMapper.kt
modules/piaotong/company/
PtCompanyModule.kt
modules/piaotong/account/
PtAccountModule.kt
modules/piaotong/auth/
PtAuthModule.kt
modules/piaotong/invoice/
PtInvoiceModule.kt
modules/piaotong/task/
PtTaskModule.kt
modules/piaotong/callback/
PtCallbackModule.kt
```
### 8.2 后端接口建议
#### 企业与账号
- `GET /api/piaotong/company/{taxpayerNum}`
- `GET /api/piaotong/company/{taxpayerNum}/bank-info`
- `GET /api/piaotong/accounts`
- `POST /api/piaotong/accounts/register`
- `PUT /api/piaotong/accounts/{id}/default`
#### 认证
- `GET /api/piaotong/accounts/{account}/auth-status`
- `POST /api/piaotong/accounts/{account}/sms-code`
- `POST /api/piaotong/accounts/{account}/sms-login`
- `POST /api/piaotong/accounts/{account}/auth-qrcode`
- `GET /api/piaotong/accounts/{account}/auth-qrcode/{authId}`
- `POST /api/piaotong/accounts/{account}/logout`
#### 开票
- `POST /api/piaotong/invoices`
- `POST /api/piaotong/invoices/validate`
- `GET /api/piaotong/invoices/{jobId}`
- `POST /api/piaotong/invoices/{jobId}/query`
- `POST /api/piaotong/invoices/{jobId}/retry`
#### 推送
- `POST /api/callbacks/piaotong/invoice-summary`
- `POST /api/callbacks/piaotong/invoice-detail`
## 9. 前端设计
### 9.1 目录建议
```text
web/src/
api/piaotong/
company.ts
account.ts
auth.ts
invoice.ts
task.ts
types/piaotong/
company.ts
account.ts
auth.ts
invoice.ts
task.ts
features/piaotong/
company/index.vue
accounts/index.vue
auth-center/index.vue
invoice-create/index.vue
tasks/index.vue
components/piaotong/
InvoiceItemEditor.vue
InvoicePreviewCard.vue
AuthQrcodePanel.vue
InvoiceStatusTag.vue
```
### 9.2 菜单建议
新增一级菜单:
- `票通开票`
二级菜单建议:
- `票通企业`
- `数电账号`
- `认证中心`
- `手工开票`
- `开票任务`
## 10. 数据库设计
### 10.1 pt_tax_account
保存:
- 企业税号
- 数电账号
- 姓名
- 身份类型
- 默认账号标记
- 最近认证状态
- 最近登录状态
- 最近风险认证状态
### 10.2 pt_invoice_job
保存:
- 任务 ID
- 企业税号
- 数电账号
- 发票请求流水号
- 发票类型
- 特殊票种
- 状态
- 票通状态码
- 状态描述
- 发票号码
- 数电发票号码
- 开票日期
- 金额
- 税额
- 原始请求
- 原始响应
### 10.3 pt_invoice_job_item
保存:
- 所属任务
- 商品名称
- 税编
- 数量
- 单价
- 金额
- 税率
- 税额
### 10.4 pt_callback_log
保存:
- 回调类型
- 回调时间
- 回调报文
- 验签结果
- 处理结果
## 11. 状态设计
建议本地状态统一为:
- `DRAFT`
- `PENDING`
- `PROCESSING`
- `NEED_AUTH`
- `SUCCESS`
- `FAILED`
票通状态映射建议:
- `0000` -> `SUCCESS`
- `6666` -> `PENDING``PROCESSING`
- `7777` -> `PROCESSING`
- `3999` -> `NEED_AUTH`
- `9999` -> `FAILED`
## 12. 关键接口映射
一期建议优先实现以下票通接口:
- `2.45 查询数电账号列表`
- `2.8 查询数电账号认证状态`
- `2.9 开具蓝字数电发票`
- `2.11 查询发票主要信息`
- `2.13 推送发票主要信息`
- `2.4 获取登录短信验证码`
- `2.5 短信登录`
- `2.6 获取实名认证二维码`
- `2.7 查询实名认证二维码扫码状态`
- `2.47 查询企业信息`
- `2.49 查询企业开户行及账号`
可选补充:
- `2.3 数电账号登记`
- `2.12 查询发票全票面信息`
- `2.14 推送发票全票面信息`
- `2.46 退出电子税局登录`
## 13. 农产品收购发票特别说明
虽然这是“通用票通开票模块”,但你这个项目核心还是农产品收购发票,所以一期最好直接支持它。
需要特别处理:
- `specialInvoiceKind=02`
- 只能开数电普通票
- `purchaseInvSellerIdType` 必填
- `buyerTaxpayerNum` 必填
- 购销双方字段语义与普通发票不同
建议做法:
- 表单层支持“普通票模式”和“农产品收购模式”切换
- 由后端统一做字段映射和报文转换
## 14. 与后续业务系统对接的衔接方式
后续如果接你自己的待开票平台,不推翻这套设计,只是增加一层适配。
后续新增的只会是:
- 待开票列表读取
- 业务单据锁定
- 开票任务自动创建
- 结果回写业务系统
也就是说,后续结构会变成:
- 你的业务平台 -> 通用票通开票模块 -> 票通
而不是重新写一套票通集成。
## 15. 推荐实施顺序
建议按下面顺序推进:
### 第一阶段
- 建票通企业页
- 建数电账号页
- 打通企业查询、账号查询、认证状态查询
### 第二阶段
- 建认证中心
- 打通短信认证、扫码认证
### 第三阶段
- 建手工开票页
- 打通蓝字开票
- 建开票任务页
### 第四阶段
- 接票通推送
- 做主动查询补偿
- 做重试
## 16. 最终结论
可以先不接你自己的待开票数据,先单独构建一个“通用票通开票模块”,而且这条路线很合理。
我建议把它当成:
- `票通能力底座`
先完成这个底座,再把你的业务系统往上挂。这样后续不管接待开票列表、锁单、回写,都会顺很多,也更适合 Codex 分阶段持续推进。