接入微信分享 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

@ -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))
}
}
}