接入微信分享 SDK 并配置 Universal Link。

集成 WechatOpenSDK、www.zhifly.cn/suixinkan/ 通用链接与邀请页分享能力,附带 AASA 部署文件与单元测试。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-02 15:02:37 +08:00
parent 5d34cda730
commit a528d9b0d7
48 changed files with 5260 additions and 506 deletions

View File

@ -7,8 +7,38 @@
import UIKit
/// AppDelegate APNs SwiftUI App adaptor
/// AppDelegate APNs SDK SwiftUI App adaptor
final class AppDelegate: NSObject, UIApplicationDelegate {
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
) -> Bool {
Task { @MainActor in
WeChatManager.shared.registerIfNeeded()
}
return true
}
func application(
_ app: UIApplication,
open url: URL,
options: [UIApplication.OpenURLOptionsKey: Any] = [:]
) -> Bool {
MainActor.assumeIsolated {
WeChatManager.shared.handleOpenURL(url)
}
}
func application(
_ application: UIApplication,
continue userActivity: NSUserActivity,
restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void
) -> Bool {
MainActor.assumeIsolated {
WeChatManager.shared.handleContinueUserActivity(userActivity)
}
}
func application(
_ application: UIApplication,
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data

View File

@ -0,0 +1,36 @@
//
// WeChatConfig.swift
// suixinkan
//
// Open SDK 线 AppID Info.plist URL Scheme
//
import Foundation
/// AppID Universal Link
enum WeChatConfig {
/// AppID线 AppID
static let appID = "wxdd80ec12c4fee322"
/// Universal Link `/`
static let universalLink = "https://www.zhifly.cn/suixinkan/"
/// Associated Domains `applinks:`
static let associatedDomain = "www.zhifly.cn"
/// AASA paths apple-app-site-association
static let aasaPathPattern = "/suixinkan/*"
/// Apple Team ID + Bundle ID AASA 使
static let aasaAppID = "56GVN5RNVN.com.yuanzhixiang.suixinkan"
/// Universal Link
static func validateUniversalLink(_ link: String) -> Bool {
guard let url = URL(string: link),
url.scheme?.lowercased() == "https",
link.hasSuffix("/") else {
return false
}
return true
}
}

View File

@ -20,6 +20,12 @@ struct suixinkanApp: App {
var body: some Scene {
WindowGroup {
RootView()
.onOpenURL { url in
WeChatManager.shared.handleOpenURL(url)
}
.onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { userActivity in
_ = WeChatManager.shared.handleContinueUserActivity(userActivity)
}
}
}
}

View File

@ -0,0 +1,59 @@
# WeChat 模块业务逻辑
## 模块职责
WeChat 模块封装微信 Open SDK 的注册、Universal Link 回跳与分享能力。当前仅接入分享(文本、网页、图片),不调用微信支付 API。
## 配置
- `WeChatConfig`AppID、Universal Link、AASA 部署参考信息
- `Info.plist`URL SchemeAppID`LSApplicationQueriesSchemes`
- `suixinkan.entitlements``applinks:www.zhifly.cn`
### Universal Link
- 地址:`https://www.zhifly.cn/suixinkan/`(必须以 `/` 结尾)
- 运维需在 `https://www.zhifly.cn/.well-known/apple-app-site-association` 部署 AASA 文件
- 示例见 [`docs/apple-app-site-association.example.json`](../../docs/apple-app-site-association.example.json)
### 微信开放平台
移动应用开发信息需填写:
| 字段 | 值 |
|------|-----|
| iOS Bundle ID | `com.yuanzhixiang.suixinkan` |
| Universal Links | `https://www.zhifly.cn/suixinkan/` |
## 核心类型
| 类型 | 职责 |
|------|------|
| `WeChatManager` | SDK 注册、URL/UL 回调、`WXApiDelegate` 分享结果 |
| `WeChatShareService` | 对外分享 API`shareText` / `shareWebPage` / `shareImage` |
| `WeChatShareSheet` | SwiftUI 分享面板(好友/朋友圈/系统兜底) |
| `WeChatThumbImageProcessor` | 缩略图压缩至 32KB 以内 |
## 回调链路
1. 启动:`AppDelegate.didFinishLaunching``WeChatManager.registerIfNeeded()`
2. URL Scheme`AppDelegate.openURL` + `suixinkanApp.onOpenURL`
3. Universal Link`AppDelegate.continueUserActivity` + `suixinkanApp.onContinueUserActivity`
## 业务接入
邀请页 `PhotographerInviteView` 通过 `WeChatShareSheet` 分享邀请链接。
后续举报、排队二维码等入口可复用 `WeChatShareService`
## 真机验收
1. Safari 访问 `https://www.zhifly.cn/suixinkan/test`,下拉应出现打开 App 入口
2. 邀请页点击「分享」→ 微信好友/朋友圈,首次可能出现「正在连接」属正常
3. 未安装微信时走系统分享兜底
## 上线前
- 替换 `WeChatConfig.appID` 与 Info.plist URL Scheme
- 确认开放平台 Universal Link 与代码一致
- 确认 AASA 文件可公网 HTTPS 访问

View File

@ -0,0 +1,119 @@
//
// WeChatManager.swift
// suixinkan
//
import Foundation
import WechatOpenSDK
/// SDK URL/Universal Link
@MainActor
final class WeChatManager: NSObject {
static let shared = WeChatManager()
/// UI
private(set) var lastShareResult: WeChatShareResult?
private var shareCompletion: ((WeChatShareResult) -> Void)?
private var isRegistered = false
private override init() {
super.init()
}
/// AppID Universal Link
func registerIfNeeded() {
guard !isRegistered else { return }
guard WeChatConfig.validateUniversalLink(WeChatConfig.universalLink) else {
assertionFailure("WeChat Universal Link 必须以 https 开头并以 / 结尾")
return
}
#if DEBUG
WXApi.startLog(by: WXLogLevel.detail) { log in
print("WeChatSDK: \(log)")
}
#endif
let success = WXApi.registerApp(WeChatConfig.appID, universalLink: WeChatConfig.universalLink)
isRegistered = success
#if DEBUG
if success {
checkUniversalLinkReady()
}
#endif
}
/// URL Scheme
@discardableResult
func handleOpenURL(_ url: URL) -> Bool {
WXApi.handleOpen(url, delegate: self)
}
/// Universal Link
@discardableResult
func handleContinueUserActivity(_ userActivity: NSUserActivity) -> Bool {
WXApi.handleOpenUniversalLink(userActivity, delegate: self)
}
///
func setShareCompletion(_ completion: ((WeChatShareResult) -> Void)?) {
shareCompletion = completion
}
///
func sendShareRequest(_ request: SendMessageToWXReq, completion: ((WeChatShareResult) -> Void)? = nil) {
guard WXApi.isWXAppInstalled() else {
let result = WeChatShareResult.notInstalled
completion?(result)
return
}
guard WXApi.isWXAppSupport() else {
let result = WeChatShareResult.unsupported
completion?(result)
return
}
if let completion {
shareCompletion = completion
}
WXApi.send(request) { success in
if !success {
Task { @MainActor in
self.finishShare(with: .sendFailed(-1))
}
}
}
}
#if DEBUG
/// Universal Link DEBUG
private func checkUniversalLinkReady() {
WXApi.checkUniversalLinkReady { step, result in
print("WeChat UL check step=\(step.rawValue) success=\(result.success) info=\(result.errorInfo ?? "") suggestion=\(result.suggestion ?? "")")
}
}
#endif
private func finishShare(with result: WeChatShareResult) {
lastShareResult = result
shareCompletion?(result)
shareCompletion = nil
}
}
extension WeChatManager: WXApiDelegate {
nonisolated func onReq(_ req: BaseReq) {
//
}
nonisolated func onResp(_ resp: BaseResp) {
guard let shareResp = resp as? SendMessageToWXResp else { return }
let result = WeChatShareResult.fromWXErrorCode(shareResp.errCode)
Task { @MainActor in
self.finishShare(with: result)
}
}
}

View File

@ -0,0 +1,70 @@
//
// WeChatShareModels.swift
// suixinkan
//
import Foundation
///
enum WeChatShareScene: Int, CaseIterable, Sendable {
///
case session = 0
///
case timeline = 1
///
case favorite = 2
///
var title: String {
switch self {
case .session: "微信好友"
case .timeline: "朋友圈"
case .favorite: "收藏"
}
}
/// SDK scene
var wxSceneValue: Int32 {
Int32(rawValue)
}
}
///
enum WeChatShareResult: Equatable, Sendable {
case success
case cancelled
case notInstalled
case unsupported
case invalidPayload(String)
case sendFailed(Int)
case unknown(Int)
/// SDK
static func fromWXErrorCode(_ code: Int32) -> WeChatShareResult {
switch code {
case 0:
.success
case -2:
.cancelled
default:
.unknown(Int(code))
}
}
}
///
struct WeChatWebPageSharePayload: Equatable, Sendable {
let title: String
let description: String
let url: String
}
///
struct WeChatTextSharePayload: Equatable, Sendable {
let text: String
}
///
struct WeChatImageSharePayload: Equatable, Sendable {
let imageData: Data
}

View File

@ -0,0 +1,115 @@
//
// WeChatShareService.swift
// suixinkan
//
import UIKit
import WechatOpenSDK
///
enum WeChatShareService {
/// Open SDK
static var isWeChatAvailable: Bool {
WXApi.isWXAppInstalled() && WXApi.isWXAppSupport()
}
///
@MainActor
static func shareText(
_ payload: WeChatTextSharePayload,
scene: WeChatShareScene,
completion: ((WeChatShareResult) -> Void)? = nil
) {
let trimmed = payload.text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else {
completion?(.invalidPayload("分享文本不能为空"))
return
}
let request = SendMessageToWXReq()
request.bText = true
request.text = trimmed
request.scene = scene.wxSceneValue
WeChatManager.shared.sendShareRequest(request, completion: completion)
}
///
@MainActor
static func shareWebPage(
_ payload: WeChatWebPageSharePayload,
scene: WeChatShareScene,
thumbImage: UIImage? = nil,
completion: ((WeChatShareResult) -> Void)? = nil
) {
let title = payload.title.trimmingCharacters(in: .whitespacesAndNewlines)
let url = payload.url.trimmingCharacters(in: .whitespacesAndNewlines)
guard !title.isEmpty else {
completion?(.invalidPayload("分享标题不能为空"))
return
}
guard !url.isEmpty else {
completion?(.invalidPayload("分享链接不能为空"))
return
}
let webpageObject = WXWebpageObject()
webpageObject.webpageUrl = url
let message = WXMediaMessage()
message.title = title
message.description = payload.description
message.mediaObject = webpageObject
if let thumbImage, let thumbData = WeChatThumbImageProcessor.makeThumbData(from: thumbImage) {
message.thumbData = thumbData
}
let request = SendMessageToWXReq()
request.bText = false
request.message = message
request.scene = scene.wxSceneValue
WeChatManager.shared.sendShareRequest(request, completion: completion)
}
///
@MainActor
static func shareImage(
_ payload: WeChatImageSharePayload,
scene: WeChatShareScene,
thumbImage: UIImage? = nil,
completion: ((WeChatShareResult) -> Void)? = nil
) {
guard !payload.imageData.isEmpty else {
completion?(.invalidPayload("分享图片不能为空"))
return
}
let imageObject = WXImageObject()
imageObject.imageData = payload.imageData
let message = WXMediaMessage()
message.mediaObject = imageObject
if let thumbImage, let thumbData = WeChatThumbImageProcessor.makeThumbData(from: thumbImage) {
message.thumbData = thumbData
} else if let image = UIImage(data: payload.imageData),
let thumbData = WeChatThumbImageProcessor.makeThumbData(from: image) {
message.thumbData = thumbData
}
let request = SendMessageToWXReq()
request.bText = false
request.message = message
request.scene = scene.wxSceneValue
WeChatManager.shared.sendShareRequest(request, completion: completion)
}
/// App
static func defaultThumbImage() -> UIImage? {
guard let icons = Bundle.main.infoDictionary?["CFBundleIcons"] as? [String: Any],
let primary = icons["CFBundlePrimaryIcon"] as? [String: Any],
let iconFiles = primary["CFBundleIconFiles"] as? [String],
let iconName = iconFiles.last else {
return nil
}
return UIImage(named: iconName)
}
}

View File

@ -0,0 +1,105 @@
//
// WeChatShareSheet.swift
// suixinkan
//
import SwiftUI
import UIKit
/// /
struct WeChatShareSheet: View {
let title: String
let description: String
let url: String
let thumbImage: UIImage?
let onResult: (String) -> Void
@Environment(\.dismiss) private var dismiss
@State private var isPresentingSystemShare = false
var body: some View {
NavigationStack {
List {
if WeChatShareService.isWeChatAvailable {
shareButton("微信好友", systemImage: "message.fill") {
share(scene: .session)
}
shareButton("朋友圈", systemImage: "person.2.fill") {
share(scene: .timeline)
}
}
shareButton("更多分享", systemImage: "square.and.arrow.up") {
isPresentingSystemShare = true
}
}
.navigationTitle("分享")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("取消") { dismiss() }
}
}
}
.sheet(isPresented: $isPresentingSystemShare) {
let text = shareText
ActivityShareView(activityItems: [text]) {
onResult("已打开系统分享")
dismiss()
}
}
}
private var shareText: String {
let trimmedURL = url.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedURL.isEmpty else { return title }
return "\(title)\n\(trimmedURL)"
}
private func shareButton(_ label: String, systemImage: String, action: @escaping () -> Void) -> some View {
Button(action: action) {
Label(label, systemImage: systemImage)
}
}
private func share(scene: WeChatShareScene) {
let payload = WeChatWebPageSharePayload(
title: title,
description: description,
url: url
)
let thumb = thumbImage ?? WeChatShareService.defaultThumbImage()
WeChatShareService.shareWebPage(payload, scene: scene, thumbImage: thumb) { result in
switch result {
case .success:
onResult("已分享到\(scene.title)")
case .cancelled:
onResult("已取消分享")
case .notInstalled:
isPresentingSystemShare = true
case .unsupported:
onResult("当前微信版本不支持分享")
case .invalidPayload(let message):
onResult(message)
case .sendFailed, .unknown:
onResult("分享失败,请稍后重试")
}
dismiss()
}
}
}
///
private struct ActivityShareView: UIViewControllerRepresentable {
let activityItems: [Any]
let onComplete: () -> Void
func makeUIViewController(context: Context) -> UIActivityViewController {
let controller = UIActivityViewController(activityItems: activityItems, applicationActivities: nil)
controller.completionWithItemsHandler = { _, _, _, _ in
onComplete()
}
return controller
}
func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {}
}

View File

@ -0,0 +1,47 @@
//
// WeChatThumbImageProcessor.swift
// suixinkan
//
import UIKit
/// 32KB
enum WeChatThumbImageProcessor {
///
static let maxThumbBytes = 32 * 1024
///
static func makeThumbData(from image: UIImage, maxBytes: Int = maxThumbBytes) -> Data? {
guard maxBytes > 0 else { return nil }
let resized = resize(image, maxPixel: 120)
var quality: CGFloat = 0.8
var data = resized.jpegData(compressionQuality: quality)
while let current = data, current.count > maxBytes, quality > 0.1 {
quality -= 0.1
data = resized.jpegData(compressionQuality: quality)
}
guard let result = data, result.count <= maxBytes else { return nil }
return result
}
/// 使
static func resize(_ image: UIImage, maxPixel: CGFloat) -> UIImage {
let size = image.size
guard size.width > 0, size.height > 0 else { return image }
let maxSide = max(size.width, size.height)
guard maxSide > maxPixel else { return image }
let scale = maxPixel / maxSide
let targetSize = CGSize(width: size.width * scale, height: size.height * scale)
let format = UIGraphicsImageRendererFormat.default()
format.scale = 1
let renderer = UIGraphicsImageRenderer(size: targetSize, format: format)
return renderer.image { _ in
image.draw(in: CGRect(origin: .zero, size: targetSize))
}
}
}

View File

@ -12,6 +12,8 @@ Invite 模块负责首页 `registration_invitation`、`photographer_invite` 和
复制邀请链接时只由 View 层写入剪贴板ViewModel 不关心系统剪贴板状态。
点击「分享」弹出 `WeChatShareSheet`,支持分享到微信好友、朋友圈;未安装微信时走系统分享兜底。分享网页使用 `WeChatShareService.shareWebPage`,缩略图优先使用邀请二维码。
## 邀请记录
`InviteRecordViewModel` 提供“邀请记录 / 奖励记录”分段:

View File

@ -13,6 +13,7 @@ struct PhotographerInviteView: View {
@EnvironmentObject private var toastCenter: ToastCenter
@Environment(\.globalLoading) private var globalLoading
@StateObject private var viewModel = PhotographerInviteViewModel()
@State private var isPresentingShareSheet = false
var body: some View {
ScrollView {
@ -37,6 +38,17 @@ struct PhotographerInviteView: View {
.navigationBarTitleDisplayMode(.inline)
.task { await reload(showLoading: true) }
.refreshable { await reload(showLoading: false) }
.sheet(isPresented: $isPresentingShareSheet) {
WeChatShareSheet(
title: "邀请你加入随心瞰商家版",
description: viewModel.inviteCode.isEmpty ? "摄影师邀请" : "邀请码:\(viewModel.inviteCode)",
url: viewModel.inviteUrl,
thumbImage: viewModel.qrImage
) { message in
toastCenter.show(message)
}
.presentationDetents([.medium])
}
}
private var inviteCard: some View {
@ -77,8 +89,11 @@ struct PhotographerInviteView: View {
.frame(height: 44)
}
Button {
viewModel.copyInviteUrl()
toastCenter.show("链接已复制,可粘贴分享")
guard !viewModel.inviteUrl.isEmpty else {
toastCenter.show("邀请链接加载中,请稍后重试")
return
}
isPresentingShareSheet = true
} label: {
Label("分享", systemImage: "square.and.arrow.up")
.frame(maxWidth: .infinity)

View File

@ -20,6 +20,25 @@
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLName</key>
<string>weixin</string>
<key>CFBundleURLSchemes</key>
<array>
<string>wxdd80ec12c4fee322</string>
</array>
</dict>
</array>
<key>LSApplicationQueriesSchemes</key>
<array>
<string>weixin</string>
<string>weixinULAPI</string>
<string>weixinURLParamsAPI</string>
</array>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSCameraUsageDescription</key>

View File

@ -4,5 +4,9 @@
<dict>
<key>aps-environment</key>
<string>development</string>
<key>com.apple.developer.associated-domains</key>
<array>
<string>applinks:www.zhifly.cn</string>
</array>
</dict>
</plist>