小程序支付与登录
1. 微信登录
1.1 wx.login 获取 code
小程序端调用 wx.login 获取临时登录凭证 code,该 code 有效期仅 5 分钟,且只能使用一次。
javascript
// 小程序端:登录流程
wx.login({
success: (res) => {
if (res.code) {
// 将 code 发送到后端
wx.request({
url: 'https://api.example.com/auth/weapp/login',
method: 'POST',
data: { code: res.code },
success: (response) => {
const { token, userInfo } = response.data;
// 存储 token 用于后续请求鉴权
wx.setStorageSync('token', token);
}
});
} else {
console.error('登录失败', res.errMsg);
}
}
});1.2 后端 code2Session
后端接收 code 后,调用微信接口 https://api.weixin.qq.com/sns/jscode2session 换取 openid 和 session_key。
java
// 后端:code2Session 接口调用
@Service
public class WeChatAuthService {
@Value("${wechat.appid}")
private String appId;
@Value("${wechat.secret}")
private String secret;
private static final String JSCODE2SESSION_URL =
"https://api.weixin.qq.com/sns/jscode2session";
public WeChatSession jscode2session(String code) {
// 请求微信接口
String response = HttpUtil.get(JSCODE2SESSION_URL, Map.of(
"appid", appId,
"secret", secret,
"js_code", code,
"grant_type", "authorization_code"
));
JSONObject json = JSON.parseObject(response);
// 错误处理
if (json.containsKey("errcode") && json.getInteger("errcode") != 0) {
String errMsg = json.getString("errmsg");
throw new BusinessException("微信登录失败: " + errMsg);
}
WeChatSession session = new WeChatSession();
session.setOpenid(json.getString("openid"));
session.setSessionKey(json.getString("session_key"));
// unionid 只在用户已关注公众号或绑定开放平台时返回
session.setUnionid(json.getString("unionid"));
return session;
}
}返回参数说明:
| 参数 | 说明 |
|---|---|
openid | 用户在该小程序的唯一标识 |
session_key | 会话密钥,用于数据解密,有效期动态管理 |
unionid | 用户在微信开放平台的唯一标识(需满足条件才返回) |
errcode | 错误码,0 为成功 |
1.3 openid 与 unionid 区别
| 标识 | 作用域 | 说明 |
|---|---|---|
| openid | 单个应用(小程序/公众号) | 同一用户在同一个小程序下的唯一标识。同一用户在不同小程序或公众号下 openid 不同 |
| unionid | 开放平台账号 | 同一用户在同一个开放平台账号下的所有应用(小程序、公众号、网站应用)共享一个 unionid,用于跨应用用户统一 |
获取 unionid 的前提条件:
- 小程序已绑定到微信开放平台(Open Platform)账号。
- 用户已关注同开放平台下的任一公众号,或用户已完成开放平台下的任一应用的授权。
java
// 判断是否需要 unionid 来统一用户身份
public String resolveUnionid(WeChatSession session) {
if (StringUtils.hasText(session.getUnionid())) {
return session.getUnionid();
}
// 未获取到 unionid 时,可使用 openid 加应用标识作为本地用户唯一键
return null;
}1.4 自定义登录态(JWT Token 签发与校验)
使用 JWT 维护用户登录态,避免每次请求都走微信 code2session。
java
// JWT 工具类
@Component
public class JwtTokenProvider {
@Value("${jwt.secret}")
private String secretKey;
@Value("${jwt.expiration:7200000}")
private long expiration; // 默认 2 小时
public String generateToken(String userId, String sessionKey) {
Date now = new Date();
Date expiryDate = new Date(now.getTime() + expiration);
return Jwts.builder()
.setSubject(userId)
.claim("session_key", sessionKey)
.setIssuedAt(now)
.setExpiration(expiryDate)
.signWith(SignatureAlgorithm.HS256, secretKey)
.compact();
}
public Claims parseToken(String token) {
return Jwts.parser()
.setSigningKey(secretKey)
.parseClaimsJws(token)
.getBody();
}
public boolean validateToken(String token) {
try {
parseToken(token);
return true;
} catch (JwtException | IllegalArgumentException e) {
return false;
}
}
}java
// 完整的登录服务
@Service
public class LoginService {
@Autowired
private WeChatAuthService weChatAuthService;
@Autowired
private JwtTokenProvider jwtTokenProvider;
@Autowired
private UserRepository userRepository;
public LoginResponse login(String code) {
// 1. code2Session
WeChatSession session = weChatAuthService.jscode2session(code);
// 2. 查找或创建用户
String openid = session.getOpenid();
User user = userRepository.findByOpenid(openid);
if (user == null) {
user = new User();
user.setOpenid(openid);
user.setUnionid(session.getUnionid());
user.setCreateTime(LocalDateTime.now());
userRepository.save(user);
}
// 3. 生成 JWT
String token = jwtTokenProvider.generateToken(
user.getId().toString(),
session.getSessionKey()
);
// 4. 返回登录结果
LoginResponse resp = new LoginResponse();
resp.setToken(token);
resp.setUserId(user.getId());
resp.setExpiresIn(7200);
return resp;
}
}1.5 手机号获取
微信小程序获取手机号需使用 getPhoneNumber 组件,配合后端解密。
html
<!-- 小程序端:使用 getPhoneNumber 组件 -->
<button open-type="getPhoneNumber" bindgetphonenumber="onGetPhoneNumber">
获取手机号
</button>javascript
// 小程序端
Page({
onGetPhoneNumber(e) {
if (e.detail.errMsg === 'getPhoneNumber:ok') {
// 将加密数据发送到后端解密
wx.request({
url: 'https://api.example.com/auth/weapp/decrypt-phone',
method: 'POST',
data: {
encryptedData: e.detail.encryptedData,
iv: e.detail.iv
},
header: {
'Authorization': 'Bearer ' + wx.getStorageSync('token')
},
success: (res) => {
console.log('手机号:', res.data.phoneNumber);
}
});
} else {
// 用户拒绝授权
console.log('用户拒绝获取手机号');
}
}
});java
// 后端:解密手机号
@Service
public class PhoneDecryptService {
@Autowired
private JwtTokenProvider jwtTokenProvider;
/**
* 解密微信加密数据
*
* @param encryptedData 包括敏感数据在内的完整用户信息的加密数据
* @param iv 加密算法的初始向量
* @param sessionKey 从 token 中提取的会话密钥
* @return 解密后的手机号信息
*/
public PhoneInfo decryptPhoneNumber(String encryptedData, String iv, String sessionKey) {
try {
// AES-128-CBC 解密,PKCS7 填充
byte[] keyBytes = Base64.decodeBase64(sessionKey);
byte[] ivBytes = Base64.decodeBase64(iv);
byte[] encryptedBytes = Base64.decodeBase64(encryptedData);
Cipher cipher = Cipher.getInstance("AES/CBC/PKCS7Padding");
SecretKeySpec keySpec = new SecretKeySpec(keyBytes, "AES");
IvParameterSpec ivSpec = new IvParameterSpec(ivBytes);
cipher.init(Cipher.DECRYPT_MODE, keySpec, ivSpec);
byte[] decryptedBytes = cipher.doFinal(encryptedBytes);
String decryptedText = new String(decryptedBytes, StandardCharsets.UTF_8);
// 解析 JSON
JSONObject json = JSON.parseObject(decryptedText);
// 校验手机号归属
Long watermarkAppId = // 获取当前小程序 appid
if (!watermarkAppId.equals(json.getJSONObject("watermark").getString("appid"))) {
throw new SecurityException("数据来源不合法");
}
PhoneInfo phoneInfo = new PhoneInfo();
phoneInfo.setPhoneNumber(json.getString("phoneNumber"));
phoneInfo.setPurePhoneNumber(json.getString("purePhoneNumber"));
phoneInfo.setCountryCode(json.getString("countryCode"));
return phoneInfo;
} catch (Exception e) {
throw new BusinessException("手机号解密失败", e);
}
}
}解密返回数据示例:
json
{
"phoneNumber": "13812345678",
"purePhoneNumber": "13812345678",
"countryCode": "86",
"watermark": {
"appid": "wx1234567890abcdef",
"timestamp": 1718000000
}
}2. 微信支付 JSAPI
2.1 统一下单
后端调用微信支付统一下单 API 获取 prepay_id。
java
// 后端:统一下单
@Service
public class WeChatPayService {
@Value("${wechat.pay.appid}")
private String appId;
@Value("${wechat.pay.mchid}")
private String mchId;
@Value("${wechat.pay.api-v3-key}")
private String apiV3Key;
@Value("${wechat.pay.notify-url}")
private String notifyUrl;
@Value("${wechat.pay.serial-no}")
private String serialNo;
/**
* JSAPI 统一下单
*
* @param openId 用户的 openid
* @param outTradeNo 商户订单号
* @param totalFee 订单总金额(单位:分)
* @param description 商品描述
* @return prepayId
*/
public String createOrder(String openId, String outTradeNo,
Integer totalFee, String description) {
// 构建请求体
JSONObject body = new JSONObject();
body.put("appid", appId);
body.put("mchid", mchId);
body.put("description", description);
body.put("out_trade_no", outTradeNo);
body.put("notify_url", notifyUrl);
// 金额
JSONObject amount = new JSONObject();
amount.put("total", totalFee);
amount.put("currency", "CNY");
body.put("amount", amount);
// 支付者
JSONObject payer = new JSONObject();
payer.put("openid", openId);
body.put("payer", payer);
// 发送 V3 接口请求
String url = "https://api.mch.weixin.qq.com/v3/pay/transactions/jsapi";
String response = WeChatV3HttpUtil.post(url, body.toString(), mchId, serialNo, apiV3Key);
JSONObject json = JSON.parseObject(response);
return json.getString("prepay_id");
}
}V3 接口请求签名说明:
java
// 微信支付 V3 HTTP 请求签名工具
@Component
public class WeChatV3HttpUtil {
/**
* 构建 Authorization 签名
*
* @param method HTTP 方法
* @param url 请求 URL
* @param body 请求体
* @param mchId 商户号
* @param serialNo 证书序列号
* @param privateKey 商户私钥
* @param apiV3Key API V3 密钥
* @return Authorization 头值
*/
public static String buildAuthorization(String method, String url,
String body, String mchId,
String serialNo, PrivateKey privateKey) {
String nonceStr = UUID.randomUUID().toString().replace("-", "");
long timestamp = System.currentTimeMillis() / 1000;
// 构建签名串
String signStr = method + "\n"
+ url + "\n"
+ timestamp + "\n"
+ nonceStr + "\n"
+ body + "\n";
// 使用商户私钥签名
String signature = sign(signStr, privateKey);
return "WECHATPAY2-SHA256-RSA2048 "
+ "mchid=\"" + mchId + "\","
+ "nonce_str=\"" + nonceStr + "\","
+ "timestamp=\"" + timestamp + "\","
+ "serial_no=\"" + serialNo + "\","
+ "signature=\"" + signature + "\"";
}
}2.2 调起支付(wx.requestPayment)
后端将统一下单返回的 prepay_id 再次签名后返回给小程序端,小程序端调用 wx.requestPayment 调起支付。
java
// 后端:调起支付参数签名
public JSONObject getPaymentParams(String prepayId, String openId) {
String timeStamp = String.valueOf(System.currentTimeMillis() / 1000);
String nonceStr = UUID.randomUUID().toString().replace("-", "");
String packageStr = "prepay_id=" + prepayId;
// 构建签名串
String signStr = appId + "\n"
+ timeStamp + "\n"
+ nonceStr + "\n"
+ packageStr + "\n";
String paySign = sign(signStr, apiV3Key);
JSONObject params = new JSONObject();
params.put("timeStamp", timeStamp);
params.put("nonceStr", nonceStr);
params.put("package", packageStr);
params.put("signType", "RSA");
params.put("paySign", paySign);
return params;
}javascript
// 小程序端:调起支付
wx.request({
url: 'https://api.example.com/pay/weapp/request-pay',
method: 'POST',
data: { orderId: orderId },
header: {
'Authorization': 'Bearer ' + wx.getStorageSync('token')
},
success: (res) => {
const payParams = res.data;
wx.requestPayment({
timeStamp: payParams.timeStamp,
nonceStr: payParams.nonceStr,
package: payParams.package,
signType: payParams.signType,
paySign: payParams.paySign,
success: (payRes) => {
// 支付成功,调用后端确认接口
confirmPayment(orderId);
},
fail: (payRes) => {
// 支付失败或取消
wx.showToast({ title: '支付未完成', icon: 'none' });
}
});
}
});2.3 支付回调通知
微信支付通过 notify_url 异步通知支付结果,后端需验签并做幂等处理。
java
// 后端:支付回调处理
@RestController
public class WeChatPayNotifyController {
@Autowired
private OrderService orderService;
/**
* 微信支付回调通知
* 注意:此接口需对外暴露,但必须验证签名
*/
@PostMapping("/api/pay/wechat/notify")
public String handleNotify(
HttpServletRequest request,
@RequestHeader("Wechatpay-Signature") String signature,
@RequestHeader("Wechatpay-Timestamp") String timestamp,
@RequestHeader("Wechatpay-Nonce") String nonce,
@RequestBody String body) {
// 1. 验签(使用微信平台证书公钥验证)
if (!verifyWeChatSign(body, signature, timestamp, nonce)) {
return failResponse("SIGNATURE_INVALID");
}
// 2. 解析回调数据
JSONObject json = JSON.parseObject(body);
JSONObject resource = decryptResource(json.getJSONObject("resource"));
String outTradeNo = resource.getString("out_trade_no");
String transactionId = resource.getString("transaction_id");
String tradeState = resource.getString("trade_state");
// 3. 幂等处理:使用 outTradeNo 加锁,防止重复处理
String lockKey = "pay:notify:" + outTradeNo;
boolean locked = redisTemplate.opsForValue()
.setIfAbsent(lockKey, "1", Duration.ofSeconds(30));
if (!locked) {
// 已有线程在处理,直接返回成功
return successResponse();
}
try {
if ("SUCCESS".equals(tradeState)) {
// 检查订单是否已处理
Order order = orderService.findByOrderNo(outTradeNo);
if (order.getStatus() == OrderStatus.PAID) {
// 已支付,直接返回成功
return successResponse();
}
// 更新订单状态为已支付
orderService.markPaid(outTradeNo, transactionId);
}
} finally {
redisTemplate.delete(lockKey);
}
return successResponse();
}
/**
* 解密回调中的加密资源
*/
private JSONObject decryptResource(JSONObject resource) {
String algorithm = resource.getString("algorithm");
String ciphertext = resource.getString("ciphertext");
String associatedData = resource.getString("associated_data");
String nonce = resource.getString("nonce");
// 使用 AEAD_AES_256_GCM 解密
String plaintext = AeadAesDecrypt.decryptToString(
apiV3Key, associatedData, nonce, ciphertext
);
return JSON.parseObject(plaintext);
}
private String successResponse() {
JSONObject resp = new JSONObject();
resp.put("code", "SUCCESS");
resp.put("message", "成功");
return resp.toString();
}
private String failResponse(String msg) {
JSONObject resp = new JSONObject();
resp.put("code", "FAIL");
resp.put("message", msg);
return resp.toString();
}
}2.4 退款接口与售后
java
// 后端:微信支付退款
@Service
public class WeChatRefundService {
@Value("${wechat.pay.mchid}")
private String mchId;
@Value("${wechat.pay.api-v3-key}")
private String apiV3Key;
/**
* 发起退款
*
* @param outTradeNo 商户订单号
* @param outRefundNo 商户退款单号
* @param refundFee 退款金额(分)
* @param totalFee 原订单金额(分)
* @return 退款单信息
*/
public RefundResult refund(String outTradeNo, String outRefundNo,
Integer refundFee, Integer totalFee) {
JSONObject body = new JSONObject();
body.put("out_trade_no", outTradeNo);
body.put("out_refund_no", outRefundNo);
JSONObject amount = new JSONObject();
amount.put("refund", refundFee);
amount.put("total", totalFee);
amount.put("currency", "CNY");
body.put("amount", amount);
// 调用退款 API
String url = "https://api.mch.weixin.qq.com/v3/refund/domestic/refunds";
String response = WeChatV3HttpUtil.post(url, body.toString(), mchId, serialNo, apiV3Key);
JSONObject json = JSON.parseObject(response);
RefundResult result = new RefundResult();
result.setRefundId(json.getString("refund_id"));
result.setOutRefundNo(json.getString("out_refund_no"));
result.setStatus(json.getString("status"));
// status: SUCCESS / CLOSED / PROCESSING / ABNORMAL
return result;
}
/**
* 查询退款
*/
public RefundResult queryRefund(String outRefundNo) {
String url = "https://api.mch.weixin.qq.com/v3/refund/domestic/refunds/"
+ outRefundNo;
String response = WeChatV3HttpUtil.get(url, mchId, serialNo, apiV3Key);
JSONObject json = JSON.parseObject(response);
RefundResult result = new RefundResult();
result.setRefundId(json.getString("refund_id"));
result.setOutRefundNo(json.getString("out_refund_no"));
result.setStatus(json.getString("status"));
return result;
}
}退款注意事项:
- 退款支持部分退款和全额退款。
- 退款需要保证幂等性,相同的
out_refund_no重复请求不会创建新退款单。 - 退款结果异步通知与支付回调类似,需设置
notify_url。 - 商户需确保账户余额充足,否则退款会失败。
3. 订阅消息
3.1 一次性订阅
一次性订阅需用户主动触发,每次订阅仅能发送一次消息。
javascript
// 小程序端:一次性订阅
wx.requestSubscribeMessage({
tmplIds: ['TEMPLATE_ID_1', 'TEMPLATE_ID_2'],
success: (res) => {
// res 格式: { TEMPLATE_ID_1: 'accept', TEMPLATE_ID_2: 'reject', errMsg: 'requestSubscribeMessage:ok' }
if (res['TEMPLATE_ID_1'] === 'accept') {
// 用户同意订阅
wx.showToast({ title: '订阅成功' });
} else if (res['TEMPLATE_ID_1'] === 'reject') {
// 用户拒绝
wx.showToast({ title: '已拒绝订阅', icon: 'none' });
}
},
fail: (err) => {
console.error('订阅消息调用失败', err);
}
});常见返回状态:
| 状态 | 说明 |
|---|---|
accept | 用户同意订阅 |
reject | 用户拒绝订阅 |
ban | 已被封禁 |
filter | 订阅列表为空(不弹窗) |
time | 订阅超时 |
3.2 长期订阅
长期订阅仅限特定类目(如政务、医疗、教育、金融等)的小程序使用,用户订阅后可长期多次发送消息。
- 申请条件:小程序主体为政府、事业单位、社会组织或企业,且服务类目为长期订阅支持的类目。
- 订阅流程与一次性订阅相同,使用
wx.requestSubscribeMessage接口,但tmplIds中填入长期订阅模板 ID。 - 长期订阅不占用单次订阅额度,用户只需订阅一次即可持续接收。
3.3 模板管理
模板在微信公众平台配置,后端管理 template_id 映射。
java
// 后端:订阅消息模板配置
@Component
@ConfigurationProperties(prefix = "wechat.template")
public class WeChatTemplateConfig {
/**
* 模板 ID 映射
* key: 业务场景标识
* value: 微信模板 ID
*/
private Map<String, String> templates = new HashMap<>();
public String getTemplateId(String bizType) {
String templateId = templates.get(bizType);
if (templateId == null) {
throw new BusinessException("未配置模板: " + bizType);
}
return templateId;
}
// getters / setters
}yaml
# application.yml
wechat:
template:
templates:
order-paid: "TEMPLATE_ID_ORDER_PAID_xxx"
order-shipped: "TEMPLATE_ID_ORDER_SHIPPED_xxx"
refund-success: "TEMPLATE_ID_REFUND_xxx"3.4 发送订阅消息
后端通过微信服务端 API 发送订阅消息。
java
// 后端:发送订阅消息
@Service
public class WeChatSubscribeService {
@Autowired
private WeChatAuthService weChatAuthService;
@Value("${wechat.appid}")
private String appId;
@Value("${wechat.secret}")
private String secret;
/**
* 发送订阅消息
*
* @param openId 接收用户的 openid
* @param templateId 模板 ID
* @param page 跳转页面
* @param data 模板数据
*/
public void sendSubscribeMessage(String openId, String templateId,
String page, Map<String, TemplateData> data) {
// 1. 获取 access_token(使用接口调用凭证,非用户 token)
String accessToken = getAccessToken();
// 2. 构建请求
String url = "https://api.weixin.qq.com/cgi-bin/message/subscribe/send"
+ "?access_token=" + accessToken;
JSONObject body = new JSONObject();
body.put("touser", openId);
body.put("template_id", templateId);
body.put("page", page);
// data 格式: { "thing1": { "value": "xxx" }, "number2": { "value": "xxx" } }
JSONObject dataJson = new JSONObject();
data.forEach((key, value) -> {
JSONObject item = new JSONObject();
item.put("value", value.getValue());
dataJson.put(key, item);
});
body.put("data", dataJson);
// 3. 发送请求
String response = HttpUtil.post(url, body.toString());
JSONObject json = JSON.parseObject(response);
int errCode = json.getIntValue("errcode");
if (errCode != 0) {
// 处理常见错误
String errMsg = json.getString("errmsg");
if (errCode == 43101) {
// 用户拒绝订阅
log.warn("用户已拒绝订阅消息: {}", openId);
} else {
log.error("发送订阅消息失败, errcode={}, errmsg={}", errCode, errMsg);
}
}
}
@Data
public static class TemplateData {
private String value;
}
/**
* 获取接口调用凭证
*/
private String getAccessToken() {
// 实际项目中应从缓存获取,避免频繁调用微信接口
String url = "https://api.weixin.qq.com/cgi-bin/token"
+ "?grant_type=client_credential"
+ "&appid=" + appId
+ "&secret=" + secret;
String response = HttpUtil.get(url);
JSONObject json = JSON.parseObject(response);
return json.getString("access_token");
}
}模板数据字段类型说明:
| 数据类型 | 说明 | 示例值长度限制 |
|---|---|---|
thing | 事物 | 20 个字符以内 |
number | 数字 | 32 个字符以内 |
letter | 字母 | 32 个字符以内 |
symbol | 符号 | 5 个字符以内 |
phrase | 短语 | 5 个字符以内 |
time | 时间 | 24 小时制(如 15:01) |
date | 日期 | 如 2025年1月1日 |
name | 姓名 | 10 个字符以内 |
phone_number | 电话 | 17 个字符以内 |
car_number | 车牌 | 8 个字符以内 |
address | 地址 | 6 个字符以内 |
4. 小程序安全
4.1 请求签名防篡改
前端请求增加签名机制,防止请求被篡改或重放。
java
// 后端:API 请求签名校验过滤器
@Component
public class SignVerifyFilter extends OncePerRequestFilter {
@Value("${api.sign.secret}")
private String signSecret;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain)
throws ServletException, IOException {
String timestamp = request.getHeader("X-Timestamp");
String nonce = request.getHeader("X-Nonce");
String sign = request.getHeader("X-Sign");
// 校验时间戳是否超时(允许 5 分钟偏差)
long now = System.currentTimeMillis() / 1000;
if (Math.abs(now - Long.parseLong(timestamp)) > 300) {
writeError(response, "请求已过期");
return;
}
// 校验 nonce 是否已使用(防止重放)
Boolean exists = redisTemplate.opsForValue()
.setIfAbsent("nonce:" + nonce, "1", Duration.ofMinutes(5));
if (Boolean.FALSE.equals(exists)) {
writeError(response, "重复请求");
return;
}
// 拼接签名串并校验
String body = getBody(request);
String signStr = timestamp + nonce + body + signSecret;
String expectedSign = DigestUtils.md5DigestAsHex(
signStr.getBytes(StandardCharsets.UTF_8)
);
if (!expectedSign.equals(sign)) {
writeError(response, "签名无效");
return;
}
filterChain.doFilter(request, response);
}
}javascript
// 小程序端:请求拦截器添加签名
function requestWithSign(url, data) {
const timestamp = Math.floor(Date.now() / 1000);
const nonce = Math.random().toString(36).substring(2, 15);
const signStr = timestamp + nonce + JSON.stringify(data) + SIGN_SECRET;
const sign = md5(signStr);
return new Promise((resolve, reject) => {
wx.request({
url: url,
method: 'POST',
data: data,
header: {
'X-Timestamp': timestamp,
'X-Nonce': nonce,
'X-Sign': sign,
'Authorization': 'Bearer ' + wx.getStorageSync('token')
},
success: resolve,
fail: reject
});
});
}4.2 小程序包加固
防止小程序代码被反编译和篡改的常用措施:
- 代码混淆:使用微信开发者工具自带的代码混淆功能,在
project.config.json中开启。
json
{
"setting": {
"uglifyFileName": true,
"uploadWithSourceMap": false,
"minified": true,
"es6": true
}
}- 服务器接口鉴权:所有敏感接口必须校验登录态(JWT token),防止未授权调用。
- 关键逻辑放在后端:支付下单、订单金额计算等关键业务逻辑必须在服务端执行,前端只做展示和交互。
- HTTPS 加密传输:所有请求使用 HTTPS,防止中间人攻击。
- 包大小检查:定期检查小程序包内容,确保未泄露敏感信息(如 API 密钥、加密密钥)。
4.3 敏感信息加密传输
对传输中的敏感信息进行端到端加密。
java
// AES-256-GCM 加密工具
@Component
public class AesGcmEncryptor {
private static final int GCM_IV_LENGTH = 12;
private static final int GCM_TAG_LENGTH = 128;
/**
* 加密
*
* @param plaintext 明文
* @param key 密钥(32 字节)
* @return Base64 编码的密文,格式: iv:密文
*/
public String encrypt(String plaintext, byte[] key) {
try {
byte[] iv = new byte[GCM_IV_LENGTH];
SecureRandom secureRandom = new SecureRandom();
secureRandom.nextBytes(iv);
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
SecretKeySpec keySpec = new SecretKeySpec(key, "AES");
GCMParameterSpec gcmSpec = new GCMParameterSpec(GCM_TAG_LENGTH, iv);
cipher.init(Cipher.ENCRYPT_MODE, keySpec, gcmSpec);
byte[] ciphertext = cipher.doFinal(
plaintext.getBytes(StandardCharsets.UTF_8)
);
// 返回 iv + 密文,Base64 编码
byte[] combined = ByteBuffer.allocate(iv.length + ciphertext.length)
.put(iv)
.put(ciphertext)
.array();
return Base64.getEncoder().encodeToString(combined);
} catch (Exception e) {
throw new SecurityException("加密失败", e);
}
}
/**
* 解密
*
* @param encryptedData Base64 编码的密文(iv:密文)
* @param key 密钥(32 字节)
* @return 明文
*/
public String decrypt(String encryptedData, byte[] key) {
try {
byte[] combined = Base64.getDecoder().decode(encryptedData);
ByteBuffer buffer = ByteBuffer.wrap(combined);
byte[] iv = new byte[GCM_IV_LENGTH];
buffer.get(iv);
byte[] ciphertext = new byte[buffer.remaining()];
buffer.get(ciphertext);
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
SecretKeySpec keySpec = new SecretKeySpec(key, "AES");
GCMParameterSpec gcmSpec = new GCMParameterSpec(GCM_TAG_LENGTH, iv);
cipher.init(Cipher.DECRYPT_MODE, keySpec, gcmSpec);
byte[] plaintext = cipher.doFinal(ciphertext);
return new String(plaintext, StandardCharsets.UTF_8);
} catch (Exception e) {
throw new SecurityException("解密失败", e);
}
}
}4.4 用户数据保护
- 隐私协议:小程序首次启动时弹出隐私协议弹窗,明确告知数据采集范围、使用目的、存储期限。
- 数据采集声明:在微信公众平台填写小程序用户隐私保护指引,明确获取的用户信息及用途。
- 最小化采集:仅采集业务必需的用户数据,不采集与业务无关的敏感信息。
- 数据脱敏:日志和数据库中的手机号、身份证等敏感信息做脱敏处理。
java
// 数据脱敏工具
public class DataMaskUtil {
/**
* 手机号脱敏: 138****5678
*/
public static String maskPhone(String phone) {
if (StringUtils.isBlank(phone) || phone.length() < 7) {
return phone;
}
return phone.substring(0, 3) + "****" + phone.substring(7);
}
/**
* 姓名脱敏: 张*
*/
public static String maskName(String name) {
if (StringUtils.isBlank(name)) {
return name;
}
return name.charAt(0) + "*".repeat(name.length() - 1);
}
}- 数据存储加密:数据库中敏感字段使用 AES-256-GCM 加密存储。
- 数据访问审计:记录所有对用户敏感数据的访问日志,包括操作人、时间、操作类型。
5. 完整业务流程串联示例
以电商小程序为例,串联完整的登录 -> 购物 -> 支付 -> 订阅消息流程。
5.1 流程总览
用户打开小程序
│
├─ 1. 登录(wx.login -> code2Session -> JWT)
│
├─ 2. 获取商品列表
│
├─ 3. 选择商品加入购物车 / 直接购买
│
├─ 4. 生成订单(后端创建订单,状态: 待支付)
│
├─ 5. 发起支付(统一下单 -> wx.requestPayment)
│
├─ 6. 支付成功(后端接收回调,更新订单状态为已支付)
│
└─ 7. 发送订阅消息(向用户发送支付成功通知)5.2 小程序端完整示例
javascript
// 小程序端完整业务流程
const app = getApp();
Page({
data: {
products: [],
cart: [],
currentOrder: null
},
onLoad() {
// 1. 登录
this.login();
},
// ===== 1. 登录 =====
login() {
const token = wx.getStorageSync('token');
if (token) {
// 检查 token 是否有效
this.validateToken(token);
return;
}
wx.login({
success: (res) => {
if (res.code) {
wx.request({
url: 'https://api.example.com/auth/weapp/login',
method: 'POST',
data: { code: res.code },
success: (response) => {
const { token, userId } = response.data;
wx.setStorageSync('token', token);
wx.setStorageSync('userId', userId);
this.loadProducts();
}
});
}
}
});
},
validateToken(token) {
wx.request({
url: 'https://api.example.com/auth/validate',
method: 'GET',
header: { 'Authorization': 'Bearer ' + token },
success: (res) => {
if (res.data.valid) {
this.loadProducts();
} else {
wx.removeStorageSync('token');
this.login();
}
},
fail: () => {
this.login();
}
});
},
// ===== 2. 获取商品列表 =====
loadProducts() {
wx.request({
url: 'https://api.example.com/products',
method: 'GET',
header: { 'Authorization': 'Bearer ' + wx.getStorageSync('token') },
success: (res) => {
this.setData({ products: res.data });
}
});
},
// ===== 3. 选择购买 =====
buyNow(e) {
const productId = e.currentTarget.dataset.id;
const product = this.data.products.find(p => p.id === productId);
// 跳转到订单确认页
wx.navigateTo({
url: `/pages/order/confirm?productId=${productId}`
});
},
// ===== 4. 生成订单并支付 =====
createOrder(productId, quantity) {
wx.request({
url: 'https://api.example.com/orders',
method: 'POST',
header: { 'Authorization': 'Bearer ' + wx.getStorageSync('token') },
data: {
productId: productId,
quantity: quantity
},
success: (res) => {
const order = res.data;
this.setData({ currentOrder: order });
// 5. 调起支付
this.requestPayment(order.orderNo);
}
});
},
requestPayment(orderNo) {
wx.request({
url: 'https://api.example.com/pay/weapp/request-pay',
method: 'POST',
header: { 'Authorization': 'Bearer ' + wx.getStorageSync('token') },
data: { orderNo: orderNo },
success: (res) => {
const params = res.data;
wx.requestPayment({
timeStamp: params.timeStamp,
nonceStr: params.nonceStr,
package: params.package,
signType: params.signType,
paySign: params.paySign,
success: () => {
// 6. 支付成功
this.onPaySuccess(orderNo);
},
fail: () => {
wx.showToast({ title: '支付取消', icon: 'none' });
}
});
}
});
},
onPaySuccess(orderNo) {
// 7. 请求后端确认支付结果
wx.request({
url: 'https://api.example.com/orders/' + orderNo + '/confirm',
method: 'GET',
header: { 'Authorization': 'Bearer ' + wx.getStorageSync('token') },
success: (res) => {
if (res.data.status === 'PAID') {
wx.showToast({ title: '支付成功' });
// 跳转到订单详情
wx.navigateTo({
url: '/pages/order/detail?orderNo=' + orderNo
});
}
}
});
}
});5.3 后端完整流程
java
// 后端:综合订单服务
@Service
public class OrderFlowService {
@Autowired
private WeChatPayService weChatPayService;
@Autowired
private WeChatSubscribeService subscribeService;
@Autowired
private OrderRepository orderRepository;
@Autowired
private UserRepository userRepository;
@Transactional
public Order createOrder(Long userId, Long productId, Integer quantity) {
// 创建订单,状态为 UNPAID
Order order = new Order();
order.setUserId(userId);
order.setProductId(productId);
order.setQuantity(quantity);
order.setOrderNo(generateOrderNo());
order.setStatus(OrderStatus.UNPAID);
order.setCreateTime(LocalDateTime.now());
orderRepository.save(order);
return order;
}
/**
* 获取支付参数
*/
public JSONObject preparePayment(String orderNo) {
Order order = orderRepository.findByOrderNo(orderNo);
User user = userRepository.findById(order.getUserId()).orElseThrow();
// 统一下单
String prepayId = weChatPayService.createOrder(
user.getOpenid(),
order.getOrderNo(),
order.getTotalFee(),
order.getDescription()
);
// 生成调起支付参数
return weChatPayService.getPaymentParams(prepayId, user.getOpenid());
}
/**
* 支付成功回调处理
*/
public void handlePaymentSuccess(String orderNo, String transactionId) {
// 更新订单状态
Order order = orderRepository.findByOrderNo(orderNo);
if (order.getStatus() != OrderStatus.UNPAID) {
return; // 幂等处理
}
order.setStatus(OrderStatus.PAID);
order.setTransactionId(transactionId);
order.setPayTime(LocalDateTime.now());
orderRepository.save(order);
// 获取用户信息和模板 ID
User user = userRepository.findById(order.getUserId()).orElseThrow();
// 发送订阅消息
Map<String, WeChatSubscribeService.TemplateData> data = new HashMap<>();
WeChatSubscribeService.TemplateData thing1 = new WeChatSubscribeService.TemplateData();
thing1.setValue(order.getDescription());
data.put("thing1", thing1);
WeChatSubscribeService.TemplateData amount2 = new WeChatSubscribeService.TemplateData();
amount2.setValue(String.valueOf(order.getTotalFee() / 100.0) + "元");
data.put("amount2", amount2);
subscribeService.sendSubscribeMessage(
user.getOpenid(),
templateConfig.getTemplateId("order-paid"),
"pages/order/detail?orderNo=" + orderNo,
data
);
}
private String generateOrderNo() {
return "ORD" + LocalDateTime.now()
.format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss"))
+ UUID.randomUUID().toString().substring(0, 6).toUpperCase();
}
}5.4 状态流转图
订单状态流转:
创建订单 (UNPAID)
│
├─ 用户支付成功 ──> PAID(已支付)
│ │
│ ├─ 商家发货 ──> SHIPPED(已发货)
│ │ │
│ │ ├─ 用户确认收货 ──> COMPLETED(已完成)
│ │ └─ 自动确认收货 ──> COMPLETED(已完成)
│ │
│ └─ 退款申请 ──> REFUNDING(退款中)
│ │
│ ├─ 退款成功 ──> REFUNDED(已退款)
│ └─ 退款失败 ──> PAID(恢复已支付)
│
└─ 超时未支付 ──> CANCELLED(已取消)
订阅消息触发时机:
PAID -> 发送"支付成功通知"
SHIPPED -> 发送"发货提醒"
REFUNDED -> 发送"退款成功通知"5.5 关键设计要点
| 环节 | 设计要点 |
|---|---|
| 登录 | JWT token 设置合理过期时间;token 刷新机制;session_key 安全存储 |
| 订单 | 订单号唯一性(分布式 ID);金额计算由后端完成;库存扣减与订单创建的原子性 |
| 支付 | 统一下单参数防篡改;回调验签与幂等;支付结果以回调为准,前端结果仅做展示 |
| 退款 | 退款幂等;退款金额不超过原订单金额;退款异步通知 |
| 订阅消息 | 记录用户订阅状态;发送失败重试机制;模板参数长度合规 |