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>
这个提交包含在:
父节点
331343e51e
当前提交
4acec019f7
@ -101,6 +101,7 @@ sdk:
|
||||
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}
|
||||
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-admin-user: ${SDK_IM_PLATFORM_EVENTS_ADMIN_USER:admin}
|
||||
im-platform-app-key: ${SDK_IM_PLATFORM_APP_KEY:ak_409e217e4aa14254ad73ad3c}
|
||||
|
||||
@ -197,10 +197,11 @@ public class LogController {
|
||||
@RequestParam String appKey,
|
||||
@RequestParam String platform,
|
||||
@RequestParam String appVersion,
|
||||
@RequestParam(required = false) String buildId,
|
||||
@RequestParam(required = false) String bundleVersion,
|
||||
@RequestParam(required = false, defaultValue = "index") String bundleName,
|
||||
@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")
|
||||
|
||||
@ -28,6 +28,7 @@ public record IssueBatchRequest(
|
||||
@JsonProperty("user") UserInfo user,
|
||||
@JsonProperty("device") DeviceInfo device,
|
||||
String release,
|
||||
String buildId,
|
||||
String environment,
|
||||
@JsonProperty("tags") Object tags
|
||||
) {}
|
||||
|
||||
@ -9,6 +9,7 @@ public record SourcemapInfo(
|
||||
@JsonProperty("platform") String platform,
|
||||
@JsonProperty("appVersion") String appVersion,
|
||||
@JsonProperty("bundleName") String bundleName,
|
||||
@JsonProperty("buildId") String buildId,
|
||||
@JsonProperty("storageKey") String storageKey,
|
||||
@JsonProperty("uploadedAt") LocalDateTime uploadedAt
|
||||
) {}
|
||||
|
||||
@ -8,5 +8,6 @@ public record SourcemapUploadResponse(
|
||||
String platform,
|
||||
@JsonProperty("appVersion") String appVersion,
|
||||
@JsonProperty("bundleName") String bundleName,
|
||||
@JsonProperty("buildId") String buildId,
|
||||
@JsonProperty("storageKey") String storageKey
|
||||
) {}
|
||||
|
||||
@ -56,6 +56,9 @@ public class LogIssueEventEntity {
|
||||
@Column(name = "app_version", length = 32)
|
||||
private String release;
|
||||
|
||||
@Column(length = 32)
|
||||
private String buildId;
|
||||
|
||||
@Column(length = 50)
|
||||
private String environment;
|
||||
|
||||
@ -119,6 +122,9 @@ public class LogIssueEventEntity {
|
||||
public String getRelease() { return 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 void setEnvironment(String environment) { this.environment = environment; }
|
||||
|
||||
|
||||
@ -23,6 +23,9 @@ public class LogSourcemapEntity {
|
||||
@Column(nullable = false, length = 128)
|
||||
private String bundleName = "index";
|
||||
|
||||
@Column(length = 32)
|
||||
private String buildId;
|
||||
|
||||
@Column(nullable = false, length = 512)
|
||||
private String storageKey;
|
||||
|
||||
@ -44,6 +47,9 @@ public class LogSourcemapEntity {
|
||||
public String getBundleName() { return 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 void setStorageKey(String storageKey) { this.storageKey = storageKey; }
|
||||
|
||||
|
||||
@ -8,7 +8,16 @@ import java.util.Optional;
|
||||
|
||||
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);
|
||||
|
||||
List<LogSourcemapEntity> findByAppKey(String appKey);
|
||||
|
||||
@ -130,6 +130,7 @@ public class LogService {
|
||||
eventEntity.setStack(stack);
|
||||
eventEntity.setPlatform(item.platform());
|
||||
eventEntity.setRelease(item.release());
|
||||
eventEntity.setBuildId(item.buildId());
|
||||
eventEntity.setEnvironment(item.environment() != null ? item.environment() : "production");
|
||||
eventEntity.setDevice(item.device() != null ? toJson(item.device()) : null);
|
||||
eventEntity.setTags(item.tags() != null ? toJson(item.tags()) : null);
|
||||
@ -137,10 +138,10 @@ public class LogService {
|
||||
eventEntity.setBreadcrumbs(toJson(item.breadcrumbs()));
|
||||
}
|
||||
eventEntity.setCreatedAt(eventTime);
|
||||
issueEventRepository.save(eventEntity);
|
||||
eventEntity = issueEventRepository.save(eventEntity);
|
||||
|
||||
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
|
||||
@ -153,18 +154,19 @@ public class LogService {
|
||||
}
|
||||
|
||||
@Async
|
||||
void triggerSymbolicationAsync(Long issueId, String appKey, String platform, String release) {
|
||||
log.debug("Symbolication triggered issueId={} appKey={} platform={} release={}", issueId, appKey, platform, release);
|
||||
void triggerSymbolicationAsync(Long eventId, String appKey, String platform, String release, String buildId) {
|
||||
log.debug("Symbolication triggered eventId={} appKey={} platform={} release={} buildId={}",
|
||||
eventId, appKey, platform, release, buildId);
|
||||
|
||||
// 查找 sourcemap 文件
|
||||
String storageKey = sourcemapService.findSourceMap(appKey, platform, release, "index");
|
||||
// 查找 sourcemap 文件:精确 buildId 匹配,兜底取最新版本
|
||||
String storageKey = sourcemapService.findSourceMap(appKey, platform, release, buildId, "index");
|
||||
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;
|
||||
}
|
||||
|
||||
// 查找事件
|
||||
LogIssueEventEntity event = issueEventRepository.findById(issueId).orElse(null);
|
||||
// 查找事件(用 eventId 精确定位,而非 issueId)
|
||||
LogIssueEventEntity event = issueEventRepository.findById(eventId).orElse(null);
|
||||
if (event == null || event.getStack() == null || event.getStack().isBlank()) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -37,13 +37,20 @@ public class SourcemapService {
|
||||
|
||||
@Transactional
|
||||
public SourcemapUploadResponse upload(String appKey, String platform, String appVersion,
|
||||
String bundleName, MultipartFile file) throws IOException {
|
||||
return upload(appKey, platform, appVersion, null, bundleName, file);
|
||||
String bundleVersion, String bundleName, MultipartFile file) throws IOException {
|
||||
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
|
||||
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()) {
|
||||
bundleName = "index";
|
||||
}
|
||||
@ -59,40 +66,63 @@ public class SourcemapService {
|
||||
|
||||
String filename = resolveFilename(platform, bundleName);
|
||||
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);
|
||||
Path filePath = dir.resolve(filename);
|
||||
file.transferTo(filePath.toFile());
|
||||
|
||||
var existing = sourcemapRepository.findByAppKeyAndPlatformAndAppVersionAndBundleName(
|
||||
appKey, platform, appVersion, bundleName);
|
||||
|
||||
LogSourcemapEntity entity;
|
||||
if (buildId != null && !buildId.isBlank()) {
|
||||
// New path: each buildId gets its own record; no overwrite
|
||||
var existing = sourcemapRepository.findByAppKeyAndPlatformAndAppVersionAndBuildIdAndBundleName(
|
||||
appKey, platform, appVersion, buildId, bundleName);
|
||||
if (existing.isPresent()) {
|
||||
entity = existing.get();
|
||||
entity.setStorageKey(filePath.toString());
|
||||
entity.setUploadedAt(LocalDateTime.now());
|
||||
} else {
|
||||
entity = new LogSourcemapEntity();
|
||||
entity.setAppKey(appKey);
|
||||
entity.setPlatform(platform);
|
||||
entity.setAppVersion(appVersion);
|
||||
entity.setBundleName(bundleName);
|
||||
entity = newEntity(appKey, platform, appVersion, buildId, bundleName, filePath.toString());
|
||||
}
|
||||
} else {
|
||||
// Legacy path: overwrite the single record for (appKey, platform, appVersion, bundleName)
|
||||
var existing = sourcemapRepository.findByAppKeyAndPlatformAndAppVersionAndBuildIdIsNullAndBundleName(
|
||||
appKey, platform, appVersion, bundleName);
|
||||
if (existing.isPresent()) {
|
||||
entity = existing.get();
|
||||
entity.setStorageKey(filePath.toString());
|
||||
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(
|
||||
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) {
|
||||
return switch (platform != null ? platform.toLowerCase() : "") {
|
||||
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.
|
||||
* 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)
|
||||
.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 {
|
||||
return Files.readString(Paths.get(storageKey));
|
||||
}
|
||||
@ -116,14 +164,10 @@ public class SourcemapService {
|
||||
public List<SourcemapInfo> listByAppKey(String appKey) {
|
||||
return sourcemapRepository.findByAppKey(appKey)
|
||||
.stream()
|
||||
.map(entity -> new SourcemapInfo(
|
||||
entity.getId(),
|
||||
entity.getAppKey(),
|
||||
entity.getPlatform(),
|
||||
entity.getAppVersion(),
|
||||
entity.getBundleName(),
|
||||
entity.getStorageKey(),
|
||||
entity.getUploadedAt()
|
||||
.map(e -> new SourcemapInfo(
|
||||
e.getId(), e.getAppKey(), e.getPlatform(),
|
||||
e.getAppVersion(), e.getBundleName(), e.getBuildId(),
|
||||
e.getStorageKey(), e.getUploadedAt()
|
||||
))
|
||||
.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;
|
||||
正在加载...
在新工单中引用
屏蔽一个用户