海关跨境申报流程165号和179号文档

前言

苦啊, 弄了快两个月了.
整个业务流程复杂的很, 但是走通过一次, 就懂了.
我要吐槽的就两个

  1. 海关文档一言难尽.
  2. 微信群办公, 每天看海关人员发文档, 都觉得心累. 还不如弄个QQ群上传群文件呢.

开发背景

说下我司背景, 我司是电商平台, 以前都是供应商进行海关申报的, 进行四单对碰, 订单、支付单、运单、清单.
现在由于海关出了两个文档

  1. 海关总署公告2018年第165号(关于实时获取跨境电子商务平台企业支付相关原始数据有关事宜的公告)
  2. 海关总署公告2018年第179号(关于实时获取跨境电子商务平台企业支付相关原始数据接入有关事宜的公告)

所以必须由我司进行支付单的推送了.
我司使用的是微信支付, 所以调用微信支付的报关接口来报支付单.

开发前的准备

  1. 采购一台实体Window Server 2012R2主机, 并要求能访问外网.
  2. 阅读新用户导航, 弄到一张法人卡操作员卡, 实际上就是两个U盘.
  3. 获取海关技术人员联系方式, 要求加入微信联调群.
  4. 准备最长两个月的开发对接时间

改造支付接口

根据海关总署公告2018年第165号.doc3.5节, 我们需要保留微信支付的原始请求体initalRequest和原始响应体initalResponse.
微信支付的请求体和响应体是xml格式的, 那就直接塞进数据库保存起来就好了, 以后再根据订单号取出来.

1
2
3
id      订单id        请求体xml      响应体xml
1 ORDER-1 <xml></xml> <xml></xml>
2 ORDER-2 <xml></xml> <xml></xml>

注册测试环境

假设现在你已经拿到了操作员卡.

跟着海关 实时数据 企业联调接口 开发步骤与概要 第四步做, 这是为了拿到证书.
要注意的有几点

  1. 安装海关控件, 必须先安装.net framework 3.5.
  2. 初始密码一般是88888888, 密码错误多次会锁卡.
  3. 证书一定是读取证书那个方框里的, 不要把证书序列号也复制进去.
  4. 证书保存完毕后, 双击打开, 可以看到证书信息, 确认公司名和法人名没错后, 就可以了.

得到证书和证书序列号后, 联系海关微信联调群里的陈书宾, 注册测试环境, 将相关信息提供给他.
目前需要以下资料

  1. 证书
  2. 证书序列号
  3. 电商平台代码
  4. 电商企业名称
  5. 联系人
  6. 联系人电话

在测试环境进行验签

请务必在完成注册测试环境步骤后, 保持操作员卡插在自己电脑的状态.
我们可以在微信联调群拿到一个html+js加签工具.rar, 海关文档只提供了js版的websocket加签客户端. 这个html的应该是其他热心企业提供的.

1
2
3
4
5
<!-- index.html-->
<!DOCTYPE html><html><head><meta http-equiv=Content-Type content="text/html; charset=UTF-8"><script type=text/javascript src=./json2.js></script><script type=text/javascript src="./client.js?d=2018-12-19_16:00:00"></script><script src=https://unpkg.com/axios/dist/axios.min.js></script><script src=https://cdn.jsdelivr.net/npm/vue@2.6.8/dist/vue.js></script><script type=text/javascript>function SignDataAsPem(){EportClient.isInstalledTest(EportClient.cusSpcSignDataAsPEM,document.getElementById("txt1").value,"88888888",function(t){console.log(JSON.stringify(t)),document.getElementById("txt2").value=JSON.stringify(t)})}</script><title>首页</title><body><div id=vue-app><h1>轮询加签</h1><button onclick=startReq()>开始轮询</button> <button onclick=stopReq()>停止轮询</button><div><h1 v-if="list.length > 0">数据加签中...</h1><h1 v-else>空闲...</h1><p v-for="(item, index) in list" :key=index>{{item.text}}</p></div><br><div><h3>加签记录:</h3><p v-for="(item, index) in responseList">{{item}}</p></div></div><br><div><h1>手动加签</h1><textarea rows=30 cols=60 id=txt1></textarea><textarea rows=30 cols=60 id=txt2></textarea><button onclick=SignDataAsPem()>加签</button></div>
<script>
"use strict";function _asyncToGenerator(e){return function(){var t=e.apply(this,arguments);return new Promise(function(e,n){function r(o,a){try{var s=t[o](a),u=s.value}catch(e){return void n(e)}if(!s.done)return Promise.resolve(u).then(function(e){r("next",e)},function(e){r("throw",e)});e(u)}return r("next")})}}function SignDataToAsPem(e,t){return console.log(e),new Promise(function(n,r){EportClient.isInstalledTest(EportClient.cusSpcSignDataAsPEM,t,"88888888",function(t,o){if(!t)return void r("error");doResponse({asin:t.Data[0],orderNo:e}).then(function(t){va.responseList.push("订单"+e+": "+t.data),n()})})})}var timeout=4e3,status=1,doRequest=function e(){var t=this;return axios.post("http://xxxxxxxx/oms/custom/lunxun").then(function(){var n=_asyncToGenerator(regeneratorRuntime.mark(function n(r){var o,a,s,u,i,c,f,p=r.data;return regeneratorRuntime.wrap(function(t){for(;;)switch(t.prev=t.next){case 0:if(o=Object.keys(p),!(o.length>0&&"error"!==o[0])){t.next=29;break}o.forEach(function(e){va.list.push({key:e,text:p[e]})}),a=!0,s=!1,u=void 0,t.prev=6,i=va.list[Symbol.iterator]();case 8:if(a=(c=i.next()).done){t.next=15;break}return f=c.value,t.next=12,SignDataToAsPem(f.key,f.text);case 12:a=!0,t.next=8;break;case 15:t.next=21;break;case 17:t.prev=17,t.t0=t.catch(6),s=!0,u=t.t0;case 21:t.prev=21,t.prev=22,!a&&i.return&&i.return();case 24:if(t.prev=24,!s){t.next=27;break}throw u;case 27:return t.finish(24);case 28:return t.finish(21);case 29:va.list=[],status&&setTimeout(e,timeout);case 31:case"end":return t.stop()}},n,t,[[6,17,21,29],[22,,24,28]])}));return function(e){return n.apply(this,arguments)}}())},doResponse=function(e){return axios({method:"post",url:"http://xxxxxxx/oms/custom/callback",data:e,transformRequest:[function(e){var t="";for(var n in e)t+=encodeURIComponent(n)+"="+encodeURIComponent(e[n])+"&";return t}],headers:{"Content-Type":"application/x-www-form-urlencoded"}})},stopReq=function(){status=0},startReq=function(){status=1,doRequest()},va=new Vue({el:"#vue-app",data:function(){return{list:[],responseList:[]}}});
</script>
1
2
3
4
// client.js
(function(window,document,navigator){var installlerUrl;var swVersionScript="https://app.singlewindow.cn/sat/swVersion.js";if(!window.SwVersion){var onloadFunc=function(){var jsDom=document.createElement("script");jsDom.setAttribute("type","text/javascript");jsDom.setAttribute("src",swVersionScript+"?d="+new Date().getTime());document.body.appendChild(jsDom);installlerUrl=window.SwVersion&&window.SwVersion.getIkeyDownloadUrl();if(!installlerUrl){setTimeout(function(){installlerUrl=window.SwVersion&&window.SwVersion.getIkeyDownloadUrl();if(window.console&&window.console.log){window.console.log("%c installlerUrl地址为:"+installlerUrl,"color:#1941EC;font-size:12px")}},3000)}};if(window.addEventListener){window.addEventListener("load",onloadFunc,false)}else{if(window.attachEvent){window.attachEvent("onload",onloadFunc)}else{window.onload=onloadFunc}}}if(!installlerUrl){setTimeout(function(){installlerUrl=window.SwVersion&&window.SwVersion.getIkeyDownloadUrl();if(window.console&&window.console.log){window.console.log("%c installlerUrl地址为:"+installlerUrl,"color:#1941EC;font-size:12px")}},3000)}var DefaultType="iKey";var toJson=function(obj){if(window.JSON){return JSON.stringify(obj)}else{alert("JSON转换错误!");return null}};var jsonToObj=function(text){if(window.JSON){return JSON.parse(text)}else{return eval("("+text+")")}};var getGuid=function(){var s4=function(){return(((1+Math.random())*65536)|0).toString(16).substring(1)};return(s4()+s4()+"-"+s4()+"-"+s4()+"-"+s4()+"-"+s4()+s4()+s4())};var splitStrData=function(dataStr){if(typeof dataStr!=="string"){throw new Error("数据类型错误")}var MaxLength=120*1024;var byteCount=0,p=0;var rst=[];for(var i=0;i<dataStr.length;i++){var _escape=escape(dataStr.charAt(i));byteCount=byteCount+((_escape.length>=4&&_escape.charAt(0)==="%"&&_escape.charAt(1)==="u")?3:1);if(byteCount>MaxLength-3){rst.push(dataStr.substring(p,i+1));p=i+1;byteCount=0}}if(p!==dataStr.length){rst.push(dataStr.substring(p))}return rst};var getDataHeader=function(checkCode,blockCheckCode,size,currsize,blockCount,blockGuid,blockNum){var rst="BLOCKTXT";rst+=equilongString(checkCode,4,"0");rst+=equilongString(blockCheckCode,4,"0");rst+=equilongString(size,16,"0");rst+=equilongString(currsize,8,"0");rst+=equilongString(blockCount,4,"0");rst+=equilongString(blockGuid,36,"0");rst+=equilongString(blockNum,4,"0");rst+="00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000";return rst.substring(0,128)};var equilongString=function(str,length,prefix){if(!str){str=""}if(typeof str!=="string"){str=str+""}if(!prefix){prefix="0"}var diff=length-str.length;for(var i=0;i<diff;i++){str=prefix+str}return str};var isIE6789=function(){var version=(!!navigator.appVersion)?navigator.appVersion.split(";"):[];var trim_Version=(version.length>1)?version[1].replace(/[ ]/g,""):"";return navigator.appName==="Microsoft Internet Explorer"&&(trim_Version==="MSIE6.0"||trim_Version==="MSIE7.0"||trim_Version==="MSIE8.0"||trim_Version==="MSIE9.0")};if(!window.WebSocket&&isIE6789()){WebSocket=function(url){this.activeXObject=new ActiveXObject("snsoft.WebSocket");var _self=this,ax=this.activeXObject;setTimeout(function(){ax.websocketOpen(_self,url)},0)};WebSocket.prototype={_callback:function(call,ev){var f;if(typeof(f=this[call])==="function"){f.call(this,ev)}},getReadyState:function(type){return this.activeXObject.getReadyState((type||DefaultType))},send:function(data){this.activeXObject.websocketSendText(data)},close:function(){this.activeXObject.websocketClose()}}}var ws;var conn=function(){if(!ws||getWebSocketReadyState(ws)===2||getWebSocketReadyState(ws)===3){try{var websocketurl=((!!window.location)&&window.location.protocol==="http:")?"ws://127.0.0.1:61232":"wss://wss.singlewindow.cn:61231";var websocketurl=true?"ws://127.0.0.1:61232":"wss://wss.singlewindow.cn:61231";if(window.console&&window.console.log){window.console.log("%c 使用"+websocketurl+"连接控件服务","color:#1941EC;font-size:12px")}ws=new WebSocket(websocketurl);ws.onmessage=function(e){if(e.data.charAt(0)==="{"){var msg=jsonToObj(e.data);if(window.console&&window.console.log){var errMsg="调用"+msg["_method"]+"方法已返回, Result="+(msg["_args"]&&msg["_args"].Result);var errStyle="color:#1941EC;font-size:12px";if(!(msg["_args"]&&msg["_args"].Result)){errMsg+=", CallbackInfos="+e.data;errStyle="color:#D94E34;font-size:14px"}window.console.log("%c "+errMsg,errStyle)}if(callbacks[msg["_method"]]){callbacks[msg["_method"]](msg["_args"],e.data)}}else{alert("数据格式非法:"+e.data)}}}catch(ex){if(console&&console.log){console.log(ex)}}}return ws};ws=conn();var callbacks={};var blockData={};var sendMessage=function(msg,callback){if(getWebSocketReadyState(ws)===1){ws.send(msg)}else{var times=0;var waitForWebSocketConn=function(){if(times>9){callback({Result:false,Data:[],Error:["连接客户端控件服务失败,请重试.","Err:Base60408"]});
conn()}else{if(getWebSocketReadyState(ws)===0){setTimeout(function(){if(getWebSocketReadyState(ws)===1){ws.send(msg)}else{times++;waitForWebSocketConn()}},500)}else{if(getWebSocketReadyState(ws)===2||getWebSocketReadyState(ws)===3){times++;conn()}}}};waitForWebSocketConn()}};callbacks._nextBlock=function(args){var guid=args.Data[0]["guid"];if(args.Data[0]["finish"]){if(blockData[guid]){delete blockData[guid]}}else{conn();var next=args.Data[0]["next"];var blockObj=blockData[guid];if(!args.Result){var retry=blockObj["retry"]||0;retry=retry+1;blockObj["retry"]=retry;if(retry>blockObj.block.length*3){alert("数据接收错误!")}}var currData=blockObj.block[next];var blockCheckCode=DIGEST.CheckCode(currData);var pakHeaser=getDataHeader(blockObj["checkcode"],blockCheckCode,blockObj["totalLength"],currData.length,blockObj.block.length,guid,next);var msg=pakHeaser+currData;sendMessage(msg)}};var DIGEST={};DIGEST._auchCRCHi=[0,193,129,64,1,192,128,65,1,192,128,65,0,193,129,64,1,192,128,65,0,193,129,64,0,193,129,64,1,192,128,65,1,192,128,65,0,193,129,64,0,193,129,64,1,192,128,65,0,193,129,64,1,192,128,65,1,192,128,65,0,193,129,64,1,192,128,65,0,193,129,64,0,193,129,64,1,192,128,65,0,193,129,64,1,192,128,65,1,192,128,65,0,193,129,64,0,193,129,64,1,192,128,65,1,192,128,65,0,193,129,64,1,192,128,65,0,193,129,64,0,193,129,64,1,192,128,65,1,192,128,65,0,193,129,64,0,193,129,64,1,192,128,65,0,193,129,64,1,192,128,65,1,192,128,65,0,193,129,64,0,193,129,64,1,192,128,65,1,192,128,65,0,193,129,64,1,192,128,65,0,193,129,64,0,193,129,64,1,192,128,65,0,193,129,64,1,192,128,65,1,192,128,65,0,193,129,64,1,192,128,65,0,193,129,64,0,193,129,64,1,192,128,65,1,192,128,65,0,193,129,64,0,193,129,64,1,192,128,65,0,193,129,64,1,192,128,65,1,192,128,65,0,193,129,64];DIGEST._auchCRCLo=[0,192,193,1,195,3,2,194,198,6,7,199,5,197,196,4,204,12,13,205,15,207,206,14,10,202,203,11,201,9,8,200,216,24,25,217,27,219,218,26,30,222,223,31,221,29,28,220,20,212,213,21,215,23,22,214,210,18,19,211,17,209,208,16,240,48,49,241,51,243,242,50,54,246,247,55,245,53,52,244,60,252,253,61,255,63,62,254,250,58,59,251,57,249,248,56,40,232,233,41,235,43,42,234,238,46,47,239,45,237,236,44,228,36,37,229,39,231,230,38,34,226,227,35,225,33,32,224,160,96,97,161,99,163,162,98,102,166,167,103,165,101,100,164,108,172,173,109,175,111,110,174,170,106,107,171,105,169,168,104,120,184,185,121,187,123,122,186,190,126,127,191,125,189,188,124,180,116,117,181,119,183,182,118,114,178,179,115,177,113,112,176,80,144,145,81,147,83,82,146,150,86,87,151,85,149,148,84,156,92,93,157,95,159,158,94,90,154,155,91,153,89,88,152,136,72,73,137,75,139,138,74,78,142,143,79,141,77,76,140,68,132,133,69,135,71,70,134,130,66,67,131,65,129,128,64];DIGEST.CheckCode=function(buffer){var hi=255;var lo=255;for(var i=0;i<buffer.length;i++){var idx=255&(hi^buffer.charCodeAt(i));hi=(lo^DIGEST._auchCRCHi[idx]);lo=DIGEST._auchCRCLo[idx]}return DIGEST.padLeft((hi<<8|lo).toString(16).toUpperCase(),4,"0")};DIGEST.padLeft=function(s,w,pc){if(pc===undefined){pc="0"}for(var i=0,c=w-s.length;i<c;i++){s=pc+s}return s};var id=0;var baseInvoke=function(method,args,callback){if(typeof args==="function"){callback=args;args={}}conn();if(window.console&&window.console.log){window.console.log("%c 调用方法"+method,"color:#95F065;font-size:12px")}callbacks[method]=callback;var _data={"_method":method};_data["_id"]=id++;args=args||{};_data["args"]=args;var s=toJson(_data);if(getWebSocketReadyState(ws)===0){setTimeout(function(){sendMessage(s,callback)},500)}else{sendMessage(s,callback)}};var baseInvokeByFrames=function(method,args,callback){if(typeof args==="function"){callback=args;args={}}conn();if(window.console&&window.console.log){window.console.log("%c 调用方法"+method,"color:#95F065;font-size:12px")}callbacks[method]=callback;var _data={"_method":method};_data["_id"]=id++;args=args||{};_data["args"]=args;var s=toJson(_data);if(getWebSocketReadyState(ws)===0||getWebSocketReadyState(ws)===3){setTimeout(function(){sendFrames(s,callback)},500)}else{sendFrames(s,callback)}};var sendFrames=function(s,callback){var checkCode=DIGEST.CheckCode(s);var guid=getGuid();while(blockData[guid]){guid=getGuid()}var splitData=splitStrData(s);blockData[guid]={checkcode:checkCode,totalLength:s.length,retry:0,block:splitData};var blockCheckCode=DIGEST.CheckCode(splitData[0]);var pakHeaser=getDataHeader(checkCode,blockCheckCode,s.length,splitData[0].length,splitData.length,guid,0);var msg=pakHeaser+splitData[0];sendMessage(msg,callback)};var getWebSocketReadyState=function(thisWs){var currWs=thisWs||conn();if(!currWs){return 0}if(currWs.readyState!==undefined){return currWs.readyState}if(currWs.getReadyState){return currWs.getReadyState()}return 0};window.EportClient={isInstalled:function(type,callback,currInstalllerUrl){if(typeof type==="function"){if(callback){currInstalllerUrl=callback}callback=type;type=DefaultType}ws=conn();var bInstalled=getWebSocketReadyState(ws)===1;var retryConut=0;function retry(){retryConut++;ws=conn();bInstalled=getWebSocketReadyState(ws)===1;
if(!bInstalled){if(retryConut<3){setTimeout(retry,1500)}else{if(retryConut===3){var iframeDom=document.createElement("iframe");iframeDom.style.cssText="width:1px;height:1px;position:fixed;top:0;left:0;display:none;";iframeDom.src="singlewindow://Restart";document.body.appendChild(iframeDom);setTimeout(retry,2500)}else{var errMsg="检测到您未安装"+type+"客户端! "+type+"下载地址为:"+currInstalllerUrl||installlerUrl||window.installlerUrl;if(callback){callback({"Result":false,"Data":[],"Error":[errMsg]})}else{if(window.console){window.console.log(errMsg)}}}}}else{var okMsg="已经安装了"+type+"客户端.";if(callback){callback({"Result":true,"Data":[okMsg],"Error":[]})}else{if(window.console){window.console.log(okMsg)}}}}retry()},isInstalledTest:function(func,arg1,arg2,arg3,arg4,arg5,arg6,arg7,arg8,arg9,arg10){if(!func){throw new Error("未知的JS的function,请检查调用isInstalledTest传入的第一个参数是否存在该函数.")}EportClient.isInstalled(DefaultType,function(msg){if(msg.Result){if(func&&(typeof func)==="function"){func.call(null,arg1,arg2,arg3,arg4,arg5,arg6,arg7,arg8,arg9,arg10)}else{alert(msg.Data[0])}}else{alertErrMsg(msg)}},installlerUrl||window.installlerUrl)},cusSpcSignDataAsPEM:function(inData,passwd,callback){baseInvoke("cus-sec_SpcSignDataAsPEM",(!!passwd)?{"inData":inData,"passwd":passwd}:{"inData":inData},callback)},swcLogin:function(passwd,callback){baseInvoke("swc_security_login",{"passwd":passwd},callback)},swcPostData:function(data,callback,method){conn();method=(method||"swc_postdata");callbacks[method]=callback;var _data={"_method":method};_data["_id"]=id++;if(typeof data==="object"){_data["args"]=toJson(data)}else{_data["args"]=data}var s=toJson(_data);var checkCode=DIGEST.CheckCode(s);var guid=getGuid();while(blockData[guid]){guid=getGuid()}var splitData=splitStrData(s);if(splitData.length>1){blockData[guid]={checkcode:checkCode,totalLength:s.length,retry:0,block:splitData}}var blockCheckCode=DIGEST.CheckCode(splitData[0]);var pakHeaser=getDataHeader(checkCode,blockCheckCode,s.length,splitData[0].length,splitData.length,guid,0);var msg=pakHeaser+splitData[0];sendMessage(msg)},data:function(){}};EportClient.data.prototype={Execute:function(callback){var d=toJson(this);EportClient.swcPostData(d,callback)}}})(window,document,navigator);
1
2
// json2.js
if(navigator.appName=="Microsoft Internet Explorer"&&(navigator.appVersion.split(";")[1].replace(/[ ]/g,"")=="MSIE6.0"||navigator.appVersion.split(";")[1].replace(/[ ]/g,"")=="MSIE7.0"||navigator.appVersion.split(";")[1].replace(/[ ]/g,"")=="MSIE8.0"||navigator.appVersion.split(";")[1].replace(/[ ]/g,"")=="MSIE9.0")){JSON={}}else{if(typeof JSON!=="object"){JSON={}}}(function(){var rx_one=/^[\],:{}\s]*$/;var rx_two=/\\(?:["\\\/bfnrt]|u[0-9a-fA-F]{4})/g;var rx_three=/"[^"\\\n\r]*"|true|false|null|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?/g;var rx_four=/(?:^|:|,)(?:\s*\[)+/g;var rx_escapable=/[\\"\u0000-\u001f\u007f-\u009f\u00ad\u0600-\u0604\u070f\u17b4\u17b5\u200c-\u200f\u2028-\u202f\u2060-\u206f\ufeff\ufff0-\uffff]/g;var rx_dangerous=/[\u0000\u00ad\u0600-\u0604\u070f\u17b4\u17b5\u200c-\u200f\u2028-\u202f\u2060-\u206f\ufeff\ufff0-\uffff]/g;function f(n){return(n<10)?"0"+n:n}function this_value(){return this.valueOf()}if(typeof Date.prototype.toJSON!=="function"){Date.prototype.toJSON=function(){return isFinite(this.valueOf())?(this.getUTCFullYear()+"-"+f(this.getUTCMonth()+1)+"-"+f(this.getUTCDate())+"T"+f(this.getUTCHours())+":"+f(this.getUTCMinutes())+":"+f(this.getUTCSeconds())+"Z"):null};Boolean.prototype.toJSON=this_value;Number.prototype.toJSON=this_value;String.prototype.toJSON=this_value}var gap;var indent;var meta;var rep;function quote(string){rx_escapable.lastIndex=0;return rx_escapable.test(string)?'"'+string.replace(rx_escapable,function(a){var c=meta[a];return typeof c==="string"?c:"\\u"+("0000"+a.charCodeAt(0).toString(16)).slice(-4)})+'"':'"'+string+'"'}function str(key,holder){var i;var k;var v;var length;var mind=gap;var partial;var value=holder[key];if(value&&typeof value==="object"&&typeof value.toJSON==="function"){value=value.toJSON(key)}if(typeof rep==="function"){value=rep.call(holder,key,value)}switch(typeof value){case"string":return quote(value);case"number":return(isFinite(value))?String(value):"null";case"boolean":case"null":return String(value);case"object":if(!value){return"null"}gap+=indent;partial=[];if(Object.prototype.toString.apply(value)==="[object Array]"){length=value.length;for(i=0;i<length;i+=1){partial[i]=str(i,value)||"null"}v=partial.length===0?"[]":gap?("[\n"+gap+partial.join(",\n"+gap)+"\n"+mind+"]"):"["+partial.join(",")+"]";gap=mind;return v}if(rep&&typeof rep==="object"){length=rep.length;for(i=0;i<length;i+=1){if(typeof rep[i]==="string"){k=rep[i];v=str(k,value);if(v){partial.push(quote(k)+((gap)?": ":":")+v)}}}}else{for(k in value){if(Object.prototype.hasOwnProperty.call(value,k)){v=str(k,value);if(v){partial.push(quote(k)+((gap)?": ":":")+v)}}}}v=partial.length===0?"{}":gap?"{\n"+gap+partial.join(",\n"+gap)+"\n"+mind+"}":"{"+partial.join(",")+"}";gap=mind;return v}}if(typeof JSON.stringify!=="function"){meta={"\b":"\\b","\t":"\\t","\n":"\\n","\f":"\\f","\r":"\\r",'"':'\\"',"\\":"\\\\"};JSON.stringify=function(value,replacer,space){var i;gap="";indent="";if(typeof space==="number"){for(i=0;i<space;i+=1){indent+=" "}}else{if(typeof space==="string"){indent=space}}rep=replacer;if(replacer&&typeof replacer!=="function"&&(typeof replacer!=="object"||typeof replacer.length!=="number")){throw new Error("JSON.stringify")}return str("",{"":value})}}if(typeof JSON.parse!=="function"){JSON.parse=function(text,reviver){var j;function walk(holder,key){var k;var v;var value=holder[key];if(value&&typeof value==="object"){for(k in value){if(Object.prototype.hasOwnProperty.call(value,k)){v=walk(value,k);if(v!==undefined){value[k]=v}else{delete value[k]}}}}return reviver.call(holder,key,value)}text=String(text);rx_dangerous.lastIndex=0;if(rx_dangerous.test(text)){text=text.replace(rx_dangerous,function(a){return("\\u"+("0000"+a.charCodeAt(0).toString(16)).slice(-4))})}if(rx_one.test(text.replace(rx_two,"@").replace(rx_three,"]").replace(rx_four,""))){j=eval("("+text+")");return(typeof reviver==="function")?walk({"":j},""):j}throw new SyntaxError("JSON.parse")}}}());

复制下面的加签原文, 应该就是将几个json字符串, 用||连接起来.
这些字段在海关总署公告2018年第165号(关于实时获取跨境电子商务平台企业支付相关原始数据有关事宜的公告)文件中有详细说明, 但是我们做测试随便填数据就行了.

1
"sessionID":"ad2254-8hewyf32-556162449"||"payExchangeInfoHead":"{"guid":"9D55BA71-22DE-41F4-8B50-C36C83B3B530","initalRequest":"原始请求","initalResponse":"原始响应","ebpCode":"4403169BVN","payCode":"4403169D3W","payTransactionId":"2018121222001354081010726129","totalAmount":100,"currency":"CNY","verDept":"3","payType":"1","tradingTime":"20181212041803","note":"批量订单,测试订单优化,生成多个so订单"}"||"payExchangeInfoLists":"[{"orderNo":"SO1710301150602574003","goodsInfo":[{"gname":"lhy-gnsku2","itemLink":"http://m.yunjiweidian.com/yunjibuyer/static/vue-buyer/idc/index.html#/detail?itemId=999760&shopId=453"}],"recpAccount":"1234567","recpCode":"7654321","recpName":"我的公司"}]"||"serviceTime":"1544519952469"

修改部分字段.

  1. ebpCode: 你司的电商平台代码
  2. payCode: 支付企业的海关注册码, 如微信支付4403169D3W

然后粘贴到上面html进行手动加签, 得到以下数据

1
{"Result":true,"Data":["BsA3cCpwsaYrFlwgkjccUGpPBNGni7CiP6F5SmCY8OWxw4xd1kwLWKwR69tSQHSmHmT2O07KYvzj5/N0NkWvoTvyNolQ822/jDTAMcrBPmv5xtu3FHEDYgdkB4sfdeu7EdyVeDWyMEPkT1n/7h80kxKerJzjQAB6HrxPSgLJ+MQ=","01304235"],"Error":[]}

Data字段里第一个就是签名, 第二个是证书编号
然后将签名和证书编号复制, 拼接成以下JSON

1
{"sessionID":"ad2254-8hewyf32-556162449","payExchangeInfoHead":{"guid":"9D55BA71-22DE-41F4-8B50-C36C83B3B530","initalRequest":"原始请求","initalResponse":"原始响应","ebpCode":"4403169BVN","payCode":"4403169D3W","payTransactionId":"2018121222001354081010726129","totalAmount":100,"currency":"CNY","verDept":"3","payType":"1","tradingTime":"20181212041803","note":"批量订单,测试订单优化,生成多个so订单"},"payExchangeInfoLists":[{"orderNo":"SO1710301150602574003","goodsInfo":[{"gname":"lhy-gnsku2","itemLink":"http://m.yunjiweidian.com/yunjibuyer/static/vue-buyer/idc/index.html#/detail?itemId=999760&shopId=453"}],"recpAccount":"1234567","recpCode":"7654321","recpName":"我的公司"}],"serviceTime":"1544519952469","certNo":"01304235","signValue":"VRx4eIXfQWItQNiJEkINMWKXMrjVGDFP/HkzqzZ7r+7IJcFK+pcHqZf+IS0PiIz29I2IsXXhV1Tg+3Tlq0fz4UjfsyPE1vEgqA51q3S/fGv4B1MlzS5KG1ETyB+FZaZegUQchK4vl4QGuSJqyi4QJ8b/eCa75KOyyNRm+wUsQtg="}

然后发送给海关测试接口, 这里使用OKHttp做客户端.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class Main {
public static void main(String[] args) throws Exception {
String json = "{\"sessionID\":\"ad2254-8hewyf32-556162449\",\"payExchangeInfoHead\":{\"guid\":\"9D55BA71-22DE-41F4-8B50-C36C83B3B530\",\"initalRequest\":\"原始请求\",\"initalResponse\":\"原始响应\",\"ebpCode\":\"4403169BVN\",\"payCode\":\"4403169D3W\",\"payTransactionId\":\"2018121222001354081010726129\",\"totalAmount\":100,\"currency\":\"CNY\",\"verDept\":\"3\",\"payType\":\"1\",\"tradingTime\":\"20181212041803\",\"note\":\"批量订单,测试订单优化,生成多个so订单\"},\"payExchangeInfoLists\":[{\"orderNo\":\"SO1710301150602574003\",\"goodsInfo\":[{\"gname\":\"lhy-gnsku2\",\"itemLink\":\"http://m.yunjiweidian.com/yunjibuyer/static/vue-buyer/idc/index.html#/detail?itemId=999760&shopId=453\"}],\"recpAccount\":\"1234567\",\"recpCode\":\"7654321\",\"recpName\":\"我的公司\"}],\"serviceTime\":\"1544519952469\",\"certNo\":\"01304235\",\"signValue\":\"VRx4eIXfQWItQNiJEkINMWKXMrjVGDFP/HkzqzZ7r+7IJcFK+pcHqZf+IS0PiIz29I2IsXXhV1Tg+3Tlq0fz4UjfsyPE1vEgqA51q3S/fGv4B1MlzS5KG1ETyB+FZaZegUQchK4vl4QGuSJqyi4QJ8b/eCa75KOyyNRm+wUsQtg=\"}\n";
String encode = URLEncoder.encode(json, "UTF-8");

OkHttpClient client = new OkHttpClient();

MediaType mediaType = MediaType.parse("application/x-www-form-urlencoded");
RequestBody body = RequestBody.create(mediaType, "payExInfoStr="+encode);
Request request = new Request.Builder()
.url("https://swapptest.singlewindow.cn/ceb2grab/grab/realTimeDataUpload")
.post(body)
.addHeader("Content-Type", "application/x-www-form-urlencoded")
.build();

Response response = client.newCall(request).execute();
System.out.println(response.body().string());
}
}

如果返回上传成功, 那就说明验签通过了.

上传失败,入库失败 java.sql.SQLException: ORA-00001: 违反唯一约束条件 (sessionID重复)

这个只需要把sessionID随便改一下, 比如改成ad2254-8hewyf32-556162450, 然后重新加签即可.
海关没做校验, 应该就是给数据库加了个唯一索引.

企业实时数据获取验签证书未在服务系统注册

修改上面提到的ebpCodepayCode以及证书编号.
并确保证书编号小写.
再不行, 在海关微信联调群问.

验签失败

海关的验签规则很严格, 每个字段顺序都有要求, 错一个字符, 多一个\n都会导致验签失败.
因为加签原文和上传JSON是两种不同的数据类型, 所以耐心一个字符一个字符地对比.
特别是换行符\n这种不可见字符, 卡了我几个钟.

这种问题就不用去海关微信联调群问了, 肯定是自己问题.

注册线上环境

插入操作员卡后, 注意, 一定要操作员卡.
打开中国国际贸易单一窗口

需要确保电商平台代码和电商平台名称这两个灰色的框框是有值的, 没有则去找海关企管科注册电商平台权限, 具体打海关电话010-65194114咨询.

还记得我们之前提供给海关的证书吗? 点击选择证书, 上传了. 证书编号记得小写.
之后就是注册服务地址, 填入http://你的域名/项目前缀/platDataOpen, 点击注册地址, 然后肯定注册失败的. 这个之后处理.

值得注意的是, 这个服务注册地址可以注册多个, 审核通过多个, 但是最终启用的只能有一个.

配置实体加签主机

有的企业是全部云主机, 会问能不能不要这个实体主机, 不可以, 因为我司现在也是全部云主机, 还是妥协弄了个实体主机.
我司用的是Windows Server 2012 R2.

和注册测试环境的步骤一样
值得注意的是, 一定要先手动安装.net framework 3.5, 一定要先手动安装.net framework 3.5.
否则海关提供的客户端安装程序可能会出现打不开的情况.

主要步骤就是

  1. 安装.net framework 3.5
  2. 安装海关提供的客户端
  3. 开放实体加签主机的6123161232端口.
  4. 修改上面提供的client.js中的ws://127.0.0.1:61232为实体加签主机的外网IP.
  5. 在外网使用html进行加签.

编写 /platDataOpen 逻辑

我们之前注册了http://你的域名/项目前缀/platDataOpen地址, 但是没有实现这个接口.
下面是一个简单的例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@RestController
public class MyController {
@PostMapping(value="/platDataOpen" , headers="content-type=application/x-www-form-urlencoded")
public JSONObject platDataOpen(@RequestParam String openReq) {
// 1. 获取请求参数
openReq = StringUtils.replace(openReq, "&quot;", "\"");
JSONObject json = JSONObject.parseObject(openReq);
String orderNo = json.getString("orderNo");
String sessionID = json.getString("sessionID");
Long serviceTime = json.getLong("serviceTime");

// 2. 海关调用此接口, 返回特定格式的json数据
JSONObject result = new JSONObject();

// 3. 上报海关, 这里是异步操作 @Async
String testUrl = "https://swapptest.singlewindow.cn/ceb2grab/grab/realTimeDataUpload";
// String onlineUrl = "https://customs.chinaport.gov.cn/ceb2grab/grab/realTimeDataUpload";
String websocketUrl = "ws://127.0.0.1:61232";
myservice.uploadCustomsDeclare(testUrl, websocketUrl, orderNo, sessionID, serviceTime);

// 4. 返回数据
result.put("code", "10000");
result.put("message", "");
result.put("serviceTime", System.currentTimeMillis());
return result;
}
}

这里是一个简单的例子, 具体的业务逻辑在uploadCustomsDeclare方法里, 不同公司的底层业务逻辑不同.
反正关键点就是

  1. 异步, 用线程池或者队列都行, 但是必须要在两分钟内上传完毕.
  2. 根据海关传过来的订单, 查询海关需要的数据, 然后写个WebSocket客户端加签, 再上传数据到海关的接口.
  3. 海关加签WebSocket客户端的接收的数据和发送的数据的数据格式是固定的JSON格式.

这里提供一个简单的依赖于Java-WebSocketWebSocket客户端.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
public class WebSocketClient {
public void 业务代码() throws Exception {
// 1. 初始化 WebSocket 客户端, 注意这里是异步回调
WebSocketClient client = getWebSocketClient(new URI("ws://127.0.0.1:61232"), message -> {
// 3. 异步回调, 解析响应体
// {"_id":0,"_method":"cus-sec_SpcSignDataAsPEM","_status":"00","_args":{"Result":true,"Data":["rgTtbpFAmxQ+3mMEaIcztj4zV3SMJ0Jo09HnAE8b+GwxuHYqgcfqEGj/DB+Vb6A8ETXtLMsHGEvsItSm+fDlwOXPPjvpoG5sDeiQXBV4qcGPnLUaDZmdSTJdhRHUn1xFMBCvzP77h2x8RRow8l2ZIyVujY/H0hxZ/flUVERsD8I=","01304235"],"Error":[]}}
JSONObject responseJSON = JSONObject.parseObject(message);
String method = responseJSON.getString("_method");
if(!"cus-sec_SpcSignDataAsPEM".equalsIgnoreCase(method)) {
return; // 如果不是加签代码, 不处理
}

JSONObject args = responseJSON.getJSONObject("_args");
boolean result = args.getBooleanValue("Result");
if(!result) {
String error = args.getJSONArray("Error").toJSONString();
logger.info("==============WebSocket连接错误:{}==============", error);
return;
}

// 4. 获取签名和证书编号
JSONArray wsData = args.getJSONArray("Data");
String signValue = wsData.getString(0);
String certNo = wsData.getString(1);

// 5. 初始化请求体上报海关
// doPost(...);
});
if(client == null) {
logger.error("Websocket客户端获取失败!");
return;
}

// 2. 连接上后, 发送 Websocket 请求
// String json = "{\"_method\":\"cus-sec_SpcSignDataAsPEM\",\"_id\":0,\"args\":{\"inData\":\"123\",\"passwd\":\"88888888\"}}";
JSONObject websocketRequest = new JSONObject();
websocketRequest.put("_method", "cus-sec_SpcSignDataAsPEM"); // 加签方法
websocketRequest.put("_id", 0);

JSONObject websocketRequestArgs = new JSONObject();
String inData = "\"sessionID\":\"" + sessionID + "\"||" +
"\"payExchangeInfoHead\":\"" + JSONObject.toJSONString(payExchangeInfoHead) + "\"||" +
"\"payExchangeInfoLists\":\"" + JSONArray.toJSONString(payExchangeInfoList) + "\"||" +
"\"serviceTime\":\"" + JSONObject.toJSONString(serviceTime) + "\"";
websocketRequestArgs.put("inData", inData);
websocketRequestArgs.put("passwd", "88888888");
websocketRequest.put("args", websocketRequestArgs);

client.send(websocketRequest.toString());
}

/**
* 获取 Websocket 客户端
* @param websocketUrl Websocket 连接地址
* @param callback 回调函数接口
*/
private WebSocketClient getWebSocketClient(URI websocketUrl, WebsocketCallback callback) {
// 1. 双重锁保证, 检查是否超时关闭
if(webSocketClient == null || webSocketClient.isClosed() || webSocketClient.isClosing()) {
synchronized (CustomsDeclareServiceImpl.class) {
if(webSocketClient == null || webSocketClient.isClosed() || webSocketClient.isClosing()) {
// 2. 初始化 Websocket 客户端, 调用 callback
webSocketClient = new WebSocketClient(websocketUrl) {
@Override
public void onOpen(ServerHandshake handshakedata) {
logger.info("==============打开WebSocket连接:{}==============", websocketUrl.toString());
}

@Override
public void onMessage(String message) {
callback.onMessage(message); // 调用回调函数
}

@Override
public void onMessage(ByteBuffer bytes) {
String message = new String(bytes.array(), StandardCharsets.UTF_8);
onMessage(message);
}

@Override
public void onClose(int code, String reason, boolean remote) {
logger.info("==============关闭WebSocket连接:{}==============", reason);
}

@Override
public void onError(Exception ex) {
logger.info("==============WebSocket连接错误:{}==============", ex.getMessage());
logger.error("==============WebSocket连接错误!==============", ex);
}
};
// 3. 阻塞等待连接
webSocketClient.connect();
int count = 0;
while (!Objects.equal(webSocketClient.getReadyState(), ReadyState.OPEN)) {
try {
count += 10;
Thread.sleep(10);
TimeUnit.MILLISECONDS.sleep(10);
if (count >= 3 * 60 * 1000) {
logger.info("WebSocket服务器连接超时或服务器已经关闭");
webSocketClient.close();
webSocketClient = null;
}
} catch (InterruptedException e) {
logger.error("发送信息异常", e);
}
}
}
}
}
return webSocketClient;
}
@FunctionalInterface
private interface WebsocketCallback {
void onMessage(String message);
}
}

写完后, 我们可以用Postman模拟海关调用我们写的接口, 看是否上传成功.

审核服务注册地址

既然接口写好了, 那就可以让海关人员审核了, 在海关微信联调群呼叫下海关人员审核即可.
在审核之前, 要确保几件事

  1. 三天内有订单, 订单需要在中国国际贸易单一窗口的订单查询里可以查到.
  2. 返回正确格式, 状态值是否为10000, 返回的json数据格式是否正确.
  3. 审核接口和企业上传原始支付数据没有直接联系, 接口审核是测试接口的连通性和返回格式是否正确.
  4. 端口必须是80, 不过多解释.
  5. 是否配置白名单, 企业的防火墙是否把海关的请求拒绝了.

注意, 我司是电商平台, 所以是不能提交订单的, 调用微信报关接口上报的是支付单.
要买了境外商品后, 联系供应商申报运单、清单、订单. 有一个订单, 然后才能审核服务注册地址.

还有就是, 海关这个订单查询系统, 必须填订单号, 才能查询到订单, 我之前没填订单号, 想查出所有订单, 结果一个订单都查不到.

后续维护

因为操作员卡现在在机房了, 但是我们有登录海关网站配置注册地址, 或者查看订单等需求.
这时候有两种方案

  1. 给实体加签主机开远程, 但是这样会有安全风险
  2. 法人卡登录

我尝试使用账号密码登录, 登录成功了, 但是海关网站会隐藏掉一些菜单, 导致你不能配置注册地址, 查看订单.

业务流程

看完前面的步骤, 最后再来梳理下业务流程, 为什么最后放流程, 因为没走完流程, 看了也是一脸懵逼.
其中支付宝(微信)和海关的业务是我猜的, 估计应该八九不离十.

  1. 我司: 用户下单, 支付, 后端保存和支付宝(微信)对接的支付请求体和支付响应体.
  2. 我司: 后端调用支付宝(微信)的报关接口进行报关.
  3. 支付宝(微信): 异步提交企业发送过来的报关信息给海关方.
  4. 海关: 接受到支付宝(微信)提交的报关信息, 海关不信任支付宝(微信)的报关信息, 将订单号发给电商平台(我司), 要求将必要数据交给海关进行对比校验.
  5. 我司: 接收到海关的订单号, 告诉海关接收到查询请求了, 异步查询相关订单数据, 发送给加签主机.
  6. 加签主机: 将数据加签, 回传给我司.
  7. 我司: 接收到加签后的数据, 拼接JSON, 上传给海关.
  8. 海关: 接收到电商平台上传的数据, 和支付宝(微信)的数据做对比, 对比无误后, 告诉支付宝(微信)报关成功,
  9. 我司: 调用支付宝(微信)查询接口, 查询报关是否成功.

参考资料