日常bb

日常bb

苹果开发者账号迁移

9
2025-12-08
苹果开发者账号迁移

苹果开发者账号迁移,会对 App、服务器哪些影响?

影响

看自身业务与苹果有产生哪些数据。

  • 苹果推送(Apns 推送);
  • 苹果快捷登录;
  • 苹果订阅。

推送

App 重新登录,代表 App 重新获取苹果推送的 token,并上传给服务器。

证书,指的是 P8 证书。

迁移完成后,App 不重新登录,旧的证书是否能推送成功?

无法推送成功,报错:InvalidProviderToken

迁移完成后,App 重新登录,旧的证书是否能推送成功?

无法推送成功,报错:InvalidProviderToken

迁移完成后,App 不重新登录,新的证书是否能推送成功?

可以推送成功。

迁移完成后,App 重新登录,新的证书是否能推送成功?

可以推送成功。

结论:迁移完成后,需马上更换推送证书。

苹果快捷登录

App 调用原生方法进行安全验证后,获得 code。服务器通过 code 和 clientId、clientSecret 去获取用户唯一标识 sub,服务器统称为 openId,苹果的则为 appleOpenId。

appleOpenId 如果存在,则代表用户已存在,直接登录。
appleOpenId 如果不存在,则代表用户不存在,进行注册并登录。

  • 迁移完成后,旧的 clientId 是否可以完成快捷登录?

可以完成登录,旧用户会得到新的 openId,导致用户信息无法进行对应。新用户则无影响。

  • 迁移完成后,新的 clientId,已存在的用户登录时,获取到的 openId 是否与之前的一致?

也不一致,需要自己根据 transfer_sub 去对应 openId。

结论:不更新 clientId、clientSecret 也可以成功登录,但是产生了新的 openId。

方案

如果仅使用了苹果推送,完成迁移后,仅需更新证书即可。

如果使用了苹果快捷登录,则需要提前完成账号迁移。使用 transfer_sub 去进行账号关联,避免旧用户登录时,当成了新用户。

核心逻辑,分为两个大的步骤:

第一步,不要先进行开发者账号迁移,先做好已有数据的准备,先根据已有的 openId 获取到 transfer_subTransferring your apps and users to another team
第二步,进行开发者账号迁移,完成迁移后,在苹果快捷时就会返回 transfer_sub 数据,依靠transfer_sub才能对应上旧用户。Bringing new apps and users into your team

详细操作步骤

第一步:数据准备

新建表 apple_migration

create table if not exists apple_migration
(
    member_id    bigint      not null primary key,
    open_id      varchar(64) not null,
    transfer_sub varchar(64),
    new_open_id  varchar(64),
    status       smallint default 0,
    create_time  bigint,
    update_time  bigint
);

comment on table apple_migration is '苹果账号迁移表';
comment on column apple_migration.transfer_sub is '迁移标识符';
comment on column apple_migration.status is '状态(0:未处理 1:已处理transferSub 2:已处理newOpenId)';

根据 member_account 表中的数据填充 apple_migration 表。记录填充的最大的 member_idmax_member_id

INSERT INTO apple_migration (member_id, open_id, transfer_sub, create_time, update_time)
SELECT 
    ma.member_id,
    ma.apple_openid,
    NULL as transfer_sub,  -- transfer_sub字段需要一个默认值,这里设为NULL
    EXTRACT(EPOCH FROM NOW()) * 1000 as create_time,  -- 使用当前时间戳(毫秒)
    EXTRACT(EPOCH FROM NOW()) * 1000 as update_time   -- 使用当前时间戳(毫秒)
FROM 
    member_account ma
WHERE 
    ma.app_id = ${app_id} -- 具体的app_id
    AND ma.apple_openid IS NOT NULL
    AND NOT EXISTS (
        SELECT 1 
        FROM apple_migration am 
        WHERE am.member_id = ma.member_id
    );

第二步:获取 transfer_sub

服务器更新,将定时 job 的代码进行更新。

并开启执行 executeGetTransferSub 任务,每 5 分钟执行 500 条的数据。

import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import com.xxl.job.core.context.XxlJobHelper;
import com.xxl.job.core.handler.annotation.XxlJob;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

@Slf4j
@RequiredArgsConstructor
@Service
public class AppleMigrationJobService {

	private final AppleMigrationManager appleMigrationManager;

	@XxlJob("executeGetTransferSub")
	public void executeGetTransferSub() {
		String param = XxlJobHelper.getJobParam();
		log.info("[Apple开发者账号迁移,检查transferSub job开始,传入参数<{}>]", param);
		JSONObject jsonObject = StrUtil.isBlank(param) ? new JSONObject() : JSONUtil.parseObj(param);
		String clientId = jsonObject.getStr("clientId");
		String clientSecret = jsonObject.getStr("clientSecret");
		String teamId = jsonObject.getStr("teamId");
		Integer limit = jsonObject.getInt("limit", 1000);
		appleMigrationManager.executeGetTransferSub(clientId, clientSecret, teamId, limit);
		log.info("[Apple开发者账号迁移,检查transferSub job完成]");
		XxlJobHelper.handleSuccess();
	}

	@XxlJob("executeGetNewOpenId")
	public void executeGetNewOpenId() {
		String param = XxlJobHelper.getJobParam();
		log.info("[Apple开发者账号迁移,检查new openid job开始,传入参数<{}>]", param);
		JSONObject jsonObject = StrUtil.isBlank(param) ? new JSONObject() : JSONUtil.parseObj(param);
		String clientId = jsonObject.getStr("clientId");
		String clientSecret = jsonObject.getStr("clientSecret");
		Integer limit = jsonObject.getInt("limit", 1000);
		appleMigrationManager.executeGetNewOpenId(clientId, clientSecret, limit);
		log.info("[Apple开发者账号迁移,检查new openid job完成]");
		XxlJobHelper.handleSuccess();
	}

}

具体实现类。

import cn.hutool.core.util.StrUtil;
import cn.hutool.http.HttpRequest;
import cn.hutool.http.HttpResponse;
import cn.hutool.http.HttpUtil;
import cn.hutool.json.JSONUtil;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.google.common.cache.Cache;
import com.google.common.cache.CacheBuilder;
import com.maxcares.taas.one.ams.entity.MemberAccount;
import com.maxcares.taas.one.ams.service.MemberAccountService;
import com.maxcares.taas.one.ums.constant.enums.AppleMigrationStatusEnum;
import com.maxcares.taas.one.ums.entity.AppleMigration;
import com.maxcares.taas.one.ums.service.AppleMigrationService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import java.util.function.Function;
import java.util.stream.Collectors;

@Slf4j
@RequiredArgsConstructor
@Component
public class AppleMigrationManager {

	private final MemberAccountService memberAccountService;
	private final AppleMigrationService appleMigrationService;

	private final Cache<String, String> cache = CacheBuilder.newBuilder()
		.expireAfterWrite(55, TimeUnit.MINUTES)
		.build();

	public void executeGetTransferSub(String clientId, String clientSecret, String teamId, Integer limit) {
		// 1. 查询待处理数据
		List<AppleMigration> appleMigrationList = appleMigrationService.list(Wrappers.lambdaQuery(AppleMigration.class)
			.isNull(AppleMigration::getTransferSub)
			.isNull(AppleMigration::getNewOpenId)
			.eq(AppleMigration::getStatus, AppleMigrationStatusEnum.NO_PROCESS.getVal())
			.orderByAsc(AppleMigration::getMemberId)
			.last("limit " + limit));

		if (appleMigrationList.isEmpty()) {
			log.info("[没有需要GetTransferSub的数据列表 clientId<{}>]", clientId);
			return;
		}

		// 2. 定义待更新列表,只存成功的记录
		List<AppleMigration> updateList = new ArrayList<>();

		for (AppleMigration appleMigration : appleMigrationList) {
			String openId = appleMigration.getOpenId();
			// 调用 API
			String transferSub = getTransferSub(openId, teamId, clientId, clientSecret);
			if (StrUtil.isBlank(transferSub)) {
				log.warn("[获取TransferSub失败或为空,跳过 memberId<{}> openId<{}>]", appleMigration.getMemberId(), openId);
				appleMigration.setStatus(AppleMigrationStatusEnum.TRANSFER_SUB.getVal());
				updateList.add(appleMigration);
				continue;
			}
			appleMigration.setTransferSub(transferSub);
			appleMigration.setStatus(AppleMigrationStatusEnum.TRANSFER_SUB.getVal());
			updateList.add(appleMigration);
		}

		// 3. 批量更新
		if (!updateList.isEmpty()) {
			appleMigrationService.updateBatchById(updateList);
		}
	}

	public void executeGetNewOpenId(String clientId, String clientSecret, Integer limit) {
		// 1. 查询待处理的迁移记录
		List<AppleMigration> appleMigrationList = appleMigrationService.list(Wrappers.lambdaQuery(AppleMigration.class)
			.isNotNull(AppleMigration::getTransferSub)
			.isNull(AppleMigration::getNewOpenId)
			.eq(AppleMigration::getStatus, AppleMigrationStatusEnum.TRANSFER_SUB.getVal())
			.orderByAsc(AppleMigration::getMemberId)
			.last("limit " + limit));

		if (appleMigrationList.isEmpty()) {
			log.info("[没有需要GetNewOpenId的数据列表 clientId<{}>]", clientId);
			return;
		}

		// 2. 查询对应的 MemberAccount
		Map<Integer, MemberAccount> memberAccountMap = memberAccountService.listByIds(appleMigrationList.stream()
				.map(AppleMigration::getMemberId)
				.collect(Collectors.toList()))
			.stream()
			.collect(Collectors.toMap(MemberAccount::getMemberId, Function.identity()));

		// 3. 定义两个新列表,只存放处理成功、需要更新的数据
		List<MemberAccount> updateMemberAccountList = new ArrayList<>();
		List<AppleMigration> updateAppleMigrationList = new ArrayList<>();

		for (AppleMigration appleMigration : appleMigrationList) {
			// 调用 API 获取新 OpenId
			String newOpenId = getNewOpenId(appleMigration.getTransferSub(), clientId, clientSecret);
			if (StrUtil.isBlank(newOpenId)) {
				log.warn("[获取NewOpenId失败或为空,跳过更新 memberId<{}>", appleMigration.getMemberId());
				appleMigration.setStatus(AppleMigrationStatusEnum.NEW_OPENID.getVal());
				updateAppleMigrationList.add(appleMigration);
				continue;
			}
			// 更新 Migration 实体
			appleMigration.setNewOpenId(newOpenId);
			// 更新 MemberAccount 实体
			MemberAccount memberAccount = memberAccountMap.get(appleMigration.getMemberId());
			if (memberAccount != null) {
				memberAccount.setAppleOpenid(newOpenId);
				updateMemberAccountList.add(memberAccount);
			}
			appleMigration.setStatus(AppleMigrationStatusEnum.NEW_OPENID.getVal());
			updateAppleMigrationList.add(appleMigration);
		}

		// 4. 批量更新(判空,防止列表为空报错)
		if (!updateMemberAccountList.isEmpty()) {
			memberAccountService.updateBatchById(updateMemberAccountList);
		}
		if (!updateAppleMigrationList.isEmpty()) {
			appleMigrationService.updateBatchById(updateAppleMigrationList);
		}
	}

	private String getTransferSub(String openId, String teamId, String clientId, String clientSecret) {
		try {
			String token = getAccessToken(clientId, clientSecret);
			if (StrUtil.isBlank(token)) {
				log.error("[apple迁移获取token失败 openId<{}>]", openId);
				return null;
			}
			cache.put("token", token);
			String transferSub = getTransferSub(openId, teamId, clientId, clientSecret, token);
			if (StrUtil.isBlank(transferSub)) {
				log.error("[apple迁移获取transferSub失败 openId<{}>]", openId);
				return null;
			}
			return transferSub;
		} catch (Exception e) {
			cache.invalidate("token");
			log.error("[apple迁移获取transferSub失败 <{}>]", e.getMessage(), e);
			return null;
		}
	}

	private String getNewOpenId(String transferSub, String clientId, String clientSecret) {
		try {
			String token = getAccessToken(clientId, clientSecret);
			if (StrUtil.isBlank(token)) {
				log.error("[apple迁移获取token失败 transferSub<{}>]", transferSub);
				return null;
			}
			cache.put("token", token);
			String newOpenId = getMemberNewOpenId(transferSub, clientId, clientSecret, token);
			if (StrUtil.isBlank(newOpenId)) {
				log.error("[apple迁移获取newOpenId失败 transferSub<{}>]", transferSub);
				return null;
			}
			return newOpenId;
		} catch (Exception e) {
			cache.invalidate("token");
			log.error("[apple迁移获取newOpenId失败 <{}>]", e.getMessage(), e);
			return null;
		}
	}

	public static String getMemberNewOpenId(String transferSub, String clientId, String clientSecret, String token) {
		Map<String, Object> formDate = Map.of(
			"transfer_sub", transferSub,
			"client_id", clientId,
			"client_secret", clientSecret
		);
		HttpResponse response = HttpUtil.createPost("https://appleid.apple.com/auth/usermigrationinfo")
			.header("Authorization", "Bearer " + token)
			.form(formDate)
			.timeout(10000)
			.execute();
		log.debug("[获取new openid response<{}>]", response);
		if (!response.isOk()) {
			log.error("[apple迁移获取new openid失败 transferSub<{}>]", transferSub);
			return null;
		}

		String body = response.body();
		return JSONUtil.parseObj(body).getStr("sub");
	}

	private String getAccessToken(String clientId, String clientSecret) {
		Map<String, Object> formDate = Map.of(
			"grant_type", "client_credentials",
			"scope", "user.migration",
			"client_id", clientId,
			"client_secret", clientSecret
		);
		String body = HttpUtil.createPost("https://appleid.apple.com/auth/token")
			.form(formDate)
			.timeout(10000)
			.execute()
			.body();
		return JSONUtil.parseObj(body).getStr("access_token");
	}

	public static String getTransferSub(String openId, String teamId, String clientId, String clientSecret, String accessToken) {
		Map<String, Object> formData = Map.of(
			"sub", openId,
			"target", teamId,
			"client_id", clientId,
			"client_secret", clientSecret);

		HttpResponse response = HttpRequest.post("https://appleid.apple.com/auth/usermigrationinfo")
			.header("Authorization", "Bearer " + accessToken)
			.form(formData)
			.timeout(10000)
			.execute();
		log.debug("[获取transferSub response<{}>]", response);
		if (!response.isOk()) {
			log.error("[apple迁移获取transferSub失败 openId<{}>]", openId);
			return null;
		}

		String body = response.body();
		return JSONUtil.parseObj(body).getStr("transfer_sub");
	}

}

第三步:数据补充

apple_migration 表中的数据都跑完后,再根据 max_member_id 将这一段时间内产生的新用户也导入进来。

INSERT INTO apple_migration (member_id, open_id, transfer_sub, create_time, update_time)
SELECT
    ma.member_id,
    ma.apple_openid,
    NULL as transfer_sub,  -- transfer_sub字段需要一个默认值,这里设为NULL
    EXTRACT(EPOCH FROM NOW()) * 1000 as create_time,  -- 使用当前时间戳(毫秒)
    EXTRACT(EPOCH FROM NOW()) * 1000 as update_time   -- 使用当前时间戳(毫秒)
FROM
    member_account ma
WHERE
    ma.app_id = ${app_id}   -- 具体的app_id
    AND ma.apple_openid IS NOT NULL
    AND ma.member_id > ${max_member_id}  -- 填充max_member_id,缩小范围,提高效率
    AND NOT EXISTS (
        SELECT 1
        FROM apple_migration am
        WHERE am.member_id = ma.member_id
    );

apple_migration 表中获取过一遍,但是未获取到有效的 transfer_sub 重新获取一次。

update apple_migration set status = 0 where transfer_sub is null and status = 1;

第四步:更新服务

更新认证服务器、更新苹果快捷登录的 clientId、clientSecret。

更新内容:如果苹果登录信息中发现有 transfer_sub 字段,则传递下去,根据 transfer_sub 在 apple_migration 表查询出 apple_openid,并更新 new_apple_openid。还需同步更新 member_account 表对应 member_id 的 apple_openid。

第五步:开发者账号迁移

将 App 转入到新的开发者账户。

转入成功后,App 登录测试,验证老用户的苹果快捷登录,登录后信息是否一致。

第六步:获取`new_open_id

开启执行 executeGetNewOpenId 任务,每 5 分钟执行 500 条的数据。

最后检查 new_open_id 是否都被填充上了新的值。

参考

苹果官方文档: