1
0
mirror of synced 2025-11-06 04:20:53 +08:00

Compare commits

...

9 Commits

Author SHA1 Message Date
Copilot
1d08953fce Merge 968a4a453f into db85c0ab8a 2025-10-06 18:05:00 +00:00
Copilot
db85c0ab8a 🎨 完善关于开放平台功能的说明文档 2025-10-04 01:47:50 +08:00
Copilot
ca567ce310 🆕 #3618 【微信支付】增加境外微信支付的支持 2025-10-04 01:44:56 +08:00
Copilot
a6825a62bb 🎨 #3628【企业微信】修复更新用户接口重置用户直属领导字段的问题 2025-10-04 01:39:35 +08:00
Mr.Robot
9fd12b2a09 🎨 WxOpenMessageRouter增加注解ConditionalOnMissingBean 2025-10-04 01:32:23 +08:00
xiaoyun461
10f71234e4 🆕 #3725【企业微信】 增加markdown_v2的消息类型支持 2025-10-04 01:31:24 +08:00
copilot-swe-agent[bot]
968a4a453f Add convenience methods for Tips control with clickable links
Co-authored-by: binarywang <1343140+binarywang@users.noreply.github.com>
2025-09-24 15:31:59 +00:00
copilot-swe-agent[bot]
028074cf5b Detailed analysis of Tips control link support
Co-authored-by: binarywang <1343140+binarywang@users.noreply.github.com>
2025-09-24 15:25:27 +00:00
copilot-swe-agent[bot]
087c85c738 Initial plan 2025-09-24 15:13:22 +00:00
20 changed files with 957 additions and 1 deletions

View File

@@ -106,6 +106,13 @@
- 企业微信:`weixin-java-cp`
- 微信视频号/微信小店:`weixin-java-channel`
**注意**
- **移动应用开发**如果你的移动应用iOS/Android App需要接入微信登录、分享等功能
- 微信登录(网页授权):使用 `weixin-java-open` 模块,在服务端处理 OAuth 授权
- 微信支付:使用 `weixin-java-pay` 模块
- 客户端集成需使用微信官方提供的移动端SDKiOS/Android本项目为服务端SDK
- **微信开放平台**`weixin-java-open`)主要用于第三方平台,代公众号或小程序进行开发和管理
---------------------------------
### 版本说明

View File

@@ -28,6 +28,7 @@ public class WxOpenServiceAutoConfiguration {
}
@Bean
@ConditionalOnMissingBean
public WxOpenMessageRouter wxOpenMessageRouter(WxOpenService wxOpenService) {
return new WxOpenMessageRouter(wxOpenService);
}

View File

@@ -70,6 +70,23 @@ public interface WxCpGroupRobotService {
*/
void sendMarkdown(String webhookUrl, String content) throws WxErrorException;
/**
* 发送markdown_v2类型的消息
*
* @param content markdown内容最长不超过4096个字节必须是utf8编码
* @throws WxErrorException 异常
*/
void sendMarkdownV2(String content) throws WxErrorException;
/**
* 发送markdown_v2类型的消息
*
* @param webhookUrl webhook地址
* @param content markdown内容最长不超过4096个字节必须是utf8编码
* @throws WxErrorException 异常
*/
void sendMarkdownV2(String webhookUrl, String content) throws WxErrorException;
/**
* 发送image类型的消息
*

View File

@@ -42,6 +42,11 @@ public class WxCpGroupRobotServiceImpl implements WxCpGroupRobotService {
this.sendMarkdown(this.getWebhookUrl(), content);
}
@Override
public void sendMarkdownV2(String content) throws WxErrorException {
this.sendMarkdownV2(this.getWebhookUrl(), content);
}
@Override
public void sendImage(String base64, String md5) throws WxErrorException {
this.sendImage(this.getWebhookUrl(), base64, md5);
@@ -70,6 +75,14 @@ public class WxCpGroupRobotServiceImpl implements WxCpGroupRobotService {
.toJson());
}
@Override
public void sendMarkdownV2(String webhookUrl, String content) throws WxErrorException {
this.cpService.postWithoutToken(webhookUrl, new WxCpGroupRobotMessage()
.setMsgType(GroupRobotMsgType.MARKDOWN_V2)
.setContent(content)
.toJson());
}
@Override
public void sendImage(String webhookUrl, String base64, String md5) throws WxErrorException {
this.cpService.postWithoutToken(webhookUrl, new WxCpGroupRobotMessage()

View File

@@ -252,6 +252,12 @@ public class WxCpGroupRobotMessage implements Serializable {
messageJson.add("markdown", text);
break;
}
case MARKDOWN_V2: {
JsonObject text = new JsonObject();
text.addProperty("content", this.getContent());
messageJson.add("markdown_v2", text);
break;
}
case IMAGE: {
JsonObject text = new JsonObject();
text.addProperty("base64", this.getBase64());

View File

@@ -6,6 +6,8 @@ import lombok.experimental.Accessors;
import java.io.Serializable;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
/**
@@ -126,6 +128,74 @@ public class ContentValue implements Serializable {
@SerializedName("tips_content")
private List<TipsContent> tipsContent;
/**
* Creates a simple Tips control with mixed plain text and clickable links.
*
* @param lang the language code (e.g., "zh_CN")
* @param textAndLinks array of objects where strings become plain text and Link objects become clickable links
* @return NewTips instance with the specified content
*/
public static NewTips of(String lang, Object... textAndLinks) {
NewTips tips = new NewTips();
TipsContent content = new TipsContent();
TipsContent.Text text = new TipsContent.Text();
List<TipsContent.SubText> subTexts = new ArrayList<>();
for (Object item : textAndLinks) {
TipsContent.SubText subText = new TipsContent.SubText();
TipsContent.SubText.Content subContent = new TipsContent.SubText.Content();
if (item instanceof String) {
// Plain text
TipsContent.SubText.Content.PlainText plainText = new TipsContent.SubText.Content.PlainText();
plainText.setContent((String) item);
subContent.setPlainText(plainText);
subText.setType(1);
} else if (item instanceof TipsContent.SubText.Content.Link) {
// Link
subContent.setLink((TipsContent.SubText.Content.Link) item);
subText.setType(2);
}
subText.setContent(subContent);
subTexts.add(subText);
}
text.setSubText(subTexts);
content.setText(text);
content.setLang(lang);
tips.setTipsContent(Arrays.asList(content));
return tips;
}
/**
* Creates a simple Tips control with only plain text.
*
* @param lang the language code (e.g., "zh_CN")
* @param textContent the plain text content
* @return NewTips instance with plain text content
*/
public static NewTips ofText(String lang, String textContent) {
return of(lang, textContent);
}
/**
* Creates a Tips control with a single clickable link.
*
* @param lang the language code (e.g., "zh_CN")
* @param linkTitle the display text for the link
* @param linkUrl the URL to link to
* @return NewTips instance with a single clickable link
*/
public static NewTips ofLink(String lang, String linkTitle, String linkUrl) {
TipsContent.SubText.Content.Link link = new TipsContent.SubText.Content.Link();
link.setTitle(linkTitle);
link.setUrl(linkUrl);
return of(lang, link);
}
/**
* The type tips_content.
*/

View File

@@ -630,6 +630,11 @@ public class WxCpConsts {
*/
public static final String MARKDOWN = "markdown";
/**
* markdown_v2消息.
*/
public static final String MARKDOWN_V2 = "markdown_v2";
/**
* 图文消息(点击跳转到外链).
*/

View File

@@ -281,7 +281,12 @@ public class WxCpUserGsonAdapter implements JsonDeserializer<WxCpUser>, JsonSeri
}
addProperty(o, MAIN_DEPARTMENT, user.getMainDepartment());
addArrayProperty(o, DIRECT_LEADER, user.getDirectLeader());
// Special handling for directLeader: include empty arrays to support WeChat Work API reset functionality
if (user.getDirectLeader() != null) {
JsonArray directLeaderArray = new JsonArray();
Arrays.stream(user.getDirectLeader()).forEach(directLeaderArray::add);
o.add(DIRECT_LEADER, directLeaderArray);
}
if (!user.getExtAttrs().isEmpty()) {
JsonArray attrsJsonArray = new JsonArray();

View File

@@ -64,6 +64,51 @@ public class WxCpGroupRobotServiceImplTest {
robotService.sendMarkdown(content);
}
/**
* Test send mark down v2.
*
* @throws WxErrorException the wx error exception
*/
@Test
public void testSendMarkDownV2() throws WxErrorException {
String content = "# 一、标题\n" +
"## 二级标题\n" +
"### 三级标题\n" +
"# 二、字体\n" +
"*斜体*\n" +
"\n" +
"**加粗**\n" +
"# 三、列表 \n" +
"- 无序列表 1 \n" +
"- 无序列表 2\n" +
" - 无序列表 2.1\n" +
" - 无序列表 2.2\n" +
"1. 有序列表 1\n" +
"2. 有序列表 2\n" +
"# 四、引用\n" +
"> 一级引用\n" +
">>二级引用\n" +
">>>三级引用\n" +
"# 五、链接\n" +
"[这是一个链接](https://work.weixin.qq.com/api/doc)\n" +
"![](https://res.mail.qq.com/node/ww/wwopenmng/images/independent/doc/test_pic_msg1.png)\n" +
"# 六、分割线\n" +
"\n" +
"---\n" +
"# 七、代码\n" +
"`这是行内代码`\n" +
"```\n" +
"这是独立代码块\n" +
"```\n" +
"\n" +
"# 八、表格\n" +
"| 姓名 | 文化衫尺寸 | 收货地址 |\n" +
"| :----- | :----: | -------: |\n" +
"| 张三 | S | 广州 |\n" +
"| 李四 | L | 深圳 |";
robotService.sendMarkdownV2(content);
}
/**
* Test send image.
*

View File

@@ -0,0 +1,124 @@
package me.chanjar.weixin.cp.bean.oa.applydata;
import me.chanjar.weixin.cp.util.json.WxCpGsonBuilder;
import org.testng.annotations.Test;
import java.util.Arrays;
import static org.testng.Assert.*;
public class ContentValueTipsTest {
@Test
public void testTipsWithLinksManualCreation() {
System.out.println("Testing ContentValue.NewTips structure with Link (manual creation):");
// Create a Tips structure with both plain text and link
ContentValue.NewTips tips = new ContentValue.NewTips();
ContentValue.NewTips.TipsContent tipsContent = new ContentValue.NewTips.TipsContent();
ContentValue.NewTips.TipsContent.Text text = new ContentValue.NewTips.TipsContent.Text();
// Create plain text subtext
ContentValue.NewTips.TipsContent.SubText plainSubText = new ContentValue.NewTips.TipsContent.SubText();
ContentValue.NewTips.TipsContent.SubText.Content plainContent = new ContentValue.NewTips.TipsContent.SubText.Content();
ContentValue.NewTips.TipsContent.SubText.Content.PlainText plainTextContent =
new ContentValue.NewTips.TipsContent.SubText.Content.PlainText();
plainTextContent.setContent("This is plain text. For more info, ");
plainContent.setPlainText(plainTextContent);
plainSubText.setType(1); // Type 1 for plain text
plainSubText.setContent(plainContent);
// Create link subtext
ContentValue.NewTips.TipsContent.SubText linkSubText = new ContentValue.NewTips.TipsContent.SubText();
ContentValue.NewTips.TipsContent.SubText.Content linkContent = new ContentValue.NewTips.TipsContent.SubText.Content();
ContentValue.NewTips.TipsContent.SubText.Content.Link link =
new ContentValue.NewTips.TipsContent.SubText.Content.Link();
link.setTitle("click here");
link.setUrl("https://work.weixin.qq.com");
linkContent.setLink(link);
linkSubText.setType(2); // Type 2 for link
linkSubText.setContent(linkContent);
text.setSubText(Arrays.asList(plainSubText, linkSubText));
tipsContent.setText(text);
tipsContent.setLang("zh_CN");
tips.setTipsContent(Arrays.asList(tipsContent));
// Convert to JSON
String json = WxCpGsonBuilder.create().toJson(tips);
System.out.println("Generated JSON:");
System.out.println(json);
// Try to parse it back
validateTipsStructure(tips, json);
}
@Test
public void testTipsWithConvenienceMethods() {
System.out.println("Testing ContentValue.NewTips with convenience methods:");
// Test 1: Simple plain text
ContentValue.NewTips textOnly = ContentValue.NewTips.ofText("zh_CN", "This is a simple text tip.");
String textJson = WxCpGsonBuilder.create().toJson(textOnly);
System.out.println("Text-only JSON: " + textJson);
validateTipsStructure(textOnly, textJson);
// Test 2: Single link
ContentValue.NewTips linkOnly = ContentValue.NewTips.ofLink("zh_CN", "Visit WeChat Work", "https://work.weixin.qq.com");
String linkJson = WxCpGsonBuilder.create().toJson(linkOnly);
System.out.println("Link-only JSON: " + linkJson);
validateTipsStructure(linkOnly, linkJson);
// Test 3: Mixed content using convenience method
ContentValue.NewTips.TipsContent.SubText.Content.Link link =
new ContentValue.NewTips.TipsContent.SubText.Content.Link();
link.setTitle("click here");
link.setUrl("https://work.weixin.qq.com");
ContentValue.NewTips mixed = ContentValue.NewTips.of("zh_CN",
"For more information, ", link, " or contact support.");
String mixedJson = WxCpGsonBuilder.create().toJson(mixed);
System.out.println("Mixed content JSON: " + mixedJson);
validateTipsStructure(mixed, mixedJson);
System.out.println("All convenience method tests passed!");
}
private void validateTipsStructure(ContentValue.NewTips tips, String json) {
try {
ContentValue.NewTips parsedTips = WxCpGsonBuilder.create().fromJson(json, ContentValue.NewTips.class);
assertNotNull(parsedTips);
assertNotNull(parsedTips.getTipsContent());
assertFalse(parsedTips.getTipsContent().isEmpty());
ContentValue.NewTips.TipsContent.Text parsedText = parsedTips.getTipsContent().get(0).getText();
assertNotNull(parsedText);
assertNotNull(parsedText.getSubText());
assertTrue(parsedText.getSubText().size() > 0);
// Verify structure based on content
for (ContentValue.NewTips.TipsContent.SubText subText : parsedText.getSubText()) {
assertNotNull(subText.getType());
assertNotNull(subText.getContent());
if (subText.getType() == 1) {
// Plain text
assertNotNull(subText.getContent().getPlainText());
assertNotNull(subText.getContent().getPlainText().getContent());
} else if (subText.getType() == 2) {
// Link
assertNotNull(subText.getContent().getLink());
assertNotNull(subText.getContent().getLink().getTitle());
assertNotNull(subText.getContent().getLink().getUrl());
}
}
System.out.println("✓ JSON parsing and validation successful");
} catch (Exception e) {
System.out.println("✗ Error parsing: " + e.getMessage());
e.printStackTrace();
fail("Failed to parse JSON: " + e.getMessage());
}
}
}

View File

@@ -0,0 +1,93 @@
package me.chanjar.weixin.cp.bean.oa.applydata;
import me.chanjar.weixin.cp.util.json.WxCpGsonBuilder;
/**
* Usage examples for ContentValue.NewTips with clickable link support.
*
* This example demonstrates how to create Tips controls that can render URLs as clickable links,
* addressing the issue where "Tips控件无法将url渲染为可点击的链接".
*
* @author WxJava Community
*/
public class TipsUsageExample {
public static void main(String[] args) {
demonstrateBasicUsage();
demonstrateAdvancedUsage();
}
/**
* Basic usage examples for creating Tips with clickable links.
*/
public static void demonstrateBasicUsage() {
System.out.println("=== Basic Tips Usage Examples ===\n");
// Example 1: Simple plain text tip
ContentValue.NewTips textTip = ContentValue.NewTips.ofText("zh_CN",
"这是一个简单的文本提示。");
System.out.println("1. Plain text tip JSON:");
System.out.println(WxCpGsonBuilder.create().toJson(textTip));
System.out.println();
// Example 2: Simple clickable link tip
ContentValue.NewTips linkTip = ContentValue.NewTips.ofLink("zh_CN",
"访问企业微信官网", "https://work.weixin.qq.com");
System.out.println("2. Single link tip JSON:");
System.out.println(WxCpGsonBuilder.create().toJson(linkTip));
System.out.println();
// Example 3: Mixed content - text with clickable link
ContentValue.NewTips.TipsContent.SubText.Content.Link helpLink =
new ContentValue.NewTips.TipsContent.SubText.Content.Link();
helpLink.setTitle("点击查看详情");
helpLink.setUrl("https://work.weixin.qq.com/help");
ContentValue.NewTips mixedTip = ContentValue.NewTips.of("zh_CN",
"如需了解更多信息,请", helpLink, "");
System.out.println("3. Mixed content tip JSON:");
System.out.println(WxCpGsonBuilder.create().toJson(mixedTip));
System.out.println();
}
/**
* Advanced usage examples showing complex Tips with multiple links and text.
*/
public static void demonstrateAdvancedUsage() {
System.out.println("=== Advanced Tips Usage Examples ===\n");
// Example 4: Complex tip with multiple links
ContentValue.NewTips.TipsContent.SubText.Content.Link docsLink =
new ContentValue.NewTips.TipsContent.SubText.Content.Link();
docsLink.setTitle("开发文档");
docsLink.setUrl("https://developer.work.weixin.qq.com");
ContentValue.NewTips.TipsContent.SubText.Content.Link supportLink =
new ContentValue.NewTips.TipsContent.SubText.Content.Link();
supportLink.setTitle("技术支持");
supportLink.setUrl("https://work.weixin.qq.com/contact");
ContentValue.NewTips complexTip = ContentValue.NewTips.of("zh_CN",
"审批流程说明:\n1. 提交申请后系统将自动处理\n2. 如有疑问请查看",
docsLink,
"或联系",
supportLink);
System.out.println("4. Complex tip with multiple links JSON:");
System.out.println(WxCpGsonBuilder.create().toJson(complexTip));
System.out.println();
// Demonstrate that the structure supports proper type differentiation
System.out.println("=== Type Verification ===");
ContentValue.NewTips parsed = WxCpGsonBuilder.create().fromJson(
WxCpGsonBuilder.create().toJson(complexTip), ContentValue.NewTips.class);
parsed.getTipsContent().get(0).getText().getSubText().forEach(subText -> {
if (subText.getType() == 1) {
System.out.println("Plain text: \"" + subText.getContent().getPlainText().getContent() + "\"");
} else if (subText.getType() == 2) {
System.out.println("Link: \"" + subText.getContent().getLink().getTitle() +
"\" -> " + subText.getContent().getLink().getUrl());
}
});
}
}

View File

@@ -180,4 +180,31 @@ public class WxCpUserGsonAdapterTest {
"{\"type\":2,\"name\":\"测试app\"," +
"\"miniprogram\":{\"appid\":\"wx8bd80126147df384\",\"pagepath\":\"/index\",\"title\":\"my miniprogram\"}}]}}");
}
/**
* Test directLeader empty array serialization.
* This test verifies that empty directLeader arrays are included in JSON as "direct_leader":[]
* instead of being omitted, which is required for WeChat Work API to reset user direct leaders.
*/
@Test
public void testDirectLeaderEmptyArraySerialization() {
WxCpUser user = new WxCpUser();
user.setUserId("testuser");
user.setName("Test User");
// Test with empty array - should be serialized as "direct_leader":[]
user.setDirectLeader(new String[]{});
String json = user.toJson();
assertThat(json).contains("\"direct_leader\":[]");
// Test with null - should not include direct_leader field
user.setDirectLeader(null);
json = user.toJson();
assertThat(json).doesNotContain("direct_leader");
// Test with non-empty array - should be serialized normally
user.setDirectLeader(new String[]{"leader1", "leader2"});
json = user.toJson();
assertThat(json).contains("\"direct_leader\":[\"leader1\",\"leader2\"]");
}
}

View File

@@ -1,3 +1,38 @@
# 微信开放平台模块 (weixin-java-open)
## 模块说明
本模块主要用于**微信第三方平台**的开发,适用于以下场景:
### 适用场景
1. **第三方平台开发**:作为第三方平台,代替多个公众号或小程序进行管理和开发
2. **代公众号实现业务**:通过授权代替公众号进行消息管理、素材管理等操作
3. **代小程序实现业务**:通过授权代替小程序进行代码管理、基本信息设置等操作
### 移动应用开发说明
**如果您要开发移动应用iOS/Android App并接入微信功能请注意**
- **微信登录**
- 移动应用的微信登录(网页授权)需要在**微信开放平台**open.weixin.qq.com创建移动应用
- 服务端处理 OAuth 授权时使用本模块 `weixin-java-open`
- 移动端需集成微信官方SDKiOS/Android本项目仅提供服务端SDK
- **微信支付**
- 使用 `weixin-java-pay` 模块,参考 [微信支付文档](../weixin-java-pay/)
- 移动应用支付使用 APP 支付类型TradeType.APP
- **微信分享**
- 需集成微信官方移动端SDK本项目不涉及客户端功能
**参考资料**
- [微信开放平台官方文档](https://open.weixin.qq.com/)
- [移动应用接入指南](https://developers.weixin.qq.com/doc/oplatform/Mobile_App/Access_Guide/iOS.html)
---
## 代码示例
消息机制未实现,下面为通知回调中设置的代码部分
以下代码可通过腾讯全网发布测试用例

View File

@@ -0,0 +1,120 @@
# 境外微信支付(Overseas WeChat Pay)支持
本次更新添加了境外微信支付的支持,解决了 [Issue #3618](https://github.com/binarywang/WxJava/issues/3618) 中提到的问题。
## 问题背景
境外微信支付需要使用新的API接口地址和额外的参数
- 使用不同的基础URL: `https://apihk.mch.weixin.qq.com`
- 需要额外的参数: `trade_type``merchant_category_code`
- 使用不同的API端点: `/global/v3/transactions/*`
## 新增功能
### 1. GlobalTradeTypeEnum
新的枚举类定义了境外支付的交易类型和对应的API端点
- `APP`: `/global/v3/transactions/app`
- `JSAPI`: `/global/v3/transactions/jsapi`
- `NATIVE`: `/global/v3/transactions/native`
- `H5`: `/global/v3/transactions/h5`
### 2. WxPayUnifiedOrderV3GlobalRequest
扩展的请求类,包含境外支付必需的额外字段:
- `trade_type`: 交易类型 (JSAPI, APP, NATIVE, H5)
- `merchant_category_code`: 商户类目代码(境外商户必填)
### 3. 新的服务方法
- `createOrderV3Global()`: 创建境外支付订单
- `unifiedOrderV3Global()`: 境外统一下单接口
## 使用示例
### JSAPI支付示例
```java
// 创建境外支付请求
WxPayUnifiedOrderV3GlobalRequest request = new WxPayUnifiedOrderV3GlobalRequest();
request.setOutTradeNo(RandomUtils.getRandomStr());
request.setDescription("境外商品购买");
request.setNotifyUrl("https://your-domain.com/notify");
// 设置金额
WxPayUnifiedOrderV3GlobalRequest.Amount amount = new WxPayUnifiedOrderV3GlobalRequest.Amount();
amount.setCurrency(WxPayConstants.CurrencyType.CNY);
amount.setTotal(100); // 1元单位为分
request.setAmount(amount);
// 设置支付者
WxPayUnifiedOrderV3GlobalRequest.Payer payer = new WxPayUnifiedOrderV3GlobalRequest.Payer();
payer.setOpenid("用户的openid");
request.setPayer(payer);
// 设置境外支付必需的参数
request.setTradeType("JSAPI");
request.setMerchantCategoryCode("5812"); // 商户类目代码
// 调用境外支付接口
WxPayUnifiedOrderV3Result.JsapiResult result = payService.createOrderV3Global(
GlobalTradeTypeEnum.JSAPI,
request
);
```
### APP支付示例
```java
WxPayUnifiedOrderV3GlobalRequest request = new WxPayUnifiedOrderV3GlobalRequest();
// ... 设置基础信息 ...
request.setTradeType("APP");
request.setMerchantCategoryCode("5812");
request.setPayer(new WxPayUnifiedOrderV3GlobalRequest.Payer()); // APP支付不需要openid
WxPayUnifiedOrderV3Result.AppResult result = payService.createOrderV3Global(
GlobalTradeTypeEnum.APP,
request
);
```
### NATIVE支付示例
```java
WxPayUnifiedOrderV3GlobalRequest request = new WxPayUnifiedOrderV3GlobalRequest();
// ... 设置基础信息 ...
request.setTradeType("NATIVE");
request.setMerchantCategoryCode("5812");
request.setPayer(new WxPayUnifiedOrderV3GlobalRequest.Payer());
String codeUrl = payService.createOrderV3Global(
GlobalTradeTypeEnum.NATIVE,
request
);
```
## 配置说明
境外支付使用相同的 `WxPayConfig` 配置,无需特殊设置:
```java
WxPayConfig config = new WxPayConfig();
config.setAppId("你的AppId");
config.setMchId("你的境外商户号");
config.setMchKey("你的商户密钥");
config.setNotifyUrl("https://your-domain.com/notify");
// V3相关配置
config.setPrivateKeyPath("你的私钥文件路径");
config.setCertSerialNo("你的商户证书序列号");
config.setApiV3Key("你的APIv3密钥");
```
**注意**: 境外支付会自动使用 `https://apihk.mch.weixin.qq.com` 作为基础URL无需手动设置。
## 兼容性
- 完全向后兼容,不影响现有的国内支付功能
- 使用相同的配置类和结果类
- 遵循现有的代码风格和架构模式
## 参考文档
- [境外微信支付文档](https://pay.weixin.qq.com/doc/global/v3/zh/4013014223)
- [原始Issue #3618](https://github.com/binarywang/WxJava/issues/3618)

View File

@@ -0,0 +1,57 @@
package com.github.binarywang.wxpay.bean.request;
import com.google.gson.annotations.SerializedName;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import lombok.experimental.Accessors;
import java.io.Serializable;
/**
* <pre>
* 境外微信支付统一下单请求参数对象.
* 参考文档https://pay.weixin.qq.com/doc/global/v3/zh/4013014223
* </pre>
*
* @author Binary Wang
*/
@Data
@NoArgsConstructor
@Accessors(chain = true)
@EqualsAndHashCode(callSuper = true)
public class WxPayUnifiedOrderV3GlobalRequest extends WxPayUnifiedOrderV3Request implements Serializable {
private static final long serialVersionUID = 1L;
/**
* <pre>
* 字段名:交易类型
* 变量名trade_type
* 是否必填:是
* 类型string[1,16]
* 描述:
* 交易类型,取值如下:
* JSAPI--JSAPI支付
* NATIVE--Native支付
* APP--APP支付
* H5--H5支付
* 示例值JSAPI
* </pre>
*/
@SerializedName(value = "trade_type")
private String tradeType;
/**
* <pre>
* 字段名:商户类目
* 变量名merchant_category_code
* 是否必填:是
* 类型string[1,32]
* 描述:
* 商户类目,境外商户必填
* 示例值5812
* </pre>
*/
@SerializedName(value = "merchant_category_code")
private String merchantCategoryCode;
}

View File

@@ -0,0 +1,36 @@
package com.github.binarywang.wxpay.bean.result.enums;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* 境外微信支付方式
* Overseas WeChat Pay trade types with global endpoints
*
* @author Binary Wang
*/
@Getter
@AllArgsConstructor
public enum GlobalTradeTypeEnum {
/**
* APP
*/
APP("/global/v3/transactions/app"),
/**
* JSAPI 或 小程序
*/
JSAPI("/global/v3/transactions/jsapi"),
/**
* NATIVE
*/
NATIVE("/global/v3/transactions/native"),
/**
* H5
*/
H5("/global/v3/transactions/h5");
/**
* 境外下单url
*/
private final String url;
}

View File

@@ -6,6 +6,7 @@ import com.github.binarywang.wxpay.bean.notify.*;
import com.github.binarywang.wxpay.bean.request.*;
import com.github.binarywang.wxpay.bean.result.*;
import com.github.binarywang.wxpay.bean.result.enums.TradeTypeEnum;
import com.github.binarywang.wxpay.bean.result.enums.GlobalTradeTypeEnum;
import com.github.binarywang.wxpay.bean.transfer.TransferBillsNotifyResult;
import com.github.binarywang.wxpay.config.WxPayConfig;
import com.github.binarywang.wxpay.constant.WxPayConstants;
@@ -640,6 +641,17 @@ public interface WxPayService {
*/
<T> T createPartnerOrderV3(TradeTypeEnum tradeType, WxPayPartnerUnifiedOrderV3Request request) throws WxPayException;
/**
* 境外微信支付调用统一下单接口,并组装生成支付所需参数对象.
*
* @param <T> 请使用{@link WxPayUnifiedOrderV3Result}里的内部类或字段
* @param tradeType the global trade type
* @param request 境外统一下单请求参数
* @return 返回 {@link WxPayUnifiedOrderV3Result}里的内部类或字段
* @throws WxPayException the wx pay exception
*/
<T> T createOrderV3Global(GlobalTradeTypeEnum tradeType, WxPayUnifiedOrderV3GlobalRequest request) throws WxPayException;
/**
* 在发起微信支付前,需要调用统一下单接口,获取"预支付交易会话标识"
*
@@ -660,6 +672,16 @@ public interface WxPayService {
*/
WxPayUnifiedOrderV3Result unifiedOrderV3(TradeTypeEnum tradeType, WxPayUnifiedOrderV3Request request) throws WxPayException;
/**
* 境外微信支付在发起支付前,需要调用统一下单接口,获取"预支付交易会话标识"
*
* @param tradeType the global trade type
* @param request 境外请求对象注意一些参数如appid、mchid等不用设置方法内会自动从配置对象中获取到前提是对应配置中已经设置
* @return the wx pay unified order result
* @throws WxPayException the wx pay exception
*/
WxPayUnifiedOrderV3Result unifiedOrderV3Global(GlobalTradeTypeEnum tradeType, WxPayUnifiedOrderV3GlobalRequest request) throws WxPayException;
/**
* <pre>
* 合单支付API(APP支付、JSAPI支付、H5支付、NATIVE支付).

View File

@@ -11,6 +11,7 @@ import com.github.binarywang.wxpay.bean.order.WxPayNativeOrderResult;
import com.github.binarywang.wxpay.bean.request.*;
import com.github.binarywang.wxpay.bean.result.*;
import com.github.binarywang.wxpay.bean.result.enums.TradeTypeEnum;
import com.github.binarywang.wxpay.bean.result.enums.GlobalTradeTypeEnum;
import com.github.binarywang.wxpay.bean.transfer.TransferBillsNotifyResult;
import com.github.binarywang.wxpay.config.WxPayConfig;
import com.github.binarywang.wxpay.config.WxPayConfigHolder;
@@ -746,6 +747,14 @@ public abstract class BaseWxPayServiceImpl implements WxPayService {
return result.getPayInfo(tradeType, appId, request.getSubMchId(), this.getConfig().getPrivateKey());
}
@Override
public <T> T createOrderV3Global(GlobalTradeTypeEnum tradeType, WxPayUnifiedOrderV3GlobalRequest request) throws WxPayException {
WxPayUnifiedOrderV3Result result = this.unifiedOrderV3Global(tradeType, request);
// Convert GlobalTradeTypeEnum to TradeTypeEnum for getPayInfo method
TradeTypeEnum domesticTradeType = TradeTypeEnum.valueOf(tradeType.name());
return result.getPayInfo(domesticTradeType, request.getAppid(), request.getMchid(), this.getConfig().getPrivateKey());
}
@Override
public WxPayUnifiedOrderV3Result unifiedPartnerOrderV3(TradeTypeEnum tradeType, WxPayPartnerUnifiedOrderV3Request request) throws WxPayException {
if (StringUtils.isBlank(request.getSpAppid())) {
@@ -790,6 +799,28 @@ public abstract class BaseWxPayServiceImpl implements WxPayService {
return GSON.fromJson(response, WxPayUnifiedOrderV3Result.class);
}
@Override
public WxPayUnifiedOrderV3Result unifiedOrderV3Global(GlobalTradeTypeEnum tradeType, WxPayUnifiedOrderV3GlobalRequest request) throws WxPayException {
if (StringUtils.isBlank(request.getAppid())) {
request.setAppid(this.getConfig().getAppId());
}
if (StringUtils.isBlank(request.getMchid())) {
request.setMchid(this.getConfig().getMchId());
}
if (StringUtils.isBlank(request.getNotifyUrl())) {
request.setNotifyUrl(this.getConfig().getNotifyUrl());
}
if (StringUtils.isBlank(request.getTradeType())) {
request.setTradeType(tradeType.name());
}
// Use global WeChat Pay base URL for overseas payments
String globalBaseUrl = "https://apihk.mch.weixin.qq.com";
String url = globalBaseUrl + tradeType.getUrl();
String response = this.postV3WithWechatpaySerial(url, GSON.toJson(request));
return GSON.fromJson(response, WxPayUnifiedOrderV3Result.class);
}
@Override
public CombineTransactionsResult combine(TradeTypeEnum tradeType, CombineTransactionsRequest request) throws WxPayException {
if (StringUtils.isBlank(request.getCombineAppid())) {

View File

@@ -0,0 +1,89 @@
package com.github.binarywang.wxpay.service.impl;
import com.github.binarywang.wxpay.bean.request.WxPayUnifiedOrderV3GlobalRequest;
import com.github.binarywang.wxpay.bean.result.enums.GlobalTradeTypeEnum;
import com.github.binarywang.wxpay.constant.WxPayConstants;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import me.chanjar.weixin.common.util.RandomUtils;
import org.testng.annotations.Test;
import static org.testng.Assert.*;
/**
* 境外微信支付测试类
*
* @author Binary Wang
*/
public class BaseWxPayServiceGlobalImplTest {
private static final Gson GSON = new GsonBuilder().create();
@Test
public void testWxPayUnifiedOrderV3GlobalRequest() {
// Test that the new request class has the required fields
WxPayUnifiedOrderV3GlobalRequest request = new WxPayUnifiedOrderV3GlobalRequest();
// Set basic order information
String outTradeNo = RandomUtils.getRandomStr();
request.setOutTradeNo(outTradeNo);
request.setDescription("Test overseas payment");
request.setNotifyUrl("https://api.example.com/notify");
// Set amount
WxPayUnifiedOrderV3GlobalRequest.Amount amount = new WxPayUnifiedOrderV3GlobalRequest.Amount();
amount.setCurrency(WxPayConstants.CurrencyType.CNY);
amount.setTotal(100); // 1 yuan in cents
request.setAmount(amount);
// Set payer
WxPayUnifiedOrderV3GlobalRequest.Payer payer = new WxPayUnifiedOrderV3GlobalRequest.Payer();
payer.setOpenid("test_openid");
request.setPayer(payer);
// Set the new required fields for global payments
request.setTradeType("JSAPI");
request.setMerchantCategoryCode("5812"); // Example category code
// Assert that all fields are properly set
assertNotNull(request.getTradeType());
assertNotNull(request.getMerchantCategoryCode());
assertEquals("JSAPI", request.getTradeType());
assertEquals("5812", request.getMerchantCategoryCode());
assertEquals(outTradeNo, request.getOutTradeNo());
assertEquals("Test overseas payment", request.getDescription());
assertEquals(100, request.getAmount().getTotal());
assertEquals("test_openid", request.getPayer().getOpenid());
// Test JSON serialization contains the new fields
String json = GSON.toJson(request);
assertTrue(json.contains("trade_type"));
assertTrue(json.contains("merchant_category_code"));
assertTrue(json.contains("JSAPI"));
assertTrue(json.contains("5812"));
}
@Test
public void testGlobalTradeTypeEnum() {
// Test that all trade types have the correct global endpoints
assertEquals("/global/v3/transactions/app", GlobalTradeTypeEnum.APP.getUrl());
assertEquals("/global/v3/transactions/jsapi", GlobalTradeTypeEnum.JSAPI.getUrl());
assertEquals("/global/v3/transactions/native", GlobalTradeTypeEnum.NATIVE.getUrl());
assertEquals("/global/v3/transactions/h5", GlobalTradeTypeEnum.H5.getUrl());
}
@Test
public void testGlobalTradeTypeEnumValues() {
// Test that we have all the main trade types
GlobalTradeTypeEnum[] tradeTypes = GlobalTradeTypeEnum.values();
assertEquals(4, tradeTypes.length);
// Test that we can convert between enum name and TradeTypeEnum
for (GlobalTradeTypeEnum globalType : tradeTypes) {
// This tests that the enum names match between Global and regular TradeTypeEnum
String name = globalType.name();
assertNotNull(name);
assertTrue(name.equals("APP") || name.equals("JSAPI") || name.equals("NATIVE") || name.equals("H5"));
}
}
}

View File

@@ -0,0 +1,153 @@
package com.github.binarywang.wxpay.service.impl;
import com.github.binarywang.wxpay.bean.request.WxPayUnifiedOrderV3GlobalRequest;
import com.github.binarywang.wxpay.bean.result.WxPayUnifiedOrderV3Result;
import com.github.binarywang.wxpay.bean.result.enums.GlobalTradeTypeEnum;
import com.github.binarywang.wxpay.config.WxPayConfig;
import com.github.binarywang.wxpay.constant.WxPayConstants;
import com.github.binarywang.wxpay.exception.WxPayException;
import com.github.binarywang.wxpay.service.WxPayService;
import me.chanjar.weixin.common.util.RandomUtils;
/**
* 境外微信支付使用示例
* Example usage for overseas WeChat Pay
*
* @author Binary Wang
*/
public class OverseasWxPayExample {
/**
* 境外微信支付JSAPI下单示例
* Example for overseas WeChat Pay JSAPI order creation
*/
public void createOverseasJsapiOrder(WxPayService payService) throws WxPayException {
// 创建境外支付请求对象
WxPayUnifiedOrderV3GlobalRequest request = new WxPayUnifiedOrderV3GlobalRequest();
// 设置基础订单信息
request.setOutTradeNo(RandomUtils.getRandomStr()); // 商户订单号
request.setDescription("境外商品购买"); // 商品描述
request.setNotifyUrl("https://your-domain.com/notify"); // 支付通知地址
// 设置金额信息
WxPayUnifiedOrderV3GlobalRequest.Amount amount = new WxPayUnifiedOrderV3GlobalRequest.Amount();
amount.setCurrency(WxPayConstants.CurrencyType.CNY); // 币种
amount.setTotal(100); // 金额,单位为分
request.setAmount(amount);
// 设置支付者信息
WxPayUnifiedOrderV3GlobalRequest.Payer payer = new WxPayUnifiedOrderV3GlobalRequest.Payer();
payer.setOpenid("用户的openid"); // 用户openid
request.setPayer(payer);
// 设置境外支付必需的参数
request.setTradeType("JSAPI"); // 交易类型
request.setMerchantCategoryCode("5812"); // 商户类目代码,境外商户必填
// 可选:设置场景信息
WxPayUnifiedOrderV3GlobalRequest.SceneInfo sceneInfo = new WxPayUnifiedOrderV3GlobalRequest.SceneInfo();
sceneInfo.setPayerClientIp("用户IP地址");
request.setSceneInfo(sceneInfo);
// 调用境外支付接口
WxPayUnifiedOrderV3Result.JsapiResult result = payService.createOrderV3Global(
GlobalTradeTypeEnum.JSAPI,
request
);
// 返回的result包含前端需要的支付参数
System.out.println("支付参数:" + result);
}
/**
* 境外微信支付APP下单示例
* Example for overseas WeChat Pay APP order creation
*/
public void createOverseasAppOrder(WxPayService payService) throws WxPayException {
WxPayUnifiedOrderV3GlobalRequest request = new WxPayUnifiedOrderV3GlobalRequest();
// 设置基础信息
request.setOutTradeNo(RandomUtils.getRandomStr());
request.setDescription("境外APP商品购买");
request.setNotifyUrl("https://your-domain.com/notify");
// 设置金额
WxPayUnifiedOrderV3GlobalRequest.Amount amount = new WxPayUnifiedOrderV3GlobalRequest.Amount();
amount.setCurrency(WxPayConstants.CurrencyType.CNY);
amount.setTotal(200); // 2元
request.setAmount(amount);
// APP支付不需要设置payer.openid但需要设置空的payer对象
request.setPayer(new WxPayUnifiedOrderV3GlobalRequest.Payer());
// 境外支付必需参数
request.setTradeType("APP");
request.setMerchantCategoryCode("5812");
// 调用境外APP支付接口
WxPayUnifiedOrderV3Result.AppResult result = payService.createOrderV3Global(
GlobalTradeTypeEnum.APP,
request
);
System.out.println("APP支付参数" + result);
}
/**
* 境外微信支付NATIVE下单示例
* Example for overseas WeChat Pay NATIVE order creation
*/
public void createOverseasNativeOrder(WxPayService payService) throws WxPayException {
WxPayUnifiedOrderV3GlobalRequest request = new WxPayUnifiedOrderV3GlobalRequest();
request.setOutTradeNo(RandomUtils.getRandomStr());
request.setDescription("境外扫码支付");
request.setNotifyUrl("https://your-domain.com/notify");
// 设置金额
WxPayUnifiedOrderV3GlobalRequest.Amount amount = new WxPayUnifiedOrderV3GlobalRequest.Amount();
amount.setCurrency(WxPayConstants.CurrencyType.CNY);
amount.setTotal(300); // 3元
request.setAmount(amount);
// NATIVE支付不需要设置payer.openid
request.setPayer(new WxPayUnifiedOrderV3GlobalRequest.Payer());
// 境外支付必需参数
request.setTradeType("NATIVE");
request.setMerchantCategoryCode("5812");
// 调用境外NATIVE支付接口
String result = payService.createOrderV3Global(
GlobalTradeTypeEnum.NATIVE,
request
);
System.out.println("NATIVE支付二维码链接" + result);
}
/**
* 配置示例
* Configuration example
*/
public WxPayConfig createOverseasConfig() {
WxPayConfig config = new WxPayConfig();
// 基础配置
config.setAppId("你的AppId");
config.setMchId("你的境外商户号");
config.setMchKey("你的商户密钥");
config.setNotifyUrl("https://your-domain.com/notify");
// 境外支付使用的是全球API在代码中会自动使用 https://apihk.mch.weixin.qq.com 作为基础URL
// 无需额外设置payBaseUrl方法内部会自动处理
// V3相关配置境外支付也使用V3接口
config.setPrivateKeyPath("你的私钥文件路径");
config.setCertSerialNo("你的商户证书序列号");
config.setApiV3Key("你的APIv3密钥");
return config;
}
}