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 tcm-training-admin-backend/src/main/java/com/tcm/training/admin/system/controller/JpushManualPushController.java
New file @@ -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)); } } tcm-training-admin-backend/src/main/java/com/tcm/training/admin/system/dto/JpushManualPushRequest.java
New file @@ -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; } tcm-training-admin-backend/src/main/java/com/tcm/training/admin/system/service/JpushManualPushAppService.java
New file @@ -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; } } tcm-training-admin-backend/src/main/java/com/tcm/training/admin/system/vo/JpushManualPushVO.java
New file @@ -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; } 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(); } 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: 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> 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", 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' traditionalApp/utils/openinstall.js
New file @@ -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 } } 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 : '' }