字节小程序官方文档
字节小程序
1.准备工作
- 获取SALT秘钥,设置token令牌(不超过位数随便写),填写url回调地址
- 经测试,如果在代码中设置了回调地址,此处配置的url不生效
- token用作验签,秘钥用作加签
2.结算及分账注意事项
- 到店服务类订单(用户需要到店履约或到店核销):履约或核销后 3 天才可调用该接口发起结算(核销状态需要通过订单信息同步接口传入);
- 其他订单:支付成功 7 天后才可调用该接口发起结算。
3.退款注意事项
- 距离支付成功超 12 个月的订单不能退款,需要商户线下自行给用户退;
- 退款逻辑按照订单维度退,结算前发起退款,直接从「在途账户」退,结算后从「可提现账户」退。
- 结算前或结算后均支持退款,且支持全额退或部分退款。
4.代码部分
ByteDanceUrlConstants(代码请求地址常量)
package com.dfjs.constant;
public class ByteDanceUrlConstants {
public static final String CODE_2_SESSION = "https://developer.toutiao.com/api/apps/v2/jscode2session";
public static final String CREATE_ORDER = "https://developer.toutiao.com/api/apps/ecpay/v1/create_order";
public static final String SETTLE = "https://developer.toutiao.com/api/apps/ecpay/v1/settle";
public static final String CREATE_REFUND = "https://developer.toutiao.com/api/apps/ecpay/v1/create_refund";
}
TTPayUtil(加签和验签工具类)
- 回调签名算法
1.将所有字段(验证时注意不包含 sign 签名本身,不包含空字段与 type 常量字段)内容与平台上配置的 token 一起,按照字典序排序 2.所有字段内容连接成一个字符串 3.使用 sha-1 算法计算字符串摘要作为签名 - 请求签名算法
1.sign, app_id , thirdparty_id 字段用于标识身份字段,不参与签名。将其他字段内容(不包含 key)与支付 SALT 一起进行字典序排序后,使用&符号链接 2.使用 md5 算法对该字符串计算摘要,作为结果 3.参与加签的字段均以 POST 请求中的 body 内容为准, 不考虑参数默认值等规则. 对于对象类型与数组类型的参数, 使用 POST 中的字符串原串进行左右去除空格后进行加签 4.如有其他安全性需要, 可以在请求中添加 nonce 字段, 该字段无任何业务影响, 仅影响加签内容, 使同一请求的多次签名不同.
代码中的SALT和token从<准备工作图示位置>查找并替换正确的值
package com.dfjs.util;
import com.dfjs.bean.BaseConfig;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Map;
import java.util.List;
@Component
public class TTPayUtil {
public String getSign(Map<String, Object> paramsMap) {
List<String> paramsArr = new ArrayList<>();
for (Map.Entry<String, Object> entry : paramsMap.entrySet()) {
String key = entry.getKey();
if (key.equals("other_settle_params")) {
continue;
}
String value = entry.getValue().toString();
value = value.trim();
if (value.startsWith("\"") && value.endsWith("\"") && value.length() > 1) {
value = value.substring(1, value.length() - 1);
}
value = value.trim();
if (value.equals("") || value.equals("null")) {
continue;
}
switch (key) {
case "app_id":
case "thirdparty_id":
case "sign":
break;
default:
paramsArr.add(value);
break;
}
}
paramsArr.add("SALT秘钥串");
Collections.sort(paramsArr);
StringBuilder signStr = new StringBuilder();
String sep = "";
for (String s : paramsArr) {
signStr.append(sep).append(s);
sep = "&";
}
return md5FromStr(signStr.toString());
}
public String md5FromStr(String inStr) {
MessageDigest md5;
try {
md5 = MessageDigest.getInstance("MD5");
} catch (NoSuchAlgorithmException e) {
e.printStackTrace();
return "";
}
byte[] byteArray = inStr.getBytes(StandardCharsets.UTF_8);
byte[] md5Bytes = md5.digest(byteArray);
StringBuilder hexValue = new StringBuilder();
for (byte md5Byte : md5Bytes) {
int val = ((int) md5Byte) & 0xff;
if (val < 16) {
hexValue.append("0");
}
hexValue.append(Integer.toHexString(val));
}
return hexValue.toString();
}
public String getCallbackSignature(int timestamp, String nonce, String msg) {
List<String> sortedString = new ArrayList<>();
sortedString.add(String.valueOf(timestamp));
sortedString.add(nonce);
sortedString.add(msg);
sortedString.add("配置好的token串");
Collections.sort(sortedString);
StringBuilder sb = new StringBuilder();
sortedString.forEach(sb::append);
return getSha1(sb.toString().getBytes());
}
public String getSha1(byte[] input) {
MessageDigest mDigest;
try {
mDigest = MessageDigest.getInstance("SHA1");
} catch (NoSuchAlgorithmException e) {
e.printStackTrace();
return "";
}
byte[] result = mDigest.digest(input);
StringBuilder sb = new StringBuilder();
for (byte b : result) {
sb.append(Integer.toString((b & 0xff) + 0x100, 16).substring(1));
}
return sb.toString();
}
}
RestTemplateUtil(rest发送请求工具类)
- 支付,结算分账,提现统一使用post请求
- JSON格式发送请求数据
- 返回值是一个JSON字符串
package com.dfjs.util;
import clojure.lang.Obj;
import com.alibaba.fastjson.JSONObject;
import com.dfjs.bean.BaseConfig;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
import java.util.Map;
@Service
public class RestTemplateUtil {
private final Logger logger = LoggerFactory.getLogger(this.getClass());
@Autowired
private RestTemplate restTemplate;
public String byteDancePostRequest(JSONObject jsonObject, String url) {
String result = "";
try {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON_UTF8);
HttpEntity<Object> formEntity = new HttpEntity<>(jsonObject, headers);
result = restTemplate.postForObject(url, formEntity, String.class);
} catch (Exception e) {
logger.error("抖音小程序post请求异常{}", url);
e.printStackTrace();
}
return result;
}
}
登陆请求代码
- 标识同一个用户应该使用mobile
- 返回的openid,unionid同一个手机号在不同端登陆时值不同(大家可以自行测试。。)
@ApiOperation(value = "抖音小程序code2Session", notes = "code:0-失败,1-成功")
@ApiImplicitParam(name = "jsonObject", value = "code", required = true, dataType = "JSONObject")
@PostMapping("/tt/loginBind")
@ResponseBody
public String loginBind(@RequestBody JSONObject jsonObject, HttpServletRequest request) {
String code = jsonObject.getString("code");
if (null == code) {
return "code丢失";
}
JSONObject requestObject = new JSONObject();
requestObject.put("appid", "小程序对应的appid");
requestObject.put("secret", "小程序对应的secret");
requestObject.put("code", code);
requestObject.put("anonymous_code", "");
String result = restTemplateUtil.byteDancePostRequest(requestObject, ByteDanceUrlConstants.CODE_2_SESSION);
if (!"".equals(result)) {
JSONObject resultObj = JSONObject.parseObject(result);
String err_no = resultObj.getString("err_no");
if (null != err_no && "0".equals(err_no)) {
JSONObject jsonData = resultObj.getJSONObject("data");
String session_key = jsonData.getString("session_key");
String openid = jsonData.getString("openid");
String unionid = jsonData.getString("unionid");
} else {
return "解析错误[" + err_no + "]";
}
} else {
return "解析异常请重试";
}
return "success";
}
抖音调用微信支付代码
@Override
public JSONObject ttAppletPay(String outTradeNo) {
JSONObject returnJson = new JSONObject();
try {
Map<String, Object> params = new TreeMap<String, Object>();
params.put("app_id","app_id");
params.put("out_order_no", outTradeNo);
params.put("total_amount", (new BigDecimal("100").multiply(new BigDecimal(100))).stripTrailingZeros().toPlainString());
params.put("subject", "商品描述");
params.put("body", "商品详情");
params.put("valid_time", 1800);
params.put("cp_extra", "xx平台充值");
params.put("notify_url", "回调通知地址");
String sign = ttPayUtil.getSign(params);
params.put("sign", sign);
JSONObject payJson = new JSONObject();
payJson.put("app_id", "app_id");
payJson.put("out_order_no", outTradeNo);
payJson.put("total_amount", new BigDecimal((new BigDecimal("100").multiply(new BigDecimal(100))).stripTrailingZeros().toPlainString()));
payJson.put("subject","商品描述");
payJson.put("body", "商品详情");
payJson.put("valid_time", 1800);
payJson.put("sign", sign);
payJson.put("cp_extra", "xx平台充值");
payJson.put("notify_url","回调通知地址");
String result = restTemplateUtil.byteDancePostRequest(payJson, ByteDanceUrlConstants.CREATE_ORDER);
if (!"".equals(result)) {
JSONObject jsonObject = JSONObject.parseObject(result);
String err_no = jsonObject.getString("err_no");
if (null != err_no && "0".equals(err_no)) {
JSONObject data = jsonObject.getJSONObject("data");
String order_id = data.getString("order_id");
String order_token = data.getString("order_token");
if (null != order_id && null != order_token) {
returnJson.put("pay_json",data)
} else {
returnJson.put("error_info","支付参数为空");
}
} else {
returnJson.put("error_info","参数错误[" + err_no + "]");
}
} else {
returnJson.put("error_info","支付订单创建失败");
}
} catch (Exception e) {
e.printStackTrace();
logger.error("抖音小程序微信支付异常:{}", e);
returnJson.put("error_info","抖音小程序微信支付异常");
}
return returnJson ;
}
小程序(前端)获取到order_id和order_token后,唤起收银台
tt.pay({
orderInfo: {
order_id: 6819903302604491021 ,
order_token:
CgsIARCABRgBIAQoARJOCkx+WgXqCUIwTel2V3siEGZ0++poigIM+SMMxtMx798Vj0ZYzoTYBqeNslodUC9X5KAOHkR1YbSBz6I6pXATh5faIGy7R72A9vwm0OczGgA= ,
},
service: 5,
success(res) {
if (res.code == 0) {
}
},
fail(res) {
},
});
支付,分账,退款回调
@ApiOperation(value = "抖音通知")
@ResponseBody
@RequestMapping("/ttPayNotify")
public JSONObject ttPayNotify(@RequestBody JSONObject object, HttpServletRequest request) {
logger.info("抖音异步通知开始==============》");
boolean flag = false;
try {
String nonce = object.getString("nonce");
Integer timestamp = object.getInteger("timestamp");
String msg_signature = object.getString("msg_signature");
String message = object.getString("msg");
String signMessage = ttPayUtil.getCallbackSignature(timestamp, nonce, message);
if (signMessage.equals(msg_signature)) {
logger.info("签名校验成功======");
JSONObject msg = object.getJSONObject("msg");
String status = msg.getString("status");
String order_id = msg.getString("order_id");
if(null != status && "success".equals(status)){
flag = true;
}else{
}
} else {
logger.info("signMessage:" + signMessage);
logger.info("msg_signature:" + msg_signature);
logger.info("签名校验失败======");
}
} catch (Exception e) {
e.printStackTrace();
logger.error("抖音回调处理失败, 信息:" + e.getMessage());
}
JSONObject returnObj = new JSONObject();
if (flag) {
returnObj.put("err_no", 0);
returnObj.put("err_tips", "success");
} else {
returnObj.put("err_no", 400);
returnObj.put("err_tips", "business fail");
}
return returnObj;
}
- 测试支付回调值
- order_id要存起来,用于之后的退款或者结算分账
{
"msg":
"{ \"appid\":\"appId不能给你们看呀\",
\"cp_orderno\":\"开发者侧生成的支付订单号\",
\"cp_extra\":\"xx平台充值\",
\"way\":\"1\",
\"channel_no\":\"432090xxxxxxxx03299703257922\",
\"channel_gateway_no\":\"\",
\"payment_order_no\":\"PCP202203291551xxxxxxxxxxx83375\",
\"out_channel_order_no\":\"432090097xxxxxxxxxxx03257922\",
\"total_amount\":15,
\"status\":\"SUCCESS\",
\"seller_uid\":\"70781xxxxxxxxxx79810\",
\"extra\":\"\",
\"item_id\":\"\",
\"paid_at\":1648540275,
\"message\":\"\",
\"order_id\":\"708042xxxxxxxxxx623\"
}",
"msg_signature":"bd8488233935xxxxxxxxxx5301341844a38f5109",
"type":"payment",
"nonce":"6696",
"timestamp":"1648540275"
}
{
"timestamp": 1602507471,
"nonce": "797",
"msg": "{\"appid\":\"tt07e3715e98c9aac0\",
\"cp_orderno\":\"out_order_no_1\",
\"cp_extra\":\"\",
\"way\":\"2\",
\"payment_order_no\":\"2021070722001450071438803941\",
\"total_amount\":9980,
\"status\":\"SUCCESS\",
\"seller_uid\":\"69631798443938962290\",
\"extra\":\"null\",
\"item_id\":\"\"}",
"msg_signature": "52fff5f7a4bf4a921c2daf83c75cf0e716432c73",
"type": "payment"
}
抖音退款代码
- outTradeNo 开发者侧支付时生成的订单号
- orderId 上面支付那块儿保存的order_id
- money 需要退款的额度,支持全退和部分退
@Override
public String ttRefund(String outTradeNo, String orderId, BigDecimal money) {
try {
Map<String, Object> params = new TreeMap<String, Object>();
params.put("app_id", "app_id");
params.put("out_order_no", outTradeNo);
params.put("out_refund_no", orderId);
params.put("reason", "订单[" + outTradeNo + "]退款或部分退款");
params.put("refund_amount", (money.multiply(new BigDecimal(100))).stripTrailingZeros().toPlainString());
params.put("cp_extra", "xx公司用户退款");
params.put("notify_url", "回调地址");
String sign = ttPayUtil.getSign(params);
params.put("sign", sign);
JSONObject refundJson = new JSONObject();
refundJson.put("out_refund_no", orderId);
refundJson.put("out_order_no", outTradeNo);
refundJson.put("reason", "订单[" + outTradeNo + "]退款或部分退款");
refundJson.put("notify_url", "回调地址");
refundJson.put("cp_extra", "xx公司用户退款");
refundJson.put("app_id", "app_id");
refundJson.put("sign", sign);
refundJson.put("refund_amount", new BigDecimal((money.multiply(new BigDecimal(100))).stripTrailingZeros().toPlainString()));
String result = restTemplateUtil.byteDancePostRequest(refundJson, ByteDanceUrlConstants.CREATE_REFUND);
if (!"".equals(result)) {
return updateRefundSettleCommon(result, BusinessConstants.TT_REFUND, outTradeNo, orderId, money);
}
} catch (Exception e) {
e.printStackTrace();
logger.error("抖音小程序退款异常:{}", e);
}
return "fail";
}
{
"msg":
"{ \"appid\":\"appId真的不能给你们看呀\",
\"cp_refundno\":\"708xx551xxx436xxxx1\",
\"cp_extra\":\"xx公司用户退款\",
\"status\":\"SUCCESS\",
\"refund_amount\":15,
\"is_all_settled\":false,
\"refunded_at\":1648792155,
\"message\":\"\",
\"order_id\":\"708145xxxxxxxxxx761\",
\"refund_source\":0,
\"refund_no\":\"7081xxxxxxxxxx04009\"}",
"msg_signature":"b43f8a5c459106259xxxxxxxxxxb7bf856d13acc",
"type":"refund",
"nonce":"8347",
"timestamp":"1648792210"
}
{
"timestamp": 1602507471,
"nonce": "797",
"msg":
"{\"appid\":\"ttb8bece032785e300\",
\"cp_refundno\":\"RD818440313350422528011772773\",
\"cp_extra\":\"\",
\"status\":\"SUCCESS\",
\"refund_amount\":13800,
\"is_all_settled\":false,
\"refunded_at\":1645523993,
\"message\":\"\",
\"order_id\":\"7064214528778766632\"}",
"msg_signature": "52fff5f7a4bf4a921c2daf83c75cf0e716432c73",
"type": "refund"
}
抖音分账结算代码
基本同退款
没找到沙盒测试方法,分账需要支付后7天才能执行,代码和测试数据完事儿再补
{
"timestamp": 1602507471,
"nonce": "797",
"msg": "{\"appid\":\"tt07e3715e98c9aac0\",
\"cp_settle_no\":\"out_settle_no_1\",
\"cp_extra\":\"2856\",
\"status\":\"SUCCESS\",
\"rake\":95,\"commission\":0}",
"type": "settle",
"msg_signature": "b313c64257660defba884af0e83be4d79794b559"
}
updateRefundSettleCommon
private String updateRefundSettleCommon(String result, Integer type, String outTradeNo, String settleRefundNo, BigDecimal money) {
String result = "";
JSONObject jsonObject = JSONObject.parseObject(result);
String err_no = jsonObject.getString("err_no");
if (null != err_no && "0".equals(err_no)) {
result = "success";
} else {
String err_tips = jsonObject.getString("err_tips");
result = err_no + ":" + err_tips;
}
return result;
}
手机号授权还没有从抖音申请下来权限。。。等写完再补代码
附:vue管理端处理退款分账 设计思路
- 同一条成功支付单可进行一次全额(部分)退款和一次分账
- 当同一条数据执行过退款和分账后,此条数据不可再操作
|