feat(bugcollect): buildId 支持相同版本多包 sourcemap 与精确符号化

- V6 迁移:log_sourcemaps 新增 build_id 列,唯一键改为
  (app_key, platform, app_version, build_id, bundle_name);
  log_issue_events 新增 build_id 列
- SourcemapService: 按 buildId 精确匹配,找不到时回退到最新上传
  (findFirstByUploadedAtDesc);存储路径包含 buildId 目录
- LogService: 修复 symbolication 事件查找 bug(原使用 issueId 查
  event 表,现改为 eventId);事件保存后取返回值获取正确 ID
- LogController/DTO: upload 接口新增可选 buildId 参数;事件批量
  上报 DTO 新增 buildId 字段
- tenant-service: 补充 bugcollect-api-url 配置项

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
这个提交包含在:
XuqmGroup 2026-06-22 16:47:34 +08:00
父节点 331343e51e
当前提交 4acec019f7
共有 11 个文件被更改,包括 143 次插入44 次删除

查看文件

@ -101,6 +101,7 @@ sdk:
im-ws-url: ${SDK_IM_WS_URL:wss://im.dev.xuqinmin.com/ws/im} im-ws-url: ${SDK_IM_WS_URL:wss://im.dev.xuqinmin.com/ws/im}
file-service-url: ${SDK_FILE_SERVICE_URL:https://file.dev.xuqinmin.com} file-service-url: ${SDK_FILE_SERVICE_URL:https://file.dev.xuqinmin.com}
im-api-url: ${SDK_IM_API_URL:https://im.dev.xuqinmin.com} im-api-url: ${SDK_IM_API_URL:https://im.dev.xuqinmin.com}
bugcollect-api-url: ${SDK_BUGCOLLECT_API_URL:https://dev.xuqinmin.com/api/bugcollect}
im-platform-events-recipient-user: ${SDK_IM_PLATFORM_EVENTS_RECIPIENT_USER:platform} im-platform-events-recipient-user: ${SDK_IM_PLATFORM_EVENTS_RECIPIENT_USER:platform}
im-platform-events-admin-user: ${SDK_IM_PLATFORM_EVENTS_ADMIN_USER:admin} im-platform-events-admin-user: ${SDK_IM_PLATFORM_EVENTS_ADMIN_USER:admin}
im-platform-app-key: ${SDK_IM_PLATFORM_APP_KEY:ak_409e217e4aa14254ad73ad3c} im-platform-app-key: ${SDK_IM_PLATFORM_APP_KEY:ak_409e217e4aa14254ad73ad3c}

查看文件

@ -197,10 +197,11 @@ public class LogController {
@RequestParam String appKey, @RequestParam String appKey,
@RequestParam String platform, @RequestParam String platform,
@RequestParam String appVersion, @RequestParam String appVersion,
@RequestParam(required = false) String buildId,
@RequestParam(required = false) String bundleVersion, @RequestParam(required = false) String bundleVersion,
@RequestParam(required = false, defaultValue = "index") String bundleName, @RequestParam(required = false, defaultValue = "index") String bundleName,
@RequestParam("file") MultipartFile file) throws IOException { @RequestParam("file") MultipartFile file) throws IOException {
return ApiResponse.success(sourcemapService.upload(appKey, platform, appVersion, bundleVersion, bundleName, file)); return ApiResponse.success(sourcemapService.upload(appKey, platform, appVersion, buildId, bundleVersion, bundleName, file));
} }
@GetMapping("/sourcemaps") @GetMapping("/sourcemaps")

查看文件

@ -28,6 +28,7 @@ public record IssueBatchRequest(
@JsonProperty("user") UserInfo user, @JsonProperty("user") UserInfo user,
@JsonProperty("device") DeviceInfo device, @JsonProperty("device") DeviceInfo device,
String release, String release,
String buildId,
String environment, String environment,
@JsonProperty("tags") Object tags @JsonProperty("tags") Object tags
) {} ) {}

查看文件

@ -9,6 +9,7 @@ public record SourcemapInfo(
@JsonProperty("platform") String platform, @JsonProperty("platform") String platform,
@JsonProperty("appVersion") String appVersion, @JsonProperty("appVersion") String appVersion,
@JsonProperty("bundleName") String bundleName, @JsonProperty("bundleName") String bundleName,
@JsonProperty("buildId") String buildId,
@JsonProperty("storageKey") String storageKey, @JsonProperty("storageKey") String storageKey,
@JsonProperty("uploadedAt") LocalDateTime uploadedAt @JsonProperty("uploadedAt") LocalDateTime uploadedAt
) {} ) {}

查看文件

@ -8,5 +8,6 @@ public record SourcemapUploadResponse(
String platform, String platform,
@JsonProperty("appVersion") String appVersion, @JsonProperty("appVersion") String appVersion,
@JsonProperty("bundleName") String bundleName, @JsonProperty("bundleName") String bundleName,
@JsonProperty("buildId") String buildId,
@JsonProperty("storageKey") String storageKey @JsonProperty("storageKey") String storageKey
) {} ) {}

查看文件

@ -56,6 +56,9 @@ public class LogIssueEventEntity {
@Column(name = "app_version", length = 32) @Column(name = "app_version", length = 32)
private String release; private String release;
@Column(length = 32)
private String buildId;
@Column(length = 50) @Column(length = 50)
private String environment; private String environment;
@ -119,6 +122,9 @@ public class LogIssueEventEntity {
public String getRelease() { return release; } public String getRelease() { return release; }
public void setRelease(String release) { this.release = release; } public void setRelease(String release) { this.release = release; }
public String getBuildId() { return buildId; }
public void setBuildId(String buildId) { this.buildId = buildId; }
public String getEnvironment() { return environment; } public String getEnvironment() { return environment; }
public void setEnvironment(String environment) { this.environment = environment; } public void setEnvironment(String environment) { this.environment = environment; }

查看文件

@ -23,6 +23,9 @@ public class LogSourcemapEntity {
@Column(nullable = false, length = 128) @Column(nullable = false, length = 128)
private String bundleName = "index"; private String bundleName = "index";
@Column(length = 32)
private String buildId;
@Column(nullable = false, length = 512) @Column(nullable = false, length = 512)
private String storageKey; private String storageKey;
@ -44,6 +47,9 @@ public class LogSourcemapEntity {
public String getBundleName() { return bundleName; } public String getBundleName() { return bundleName; }
public void setBundleName(String bundleName) { this.bundleName = bundleName; } public void setBundleName(String bundleName) { this.bundleName = bundleName; }
public String getBuildId() { return buildId; }
public void setBuildId(String buildId) { this.buildId = buildId; }
public String getStorageKey() { return storageKey; } public String getStorageKey() { return storageKey; }
public void setStorageKey(String storageKey) { this.storageKey = storageKey; } public void setStorageKey(String storageKey) { this.storageKey = storageKey; }

查看文件

@ -8,7 +8,16 @@ import java.util.Optional;
public interface LogSourcemapRepository extends JpaRepository<LogSourcemapEntity, Long> { public interface LogSourcemapRepository extends JpaRepository<LogSourcemapEntity, Long> {
Optional<LogSourcemapEntity> findByAppKeyAndPlatformAndAppVersionAndBundleName( /** Exact match by buildId (for events that carry a buildId). */
Optional<LogSourcemapEntity> findByAppKeyAndPlatformAndAppVersionAndBuildIdAndBundleName(
String appKey, String platform, String appVersion, String buildId, String bundleName);
/** Backward-compat: exact match on (appKey, platform, appVersion, bundleName) where buildId is NULL. */
Optional<LogSourcemapEntity> findByAppKeyAndPlatformAndAppVersionAndBuildIdIsNullAndBundleName(
String appKey, String platform, String appVersion, String bundleName);
/** Fallback: latest upload for a version regardless of buildId. */
Optional<LogSourcemapEntity> findFirstByAppKeyAndPlatformAndAppVersionAndBundleNameOrderByUploadedAtDesc(
String appKey, String platform, String appVersion, String bundleName); String appKey, String platform, String appVersion, String bundleName);
List<LogSourcemapEntity> findByAppKey(String appKey); List<LogSourcemapEntity> findByAppKey(String appKey);

查看文件

@ -130,6 +130,7 @@ public class LogService {
eventEntity.setStack(stack); eventEntity.setStack(stack);
eventEntity.setPlatform(item.platform()); eventEntity.setPlatform(item.platform());
eventEntity.setRelease(item.release()); eventEntity.setRelease(item.release());
eventEntity.setBuildId(item.buildId());
eventEntity.setEnvironment(item.environment() != null ? item.environment() : "production"); eventEntity.setEnvironment(item.environment() != null ? item.environment() : "production");
eventEntity.setDevice(item.device() != null ? toJson(item.device()) : null); eventEntity.setDevice(item.device() != null ? toJson(item.device()) : null);
eventEntity.setTags(item.tags() != null ? toJson(item.tags()) : null); eventEntity.setTags(item.tags() != null ? toJson(item.tags()) : null);
@ -137,10 +138,10 @@ public class LogService {
eventEntity.setBreadcrumbs(toJson(item.breadcrumbs())); eventEntity.setBreadcrumbs(toJson(item.breadcrumbs()));
} }
eventEntity.setCreatedAt(eventTime); eventEntity.setCreatedAt(eventTime);
issueEventRepository.save(eventEntity); eventEntity = issueEventRepository.save(eventEntity);
if (isNew) triggerWebhookAsync(issue); if (isNew) triggerWebhookAsync(issue);
triggerSymbolicationAsync(issue.getId(), item.appKey(), item.platform(), item.release()); triggerSymbolicationAsync(eventEntity.getId(), item.appKey(), item.platform(), item.release(), item.buildId());
} }
@Async @Async
@ -153,18 +154,19 @@ public class LogService {
} }
@Async @Async
void triggerSymbolicationAsync(Long issueId, String appKey, String platform, String release) { void triggerSymbolicationAsync(Long eventId, String appKey, String platform, String release, String buildId) {
log.debug("Symbolication triggered issueId={} appKey={} platform={} release={}", issueId, appKey, platform, release); log.debug("Symbolication triggered eventId={} appKey={} platform={} release={} buildId={}",
eventId, appKey, platform, release, buildId);
// 查找 sourcemap 文件 // 查找 sourcemap 文件精确 buildId 匹配兜底取最新版本
String storageKey = sourcemapService.findSourceMap(appKey, platform, release, "index"); String storageKey = sourcemapService.findSourceMap(appKey, platform, release, buildId, "index");
if (storageKey == null) { if (storageKey == null) {
log.debug("No sourcemap found for appKey={} platform={} release={}", appKey, platform, release); log.debug("No sourcemap found for appKey={} platform={} release={} buildId={}", appKey, platform, release, buildId);
return; return;
} }
// 查找事件 // 查找事件 eventId 精确定位而非 issueId
LogIssueEventEntity event = issueEventRepository.findById(issueId).orElse(null); LogIssueEventEntity event = issueEventRepository.findById(eventId).orElse(null);
if (event == null || event.getStack() == null || event.getStack().isBlank()) { if (event == null || event.getStack() == null || event.getStack().isBlank()) {
return; return;
} }

查看文件

@ -37,13 +37,20 @@ public class SourcemapService {
@Transactional @Transactional
public SourcemapUploadResponse upload(String appKey, String platform, String appVersion, public SourcemapUploadResponse upload(String appKey, String platform, String appVersion,
String bundleName, MultipartFile file) throws IOException { String bundleVersion, String bundleName, MultipartFile file) throws IOException {
return upload(appKey, platform, appVersion, null, bundleName, file); return upload(appKey, platform, appVersion, null, bundleVersion, bundleName, file);
} }
/**
* Upload a sourcemap file.
* When buildId is provided, each unique (appKey, platform, appVersion, buildId, bundleName)
* produces a separate record multiple builds of the same version coexist.
* When buildId is null (backward compat), the legacy single-record-per-version behaviour is kept.
*/
@Transactional @Transactional
public SourcemapUploadResponse upload(String appKey, String platform, String appVersion, public SourcemapUploadResponse upload(String appKey, String platform, String appVersion,
String bundleVersion, String bundleName, MultipartFile file) throws IOException { String buildId, String bundleVersion, String bundleName,
MultipartFile file) throws IOException {
if (bundleName == null || bundleName.isBlank()) { if (bundleName == null || bundleName.isBlank()) {
bundleName = "index"; bundleName = "index";
} }
@ -59,40 +66,63 @@ public class SourcemapService {
String filename = resolveFilename(platform, bundleName); String filename = resolveFilename(platform, bundleName);
String versionKey = bundleVersion != null && !bundleVersion.isBlank() ? bundleVersion : appVersion; String versionKey = bundleVersion != null && !bundleVersion.isBlank() ? bundleVersion : appVersion;
// When buildId is present, store under a subdirectory to avoid file collisions across builds
String buildDir = buildId != null && !buildId.isBlank() ? buildId : "legacy";
Path dir = Paths.get(storageDir, appKey, platform, versionKey); Path dir = Paths.get(storageDir, appKey, platform, versionKey, buildDir);
Files.createDirectories(dir); Files.createDirectories(dir);
Path filePath = dir.resolve(filename); Path filePath = dir.resolve(filename);
file.transferTo(filePath.toFile()); file.transferTo(filePath.toFile());
var existing = sourcemapRepository.findByAppKeyAndPlatformAndAppVersionAndBundleName(
appKey, platform, appVersion, bundleName);
LogSourcemapEntity entity; LogSourcemapEntity entity;
if (existing.isPresent()) { if (buildId != null && !buildId.isBlank()) {
entity = existing.get(); // New path: each buildId gets its own record; no overwrite
entity.setStorageKey(filePath.toString()); var existing = sourcemapRepository.findByAppKeyAndPlatformAndAppVersionAndBuildIdAndBundleName(
entity.setUploadedAt(LocalDateTime.now()); appKey, platform, appVersion, buildId, bundleName);
if (existing.isPresent()) {
entity = existing.get();
entity.setStorageKey(filePath.toString());
entity.setUploadedAt(LocalDateTime.now());
} else {
entity = newEntity(appKey, platform, appVersion, buildId, bundleName, filePath.toString());
}
} else { } else {
entity = new LogSourcemapEntity(); // Legacy path: overwrite the single record for (appKey, platform, appVersion, bundleName)
entity.setAppKey(appKey); var existing = sourcemapRepository.findByAppKeyAndPlatformAndAppVersionAndBuildIdIsNullAndBundleName(
entity.setPlatform(platform); appKey, platform, appVersion, bundleName);
entity.setAppVersion(appVersion); if (existing.isPresent()) {
entity.setBundleName(bundleName); entity = existing.get();
entity.setStorageKey(filePath.toString()); entity.setStorageKey(filePath.toString());
entity.setUploadedAt(LocalDateTime.now()); entity.setUploadedAt(LocalDateTime.now());
} else {
entity = newEntity(appKey, platform, appVersion, null, bundleName, filePath.toString());
}
} }
entity = sourcemapRepository.save(entity);
log.info("SourceMap uploaded: appKey={}, platform={}, version={}, bundle={}", appKey, platform, versionKey, bundleName); entity = sourcemapRepository.save(entity);
log.info("Sourcemap uploaded: appKey={} platform={} version={} buildId={} bundle={}",
appKey, platform, versionKey, buildId, bundleName);
return new SourcemapUploadResponse( return new SourcemapUploadResponse(
entity.getId(), entity.getAppKey(), entity.getPlatform(), entity.getId(), entity.getAppKey(), entity.getPlatform(),
entity.getAppVersion(), entity.getBundleName(), entity.getStorageKey() entity.getAppVersion(), entity.getBundleName(), entity.getBuildId(), entity.getStorageKey()
); );
} }
/** Produce platform-appropriate filename, e.g. index.android.bundle.map / index.ios.bundle.map */ private LogSourcemapEntity newEntity(String appKey, String platform, String appVersion,
String buildId, String bundleName, String storageKey) {
var e = new LogSourcemapEntity();
e.setAppKey(appKey);
e.setPlatform(platform);
e.setAppVersion(appVersion);
e.setBuildId(buildId);
e.setBundleName(bundleName);
e.setStorageKey(storageKey);
e.setUploadedAt(LocalDateTime.now());
return e;
}
/** Produce platform-appropriate filename, e.g. index.android.bundle.map */
private String resolveFilename(String platform, String bundleName) { private String resolveFilename(String platform, String bundleName) {
return switch (platform != null ? platform.toLowerCase() : "") { return switch (platform != null ? platform.toLowerCase() : "") {
case "android" -> bundleName + ".android.bundle.map"; case "android" -> bundleName + ".android.bundle.map";
@ -102,13 +132,31 @@ public class SourcemapService {
}; };
} }
public String findSourceMap(String appKey, String platform, String appVersion, String bundleName) { /**
return sourcemapRepository.findByAppKeyAndPlatformAndAppVersionAndBundleName( * Locate a sourcemap file.
appKey, platform, appVersion, bundleName) * Priority: exact (appKey, platform, appVersion, buildId, bundleName)
* version-only latest (appKey, platform, appVersion, bundleName)
*/
public String findSourceMap(String appKey, String platform, String appVersion,
String buildId, String bundleName) {
if (buildId != null && !buildId.isBlank()) {
var exact = sourcemapRepository.findByAppKeyAndPlatformAndAppVersionAndBuildIdAndBundleName(
appKey, platform, appVersion, buildId, bundleName);
if (exact.isPresent()) return exact.get().getStorageKey();
}
// Fallback: newest upload for this version
return sourcemapRepository
.findFirstByAppKeyAndPlatformAndAppVersionAndBundleNameOrderByUploadedAtDesc(
appKey, platform, appVersion, bundleName)
.map(LogSourcemapEntity::getStorageKey) .map(LogSourcemapEntity::getStorageKey)
.orElse(null); .orElse(null);
} }
/** Backward-compat overload without buildId. */
public String findSourceMap(String appKey, String platform, String appVersion, String bundleName) {
return findSourceMap(appKey, platform, appVersion, null, bundleName);
}
public String readSourceMapContent(String storageKey) throws IOException { public String readSourceMapContent(String storageKey) throws IOException {
return Files.readString(Paths.get(storageKey)); return Files.readString(Paths.get(storageKey));
} }
@ -116,14 +164,10 @@ public class SourcemapService {
public List<SourcemapInfo> listByAppKey(String appKey) { public List<SourcemapInfo> listByAppKey(String appKey) {
return sourcemapRepository.findByAppKey(appKey) return sourcemapRepository.findByAppKey(appKey)
.stream() .stream()
.map(entity -> new SourcemapInfo( .map(e -> new SourcemapInfo(
entity.getId(), e.getId(), e.getAppKey(), e.getPlatform(),
entity.getAppKey(), e.getAppVersion(), e.getBundleName(), e.getBuildId(),
entity.getPlatform(), e.getStorageKey(), e.getUploadedAt()
entity.getAppVersion(),
entity.getBundleName(),
entity.getStorageKey(),
entity.getUploadedAt()
)) ))
.toList(); .toList();
} }

查看文件

@ -0,0 +1,27 @@
-- V6: build_id support for multi-build-per-version sourcemap tracking
-- ─────────────────────────────────────────────────────────────────────
-- log_sourcemaps:
-- • Drop old unique key that prevented multiple builds of the same version
-- • Add build_id (nullable for backward compat with pre-V6 uploads)
-- • New unique key includes build_id; MySQL treats NULL as distinct in UNIQUE,
-- so old rows (build_id=NULL) are not affected and remain individually unique
-- • Add fallback index for version-only lookup (for events without buildId)
--
-- log_issue_events:
-- • Add build_id so events can be matched to the exact sourcemap build
ALTER TABLE log_sourcemaps
ADD COLUMN build_id VARCHAR(32) NULL COMMENT 'Gradle build timestamp yyyyMMddHHmmss; NULL = pre-V6 upload'
AFTER bundle_name;
ALTER TABLE log_sourcemaps DROP KEY uk_map;
ALTER TABLE log_sourcemaps
ADD UNIQUE KEY uk_map_build (app_key, platform, app_version, build_id, bundle_name);
ALTER TABLE log_sourcemaps
ADD INDEX idx_sm_version_latest (app_key, platform, app_version, bundle_name, uploaded_at);
ALTER TABLE log_issue_events
ADD COLUMN build_id VARCHAR(32) NULL COMMENT 'Matches log_sourcemaps.build_id for exact symbolication'
AFTER release;