From ad93fcac51bebf9ff6204e3cd2c3cba5cc5d6a6f Mon Sep 17 00:00:00 2001
From: 13404089107 <puwei@sinata.cn>
Date: 星期四, 30 四月 2026 13:54:39 +0800
Subject: [PATCH] Merge branch 'master' of http://120.76.84.145:10101/gitblit/r/java/tcm-training

---
 tcm-training-admin-backend/src/main/java/com/tcm/training/admin/system/vo/JpushManualPushVO.java                 |   44 ++++
 tcm-training-admin-backend/src/main/java/com/tcm/training/admin/system/controller/JpushManualPushController.java |   76 +++++++
 traditionalApp/pages/register/register.vue                                                                       |    3 
 traditionalApp/utils/openinstall.js                                                                              |   98 +++++++++
 tcm-training-admin-backend/src/main/java/com/tcm/training/admin/system/dto/JpushManualPushRequest.java           |   18 +
 tcm-training-admin-backend/src/main/resources/application.yml                                                    |    6 
 traditionalApp/utils/utils.js                                                                                    |   84 +++++++
 tcm-training-admin-backend/src/main/java/com/tcm/training/admin/utils/JGPushUtil.java                            |    2 
 tcm-training-admin-backend/src/main/java/com/tcm/training/admin/system/service/JpushManualPushAppService.java    |  163 ++++++++++++++++
 traditionalApp/App.vue                                                                                           |   49 +++-
 tcm-training-admin-backend/src/main/java/com/tcm/training/admin/config/SaTokenWebConfig.java                     |   11 +
 traditionalApp/manifest.json                                                                                     |   35 +++
 12 files changed, 566 insertions(+), 23 deletions(-)

diff --git a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/config/SaTokenWebConfig.java b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/config/SaTokenWebConfig.java
index 618af18..9bca475 100644
--- a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/config/SaTokenWebConfig.java
+++ b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/config/SaTokenWebConfig.java
@@ -70,11 +70,22 @@
     private boolean isPublicAdminApi(String requestPath) {
         return "/api/auth/login".equals(requestPath)
                 || "/api/auth/captcha".equals(requestPath)
+                || isJpushManualPushApi(requestPath)
                 || requestPath.startsWith("/api/open/")
                 || requestPath.startsWith("/api/v1/open/");
     }
 
     /**
+     * 判断当前后台路由是否属于极光手动推送联调接口。
+     */
+    private boolean isJpushManualPushApi(String requestPath) {
+        return "/api/jpush/manual-push".equals(requestPath)
+                || "/api/v1/jpush/manual-push".equals(requestPath)
+                || requestPath.startsWith("/api/jpush/manual-push/")
+                || requestPath.startsWith("/api/v1/jpush/manual-push/");
+    }
+
+    /**
      * 将本地上传目录映射为静态资源,可通过 /uploads/** 访问。
      */
     @Override
diff --git a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/system/controller/JpushManualPushController.java b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/system/controller/JpushManualPushController.java
new file mode 100644
index 0000000..6d1e468
--- /dev/null
+++ b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/system/controller/JpushManualPushController.java
@@ -0,0 +1,76 @@
+package com.tcm.training.admin.system.controller;
+
+import com.tcm.training.admin.common.ApiResponse;
+import com.tcm.training.admin.system.dto.JpushManualPushRequest;
+import com.tcm.training.admin.system.service.JpushManualPushAppService;
+import com.tcm.training.admin.system.vo.JpushManualPushVO;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.validation.Valid;
+
+/**
+ * 极光手动推送联调接口。
+ */
+@Validated
+@RestController
+@RequestMapping({"/api/v1", "/api"})
+public class JpushManualPushController {
+
+    private final JpushManualPushAppService jpushManualPushAppService;
+
+    public JpushManualPushController(JpushManualPushAppService jpushManualPushAppService) {
+        this.jpushManualPushAppService = jpushManualPushAppService;
+    }
+
+    /**
+     * 按APP用户ID推送固定联调消息。
+     */
+    @PostMapping("/jpush/manual-push")
+    public ApiResponse<JpushManualPushVO> push(@Valid @RequestBody JpushManualPushRequest request) {
+        return ApiResponse.ok(jpushManualPushAppService.push(request));
+    }
+
+    /**
+     * 生成会员到期提醒测试通知并推送。
+     */
+    @PostMapping("/jpush/manual-push/member-expire-remind")
+    public ApiResponse<JpushManualPushVO> pushMemberExpireRemind(@Valid @RequestBody JpushManualPushRequest request) {
+        return ApiResponse.ok(jpushManualPushAppService.pushMemberExpireRemind(request));
+    }
+
+    /**
+     * 生成直播提醒测试通知并推送。
+     */
+    @PostMapping("/jpush/manual-push/live-remind")
+    public ApiResponse<JpushManualPushVO> pushLiveRemind(@Valid @RequestBody JpushManualPushRequest request) {
+        return ApiResponse.ok(jpushManualPushAppService.pushLiveRemind(request));
+    }
+
+    /**
+     * 生成活动通知测试通知并推送。
+     */
+    @PostMapping("/jpush/manual-push/activity-notice")
+    public ApiResponse<JpushManualPushVO> pushActivityNotice(@Valid @RequestBody JpushManualPushRequest request) {
+        return ApiResponse.ok(jpushManualPushAppService.pushActivityNotice(request));
+    }
+
+    /**
+     * 生成订单状态更新测试通知并推送。
+     */
+    @PostMapping("/jpush/manual-push/order-status-update")
+    public ApiResponse<JpushManualPushVO> pushOrderStatusUpdate(@Valid @RequestBody JpushManualPushRequest request) {
+        return ApiResponse.ok(jpushManualPushAppService.pushOrderStatusUpdate(request));
+    }
+
+    /**
+     * 生成课程更新测试通知并推送。
+     */
+    @PostMapping("/jpush/manual-push/course-update-notice")
+    public ApiResponse<JpushManualPushVO> pushCourseUpdateNotice(@Valid @RequestBody JpushManualPushRequest request) {
+        return ApiResponse.ok(jpushManualPushAppService.pushCourseUpdateNotice(request));
+    }
+}
diff --git a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/system/dto/JpushManualPushRequest.java b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/system/dto/JpushManualPushRequest.java
new file mode 100644
index 0000000..1a7b5f0
--- /dev/null
+++ b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/system/dto/JpushManualPushRequest.java
@@ -0,0 +1,18 @@
+package com.tcm.training.admin.system.dto;
+
+import lombok.Getter;
+import lombok.Setter;
+
+import javax.validation.constraints.NotNull;
+
+/**
+ * 极光手动推送联调请求。
+ */
+@Getter
+@Setter
+public class JpushManualPushRequest {
+
+    /** 推送目标APP用户ID。 */
+    @NotNull(message = "请选择推送用户")
+    private Long appUserId;
+}
diff --git a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/system/service/JpushManualPushAppService.java b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/system/service/JpushManualPushAppService.java
new file mode 100644
index 0000000..c549b1d
--- /dev/null
+++ b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/system/service/JpushManualPushAppService.java
@@ -0,0 +1,163 @@
+package com.tcm.training.admin.system.service;
+
+import com.tcm.training.admin.app.notice.entity.AppSystemNotice;
+import com.tcm.training.admin.app.notice.service.AppSystemNoticeDispatchCommand;
+import com.tcm.training.admin.app.notice.service.AppSystemNoticeDispatchService;
+import com.tcm.training.admin.app.notice.service.AppSystemNoticeType;
+import com.tcm.training.admin.system.dto.JpushManualPushRequest;
+import com.tcm.training.admin.system.vo.JpushManualPushVO;
+import com.tcm.training.admin.utils.JGPushUtil;
+import org.springframework.stereotype.Service;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/**
+ * 极光手动推送联调应用服务。
+ */
+@Service
+public class JpushManualPushAppService {
+
+    private static final String TEST_TITLE = "极光推送联调";
+    private static final String TEST_CONTENT = "这是一条极光推送联调测试消息";
+    private static final String TEST_SCENE = "JPUSH_MANUAL_TEST";
+    private static final String MANUAL_NOTICE_TEST_SCENE = "MANUAL_NOTICE_TEST";
+    private static final String PUSH_STATUS_SUCCESS = "SUCCESS";
+
+    private final JGPushUtil jgPushUtil;
+    private final AppSystemNoticeDispatchService appSystemNoticeDispatchService;
+
+    public JpushManualPushAppService(JGPushUtil jgPushUtil,
+                                     AppSystemNoticeDispatchService appSystemNoticeDispatchService) {
+        this.jgPushUtil = jgPushUtil;
+        this.appSystemNoticeDispatchService = appSystemNoticeDispatchService;
+    }
+
+    /**
+     * 按APP用户ID发起固定内容的极光推送联调。
+     */
+    public JpushManualPushVO push(JpushManualPushRequest request) {
+        String alias = String.valueOf(request.getAppUserId());
+        Map<String, String> payload = new LinkedHashMap<String, String>();
+        payload.put("scene", TEST_SCENE);
+        payload.put("appUserId", alias);
+
+        // 极光APP端alias约定为APP用户ID字符串,用于验证设备绑定与推送链路。
+        jgPushUtil.push(TEST_CONTENT, TEST_TITLE, payload, alias);
+
+        JpushManualPushVO result = new JpushManualPushVO();
+        result.setAppUserId(request.getAppUserId());
+        result.setAlias(alias);
+        result.setTitle(TEST_TITLE);
+        result.setContent(TEST_CONTENT);
+        result.setPushed(true);
+        return result;
+    }
+
+    /**
+     * 生成会员到期提醒测试通知并尝试极光推送。
+     */
+    public JpushManualPushVO pushMemberExpireRemind(JpushManualPushRequest request) {
+        return pushNoticeType(request,
+                AppSystemNoticeType.MEMBER_EXPIRE_REMIND,
+                "会员到期提醒",
+                "您的会员有效期将于测试日期到期",
+                false);
+    }
+
+    /**
+     * 生成直播提醒测试通知并尝试极光推送。
+     */
+    public JpushManualPushVO pushLiveRemind(JpushManualPushRequest request) {
+        return pushNoticeType(request,
+                AppSystemNoticeType.LIVE_REMIND,
+                "直播提醒",
+                "测试直播课程 即将开播",
+                true);
+    }
+
+    /**
+     * 生成活动通知测试通知并尝试极光推送。
+     */
+    public JpushManualPushVO pushActivityNotice(JpushManualPushRequest request) {
+        return pushNoticeType(request,
+                AppSystemNoticeType.ACTIVITY_NOTICE,
+                "活动通知",
+                "这是一条活动通知联调测试消息",
+                true);
+    }
+
+    /**
+     * 生成订单状态更新测试通知并尝试极光推送。
+     */
+    public JpushManualPushVO pushOrderStatusUpdate(JpushManualPushRequest request) {
+        return pushNoticeType(request,
+                AppSystemNoticeType.ORDER_STATUS_UPDATE,
+                "订单状态更新",
+                "您的礼品兑换订单状态已更新",
+                true);
+    }
+
+    /**
+     * 生成课程更新测试通知并尝试极光推送。
+     */
+    public JpushManualPushVO pushCourseUpdateNotice(JpushManualPushRequest request) {
+        return pushNoticeType(request,
+                AppSystemNoticeType.COURSE_UPDATE_NOTICE,
+                "课程更新通知",
+                "测试课程 已更新新章节",
+                true);
+    }
+
+    /**
+     * 按指定通知类型生成系统通知,并复用现有通知推送开关和极光推送链路。
+     */
+    private JpushManualPushVO pushNoticeType(JpushManualPushRequest request,
+                                             String noticeTypeCode,
+                                             String title,
+                                             String content,
+                                             boolean requireUserSetting) {
+        Long referenceId = System.currentTimeMillis();
+        AppSystemNoticeDispatchCommand command = new AppSystemNoticeDispatchCommand();
+        command.setAppUserId(request.getAppUserId());
+        command.setNoticeTypeCode(noticeTypeCode);
+        command.setTitle(title);
+        command.setContent(content);
+        command.setReferenceType("MANUAL_TEST_" + noticeTypeCode);
+        command.setReferenceId(referenceId);
+        command.setRequireUserSetting(requireUserSetting);
+        command.setPayload(buildManualNoticePayload(request.getAppUserId(), noticeTypeCode, referenceId));
+        AppSystemNotice notice = appSystemNoticeDispatchService.createAndPush(command);
+        return toNoticePushVO(notice);
+    }
+
+    /**
+     * 构造手动通知测试透传参数,便于APP端区分联调消息。
+     */
+    private Map<String, String> buildManualNoticePayload(Long appUserId, String noticeTypeCode, Long referenceId) {
+        Map<String, String> payload = new LinkedHashMap<String, String>();
+        payload.put("scene", MANUAL_NOTICE_TEST_SCENE);
+        payload.put("noticeTypeCode", noticeTypeCode);
+        payload.put("appUserId", String.valueOf(appUserId));
+        payload.put("referenceId", String.valueOf(referenceId));
+        return payload;
+    }
+
+    /**
+     * 转换系统通知推送结果为联调接口响应。
+     */
+    private JpushManualPushVO toNoticePushVO(AppSystemNotice notice) {
+        JpushManualPushVO result = new JpushManualPushVO();
+        result.setNoticeId(notice.getId());
+        result.setAppUserId(notice.getAppUserId());
+        result.setNoticeTypeCode(notice.getNoticeTypeCode());
+        result.setAlias(String.valueOf(notice.getAppUserId()));
+        result.setTitle(notice.getTitle());
+        result.setContent(notice.getContent());
+        result.setPushStatus(notice.getPushStatus());
+        result.setPushErrorMessage(notice.getPushErrorMessage());
+        result.setPushedAt(notice.getPushedAt());
+        result.setPushed(PUSH_STATUS_SUCCESS.equals(notice.getPushStatus()));
+        return result;
+    }
+}
diff --git a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/system/vo/JpushManualPushVO.java b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/system/vo/JpushManualPushVO.java
new file mode 100644
index 0000000..eb17905
--- /dev/null
+++ b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/system/vo/JpushManualPushVO.java
@@ -0,0 +1,44 @@
+package com.tcm.training.admin.system.vo;
+
+import lombok.Getter;
+import lombok.Setter;
+
+import java.time.LocalDateTime;
+
+/**
+ * 极光手动推送联调结果。
+ */
+@Getter
+@Setter
+public class JpushManualPushVO {
+
+    /** 系统通知ID。 */
+    private Long noticeId;
+
+    /** 推送目标APP用户ID。 */
+    private Long appUserId;
+
+    /** 通知类型编码。 */
+    private String noticeTypeCode;
+
+    /** 极光推送别名。 */
+    private String alias;
+
+    /** 推送标题。 */
+    private String title;
+
+    /** 推送内容。 */
+    private String content;
+
+    /** 推送状态。 */
+    private String pushStatus;
+
+    /** 推送错误信息。 */
+    private String pushErrorMessage;
+
+    /** 推送完成时间。 */
+    private LocalDateTime pushedAt;
+
+    /** 是否推送成功。 */
+    private Boolean pushed;
+}
diff --git a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/utils/JGPushUtil.java b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/utils/JGPushUtil.java
index b172b21..2f8708b 100644
--- a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/utils/JGPushUtil.java
+++ b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/utils/JGPushUtil.java
@@ -50,7 +50,7 @@
                 .setPlatform(Platform.all())//设置推送平台为全部平台
                 .setAudience(Audience.alias(tags))//通过别名的方式发送(user_id)
                 .setNotification(Notification.android(content, title, map))
-                .setNotification(Notification.ios(content, map))
+                // .setNotification(Notification.ios(content, map))
                 // .setNotification(Notification.winphone(content, map))
                 .build();
     }
diff --git a/tcm-training-admin-backend/src/main/resources/application.yml b/tcm-training-admin-backend/src/main/resources/application.yml
index 623eac6..29724ec 100644
--- a/tcm-training-admin-backend/src/main/resources/application.yml
+++ b/tcm-training-admin-backend/src/main/resources/application.yml
@@ -122,10 +122,10 @@
       jwks-url: https://appleid.apple.com/auth/keys
   share:
     # 邀请二维码默认指向线上下载页,开发环境可在 application-local.yml 覆盖。
-    landing-base-url: https://haizhentong.com/app-download/
+    landing-base-url: https://ag8spw.oplinking.com/
 jpush:
-  appKey: d2c5362f73af203f25704b8d
-  masterSecret: c6996db56be1a93ae16fc1b0
+  appKey: b5aed7d076638a0d835786bd
+  masterSecret: e17305459be4f0011ca15c9e
 
 logging:
   level:
diff --git a/traditionalApp/App.vue b/traditionalApp/App.vue
index b53ec9b..782cb6e 100644
--- a/traditionalApp/App.vue
+++ b/traditionalApp/App.vue
@@ -8,40 +8,70 @@
 		extractInviteCode
 	} from '@/utils/utils.js'
 	import { initJPushLifecycle } from '@/utils/push.js'
+	import {
+		getOpeninstallInstallData,
+		initOpeninstall,
+		registerOpeninstallWakeUp
+	} from '@/utils/openinstall.js'
 
-	let lastHandledBindTicket = ''
+	let lastHandledInvitePayload = ''
 
 	/**
-	 * 应用启动时保留钩子,当前不再依赖系统剪贴板承接推广关系。
+	 * 应用启动时初始化极光与 openinstall,承接推广关系参数。
 	 */
 	onLaunch(() => {
 		// #ifdef APP-PLUS
 		initJPushLifecycle()
+		initOpeninstall()
+		registerOpeninstallWakeUp(handleOpeninstallPayload)
+		getOpeninstallInstallData(handleOpeninstallPayload)
 		// #endif
 	})
 
 	/**
-	 * App 被外部 Scheme 拉起时,解析 bindTicket 并跳转注册页。
+	 * App 被外部 Scheme 拉起时,解析 bindTicket 或 inviteCode 并跳转注册页。
 	 */
 	onShow(() => {
 		handleOpenByScheme()
 	})
 
 	/**
-	 * 读取原生启动参数中的绑定票据,并在命中时跳转注册页。
+	 * 读取原生启动参数中的绑定票据或邀请码,并在命中时跳转注册页。
 	 */
 	function handleOpenByScheme() {
 		// #ifdef APP-PLUS
 		const runtimeArguments = typeof plus !== 'undefined' && plus.runtime ? plus.runtime.arguments : ''
-		const bindTicket = extractBindTicket(runtimeArguments)
-		if (!bindTicket || bindTicket === lastHandledBindTicket) {
+		handleInvitePayload(runtimeArguments)
+		// #endif
+	}
+
+	/**
+	 * 处理 openinstall 安装参数或唤醒参数。
+	 */
+	function handleOpeninstallPayload(payload) {
+		handleInvitePayload(payload)
+	}
+
+	/**
+	 * 统一解析外部推广参数,并跳转到注册页回填。 
+	 */
+	function handleInvitePayload(payload) {
+		const bindTicket = extractBindTicket(payload)
+		const inviteCode = extractInviteCode(payload)
+		if (!bindTicket && !inviteCode) {
 			return
 		}
-		lastHandledBindTicket = bindTicket
-		const inviteCode = extractInviteCode(runtimeArguments)
+		const payloadKey = `${bindTicket || ''}_${inviteCode || ''}`
+		if (payloadKey === lastHandledInvitePayload) {
+			return
+		}
+		lastHandledInvitePayload = payloadKey
 		const currentPages = getCurrentPages()
 		const currentRoute = currentPages && currentPages.length > 0 ? currentPages[currentPages.length - 1].route : ''
-		const targetParams = [`bindTicket=${encodeURIComponent(bindTicket)}`]
+		const targetParams = []
+		if (bindTicket) {
+			targetParams.push(`bindTicket=${encodeURIComponent(bindTicket)}`)
+		}
 		if (inviteCode) {
 			targetParams.push(`inviteCode=${encodeURIComponent(inviteCode)}`)
 		}
@@ -55,7 +85,6 @@
 		uni.navigateTo({
 			url: targetUrl
 		})
-		// #endif
 	}
 </script>
 
diff --git a/traditionalApp/manifest.json b/traditionalApp/manifest.json
index 81fab2c..a3df16a 100644
--- a/traditionalApp/manifest.json
+++ b/traditionalApp/manifest.json
@@ -53,6 +53,11 @@
             /* ios打包配置 */
             "ios" : {
                 "dSYMs" : false,
+				"privacyDescription": {
+					"NSCameraUsageDescription": "需要使用相机拍摄照片 / 视频,用于扫码自动填充邀请码、拍摄照片作为头像",
+					"NSPhotoLibraryUsageDescription": "需要访问手机相册,用于选择图片 / 视频上传、更换个人头像、选择邀请二维码",
+					"NSPhotoLibraryAddUsageDescription": "需要访问手机相册,用于选择图片 / 视频上传、更换个人头像、选择邀请二维码"
+				},
                 "capabilities" : {
                     "entitlements" : {
                         "com.apple.developer.associated-domains" : [
@@ -60,7 +65,8 @@
                         ]
                     }
                 },
-                "idfa" : false
+                "idfa" : false,
+				"urltypes": "ag8spw"
             },
             /* SDK配置 */
             "sdkConfigs" : {
@@ -124,6 +130,33 @@
             }
         },
         "nativePlugins" : {
+			"openinstall-plugin": {
+				"APP_KEY_ANDROID": "ag8spw",
+				"APP_KEY_IOS": "ag8spw",
+				"__plugin_info__": {
+					"name": "openinstall",
+					"description": "openinstall 一键拉起与免填邀请码插件",
+					"platforms": "Android,iOS",
+					"url": "https://ext.dcloud.net.cn/plugin?id=692",
+					"android_package_name": "com.HaiZhenKangDao.hzt",
+					"ios_bundle_id": "com.HaiZhenKangDao.hzt",
+					"isCloud": true,
+					"bought": 1,
+					"pid": "692",
+					"parameters": {
+						"APP_KEY_ANDROID": {
+							"des": "[Android]openinstall 控制台分配的 AppKey",
+							"key": "com.openinstall.APP_KEY",
+							"value": ""
+						},
+						"APP_KEY_IOS": {
+							"des": "[iOS]openinstall 控制台分配的 AppKey",
+							"key": "com.openinstall.APP_KEY",
+							"value": ""
+						}
+					}
+				}
+			},
             "JG-JCore" : {
                 "JPUSH_APPKEY_ANDROID" : "b5aed7d076638a0d835786bd",
                 "JPUSH_APPKEY_IOS" : "b5aed7d076638a0d835786bd",
diff --git a/traditionalApp/pages/register/register.vue b/traditionalApp/pages/register/register.vue
index 5902300..0d7abf3 100644
--- a/traditionalApp/pages/register/register.vue
+++ b/traditionalApp/pages/register/register.vue
@@ -176,6 +176,7 @@
 	validateInviteBindTicket
 } from './service.js'
 import { extractBindTicket, extractInviteCode, normalizeOptionalText } from '@/utils/utils.js'
+import { reportOpeninstallRegister } from '@/utils/openinstall.js'
 import { confirmCameraForInviteCodeScan, confirmMediaForLicense } from '@/utils/permission.js'
 
 interface RegionItem {
@@ -816,6 +817,8 @@
  * 注册成功后提示并返回登录页。
  */
 const handleRegisterSuccess = async (message: string) => {
+	// 注册成功后通知 openinstall,用于统计推广链路的注册转化。
+	reportOpeninstallRegister()
 	uni.showToast({
 		title: message,
 		icon: 'success'
diff --git a/traditionalApp/utils/openinstall.js b/traditionalApp/utils/openinstall.js
new file mode 100644
index 0000000..7faae0b
--- /dev/null
+++ b/traditionalApp/utils/openinstall.js
@@ -0,0 +1,98 @@
+let openinstallPlugin = null
+let initialized = false
+let wakeUpRegistered = false
+
+/**
+ * 安全获取 openinstall 原生插件实例,非 APP 或插件不存在时返回空。
+ */
+export const getOpeninstallPlugin = () => {
+	// #ifdef APP-PLUS
+	if (openinstallPlugin) {
+		return openinstallPlugin
+	}
+	try {
+		openinstallPlugin = uni.requireNativePlugin('openinstall-plugin')
+	} catch (error) {
+		console.log('openinstall 插件不可用', error)
+		openinstallPlugin = null
+	}
+	return openinstallPlugin
+	// #endif
+	// #ifndef APP-PLUS
+	return null
+	// #endif
+}
+
+/**
+ * 初始化 openinstall 插件,避免多次启动重复初始化。
+ */
+export const initOpeninstall = () => {
+	const plugin = getOpeninstallPlugin()
+	if (!plugin || initialized) {
+		return false
+	}
+	try {
+		plugin.init()
+		initialized = true
+		return true
+	} catch (error) {
+		console.log('openinstall 初始化失败', error)
+		return false
+	}
+}
+
+/**
+ * 注册 openinstall 拉起参数监听,用于已安装 APP 的扫码唤醒场景。
+ */
+export const registerOpeninstallWakeUp = (callback) => {
+	const plugin = getOpeninstallPlugin()
+	if (!plugin || wakeUpRegistered || typeof callback !== 'function') {
+		return false
+	}
+	try {
+		plugin.registerWakeUp((result) => {
+			callback(result)
+		})
+		wakeUpRegistered = true
+		return true
+	} catch (error) {
+		console.log('openinstall 拉起监听注册失败', error)
+		return false
+	}
+}
+
+/**
+ * 读取 openinstall 安装参数,用于未安装 APP 下载后首次打开场景。
+ */
+export const getOpeninstallInstallData = (callback) => {
+	const plugin = getOpeninstallPlugin()
+	if (!plugin || typeof callback !== 'function') {
+		return false
+	}
+	try {
+		plugin.getInstall(8, (result) => {
+			callback(result)
+		})
+		return true
+	} catch (error) {
+		console.log('openinstall 安装参数获取失败', error)
+		return false
+	}
+}
+
+/**
+ * 注册成功后上报 openinstall,便于后台统计注册转化。
+ */
+export const reportOpeninstallRegister = () => {
+	const plugin = getOpeninstallPlugin()
+	if (!plugin) {
+		return false
+	}
+	try {
+		plugin.reportRegister()
+		return true
+	} catch (error) {
+		console.log('openinstall 注册上报失败', error)
+		return false
+	}
+}
diff --git a/traditionalApp/utils/utils.js b/traditionalApp/utils/utils.js
index f129ac8..7032f75 100644
--- a/traditionalApp/utils/utils.js
+++ b/traditionalApp/utils/utils.js
@@ -225,33 +225,101 @@
 }
 
 /**
- * 从 URL、纯文本或扫码内容中提取 6 位邀请码。
+ * 将外部唤醒参数归一化为可解析文本,兼容 openinstall 对象、JSON 和 URL。
+ */
+export const normalizeInvitePayloadText = (value) => {
+	if (!value) {
+		return ''
+	}
+	if (typeof value === 'string') {
+		return value.trim()
+	}
+	if (typeof value !== 'object') {
+		return ''
+	}
+	const candidateKeys = ['bindData', 'data', 'channelData', 'params', 'wakeUpData']
+	for (const key of candidateKeys) {
+		const nestedValue = value[key]
+		if (nestedValue) {
+			const nestedText = normalizeInvitePayloadText(nestedValue)
+			if (nestedText) {
+				return nestedText
+			}
+		}
+	}
+	try {
+		return JSON.stringify(value)
+	} catch {
+		return ''
+	}
+}
+
+/**
+ * 从 URL、纯文本、对象或扫码内容中提取 6 位邀请码。
  */
 export const extractInviteCode = (value) => {
-	const normalizedValue = normalizeOptionalText(value)
+	const normalizedValue = normalizeInvitePayloadText(value)
 	if (!normalizedValue) {
 		return ''
 	}
-	const queryMatchedInviteCode = normalizedValue.match(/[?&]inviteCode=([^&#]+)/i)
+	const objectInviteCode = extractObjectField(normalizedValue, 'inviteCode')
+	if (objectInviteCode) {
+		return normalizeInviteCodeText(objectInviteCode)
+	}
+	const queryMatchedInviteCode = normalizedValue.match(/(?:^|[?&])inviteCode=([^&#]+)/i)
 	if (queryMatchedInviteCode && queryMatchedInviteCode[1]) {
-		const decodedInviteCode = decodeURIComponent(queryMatchedInviteCode[1]).trim().toUpperCase()
-		return /^[A-Z0-9]{6}$/.test(decodedInviteCode) ? decodedInviteCode : ''
+		return normalizeInviteCodeText(decodeURIComponent(queryMatchedInviteCode[1]))
 	}
 	const matchedInviteCode = normalizedValue.toUpperCase().match(/[A-Z0-9]{6}/)
 	return matchedInviteCode ? matchedInviteCode[0] : ''
 }
 
 /**
- * 从 URL 中提取 bindTicket 参数。
+ * 从 URL、对象或 JSON 文本中提取 bindTicket 参数。
  */
 export const extractBindTicket = (value) => {
-	const normalizedValue = normalizeOptionalText(value)
+	const normalizedValue = normalizeInvitePayloadText(value)
 	if (!normalizedValue) {
 		return ''
 	}
-	const queryMatchedBindTicket = normalizedValue.match(/[?&]bindTicket=([^&#]+)/i)
+	const objectBindTicket = extractObjectField(normalizedValue, 'bindTicket')
+	if (objectBindTicket) {
+		return objectBindTicket.trim()
+	}
+	const queryMatchedBindTicket = normalizedValue.match(/(?:^|[?&])bindTicket=([^&#]+)/i)
 	if (!queryMatchedBindTicket || !queryMatchedBindTicket[1]) {
 		return ''
 	}
 	return decodeURIComponent(queryMatchedBindTicket[1]).trim()
 }
+
+/**
+ * 从 JSON 字符串中读取指定字段。
+ */
+const extractObjectField = (value, fieldName) => {
+	try {
+		const parsedValue = JSON.parse(value)
+		if (!parsedValue || typeof parsedValue !== 'object') {
+			return ''
+		}
+		const directValue = parsedValue[fieldName]
+		if (typeof directValue === 'string') {
+			return directValue
+		}
+		const bindDataValue = parsedValue.bindData || parsedValue.data || parsedValue.params
+		if (bindDataValue && bindDataValue !== value) {
+			return extractObjectField(normalizeInvitePayloadText(bindDataValue), fieldName)
+		}
+	} catch {
+		return ''
+	}
+	return ''
+}
+
+/**
+ * 标准化邀请码文本。
+ */
+const normalizeInviteCodeText = (value) => {
+	const inviteCode = normalizeOptionalText(value).toUpperCase()
+	return /^[A-Z0-9]{6}$/.test(inviteCode) ? inviteCode : ''
+}

--
Gitblit v1.7.1