chore: bump version to 1.3.0-SNAPSHOT
@ -135,7 +135,8 @@ public class McpSkillBridge {
|
|||||||
s.setId(virtualIdFor(server));
|
s.setId(virtualIdFor(server));
|
||||||
s.setName(slugify(server.getName()));
|
s.setName(slugify(server.getName()));
|
||||||
s.setNameEn(displayName(server));
|
s.setNameEn(displayName(server));
|
||||||
s.setNameZh(displayName(server));
|
s.setNameZh(server.getDescription() != null && !server.getDescription().isBlank()
|
||||||
|
? displayName(server) : null);
|
||||||
s.setDescription(buildDescription(server));
|
s.setDescription(buildDescription(server));
|
||||||
s.setSkillType("mcp");
|
s.setSkillType("mcp");
|
||||||
s.setIcon(iconFor(server));
|
s.setIcon(iconFor(server));
|
||||||
|
|||||||
@ -17,8 +17,6 @@ import org.springframework.stereotype.Component;
|
|||||||
import vip.mate.tool.mcp.model.McpServerEntity;
|
import vip.mate.tool.mcp.model.McpServerEntity;
|
||||||
|
|
||||||
import jakarta.annotation.PreDestroy;
|
import jakarta.annotation.PreDestroy;
|
||||||
import java.net.URI;
|
|
||||||
import java.net.URISyntaxException;
|
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
@ -373,9 +371,7 @@ public class McpClientManager {
|
|||||||
Duration connectTimeout = Duration.ofSeconds(
|
Duration connectTimeout = Duration.ofSeconds(
|
||||||
server.getConnectTimeoutSeconds() != null ? server.getConnectTimeoutSeconds() : 30);
|
server.getConnectTimeoutSeconds() != null ? server.getConnectTimeoutSeconds() : 30);
|
||||||
|
|
||||||
HttpEndpointConfig endpointConfig = splitHttpUrl(server.getUrl(), "/sse");
|
var builder = HttpClientSseClientTransport.builder(server.getUrl())
|
||||||
var builder = HttpClientSseClientTransport.builder(endpointConfig.baseUrl())
|
|
||||||
.sseEndpoint(endpointConfig.endpoint())
|
|
||||||
.connectTimeout(connectTimeout);
|
.connectTimeout(connectTimeout);
|
||||||
|
|
||||||
// Add headers via request customizer
|
// Add headers via request customizer
|
||||||
@ -395,9 +391,7 @@ public class McpClientManager {
|
|||||||
Duration connectTimeout = Duration.ofSeconds(
|
Duration connectTimeout = Duration.ofSeconds(
|
||||||
server.getConnectTimeoutSeconds() != null ? server.getConnectTimeoutSeconds() : 30);
|
server.getConnectTimeoutSeconds() != null ? server.getConnectTimeoutSeconds() : 30);
|
||||||
|
|
||||||
HttpEndpointConfig endpointConfig = splitHttpUrl(server.getUrl(), "/mcp");
|
var builder = HttpClientStreamableHttpTransport.builder(server.getUrl())
|
||||||
var builder = HttpClientStreamableHttpTransport.builder(endpointConfig.baseUrl())
|
|
||||||
.endpoint(endpointConfig.endpoint())
|
|
||||||
.connectTimeout(connectTimeout);
|
.connectTimeout(connectTimeout);
|
||||||
|
|
||||||
// Add headers via request customizer
|
// Add headers via request customizer
|
||||||
@ -413,45 +407,6 @@ public class McpClientManager {
|
|||||||
return builder.build();
|
return builder.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Splits a full HTTP MCP URL into a {@code scheme://authority} base and a
|
|
||||||
* {@code path[?query]} endpoint suffix. The underlying SDK builders take
|
|
||||||
* the two halves separately and resolve them via {@link URI#resolve(URI)},
|
|
||||||
* which replaces the base URL's path with the endpoint when the endpoint
|
|
||||||
* starts with {@code /}. Passing a full URL as the base would therefore
|
|
||||||
* silently route every request to the SDK's default endpoint
|
|
||||||
* (e.g. {@code /mcp}) and drop any user-configured path or query string.
|
|
||||||
*
|
|
||||||
* @param url the user-configured full URL
|
|
||||||
* @param defaultEndpoint endpoint to use when the URL has no path
|
|
||||||
*/
|
|
||||||
static HttpEndpointConfig splitHttpUrl(String url, String defaultEndpoint) {
|
|
||||||
String trimmed = url != null ? url.trim() : "";
|
|
||||||
if (trimmed.isEmpty()) {
|
|
||||||
throw new IllegalArgumentException("MCP server URL must not be empty");
|
|
||||||
}
|
|
||||||
URI uri;
|
|
||||||
try {
|
|
||||||
uri = new URI(trimmed);
|
|
||||||
} catch (URISyntaxException e) {
|
|
||||||
throw new IllegalArgumentException("Invalid MCP server URL: " + url, e);
|
|
||||||
}
|
|
||||||
if (uri.getScheme() == null || uri.getRawAuthority() == null) {
|
|
||||||
throw new IllegalArgumentException("MCP server URL must include scheme and host: " + url);
|
|
||||||
}
|
|
||||||
String path = uri.getRawPath();
|
|
||||||
String endpoint = (path == null || path.isEmpty() || "/".equals(path)) ? defaultEndpoint : path;
|
|
||||||
String query = uri.getRawQuery();
|
|
||||||
if (query != null && !query.isEmpty()) {
|
|
||||||
endpoint += "?" + query;
|
|
||||||
}
|
|
||||||
String baseUrl = uri.getScheme() + "://" + uri.getRawAuthority();
|
|
||||||
return new HttpEndpointConfig(baseUrl, endpoint);
|
|
||||||
}
|
|
||||||
|
|
||||||
record HttpEndpointConfig(String baseUrl, String endpoint) {
|
|
||||||
}
|
|
||||||
|
|
||||||
private Map<String, String> parseHeaders(McpServerEntity server) {
|
private Map<String, String> parseHeaders(McpServerEntity server) {
|
||||||
if (server.getHeadersJson() != null && !server.getHeadersJson().isBlank()) {
|
if (server.getHeadersJson() != null && !server.getHeadersJson().isBlank()) {
|
||||||
Map<String, String> headers = JSONUtil.toBean(server.getHeadersJson(),
|
Map<String, String> headers = JSONUtil.toBean(server.getHeadersJson(),
|
||||||
|
|||||||
@ -1,71 +0,0 @@
|
|||||||
package vip.mate.trigger.model;
|
|
||||||
|
|
||||||
import com.baomidou.mybatisplus.annotation.FieldFill;
|
|
||||||
import com.baomidou.mybatisplus.annotation.FieldStrategy;
|
|
||||||
import com.baomidou.mybatisplus.annotation.IdType;
|
|
||||||
import com.baomidou.mybatisplus.annotation.TableField;
|
|
||||||
import com.baomidou.mybatisplus.annotation.TableId;
|
|
||||||
import com.baomidou.mybatisplus.annotation.TableLogic;
|
|
||||||
import com.baomidou.mybatisplus.annotation.TableName;
|
|
||||||
import lombok.Data;
|
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Trigger row. {@code patternVersion} is a lamport counter that fire callbacks
|
|
||||||
* compare against the row on every fire; mismatches mean another instance has
|
|
||||||
* updated the cron expression and the local schedule must self-cancel.
|
|
||||||
*/
|
|
||||||
@Data
|
|
||||||
@TableName("mate_trigger")
|
|
||||||
public class TriggerEntity {
|
|
||||||
|
|
||||||
@TableId(type = IdType.ASSIGN_ID)
|
|
||||||
private Long id;
|
|
||||||
|
|
||||||
private Long workspaceId;
|
|
||||||
|
|
||||||
@TableField(value = "name", updateStrategy = FieldStrategy.ALWAYS)
|
|
||||||
private String name;
|
|
||||||
|
|
||||||
/** Pattern flavour: cron / webhook / channel_message / agent_lifecycle / content_match / workflow_completion. */
|
|
||||||
private String patternType;
|
|
||||||
|
|
||||||
@TableField(value = "pattern_json", updateStrategy = FieldStrategy.ALWAYS)
|
|
||||||
private String patternJson;
|
|
||||||
|
|
||||||
/** Routing target type: agent or workflow. */
|
|
||||||
private String targetType;
|
|
||||||
|
|
||||||
private Long targetId;
|
|
||||||
|
|
||||||
@TableField(value = "payload_template", updateStrategy = FieldStrategy.ALWAYS)
|
|
||||||
private String payloadTemplate;
|
|
||||||
|
|
||||||
private Integer rateLimitPerMin;
|
|
||||||
|
|
||||||
private Integer dedupWindowSecs;
|
|
||||||
|
|
||||||
private Boolean botSelfFilter;
|
|
||||||
|
|
||||||
private Boolean enabled;
|
|
||||||
|
|
||||||
private Long fireCount;
|
|
||||||
|
|
||||||
private Long maxFires;
|
|
||||||
|
|
||||||
@TableField(value = "last_fired_at", updateStrategy = FieldStrategy.ALWAYS)
|
|
||||||
private LocalDateTime lastFiredAt;
|
|
||||||
|
|
||||||
/** Lamport counter — bump on every cron expression / payload template change. */
|
|
||||||
private Long patternVersion;
|
|
||||||
|
|
||||||
@TableField(fill = FieldFill.INSERT)
|
|
||||||
private LocalDateTime createTime;
|
|
||||||
|
|
||||||
@TableField(fill = FieldFill.INSERT_UPDATE)
|
|
||||||
private LocalDateTime updateTime;
|
|
||||||
|
|
||||||
@TableLogic
|
|
||||||
private Integer deleted;
|
|
||||||
}
|
|
||||||
@ -1,31 +0,0 @@
|
|||||||
package vip.mate.trigger.model;
|
|
||||||
|
|
||||||
import com.baomidou.mybatisplus.annotation.IdType;
|
|
||||||
import com.baomidou.mybatisplus.annotation.TableId;
|
|
||||||
import com.baomidou.mybatisplus.annotation.TableName;
|
|
||||||
import lombok.Data;
|
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Trigger dedup-window row. {@code dedupKey} carries envelope.eventId, falling
|
|
||||||
* back to a content sha256 when the upstream channel did not provide a stable
|
|
||||||
* id. {@code expiresAt} is set on insert to {@code receivedAt + dedupWindowSecs}
|
|
||||||
* so the cleanup task can sweep expired rows.
|
|
||||||
*/
|
|
||||||
@Data
|
|
||||||
@TableName("mate_trigger_event")
|
|
||||||
public class TriggerEventEntity {
|
|
||||||
|
|
||||||
@TableId(type = IdType.ASSIGN_ID)
|
|
||||||
private Long id;
|
|
||||||
|
|
||||||
private Long triggerId;
|
|
||||||
|
|
||||||
private String dedupKey;
|
|
||||||
|
|
||||||
/** Filled by DB DEFAULT CURRENT_TIMESTAMP when left null on insert. */
|
|
||||||
private LocalDateTime receivedAt;
|
|
||||||
|
|
||||||
private LocalDateTime expiresAt;
|
|
||||||
}
|
|
||||||
@ -1,9 +0,0 @@
|
|||||||
package vip.mate.trigger.repository;
|
|
||||||
|
|
||||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
|
||||||
import org.apache.ibatis.annotations.Mapper;
|
|
||||||
import vip.mate.trigger.model.TriggerEventEntity;
|
|
||||||
|
|
||||||
@Mapper
|
|
||||||
public interface TriggerEventMapper extends BaseMapper<TriggerEventEntity> {
|
|
||||||
}
|
|
||||||
@ -1,9 +0,0 @@
|
|||||||
package vip.mate.trigger.repository;
|
|
||||||
|
|
||||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
|
||||||
import org.apache.ibatis.annotations.Mapper;
|
|
||||||
import vip.mate.trigger.model.TriggerEntity;
|
|
||||||
|
|
||||||
@Mapper
|
|
||||||
public interface TriggerMapper extends BaseMapper<TriggerEntity> {
|
|
||||||
}
|
|
||||||
@ -130,21 +130,6 @@ public class WikiProperties {
|
|||||||
*/
|
*/
|
||||||
private int embeddingMaxChars = 6000;
|
private int embeddingMaxChars = 6000;
|
||||||
|
|
||||||
/**
|
|
||||||
* Circuit-breaker threshold: abort an embedding pass after this many
|
|
||||||
* consecutive batch failures (auth / rate-limit / network errors that
|
|
||||||
* cause an entire batch to embed zero chunks). Without it, a broken
|
|
||||||
* provider would silently iterate through every pending chunk in the
|
|
||||||
* KB, producing only log noise and wasted wall-clock time before the
|
|
||||||
* user can intervene.
|
|
||||||
* <p>
|
|
||||||
* Set too low and a transient blip aborts a healthy pass; set too
|
|
||||||
* high and the user waits forever on a clearly-broken provider.
|
|
||||||
* Default 5 covers most real outages while tolerating a couple of
|
|
||||||
* isolated 5xx hiccups.
|
|
||||||
*/
|
|
||||||
private int embeddingConsecutiveFailureThreshold = 5;
|
|
||||||
|
|
||||||
/** 混合搜索默认模式:keyword / semantic / hybrid */
|
/** 混合搜索默认模式:keyword / semantic / hybrid */
|
||||||
private String searchDefaultMode = "hybrid";
|
private String searchDefaultMode = "hybrid";
|
||||||
|
|
||||||
|
|||||||
@ -300,23 +300,6 @@ public class WikiController {
|
|||||||
return R.ok();
|
return R.ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
@RequireWorkspaceRole("member")
|
|
||||||
@Operation(summary = "请求取消正在进行的处理(仅在 processing 状态有效)")
|
|
||||||
@PostMapping("/knowledge-bases/{kbId}/raw/{rawId}/cancel")
|
|
||||||
public R<Void> cancelRaw(@PathVariable Long kbId, @PathVariable Long rawId,
|
|
||||||
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
|
||||||
verifyKBWorkspace(kbId, workspaceId);
|
|
||||||
WikiRawMaterialEntity raw = rawService.getById(rawId);
|
|
||||||
if (raw == null || !kbId.equals(raw.getKbId())) {
|
|
||||||
return R.fail("Raw material not found in this knowledge base");
|
|
||||||
}
|
|
||||||
// requestCancel is idempotent: a no-op when the row is not processing,
|
|
||||||
// so repeated clicks (or a click after the run already finished) are
|
|
||||||
// safe and do not surface an error to the user.
|
|
||||||
rawService.requestCancel(rawId);
|
|
||||||
return R.ok();
|
|
||||||
}
|
|
||||||
|
|
||||||
@RequireWorkspaceRole("viewer")
|
@RequireWorkspaceRole("viewer")
|
||||||
@Operation(summary = "下载原始材料")
|
@Operation(summary = "下载原始材料")
|
||||||
@GetMapping("/knowledge-bases/{kbId}/raw/{rawId}/download")
|
@GetMapping("/knowledge-bases/{kbId}/raw/{rawId}/download")
|
||||||
|
|||||||
@ -1,40 +0,0 @@
|
|||||||
package vip.mate.wiki.job;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Thrown by {@link vip.mate.wiki.service.WikiEmbeddingService#embedMissingChunks(Long)}
|
|
||||||
* when the embedding provider has failed N batches in a row, where N is
|
|
||||||
* controlled by {@code mate.wiki.embedding-consecutive-failure-threshold}.
|
|
||||||
*
|
|
||||||
* <p>Without this circuit, a misconfigured or unavailable provider (out
|
|
||||||
* of credits, wrong API key, network partition) silently churns through
|
|
||||||
* every pending chunk one batch at a time — producing log noise but no
|
|
||||||
* actual progress, and consuming wall-clock time the user sees as a
|
|
||||||
* stuck "task in loop". The circuit lets the embedding pass abort fast
|
|
||||||
* so the user can fix configuration and retry.
|
|
||||||
*
|
|
||||||
* <p>This is a soft failure: the next call into {@code embedMissingChunks}
|
|
||||||
* starts a fresh counter and will retry the provider, so once the user
|
|
||||||
* has corrected the configuration the embedding pass picks up where it
|
|
||||||
* left off without manual intervention.
|
|
||||||
*/
|
|
||||||
public class WikiEmbeddingProviderFailingException extends RuntimeException {
|
|
||||||
|
|
||||||
private final int consecutiveFailures;
|
|
||||||
private final int remainingChunks;
|
|
||||||
|
|
||||||
public WikiEmbeddingProviderFailingException(String message,
|
|
||||||
int consecutiveFailures,
|
|
||||||
int remainingChunks) {
|
|
||||||
super(message);
|
|
||||||
this.consecutiveFailures = consecutiveFailures;
|
|
||||||
this.remainingChunks = remainingChunks;
|
|
||||||
}
|
|
||||||
|
|
||||||
public int getConsecutiveFailures() {
|
|
||||||
return consecutiveFailures;
|
|
||||||
}
|
|
||||||
|
|
||||||
public int getRemainingChunks() {
|
|
||||||
return remainingChunks;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -46,18 +46,9 @@ public class WikiRawMaterialEntity {
|
|||||||
/** 文件大小(字节) */
|
/** 文件大小(字节) */
|
||||||
private Long fileSize;
|
private Long fileSize;
|
||||||
|
|
||||||
/** 处理状态:pending / processing / completed / failed / partial / cancelled */
|
/** 处理状态:pending / processing / completed / failed */
|
||||||
private String processingStatus;
|
private String processingStatus;
|
||||||
|
|
||||||
/**
|
|
||||||
* User-requested cancellation flag. Set to {@code true} via the cancel
|
|
||||||
* endpoint while a raw material is in {@code processing}. The pipeline
|
|
||||||
* observes the flag at its abort checkpoints and exits early with
|
|
||||||
* {@code processingStatus = "cancelled"}; the flag is cleared on the
|
|
||||||
* next successful claim for processing.
|
|
||||||
*/
|
|
||||||
private Boolean cancelRequested;
|
|
||||||
|
|
||||||
/** 上次处理时间 */
|
/** 上次处理时间 */
|
||||||
private LocalDateTime lastProcessedAt;
|
private LocalDateTime lastProcessedAt;
|
||||||
|
|
||||||
|
|||||||
@ -140,12 +140,7 @@ public class WikiEmbeddingService {
|
|||||||
|
|
||||||
int batchSize = Math.max(1, properties.getEmbeddingBatchSize());
|
int batchSize = Math.max(1, properties.getEmbeddingBatchSize());
|
||||||
int maxChars = Math.max(500, properties.getEmbeddingMaxChars());
|
int maxChars = Math.max(500, properties.getEmbeddingMaxChars());
|
||||||
int threshold = Math.max(1, properties.getEmbeddingConsecutiveFailureThreshold());
|
|
||||||
int total = 0;
|
int total = 0;
|
||||||
// Consecutive failure counter: resets on any successful batch / long
|
|
||||||
// chunk, increments when a unit returns zero progress. Crossing the
|
|
||||||
// threshold trips the circuit and aborts the rest of this pass.
|
|
||||||
int consecutiveFailures = 0;
|
|
||||||
|
|
||||||
for (int offset = 0; offset < pending.size(); offset += batchSize) {
|
for (int offset = 0; offset < pending.size(); offset += batchSize) {
|
||||||
List<WikiChunkEntity> batch = pending.subList(offset, Math.min(offset + batchSize, pending.size()));
|
List<WikiChunkEntity> batch = pending.subList(offset, Math.min(offset + batchSize, pending.size()));
|
||||||
@ -165,30 +160,13 @@ public class WikiEmbeddingService {
|
|||||||
|
|
||||||
// Short chunks: existing batch path
|
// Short chunks: existing batch path
|
||||||
if (!shortBatch.isEmpty()) {
|
if (!shortBatch.isEmpty()) {
|
||||||
int embedded = embedShortBatch(shortBatch, r.model(), modelName, kbId);
|
total += embedShortBatch(shortBatch, r.model(), modelName, kbId);
|
||||||
total += embedded;
|
|
||||||
if (embedded == 0) {
|
|
||||||
consecutiveFailures++;
|
|
||||||
if (consecutiveFailures >= threshold) {
|
|
||||||
int remaining = pending.size() - (offset + batch.size());
|
|
||||||
throw circuitOpen(kbId, modelName, consecutiveFailures, remaining);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
consecutiveFailures = 0;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Long chunks: each goes through sub-segment split + mean pool
|
// Long chunks: each goes through sub-segment split + mean pool
|
||||||
for (WikiChunkEntity longChunk : longChunks) {
|
for (WikiChunkEntity longChunk : longChunks) {
|
||||||
if (embedLongChunk(longChunk, r.model(), modelName, maxChars)) {
|
if (embedLongChunk(longChunk, r.model(), modelName, maxChars)) {
|
||||||
total++;
|
total++;
|
||||||
consecutiveFailures = 0;
|
|
||||||
} else {
|
|
||||||
consecutiveFailures++;
|
|
||||||
if (consecutiveFailures >= threshold) {
|
|
||||||
int remaining = pending.size() - (offset + batch.size());
|
|
||||||
throw circuitOpen(kbId, modelName, consecutiveFailures, remaining);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -203,15 +181,6 @@ public class WikiEmbeddingService {
|
|||||||
return total;
|
return total;
|
||||||
}
|
}
|
||||||
|
|
||||||
private vip.mate.wiki.job.WikiEmbeddingProviderFailingException circuitOpen(
|
|
||||||
Long kbId, String modelName, int failures, int remaining) {
|
|
||||||
String message = "Embedding provider unavailable: " + failures
|
|
||||||
+ " consecutive batch failures (kbId=" + kbId + ", model=" + modelName
|
|
||||||
+ "). Aborted with " + remaining + " chunk(s) still pending.";
|
|
||||||
log.warn("[WikiEmbedding] Circuit opened — {}", message);
|
|
||||||
return new vip.mate.wiki.job.WikiEmbeddingProviderFailingException(message, failures, remaining);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Embed a batch of chunks whose content fits within the per-segment char limit.
|
* Embed a batch of chunks whose content fits within the per-segment char limit.
|
||||||
* One API call per batch; individual results are persisted independently.
|
* One API call per batch; individual results are persisted independently.
|
||||||
|
|||||||
@ -303,17 +303,7 @@ public class WikiProcessingService {
|
|||||||
|
|
||||||
String finalStatus;
|
String finalStatus;
|
||||||
String finalDetail = null;
|
String finalDetail = null;
|
||||||
// Cancellation takes precedence over the normal terminal-state logic:
|
if (totalPages == 0) {
|
||||||
// chunks that observed the cancel flag returned early as "failed", but
|
|
||||||
// those aren't real failures — the user asked to stop. Surface that
|
|
||||||
// intent explicitly so the UI can show "cancelled" instead of "failed"
|
|
||||||
// or "partial".
|
|
||||||
if (rawService.isCancelRequested(rawId)) {
|
|
||||||
finalDetail = "Cancelled by user (" + totalPages + " page(s) generated, "
|
|
||||||
+ (totalChunks - failedChunks) + "/" + totalChunks + " chunks completed before stop).";
|
|
||||||
rawService.updateProcessingStatus(rawId, "cancelled", finalDetail);
|
|
||||||
finalStatus = "cancelled";
|
|
||||||
} else if (totalPages == 0) {
|
|
||||||
// RFC-051 follow-up: previously this was an unconditional "failed".
|
// RFC-051 follow-up: previously this was an unconditional "failed".
|
||||||
// But chunks were already persisted (and the materials are searchable
|
// But chunks were already persisted (and the materials are searchable
|
||||||
// via wiki_semantic_search) — the only thing that actually went wrong
|
// via wiki_semantic_search) — the only thing that actually went wrong
|
||||||
@ -381,34 +371,31 @@ public class WikiProcessingService {
|
|||||||
var terminalStage = switch (finalStatus) {
|
var terminalStage = switch (finalStatus) {
|
||||||
case "failed" -> vip.mate.wiki.job.WikiJobStage.FAILED;
|
case "failed" -> vip.mate.wiki.job.WikiJobStage.FAILED;
|
||||||
case "partial" -> vip.mate.wiki.job.WikiJobStage.PARTIAL;
|
case "partial" -> vip.mate.wiki.job.WikiJobStage.PARTIAL;
|
||||||
case "cancelled" -> vip.mate.wiki.job.WikiJobStage.CANCELLED;
|
|
||||||
default -> vip.mate.wiki.job.WikiJobStage.COMPLETED;
|
default -> vip.mate.wiki.job.WikiJobStage.COMPLETED;
|
||||||
};
|
};
|
||||||
wikiJobService.transition(jobId, terminalStage);
|
wikiJobService.transition(jobId, terminalStage);
|
||||||
} catch (Exception ignored) {}
|
} catch (Exception ignored) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip the post-terminal side effects (log line, overview rebuild,
|
// RFC-051 PR-2c: log every non-failed eager ingest. Failures already get a
|
||||||
// KB-dirty event) for cancelled and failed runs. A cancelled run
|
// RAW_FAILED broadcast and an error message in the raw row. Title goes first
|
||||||
// means the user explicitly stopped — don't burn LLM tokens on
|
// so the log reads as "what just landed" instead of an opaque raw id.
|
||||||
// overview regeneration over an unstable partial state.
|
if (logService != null && !"failed".equals(finalStatus)) {
|
||||||
boolean nonTerminalSideEffects = !"failed".equals(finalStatus) && !"cancelled".equals(finalStatus);
|
|
||||||
if (logService != null && nonTerminalSideEffects) {
|
|
||||||
String title = (raw.getTitle() == null || raw.getTitle().isBlank())
|
String title = (raw.getTitle() == null || raw.getTitle().isBlank())
|
||||||
? ("raw#" + rawId) : raw.getTitle();
|
? ("raw#" + rawId) : raw.getTitle();
|
||||||
logService.append(kb.getId(), WikiLogService.EventType.INGEST,
|
logService.append(kb.getId(), WikiLogService.EventType.INGEST,
|
||||||
"eager " + finalStatus + " · " + title
|
"eager " + finalStatus + " · " + title
|
||||||
+ " · " + totalPages + " pages · " + totalChunks + " chunks");
|
+ " · " + totalPages + " pages · " + totalChunks + " chunks");
|
||||||
}
|
}
|
||||||
// Refresh overview stats whenever a raw lands in a terminal state
|
// RFC-051 PR-2b: refresh overview stats whenever a raw lands in a terminal state
|
||||||
// (completed or partial). Failures and cancellations don't shift the stats meaningfully.
|
// (completed or partial). Failures don't shift the stats meaningfully.
|
||||||
if (overviewService != null && nonTerminalSideEffects) {
|
if (overviewService != null && !"failed".equals(finalStatus)) {
|
||||||
overviewService.rebuild(kb.getId());
|
overviewService.rebuild(kb.getId());
|
||||||
}
|
}
|
||||||
// Tier 2: signal "KB content is dirty" so WikiNarrativeService can
|
// Tier 2: signal "KB content is dirty" so WikiNarrativeService can
|
||||||
// schedule (debounced) an LLM-generated overview narrative refresh.
|
// schedule (debounced) an LLM-generated overview narrative refresh.
|
||||||
// Stats rebuild above is sync; narrative regen runs after-commit.
|
// Stats rebuild above is sync; narrative regen runs after-commit.
|
||||||
if (nonTerminalSideEffects) {
|
if (!"failed".equals(finalStatus)) {
|
||||||
eventPublisher.publishEvent(new vip.mate.wiki.event.WikiKbDirtyEvent(this, kb.getId()));
|
eventPublisher.publishEvent(new vip.mate.wiki.event.WikiKbDirtyEvent(this, kb.getId()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -423,12 +410,7 @@ public class WikiProcessingService {
|
|||||||
// RFC-051 follow-up: trigger embedding whenever chunks landed, not only when
|
// RFC-051 follow-up: trigger embedding whenever chunks landed, not only when
|
||||||
// pages were produced. Otherwise the partial-with-no-pages case above ends up
|
// pages were produced. Otherwise the partial-with-no-pages case above ends up
|
||||||
// with chunks in DB but never embedded, so semantic search silently misses them.
|
// with chunks in DB but never embedded, so semantic search silently misses them.
|
||||||
// Skip the post-ingest embedding sweep when this run was cancelled.
|
if (totalChunks > 0) {
|
||||||
// The user almost certainly stopped because the embedding provider
|
|
||||||
// is failing (out of credits, wrong key, etc.); kicking off another
|
|
||||||
// embedding pass on the same provider would just churn through
|
|
||||||
// every pending chunk and produce more "all chunks failed" noise.
|
|
||||||
if (totalChunks > 0 && !"cancelled".equals(finalStatus)) {
|
|
||||||
final Long fKbId = kb.getId();
|
final Long fKbId = kb.getId();
|
||||||
WIKI_EXECUTOR.submit(() -> {
|
WIKI_EXECUTOR.submit(() -> {
|
||||||
try {
|
try {
|
||||||
@ -436,12 +418,6 @@ public class WikiProcessingService {
|
|||||||
if (embedded > 0) {
|
if (embedded > 0) {
|
||||||
log.info("[Wiki] Async embedding completed: kbId={}, embedded={}", fKbId, embedded);
|
log.info("[Wiki] Async embedding completed: kbId={}, embedded={}", fKbId, embedded);
|
||||||
}
|
}
|
||||||
} catch (vip.mate.wiki.job.WikiEmbeddingProviderFailingException ex) {
|
|
||||||
// Circuit-breaker tripped — the provider has consistently failed.
|
|
||||||
// The exception's own log line in WikiEmbeddingService is enough;
|
|
||||||
// emit a calmer notice here instead of a generic failure log.
|
|
||||||
log.warn("[Wiki] Async embedding aborted by circuit-breaker for kbId={}: {}",
|
|
||||||
fKbId, ex.getMessage());
|
|
||||||
} catch (Exception ex) {
|
} catch (Exception ex) {
|
||||||
log.warn("[Wiki] Async embedding failed for kbId={}: {}", fKbId, ex.getMessage());
|
log.warn("[Wiki] Async embedding failed for kbId={}: {}", fKbId, ex.getMessage());
|
||||||
}
|
}
|
||||||
@ -449,39 +425,16 @@ public class WikiProcessingService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
// If the user requested cancellation while this run was in flight,
|
log.error("[Wiki] Processing failed for raw={}: {}", rawId, e.getMessage(), e);
|
||||||
// surface the abort as 'cancelled' rather than 'failed' even when
|
rawService.updateProcessingStatus(rawId, "failed", e.getMessage());
|
||||||
// the exception bubbled up from somewhere mid-pipeline (e.g. a
|
|
||||||
// checkpoint rejected between chunks).
|
|
||||||
boolean cancelled = rawService.isCancelRequested(rawId);
|
|
||||||
String terminalStatus = cancelled ? "cancelled" : "failed";
|
|
||||||
String detail = cancelled
|
|
||||||
? "Cancelled by user (interrupted: " + (e.getMessage() == null ? "unknown" : e.getMessage()) + ")"
|
|
||||||
: e.getMessage();
|
|
||||||
if (cancelled) {
|
|
||||||
log.info("[Wiki] Processing cancelled for raw={}: {}", rawId, e.getMessage());
|
|
||||||
} else {
|
|
||||||
log.error("[Wiki] Processing failed for raw={}: {}", rawId, e.getMessage(), e);
|
|
||||||
}
|
|
||||||
rawService.updateProcessingStatus(rawId, terminalStatus, detail);
|
|
||||||
kbService.updateStatus(kb.getId(), "active");
|
kbService.updateStatus(kb.getId(), "active");
|
||||||
|
// Transition job to failed
|
||||||
if (wikiJobService != null && jobId != null) {
|
if (wikiJobService != null && jobId != null) {
|
||||||
try {
|
try { wikiJobService.transition(jobId, vip.mate.wiki.job.WikiJobStage.FAILED); } catch (Exception ignored) {}
|
||||||
wikiJobService.transition(jobId, cancelled
|
|
||||||
? vip.mate.wiki.job.WikiJobStage.CANCELLED
|
|
||||||
: vip.mate.wiki.job.WikiJobStage.FAILED);
|
|
||||||
} catch (Exception ignored) {}
|
|
||||||
}
|
|
||||||
// Broadcast: cancelled rows reuse the COMPLETED event with status="cancelled"
|
|
||||||
// so subscribers can render the terminal-but-not-error UI; only true failures
|
|
||||||
// go through RAW_FAILED (which the UI surfaces as a red banner).
|
|
||||||
if (cancelled) {
|
|
||||||
progressBus.broadcast(kb.getId(), WikiProgressBus.EVENT_RAW_COMPLETED,
|
|
||||||
java.util.Map.of("rawId", rawId, "status", "cancelled"));
|
|
||||||
} else {
|
|
||||||
progressBus.broadcast(kb.getId(), WikiProgressBus.EVENT_RAW_FAILED,
|
|
||||||
java.util.Map.of("rawId", rawId, "error", e.getMessage() == null ? "unknown" : e.getMessage()));
|
|
||||||
}
|
}
|
||||||
|
// RFC-012 M3:广播异常终态
|
||||||
|
progressBus.broadcast(kb.getId(), WikiProgressBus.EVENT_RAW_FAILED,
|
||||||
|
java.util.Map.of("rawId", rawId, "error", e.getMessage() == null ? "unknown" : e.getMessage()));
|
||||||
} finally {
|
} finally {
|
||||||
// RFC-012 M2 v2 UI v2:写入最终进度并清理共享计数器
|
// RFC-012 M2 v2 UI v2:写入最终进度并清理共享计数器
|
||||||
ProgressCounter pc = progressCounters.remove(rawId);
|
ProgressCounter pc = progressCounters.remove(rawId);
|
||||||
@ -2211,15 +2164,10 @@ public class WikiProcessingService {
|
|||||||
* @return {@code true} if the raw is gone; caller should stop work
|
* @return {@code true} if the raw is gone; caller should stop work
|
||||||
*/
|
*/
|
||||||
private boolean isAborted(Long rawId, String ctx) {
|
private boolean isAborted(Long rawId, String ctx) {
|
||||||
WikiRawMaterialEntity raw = rawService.getById(rawId);
|
if (rawService.getById(rawId) == null) {
|
||||||
if (raw == null) {
|
|
||||||
log.info("[Wiki] Aborting {} for raw={}: raw was deleted mid-processing", ctx, rawId);
|
log.info("[Wiki] Aborting {} for raw={}: raw was deleted mid-processing", ctx, rawId);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (Boolean.TRUE.equals(raw.getCancelRequested())) {
|
|
||||||
log.info("[Wiki] Aborting {} for raw={}: cancellation requested by user", ctx, rawId);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2312,9 +2260,6 @@ public class WikiProcessingService {
|
|||||||
if (embedded > 0) {
|
if (embedded > 0) {
|
||||||
log.info("[Wiki] Lazy async embedding completed: kbId={}, embedded={}", fKbId, embedded);
|
log.info("[Wiki] Lazy async embedding completed: kbId={}, embedded={}", fKbId, embedded);
|
||||||
}
|
}
|
||||||
} catch (vip.mate.wiki.job.WikiEmbeddingProviderFailingException ex) {
|
|
||||||
log.warn("[Wiki] Lazy async embedding aborted by circuit-breaker for kbId={}: {}",
|
|
||||||
fKbId, ex.getMessage());
|
|
||||||
} catch (Exception ex) {
|
} catch (Exception ex) {
|
||||||
log.warn("[Wiki] Lazy async embedding failed for kbId={}: {}", fKbId, ex.getMessage());
|
log.warn("[Wiki] Lazy async embedding failed for kbId={}: {}", fKbId, ex.getMessage());
|
||||||
}
|
}
|
||||||
|
|||||||
@ -232,47 +232,10 @@ public class WikiRawMaterialService {
|
|||||||
entity.setProgressPhase(null);
|
entity.setProgressPhase(null);
|
||||||
entity.setProgressTotal(0);
|
entity.setProgressTotal(0);
|
||||||
entity.setProgressDone(0);
|
entity.setProgressDone(0);
|
||||||
// Fresh start clears any stale cancel request from a previous run.
|
|
||||||
entity.setCancelRequested(Boolean.FALSE);
|
|
||||||
rawMapper.updateById(entity);
|
rawMapper.updateById(entity);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Mark a raw material for cancellation. Only valid while it is currently
|
|
||||||
* being processed; for any other status this is a no-op so the call is
|
|
||||||
* idempotent and safe to retry from the UI.
|
|
||||||
*
|
|
||||||
* @return {@code true} if the flag was set, {@code false} otherwise
|
|
||||||
*/
|
|
||||||
@Transactional
|
|
||||||
public boolean requestCancel(Long id) {
|
|
||||||
WikiRawMaterialEntity entity = rawMapper.selectById(id);
|
|
||||||
if (entity == null) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (!"processing".equals(entity.getProcessingStatus())) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (Boolean.TRUE.equals(entity.getCancelRequested())) {
|
|
||||||
// Already requested; treat as success without redundant write.
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
entity.setCancelRequested(Boolean.TRUE);
|
|
||||||
rawMapper.updateById(entity);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns {@code true} if the user has asked to cancel this raw material's
|
|
||||||
* current processing run. Used by abort checkpoints inside the processing
|
|
||||||
* pipeline to bail out early.
|
|
||||||
*/
|
|
||||||
public boolean isCancelRequested(Long id) {
|
|
||||||
WikiRawMaterialEntity entity = rawMapper.selectById(id);
|
|
||||||
return entity != null && Boolean.TRUE.equals(entity.getCancelRequested());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* RFC-012 M2 v2 UI:更新 wiki 两阶段消化的进度字段。
|
* RFC-012 M2 v2 UI:更新 wiki 两阶段消化的进度字段。
|
||||||
* <p>
|
* <p>
|
||||||
@ -303,12 +266,6 @@ public class WikiRawMaterialService {
|
|||||||
if ("completed".equals(status)) {
|
if ("completed".equals(status)) {
|
||||||
entity.setLastProcessedAt(java.time.LocalDateTime.now());
|
entity.setLastProcessedAt(java.time.LocalDateTime.now());
|
||||||
}
|
}
|
||||||
// Cancellation flag is only meaningful while a row is being processed.
|
|
||||||
// Any transition out of 'processing' clears it so the field reflects
|
|
||||||
// an idle row's true state and the next reprocess starts clean.
|
|
||||||
if (!"processing".equals(status)) {
|
|
||||||
entity.setCancelRequested(Boolean.FALSE);
|
|
||||||
}
|
|
||||||
rawMapper.updateById(entity);
|
rawMapper.updateById(entity);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,61 +0,0 @@
|
|||||||
package vip.mate.workflow.model;
|
|
||||||
|
|
||||||
import com.baomidou.mybatisplus.annotation.FieldFill;
|
|
||||||
import com.baomidou.mybatisplus.annotation.FieldStrategy;
|
|
||||||
import com.baomidou.mybatisplus.annotation.IdType;
|
|
||||||
import com.baomidou.mybatisplus.annotation.TableField;
|
|
||||||
import com.baomidou.mybatisplus.annotation.TableId;
|
|
||||||
import com.baomidou.mybatisplus.annotation.TableLogic;
|
|
||||||
import com.baomidou.mybatisplus.annotation.TableName;
|
|
||||||
import lombok.Data;
|
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stable workflow identity. The current draft is stored inline (1:1 with the
|
|
||||||
* workflow row) so PK uniqueness automatically guarantees a single draft;
|
|
||||||
* published snapshots live in {@code mate_workflow_revision}.
|
|
||||||
*/
|
|
||||||
@Data
|
|
||||||
@TableName("mate_workflow")
|
|
||||||
public class WorkflowEntity {
|
|
||||||
|
|
||||||
@TableId(type = IdType.ASSIGN_ID)
|
|
||||||
private Long id;
|
|
||||||
|
|
||||||
private Long workspaceId;
|
|
||||||
|
|
||||||
private String name;
|
|
||||||
|
|
||||||
private String description;
|
|
||||||
|
|
||||||
private Boolean enabled;
|
|
||||||
|
|
||||||
/** Inline draft graph_json; null when there is no active draft. */
|
|
||||||
@TableField(value = "draft_json", updateStrategy = FieldStrategy.ALWAYS)
|
|
||||||
private String draftJson;
|
|
||||||
|
|
||||||
@TableField(value = "draft_schema_version", updateStrategy = FieldStrategy.ALWAYS)
|
|
||||||
private String draftSchemaVersion;
|
|
||||||
|
|
||||||
@TableField(value = "draft_updated_by", updateStrategy = FieldStrategy.ALWAYS)
|
|
||||||
private Long draftUpdatedBy;
|
|
||||||
|
|
||||||
@TableField(value = "draft_updated_at", updateStrategy = FieldStrategy.ALWAYS)
|
|
||||||
private LocalDateTime draftUpdatedAt;
|
|
||||||
|
|
||||||
/** Pointer to the most recently published revision; null if never published. */
|
|
||||||
@TableField(value = "latest_revision_id", updateStrategy = FieldStrategy.ALWAYS)
|
|
||||||
private Long latestRevisionId;
|
|
||||||
|
|
||||||
private Long createdBy;
|
|
||||||
|
|
||||||
@TableField(fill = FieldFill.INSERT)
|
|
||||||
private LocalDateTime createTime;
|
|
||||||
|
|
||||||
@TableField(fill = FieldFill.INSERT_UPDATE)
|
|
||||||
private LocalDateTime updateTime;
|
|
||||||
|
|
||||||
@TableLogic
|
|
||||||
private Integer deleted;
|
|
||||||
}
|
|
||||||
@ -1,48 +0,0 @@
|
|||||||
package vip.mate.workflow.model;
|
|
||||||
|
|
||||||
import com.baomidou.mybatisplus.annotation.FieldStrategy;
|
|
||||||
import com.baomidou.mybatisplus.annotation.IdType;
|
|
||||||
import com.baomidou.mybatisplus.annotation.TableField;
|
|
||||||
import com.baomidou.mybatisplus.annotation.TableId;
|
|
||||||
import com.baomidou.mybatisplus.annotation.TableName;
|
|
||||||
import lombok.Data;
|
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Payload body addressed by a stable URI. Small payloads (< 256KB) live
|
|
||||||
* inline in {@code contentBytes}; larger payloads point at filesystem or
|
|
||||||
* object storage via {@code storageKind} + {@code storageRef}. {@code sha256}
|
|
||||||
* is for tamper detection only — v0 does not deduplicate across runs.
|
|
||||||
*/
|
|
||||||
@Data
|
|
||||||
@TableName("mate_workflow_payload")
|
|
||||||
public class WorkflowPayloadEntity {
|
|
||||||
|
|
||||||
@TableId(type = IdType.ASSIGN_ID)
|
|
||||||
private Long id;
|
|
||||||
|
|
||||||
private String payloadUri;
|
|
||||||
|
|
||||||
private Long workspaceId;
|
|
||||||
|
|
||||||
@TableField(value = "content_bytes", updateStrategy = FieldStrategy.ALWAYS)
|
|
||||||
private byte[] contentBytes;
|
|
||||||
|
|
||||||
/** Storage flavour: inline / fs / s3 / oss. */
|
|
||||||
private String storageKind;
|
|
||||||
|
|
||||||
@TableField(value = "storage_ref", updateStrategy = FieldStrategy.ALWAYS)
|
|
||||||
private String storageRef;
|
|
||||||
|
|
||||||
@TableField(value = "content_type", updateStrategy = FieldStrategy.ALWAYS)
|
|
||||||
private String contentType;
|
|
||||||
|
|
||||||
@TableField(value = "sha256", updateStrategy = FieldStrategy.ALWAYS)
|
|
||||||
private String sha256;
|
|
||||||
|
|
||||||
@TableField(value = "size_bytes", updateStrategy = FieldStrategy.ALWAYS)
|
|
||||||
private Long sizeBytes;
|
|
||||||
|
|
||||||
private LocalDateTime createdAt;
|
|
||||||
}
|
|
||||||
@ -1,41 +0,0 @@
|
|||||||
package vip.mate.workflow.model;
|
|
||||||
|
|
||||||
import com.baomidou.mybatisplus.annotation.FieldFill;
|
|
||||||
import com.baomidou.mybatisplus.annotation.FieldStrategy;
|
|
||||||
import com.baomidou.mybatisplus.annotation.IdType;
|
|
||||||
import com.baomidou.mybatisplus.annotation.TableField;
|
|
||||||
import com.baomidou.mybatisplus.annotation.TableId;
|
|
||||||
import com.baomidou.mybatisplus.annotation.TableName;
|
|
||||||
import lombok.Data;
|
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Immutable published snapshot of a workflow. The {@code revision} column is
|
|
||||||
* monotonic per workflow; rows are append-only after publish.
|
|
||||||
*/
|
|
||||||
@Data
|
|
||||||
@TableName("mate_workflow_revision")
|
|
||||||
public class WorkflowRevisionEntity {
|
|
||||||
|
|
||||||
@TableId(type = IdType.ASSIGN_ID)
|
|
||||||
private Long id;
|
|
||||||
|
|
||||||
private Long workflowId;
|
|
||||||
|
|
||||||
private Integer revision;
|
|
||||||
|
|
||||||
@TableField(value = "graph_json", updateStrategy = FieldStrategy.ALWAYS)
|
|
||||||
private String graphJson;
|
|
||||||
|
|
||||||
private String schemaVersion;
|
|
||||||
|
|
||||||
@TableField(value = "published_note", updateStrategy = FieldStrategy.ALWAYS)
|
|
||||||
private String publishedNote;
|
|
||||||
|
|
||||||
@TableField(value = "published_by", updateStrategy = FieldStrategy.ALWAYS)
|
|
||||||
private Long publishedBy;
|
|
||||||
|
|
||||||
@TableField(fill = FieldFill.INSERT)
|
|
||||||
private LocalDateTime createTime;
|
|
||||||
}
|
|
||||||
@ -1,61 +0,0 @@
|
|||||||
package vip.mate.workflow.model;
|
|
||||||
|
|
||||||
import com.baomidou.mybatisplus.annotation.FieldFill;
|
|
||||||
import com.baomidou.mybatisplus.annotation.FieldStrategy;
|
|
||||||
import com.baomidou.mybatisplus.annotation.IdType;
|
|
||||||
import com.baomidou.mybatisplus.annotation.TableField;
|
|
||||||
import com.baomidou.mybatisplus.annotation.TableId;
|
|
||||||
import com.baomidou.mybatisplus.annotation.TableLogic;
|
|
||||||
import com.baomidou.mybatisplus.annotation.TableName;
|
|
||||||
import lombok.Data;
|
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Workflow run instance. Run is locked to a specific revision for stability
|
|
||||||
* even when later revisions are published. Initial input and final output are
|
|
||||||
* stored as payload URIs to avoid bloating the run row.
|
|
||||||
*/
|
|
||||||
@Data
|
|
||||||
@TableName("mate_workflow_run")
|
|
||||||
public class WorkflowRunEntity {
|
|
||||||
|
|
||||||
@TableId(type = IdType.ASSIGN_ID)
|
|
||||||
private Long id;
|
|
||||||
|
|
||||||
private Long workflowId;
|
|
||||||
|
|
||||||
private Long revisionId;
|
|
||||||
|
|
||||||
private Long workspaceId;
|
|
||||||
|
|
||||||
/** State machine value: pending / running / paused / succeeded / failed / cancelled / timed_out. */
|
|
||||||
private String state;
|
|
||||||
|
|
||||||
@TableField(value = "triggered_by", updateStrategy = FieldStrategy.ALWAYS)
|
|
||||||
private String triggeredBy;
|
|
||||||
|
|
||||||
@TableField(value = "triggered_meta", updateStrategy = FieldStrategy.ALWAYS)
|
|
||||||
private String triggeredMeta;
|
|
||||||
|
|
||||||
@TableField(value = "initial_input_ref", updateStrategy = FieldStrategy.ALWAYS)
|
|
||||||
private String initialInputRef;
|
|
||||||
|
|
||||||
@TableField(value = "final_output_ref", updateStrategy = FieldStrategy.ALWAYS)
|
|
||||||
private String finalOutputRef;
|
|
||||||
|
|
||||||
@TableField(value = "error_message", updateStrategy = FieldStrategy.ALWAYS)
|
|
||||||
private String errorMessage;
|
|
||||||
|
|
||||||
@TableField(value = "started_at", updateStrategy = FieldStrategy.ALWAYS)
|
|
||||||
private LocalDateTime startedAt;
|
|
||||||
|
|
||||||
@TableField(value = "completed_at", updateStrategy = FieldStrategy.ALWAYS)
|
|
||||||
private LocalDateTime completedAt;
|
|
||||||
|
|
||||||
@TableField(fill = FieldFill.INSERT)
|
|
||||||
private LocalDateTime createTime;
|
|
||||||
|
|
||||||
@TableLogic
|
|
||||||
private Integer deleted;
|
|
||||||
}
|
|
||||||
@ -1,51 +0,0 @@
|
|||||||
package vip.mate.workflow.model;
|
|
||||||
|
|
||||||
import com.baomidou.mybatisplus.annotation.FieldStrategy;
|
|
||||||
import com.baomidou.mybatisplus.annotation.IdType;
|
|
||||||
import com.baomidou.mybatisplus.annotation.TableField;
|
|
||||||
import com.baomidou.mybatisplus.annotation.TableId;
|
|
||||||
import com.baomidou.mybatisplus.annotation.TableName;
|
|
||||||
import lombok.Data;
|
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Durable workflow pause row. Holds the resume token and links back to the
|
|
||||||
* external approval row (or other callback source) so that resume can be
|
|
||||||
* triggered idempotently after a JVM restart.
|
|
||||||
*/
|
|
||||||
@Data
|
|
||||||
@TableName("mate_workflow_run_pause")
|
|
||||||
public class WorkflowRunPauseEntity {
|
|
||||||
|
|
||||||
@TableId(type = IdType.ASSIGN_ID)
|
|
||||||
private Long id;
|
|
||||||
|
|
||||||
private Long runId;
|
|
||||||
|
|
||||||
private Long stepId;
|
|
||||||
|
|
||||||
/** Source of the pause: await_approval, external_callback, etc. */
|
|
||||||
private String pauseKind;
|
|
||||||
|
|
||||||
/** Random server-generated token used as the resume entry key. */
|
|
||||||
private String pauseToken;
|
|
||||||
|
|
||||||
@TableField(value = "external_approval_id", updateStrategy = FieldStrategy.ALWAYS)
|
|
||||||
private Long externalApprovalId;
|
|
||||||
|
|
||||||
private LocalDateTime pausedAt;
|
|
||||||
|
|
||||||
@TableField(value = "resume_deadline", updateStrategy = FieldStrategy.ALWAYS)
|
|
||||||
private LocalDateTime resumeDeadline;
|
|
||||||
|
|
||||||
@TableField(value = "resume_payload_ref", updateStrategy = FieldStrategy.ALWAYS)
|
|
||||||
private String resumePayloadRef;
|
|
||||||
|
|
||||||
@TableField(value = "resumed_at", updateStrategy = FieldStrategy.ALWAYS)
|
|
||||||
private LocalDateTime resumedAt;
|
|
||||||
|
|
||||||
/** Outcome on resume: approved / rejected / timeout / cancelled. */
|
|
||||||
@TableField(value = "resume_outcome", updateStrategy = FieldStrategy.ALWAYS)
|
|
||||||
private String resumeOutcome;
|
|
||||||
}
|
|
||||||
@ -1,68 +0,0 @@
|
|||||||
package vip.mate.workflow.model;
|
|
||||||
|
|
||||||
import com.baomidou.mybatisplus.annotation.FieldStrategy;
|
|
||||||
import com.baomidou.mybatisplus.annotation.IdType;
|
|
||||||
import com.baomidou.mybatisplus.annotation.TableField;
|
|
||||||
import com.baomidou.mybatisplus.annotation.TableId;
|
|
||||||
import com.baomidou.mybatisplus.annotation.TableName;
|
|
||||||
import lombok.Data;
|
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Per-step execution row. {@code stepIndex} is the zero-based index in the
|
|
||||||
* revision's steps array; {@code iterationIndex} is reserved for fan_out
|
|
||||||
* iterations (and future loop bodies).
|
|
||||||
*/
|
|
||||||
@Data
|
|
||||||
@TableName("mate_workflow_run_step")
|
|
||||||
public class WorkflowRunStepEntity {
|
|
||||||
|
|
||||||
@TableId(type = IdType.ASSIGN_ID)
|
|
||||||
private Long id;
|
|
||||||
|
|
||||||
private Long runId;
|
|
||||||
|
|
||||||
private Integer stepIndex;
|
|
||||||
|
|
||||||
@TableField(value = "iteration_index", updateStrategy = FieldStrategy.ALWAYS)
|
|
||||||
private Integer iterationIndex;
|
|
||||||
|
|
||||||
@TableField(value = "step_name", updateStrategy = FieldStrategy.ALWAYS)
|
|
||||||
private String stepName;
|
|
||||||
|
|
||||||
@TableField(value = "agent_id", updateStrategy = FieldStrategy.ALWAYS)
|
|
||||||
private Long agentId;
|
|
||||||
|
|
||||||
private String state;
|
|
||||||
|
|
||||||
@TableField(value = "input_ref", updateStrategy = FieldStrategy.ALWAYS)
|
|
||||||
private String inputRef;
|
|
||||||
|
|
||||||
@TableField(value = "output_ref", updateStrategy = FieldStrategy.ALWAYS)
|
|
||||||
private String outputRef;
|
|
||||||
|
|
||||||
@TableField(value = "output_summary", updateStrategy = FieldStrategy.ALWAYS)
|
|
||||||
private String outputSummary;
|
|
||||||
|
|
||||||
@TableField(value = "output_content_type", updateStrategy = FieldStrategy.ALWAYS)
|
|
||||||
private String outputContentType;
|
|
||||||
|
|
||||||
@TableField(value = "error_message", updateStrategy = FieldStrategy.ALWAYS)
|
|
||||||
private String errorMessage;
|
|
||||||
|
|
||||||
@TableField(value = "duration_ms", updateStrategy = FieldStrategy.ALWAYS)
|
|
||||||
private Long durationMs;
|
|
||||||
|
|
||||||
@TableField(value = "token_input", updateStrategy = FieldStrategy.ALWAYS)
|
|
||||||
private Integer tokenInput;
|
|
||||||
|
|
||||||
@TableField(value = "token_output", updateStrategy = FieldStrategy.ALWAYS)
|
|
||||||
private Integer tokenOutput;
|
|
||||||
|
|
||||||
@TableField(value = "started_at", updateStrategy = FieldStrategy.ALWAYS)
|
|
||||||
private LocalDateTime startedAt;
|
|
||||||
|
|
||||||
@TableField(value = "completed_at", updateStrategy = FieldStrategy.ALWAYS)
|
|
||||||
private LocalDateTime completedAt;
|
|
||||||
}
|
|
||||||
@ -1,9 +0,0 @@
|
|||||||
package vip.mate.workflow.repository;
|
|
||||||
|
|
||||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
|
||||||
import org.apache.ibatis.annotations.Mapper;
|
|
||||||
import vip.mate.workflow.model.WorkflowEntity;
|
|
||||||
|
|
||||||
@Mapper
|
|
||||||
public interface WorkflowMapper extends BaseMapper<WorkflowEntity> {
|
|
||||||
}
|
|
||||||
@ -1,9 +0,0 @@
|
|||||||
package vip.mate.workflow.repository;
|
|
||||||
|
|
||||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
|
||||||
import org.apache.ibatis.annotations.Mapper;
|
|
||||||
import vip.mate.workflow.model.WorkflowPayloadEntity;
|
|
||||||
|
|
||||||
@Mapper
|
|
||||||
public interface WorkflowPayloadMapper extends BaseMapper<WorkflowPayloadEntity> {
|
|
||||||
}
|
|
||||||
@ -1,9 +0,0 @@
|
|||||||
package vip.mate.workflow.repository;
|
|
||||||
|
|
||||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
|
||||||
import org.apache.ibatis.annotations.Mapper;
|
|
||||||
import vip.mate.workflow.model.WorkflowRevisionEntity;
|
|
||||||
|
|
||||||
@Mapper
|
|
||||||
public interface WorkflowRevisionMapper extends BaseMapper<WorkflowRevisionEntity> {
|
|
||||||
}
|
|
||||||
@ -1,9 +0,0 @@
|
|||||||
package vip.mate.workflow.repository;
|
|
||||||
|
|
||||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
|
||||||
import org.apache.ibatis.annotations.Mapper;
|
|
||||||
import vip.mate.workflow.model.WorkflowRunEntity;
|
|
||||||
|
|
||||||
@Mapper
|
|
||||||
public interface WorkflowRunMapper extends BaseMapper<WorkflowRunEntity> {
|
|
||||||
}
|
|
||||||
@ -1,9 +0,0 @@
|
|||||||
package vip.mate.workflow.repository;
|
|
||||||
|
|
||||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
|
||||||
import org.apache.ibatis.annotations.Mapper;
|
|
||||||
import vip.mate.workflow.model.WorkflowRunPauseEntity;
|
|
||||||
|
|
||||||
@Mapper
|
|
||||||
public interface WorkflowRunPauseMapper extends BaseMapper<WorkflowRunPauseEntity> {
|
|
||||||
}
|
|
||||||
@ -1,9 +0,0 @@
|
|||||||
package vip.mate.workflow.repository;
|
|
||||||
|
|
||||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
|
||||||
import org.apache.ibatis.annotations.Mapper;
|
|
||||||
import vip.mate.workflow.model.WorkflowRunStepEntity;
|
|
||||||
|
|
||||||
@Mapper
|
|
||||||
public interface WorkflowRunStepMapper extends BaseMapper<WorkflowRunStepEntity> {
|
|
||||||
}
|
|
||||||
@ -556,7 +556,6 @@ public class ConversationService {
|
|||||||
case "text" -> appendSegment(text, part.getText());
|
case "text" -> appendSegment(text, part.getText());
|
||||||
case "thinking", "tool_call", "parse_error" -> { /* skip — frontend reads these from contentParts directly */ }
|
case "thinking", "tool_call", "parse_error" -> { /* skip — frontend reads these from contentParts directly */ }
|
||||||
case "file" -> appendSegment(text, renderFilePart(part));
|
case "file" -> appendSegment(text, renderFilePart(part));
|
||||||
case "image", "video", "audio", "model3d" -> appendSegment(text, renderMediaPart(part));
|
|
||||||
default -> appendSegment(text, part.getText());
|
default -> appendSegment(text, part.getText());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -615,36 +614,6 @@ public class ConversationService {
|
|||||||
return "[附件] " + name + "(路径: " + path + ")";
|
return "[附件] " + name + "(路径: " + path + ")";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Render an image/video/audio/3D-model content part for the LLM prompt.
|
|
||||||
* <p>
|
|
||||||
* Without this marker, media parts are invisible in the rendered text — the LLM
|
|
||||||
* sees only the user's accompanying text and has no idea an attachment was sent.
|
|
||||||
* That fails closed when the multimodal Media injection in {@code BaseAgent} is
|
|
||||||
* upstream-stripped (model heuristic claims vision but the actual provider drops
|
|
||||||
* the image), leaving the agent to ask "which image?" for an attachment the user
|
|
||||||
* already uploaded. The path lets file-reading tools ({@code read_file},
|
|
||||||
* {@code extract_document_text}, {@code detect_file_type}) work as a fallback.
|
|
||||||
*/
|
|
||||||
private String renderMediaPart(MessageContentPart part) {
|
|
||||||
String label = switch (part.getType()) {
|
|
||||||
case "image" -> "[图片]";
|
|
||||||
case "video" -> "[视频]";
|
|
||||||
case "audio" -> "[音频]";
|
|
||||||
case "model3d" -> "[3D 模型]";
|
|
||||||
default -> "[附件]";
|
|
||||||
};
|
|
||||||
String name = safe(part.getFileName());
|
|
||||||
if (name.isBlank()) {
|
|
||||||
name = "未命名";
|
|
||||||
}
|
|
||||||
String path = safe(part.getPath());
|
|
||||||
if (path.isBlank()) {
|
|
||||||
return label + " " + name;
|
|
||||||
}
|
|
||||||
return label + " " + name + "(路径: " + path + ")";
|
|
||||||
}
|
|
||||||
|
|
||||||
private void appendSegment(StringBuilder builder, String text) {
|
private void appendSegment(StringBuilder builder, String text) {
|
||||||
String safeText = safe(text);
|
String safeText = safe(text);
|
||||||
if (safeText.isBlank()) {
|
if (safeText.isBlank()) {
|
||||||
|
|||||||
@ -1,6 +0,0 @@
|
|||||||
-- V95: cancellation flag for in-progress wiki raw material processing.
|
|
||||||
-- Lets the user request a stop on a long-running PDF analysis (e.g. when
|
|
||||||
-- the embedding model has run out of credits) without having to delete
|
|
||||||
-- the raw material. The processing pipeline checks the flag at its
|
|
||||||
-- existing abort checkpoints and bails out with a 'cancelled' status.
|
|
||||||
ALTER TABLE mate_wiki_raw_material ADD COLUMN IF NOT EXISTS cancel_requested BOOLEAN NOT NULL DEFAULT FALSE;
|
|
||||||
@ -1,173 +0,0 @@
|
|||||||
-- V96: Foundational schema for the workflow runtime.
|
|
||||||
-- Eight tables establish workflow identity (workflow + immutable revisions),
|
|
||||||
-- run state (run + per-step rows + durable pause rows for await_approval),
|
|
||||||
-- payload URI storage with inline / filesystem fallback, and trigger
|
|
||||||
-- definitions paired with a dedup-window table for envelope-based event
|
|
||||||
-- governance. H2 dialect uses CLOB for MEDIUMTEXT and BLOB for LONGBLOB;
|
|
||||||
-- secondary indexes are emitted as separate CREATE INDEX statements per
|
|
||||||
-- project convention.
|
|
||||||
|
|
||||||
-- 1. Stable workflow identity + draft (1:1 with workflow row).
|
|
||||||
CREATE TABLE IF NOT EXISTS mate_workflow (
|
|
||||||
id BIGINT NOT NULL PRIMARY KEY,
|
|
||||||
workspace_id BIGINT NOT NULL,
|
|
||||||
name VARCHAR(128) NOT NULL,
|
|
||||||
description VARCHAR(1024),
|
|
||||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
|
||||||
draft_json CLOB,
|
|
||||||
draft_schema_version VARCHAR(8),
|
|
||||||
draft_updated_by BIGINT,
|
|
||||||
draft_updated_at TIMESTAMP,
|
|
||||||
latest_revision_id BIGINT,
|
|
||||||
created_by BIGINT,
|
|
||||||
create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
deleted INT NOT NULL DEFAULT 0
|
|
||||||
);
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS uk_workflow_workspace_name
|
|
||||||
ON mate_workflow (workspace_id, name, deleted);
|
|
||||||
|
|
||||||
-- 2. Immutable published revisions; integer revision is monotonic per workflow.
|
|
||||||
CREATE TABLE IF NOT EXISTS mate_workflow_revision (
|
|
||||||
id BIGINT NOT NULL PRIMARY KEY,
|
|
||||||
workflow_id BIGINT NOT NULL,
|
|
||||||
revision INT NOT NULL,
|
|
||||||
graph_json CLOB NOT NULL,
|
|
||||||
schema_version VARCHAR(8) NOT NULL,
|
|
||||||
published_note VARCHAR(512),
|
|
||||||
published_by BIGINT,
|
|
||||||
create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS uk_workflow_revision
|
|
||||||
ON mate_workflow_revision (workflow_id, revision);
|
|
||||||
|
|
||||||
-- 3. Workflow run instance; payload bodies live behind URIs in mate_workflow_payload.
|
|
||||||
CREATE TABLE IF NOT EXISTS mate_workflow_run (
|
|
||||||
id BIGINT NOT NULL PRIMARY KEY,
|
|
||||||
workflow_id BIGINT NOT NULL,
|
|
||||||
revision_id BIGINT NOT NULL,
|
|
||||||
workspace_id BIGINT NOT NULL,
|
|
||||||
state VARCHAR(16) NOT NULL,
|
|
||||||
triggered_by VARCHAR(32),
|
|
||||||
triggered_meta CLOB,
|
|
||||||
initial_input_ref VARCHAR(256),
|
|
||||||
final_output_ref VARCHAR(256),
|
|
||||||
error_message VARCHAR(2048),
|
|
||||||
started_at TIMESTAMP,
|
|
||||||
completed_at TIMESTAMP,
|
|
||||||
create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
deleted INT NOT NULL DEFAULT 0
|
|
||||||
);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_workflow_run_started
|
|
||||||
ON mate_workflow_run (workflow_id, started_at);
|
|
||||||
|
|
||||||
-- 4. Per-step run row; iteration_index reserved for fan_out (and future loop).
|
|
||||||
CREATE TABLE IF NOT EXISTS mate_workflow_run_step (
|
|
||||||
id BIGINT NOT NULL PRIMARY KEY,
|
|
||||||
run_id BIGINT NOT NULL,
|
|
||||||
step_index INT NOT NULL,
|
|
||||||
iteration_index INT,
|
|
||||||
step_name VARCHAR(128),
|
|
||||||
agent_id BIGINT,
|
|
||||||
state VARCHAR(16),
|
|
||||||
input_ref VARCHAR(256),
|
|
||||||
output_ref VARCHAR(256),
|
|
||||||
output_summary VARCHAR(512),
|
|
||||||
output_content_type VARCHAR(64),
|
|
||||||
error_message VARCHAR(2048),
|
|
||||||
duration_ms BIGINT,
|
|
||||||
token_input INT,
|
|
||||||
token_output INT,
|
|
||||||
started_at TIMESTAMP,
|
|
||||||
completed_at TIMESTAMP
|
|
||||||
);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_workflow_run_step
|
|
||||||
ON mate_workflow_run_step (run_id, step_index, iteration_index);
|
|
||||||
|
|
||||||
-- 5. Durable pause rows so await_approval can resume across restarts.
|
|
||||||
-- pause_token is the resume entry key; external_approval_id ties back to
|
|
||||||
-- ApprovalWorkflowService rows so the approval callback can find the pause.
|
|
||||||
CREATE TABLE IF NOT EXISTS mate_workflow_run_pause (
|
|
||||||
id BIGINT NOT NULL PRIMARY KEY,
|
|
||||||
run_id BIGINT NOT NULL,
|
|
||||||
step_id BIGINT NOT NULL,
|
|
||||||
pause_kind VARCHAR(32) NOT NULL,
|
|
||||||
pause_token VARCHAR(128) NOT NULL,
|
|
||||||
external_approval_id BIGINT,
|
|
||||||
paused_at TIMESTAMP NOT NULL,
|
|
||||||
resume_deadline TIMESTAMP,
|
|
||||||
resume_payload_ref VARCHAR(256),
|
|
||||||
resumed_at TIMESTAMP,
|
|
||||||
resume_outcome VARCHAR(32)
|
|
||||||
);
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS uk_workflow_pause_run_step
|
|
||||||
ON mate_workflow_run_pause (run_id, step_id);
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS uk_workflow_pause_token
|
|
||||||
ON mate_workflow_run_pause (pause_token);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_workflow_pause_external_approval
|
|
||||||
ON mate_workflow_run_pause (external_approval_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_workflow_pause_open_deadline
|
|
||||||
ON mate_workflow_run_pause (resumed_at, resume_deadline);
|
|
||||||
|
|
||||||
-- 6. Payload URI storage. Inline blob for < 256KB; storage_kind=fs/s3/oss
|
|
||||||
-- carries the external object key in storage_ref. sha256 is for tamper
|
|
||||||
-- detection only — v0 does not deduplicate across runs.
|
|
||||||
CREATE TABLE IF NOT EXISTS mate_workflow_payload (
|
|
||||||
id BIGINT NOT NULL PRIMARY KEY,
|
|
||||||
payload_uri VARCHAR(256) NOT NULL,
|
|
||||||
workspace_id BIGINT NOT NULL,
|
|
||||||
content_bytes BLOB,
|
|
||||||
storage_kind VARCHAR(16) NOT NULL,
|
|
||||||
storage_ref VARCHAR(512),
|
|
||||||
content_type VARCHAR(64),
|
|
||||||
sha256 CHAR(64),
|
|
||||||
size_bytes BIGINT,
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS uk_workflow_payload_uri
|
|
||||||
ON mate_workflow_payload (payload_uri);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_workflow_payload_workspace_created
|
|
||||||
ON mate_workflow_payload (workspace_id, created_at);
|
|
||||||
|
|
||||||
-- 7. Trigger definitions. pattern_version is a lamport counter that fire
|
|
||||||
-- callbacks compare against on every fire to detect that another instance
|
|
||||||
-- has updated the cron expression and self-cancel the local schedule.
|
|
||||||
CREATE TABLE IF NOT EXISTS mate_trigger (
|
|
||||||
id BIGINT NOT NULL PRIMARY KEY,
|
|
||||||
workspace_id BIGINT NOT NULL,
|
|
||||||
name VARCHAR(128),
|
|
||||||
pattern_type VARCHAR(32) NOT NULL,
|
|
||||||
pattern_json CLOB NOT NULL,
|
|
||||||
target_type VARCHAR(16) NOT NULL,
|
|
||||||
target_id BIGINT NOT NULL,
|
|
||||||
payload_template CLOB,
|
|
||||||
rate_limit_per_min INT NOT NULL DEFAULT 60,
|
|
||||||
dedup_window_secs INT NOT NULL DEFAULT 60,
|
|
||||||
bot_self_filter BOOLEAN NOT NULL DEFAULT TRUE,
|
|
||||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
|
||||||
fire_count BIGINT NOT NULL DEFAULT 0,
|
|
||||||
max_fires BIGINT NOT NULL DEFAULT 0,
|
|
||||||
last_fired_at TIMESTAMP,
|
|
||||||
pattern_version BIGINT NOT NULL DEFAULT 1,
|
|
||||||
create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
deleted INT NOT NULL DEFAULT 0
|
|
||||||
);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_trigger_workspace_enabled
|
|
||||||
ON mate_trigger (workspace_id, enabled, deleted);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_trigger_target
|
|
||||||
ON mate_trigger (target_type, target_id);
|
|
||||||
|
|
||||||
-- 8. Event dedup window. dedup_key is envelope.eventId, falling back to
|
|
||||||
-- sourceHash when the upstream channel did not provide a stable id.
|
|
||||||
CREATE TABLE IF NOT EXISTS mate_trigger_event (
|
|
||||||
id BIGINT NOT NULL PRIMARY KEY,
|
|
||||||
trigger_id BIGINT NOT NULL,
|
|
||||||
dedup_key VARCHAR(128) NOT NULL,
|
|
||||||
received_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
expires_at TIMESTAMP NOT NULL
|
|
||||||
);
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS uk_trigger_dedup
|
|
||||||
ON mate_trigger_event (trigger_id, dedup_key);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_trigger_event_expires
|
|
||||||
ON mate_trigger_event (expires_at);
|
|
||||||
@ -1,9 +0,0 @@
|
|||||||
-- V95: cancellation flag for in-progress wiki raw material processing.
|
|
||||||
-- Lets the user request a stop on a long-running PDF analysis (e.g. when
|
|
||||||
-- the embedding model has run out of credits) without having to delete
|
|
||||||
-- the raw material. The processing pipeline checks the flag at its
|
|
||||||
-- existing abort checkpoints and bails out with a 'cancelled' status.
|
|
||||||
-- MySQL lacks `ADD COLUMN IF NOT EXISTS`; use INFORMATION_SCHEMA guard instead.
|
|
||||||
SET @c := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mate_wiki_raw_material' AND COLUMN_NAME = 'cancel_requested');
|
|
||||||
SET @s := IF(@c = 0, 'ALTER TABLE mate_wiki_raw_material ADD COLUMN cancel_requested BOOLEAN NOT NULL DEFAULT FALSE', 'SELECT 1');
|
|
||||||
PREPARE stmt FROM @s; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
|
||||||
@ -1,162 +0,0 @@
|
|||||||
-- V96: Foundational schema for the workflow runtime.
|
|
||||||
-- Eight tables establish workflow identity (workflow + immutable revisions),
|
|
||||||
-- run state (run + per-step rows + durable pause rows for await_approval),
|
|
||||||
-- payload URI storage with inline / filesystem fallback, and trigger
|
|
||||||
-- definitions paired with a dedup-window table for envelope-based event
|
|
||||||
-- governance. CREATE TABLE IF NOT EXISTS is itself idempotent on MySQL.
|
|
||||||
|
|
||||||
-- 1. Stable workflow identity + draft (1:1 with workflow row).
|
|
||||||
CREATE TABLE IF NOT EXISTS mate_workflow (
|
|
||||||
id BIGINT NOT NULL PRIMARY KEY,
|
|
||||||
workspace_id BIGINT NOT NULL,
|
|
||||||
name VARCHAR(128) NOT NULL,
|
|
||||||
description VARCHAR(1024),
|
|
||||||
enabled TINYINT NOT NULL DEFAULT 1,
|
|
||||||
draft_json MEDIUMTEXT,
|
|
||||||
draft_schema_version VARCHAR(8),
|
|
||||||
draft_updated_by BIGINT,
|
|
||||||
draft_updated_at DATETIME(3),
|
|
||||||
latest_revision_id BIGINT,
|
|
||||||
created_by BIGINT,
|
|
||||||
create_time DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
|
||||||
update_time DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
|
||||||
deleted INT NOT NULL DEFAULT 0,
|
|
||||||
UNIQUE KEY uk_workflow_workspace_name (workspace_id, name, deleted)
|
|
||||||
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci
|
|
||||||
COMMENT = 'Workflow definition with stable identity and inline draft.';
|
|
||||||
|
|
||||||
-- 2. Immutable published revisions; integer revision is monotonic per workflow.
|
|
||||||
CREATE TABLE IF NOT EXISTS mate_workflow_revision (
|
|
||||||
id BIGINT NOT NULL PRIMARY KEY,
|
|
||||||
workflow_id BIGINT NOT NULL,
|
|
||||||
revision INT NOT NULL,
|
|
||||||
graph_json MEDIUMTEXT NOT NULL,
|
|
||||||
schema_version VARCHAR(8) NOT NULL,
|
|
||||||
published_note VARCHAR(512),
|
|
||||||
published_by BIGINT,
|
|
||||||
create_time DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
|
||||||
UNIQUE KEY uk_workflow_revision (workflow_id, revision)
|
|
||||||
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci
|
|
||||||
COMMENT = 'Immutable published workflow revisions.';
|
|
||||||
|
|
||||||
-- 3. Workflow run instance; payload bodies live behind URIs in mate_workflow_payload.
|
|
||||||
CREATE TABLE IF NOT EXISTS mate_workflow_run (
|
|
||||||
id BIGINT NOT NULL PRIMARY KEY,
|
|
||||||
workflow_id BIGINT NOT NULL,
|
|
||||||
revision_id BIGINT NOT NULL,
|
|
||||||
workspace_id BIGINT NOT NULL,
|
|
||||||
state VARCHAR(16) NOT NULL,
|
|
||||||
triggered_by VARCHAR(32),
|
|
||||||
triggered_meta MEDIUMTEXT,
|
|
||||||
initial_input_ref VARCHAR(256),
|
|
||||||
final_output_ref VARCHAR(256),
|
|
||||||
error_message VARCHAR(2048),
|
|
||||||
started_at DATETIME(3),
|
|
||||||
completed_at DATETIME(3),
|
|
||||||
create_time DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
|
||||||
deleted INT NOT NULL DEFAULT 0,
|
|
||||||
KEY idx_workflow_run_started (workflow_id, started_at)
|
|
||||||
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci
|
|
||||||
COMMENT = 'Workflow run instances locked to a specific revision.';
|
|
||||||
|
|
||||||
-- 4. Per-step run row; iteration_index reserved for fan_out (and future loop).
|
|
||||||
CREATE TABLE IF NOT EXISTS mate_workflow_run_step (
|
|
||||||
id BIGINT NOT NULL PRIMARY KEY,
|
|
||||||
run_id BIGINT NOT NULL,
|
|
||||||
step_index INT NOT NULL,
|
|
||||||
iteration_index INT,
|
|
||||||
step_name VARCHAR(128),
|
|
||||||
agent_id BIGINT,
|
|
||||||
state VARCHAR(16),
|
|
||||||
input_ref VARCHAR(256),
|
|
||||||
output_ref VARCHAR(256),
|
|
||||||
output_summary VARCHAR(512),
|
|
||||||
output_content_type VARCHAR(64),
|
|
||||||
error_message VARCHAR(2048),
|
|
||||||
duration_ms BIGINT,
|
|
||||||
token_input INT,
|
|
||||||
token_output INT,
|
|
||||||
started_at DATETIME(3),
|
|
||||||
completed_at DATETIME(3),
|
|
||||||
KEY idx_workflow_run_step (run_id, step_index, iteration_index)
|
|
||||||
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci
|
|
||||||
COMMENT = 'Per-step run rows with input/output references and timings.';
|
|
||||||
|
|
||||||
-- 5. Durable pause rows so await_approval can resume across restarts.
|
|
||||||
CREATE TABLE IF NOT EXISTS mate_workflow_run_pause (
|
|
||||||
id BIGINT NOT NULL PRIMARY KEY,
|
|
||||||
run_id BIGINT NOT NULL,
|
|
||||||
step_id BIGINT NOT NULL,
|
|
||||||
pause_kind VARCHAR(32) NOT NULL,
|
|
||||||
pause_token VARCHAR(128) NOT NULL,
|
|
||||||
external_approval_id BIGINT,
|
|
||||||
paused_at DATETIME(3) NOT NULL,
|
|
||||||
resume_deadline DATETIME(3),
|
|
||||||
resume_payload_ref VARCHAR(256),
|
|
||||||
resumed_at DATETIME(3),
|
|
||||||
resume_outcome VARCHAR(32),
|
|
||||||
UNIQUE KEY uk_workflow_pause_run_step (run_id, step_id),
|
|
||||||
UNIQUE KEY uk_workflow_pause_token (pause_token),
|
|
||||||
KEY idx_workflow_pause_external_approval (external_approval_id),
|
|
||||||
KEY idx_workflow_pause_open_deadline (resumed_at, resume_deadline)
|
|
||||||
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci
|
|
||||||
COMMENT = 'Durable workflow pause rows for await_approval resume.';
|
|
||||||
|
|
||||||
-- 6. Payload URI storage. Inline blob for < 256KB; storage_kind=fs/s3/oss
|
|
||||||
-- carries the external object key in storage_ref.
|
|
||||||
CREATE TABLE IF NOT EXISTS mate_workflow_payload (
|
|
||||||
id BIGINT NOT NULL PRIMARY KEY,
|
|
||||||
payload_uri VARCHAR(256) NOT NULL,
|
|
||||||
workspace_id BIGINT NOT NULL,
|
|
||||||
content_bytes LONGBLOB,
|
|
||||||
storage_kind VARCHAR(16) NOT NULL,
|
|
||||||
storage_ref VARCHAR(512),
|
|
||||||
content_type VARCHAR(64),
|
|
||||||
sha256 CHAR(64),
|
|
||||||
size_bytes BIGINT,
|
|
||||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
|
||||||
UNIQUE KEY uk_workflow_payload_uri (payload_uri),
|
|
||||||
KEY idx_workflow_payload_workspace_created (workspace_id, created_at)
|
|
||||||
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci
|
|
||||||
COMMENT = 'Payload bodies addressed by stable URIs.';
|
|
||||||
|
|
||||||
-- 7. Trigger definitions. pattern_version is a lamport counter that fire
|
|
||||||
-- callbacks compare against on every fire to detect that another instance
|
|
||||||
-- has updated the cron expression and self-cancel the local schedule.
|
|
||||||
CREATE TABLE IF NOT EXISTS mate_trigger (
|
|
||||||
id BIGINT NOT NULL PRIMARY KEY,
|
|
||||||
workspace_id BIGINT NOT NULL,
|
|
||||||
name VARCHAR(128),
|
|
||||||
pattern_type VARCHAR(32) NOT NULL,
|
|
||||||
pattern_json MEDIUMTEXT NOT NULL,
|
|
||||||
target_type VARCHAR(16) NOT NULL,
|
|
||||||
target_id BIGINT NOT NULL,
|
|
||||||
payload_template MEDIUMTEXT,
|
|
||||||
rate_limit_per_min INT NOT NULL DEFAULT 60,
|
|
||||||
dedup_window_secs INT NOT NULL DEFAULT 60,
|
|
||||||
bot_self_filter TINYINT NOT NULL DEFAULT 1,
|
|
||||||
enabled TINYINT NOT NULL DEFAULT 1,
|
|
||||||
fire_count BIGINT NOT NULL DEFAULT 0,
|
|
||||||
max_fires BIGINT NOT NULL DEFAULT 0,
|
|
||||||
last_fired_at DATETIME(3),
|
|
||||||
pattern_version BIGINT NOT NULL DEFAULT 1,
|
|
||||||
create_time DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
|
||||||
update_time DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
|
||||||
deleted INT NOT NULL DEFAULT 0,
|
|
||||||
KEY idx_trigger_workspace_enabled (workspace_id, enabled, deleted),
|
|
||||||
KEY idx_trigger_target (target_type, target_id)
|
|
||||||
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci
|
|
||||||
COMMENT = 'Workflow / agent trigger definitions with pattern versioning.';
|
|
||||||
|
|
||||||
-- 8. Event dedup window. dedup_key is envelope.eventId, falling back to
|
|
||||||
-- sourceHash when the upstream channel did not provide a stable id.
|
|
||||||
CREATE TABLE IF NOT EXISTS mate_trigger_event (
|
|
||||||
id BIGINT NOT NULL PRIMARY KEY,
|
|
||||||
trigger_id BIGINT NOT NULL,
|
|
||||||
dedup_key VARCHAR(128) NOT NULL,
|
|
||||||
received_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
|
||||||
expires_at DATETIME(3) NOT NULL,
|
|
||||||
UNIQUE KEY uk_trigger_dedup (trigger_id, dedup_key),
|
|
||||||
KEY idx_trigger_event_expires (expires_at)
|
|
||||||
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci
|
|
||||||
COMMENT = 'Per-trigger event dedup window with TTL-style expiry.';
|
|
||||||
@ -602,8 +602,6 @@ export const wikiApi = {
|
|||||||
http.delete(`/wiki/knowledge-bases/${kbId}/raw/${rawId}`),
|
http.delete(`/wiki/knowledge-bases/${kbId}/raw/${rawId}`),
|
||||||
reprocessRaw: (kbId: number, rawId: number) =>
|
reprocessRaw: (kbId: number, rawId: number) =>
|
||||||
http.post(`/wiki/knowledge-bases/${kbId}/raw/${rawId}/reprocess`),
|
http.post(`/wiki/knowledge-bases/${kbId}/raw/${rawId}/reprocess`),
|
||||||
cancelRaw: (kbId: number, rawId: number) =>
|
|
||||||
http.post(`/wiki/knowledge-bases/${kbId}/raw/${rawId}/cancel`),
|
|
||||||
downloadRaw: (kbId: number, rawId: number) =>
|
downloadRaw: (kbId: number, rawId: number) =>
|
||||||
http.get<Blob>(`/wiki/knowledge-bases/${kbId}/raw/${rawId}/download`, {
|
http.get<Blob>(`/wiki/knowledge-bases/${kbId}/raw/${rawId}/download`, {
|
||||||
responseType: 'blob',
|
responseType: 'blob',
|
||||||
|
|||||||
@ -424,7 +424,6 @@ import { ref, computed, onMounted } from 'vue'
|
|||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { CHANNEL_FIELD_DEFS } from '@/types'
|
import { CHANNEL_FIELD_DEFS } from '@/types'
|
||||||
import { copyToClipboard } from '@/utils/clipboard'
|
|
||||||
import type { Agent, Channel, ChannelFieldDef } from '@/types'
|
import type { Agent, Channel, ChannelFieldDef } from '@/types'
|
||||||
import {
|
import {
|
||||||
buildConfigJson,
|
buildConfigJson,
|
||||||
@ -630,7 +629,7 @@ const copyLabel = ref(t('channels.webhook.copy'))
|
|||||||
|
|
||||||
async function copyWebhookUrl() {
|
async function copyWebhookUrl() {
|
||||||
try {
|
try {
|
||||||
await copyToClipboard(webhookUrl.value)
|
await navigator.clipboard.writeText(webhookUrl.value)
|
||||||
copyLabel.value = t('channels.webhook.copied')
|
copyLabel.value = t('channels.webhook.copied')
|
||||||
setTimeout(() => { copyLabel.value = t('channels.webhook.copy') }, 2000)
|
setTimeout(() => { copyLabel.value = t('channels.webhook.copy') }, 2000)
|
||||||
} catch {
|
} catch {
|
||||||
@ -640,7 +639,7 @@ async function copyWebhookUrl() {
|
|||||||
|
|
||||||
async function copyText(text: string) {
|
async function copyText(text: string) {
|
||||||
try {
|
try {
|
||||||
await copyToClipboard(text)
|
await navigator.clipboard.writeText(text)
|
||||||
ElMessage.success(t('common.copied'))
|
ElMessage.success(t('common.copied'))
|
||||||
} catch {
|
} catch {
|
||||||
ElMessage.warning(t('channels.webhook.copyFailed'))
|
ElMessage.warning(t('channels.webhook.copyFailed'))
|
||||||
|
|||||||
@ -366,7 +366,6 @@ import { useMarkdownRenderer } from '@/composables/useMarkdownRenderer'
|
|||||||
import { useAuthenticatedAttachment } from '@/composables/useAuthenticatedAttachment'
|
import { useAuthenticatedAttachment } from '@/composables/useAuthenticatedAttachment'
|
||||||
import { useToolLabel } from '@/composables/useToolLabel'
|
import { useToolLabel } from '@/composables/useToolLabel'
|
||||||
import { http } from '@/api'
|
import { http } from '@/api'
|
||||||
import { copyToClipboard } from '@/utils/clipboard'
|
|
||||||
import TypingCursor from './TypingCursor.vue'
|
import TypingCursor from './TypingCursor.vue'
|
||||||
import BrowserTimeline from './BrowserTimeline.vue'
|
import BrowserTimeline from './BrowserTimeline.vue'
|
||||||
import ToolCallSegment from './ToolCallSegment.vue'
|
import ToolCallSegment from './ToolCallSegment.vue'
|
||||||
@ -564,7 +563,7 @@ let copyTimer: ReturnType<typeof setTimeout> | null = null
|
|||||||
function copyMessage() {
|
function copyMessage() {
|
||||||
const text = displayContent.value || props.message.content || ''
|
const text = displayContent.value || props.message.content || ''
|
||||||
if (!text) return
|
if (!text) return
|
||||||
copyToClipboard(text).then(() => {
|
navigator.clipboard.writeText(text).then(() => {
|
||||||
copyState.value = 'copied'
|
copyState.value = 'copied'
|
||||||
if (copyTimer) clearTimeout(copyTimer)
|
if (copyTimer) clearTimeout(copyTimer)
|
||||||
copyTimer = setTimeout(() => { copyState.value = 'idle' }, 2000)
|
copyTimer = setTimeout(() => { copyState.value = 'idle' }, 2000)
|
||||||
|
|||||||
@ -1683,9 +1683,6 @@ export default {
|
|||||||
noRawMaterials: 'No raw materials yet',
|
noRawMaterials: 'No raw materials yet',
|
||||||
reprocess: 'Reprocess',
|
reprocess: 'Reprocess',
|
||||||
resume: 'Resume',
|
resume: 'Resume',
|
||||||
cancel: 'Cancel processing',
|
|
||||||
cancelling: 'Cancelling…',
|
|
||||||
cancelledHint: 'Cancelled by user',
|
|
||||||
download: 'Download original file',
|
download: 'Download original file',
|
||||||
downloadFailed: 'Download failed',
|
downloadFailed: 'Download failed',
|
||||||
processAll: 'Process All Pending',
|
processAll: 'Process All Pending',
|
||||||
@ -1714,8 +1711,6 @@ export default {
|
|||||||
completed: 'COMPLETED',
|
completed: 'COMPLETED',
|
||||||
partial: 'PARTIAL',
|
partial: 'PARTIAL',
|
||||||
failed: 'FAILED',
|
failed: 'FAILED',
|
||||||
cancelled: 'CANCELLED',
|
|
||||||
cancelling: 'CANCELLING…',
|
|
||||||
},
|
},
|
||||||
progress: {
|
progress: {
|
||||||
preparing: 'Preparing…',
|
preparing: 'Preparing…',
|
||||||
|
|||||||
@ -1695,9 +1695,6 @@ export default {
|
|||||||
noRawMaterials: '暂无原始材料',
|
noRawMaterials: '暂无原始材料',
|
||||||
reprocess: '重新处理',
|
reprocess: '重新处理',
|
||||||
resume: '继续生成',
|
resume: '继续生成',
|
||||||
cancel: '取消处理',
|
|
||||||
cancelling: '正在取消…',
|
|
||||||
cancelledHint: '用户已取消处理',
|
|
||||||
download: '下载原始文件',
|
download: '下载原始文件',
|
||||||
downloadFailed: '下载失败',
|
downloadFailed: '下载失败',
|
||||||
processAll: '处理所有待处理材料',
|
processAll: '处理所有待处理材料',
|
||||||
@ -1726,8 +1723,6 @@ export default {
|
|||||||
completed: '已完成',
|
completed: '已完成',
|
||||||
partial: '部分完成',
|
partial: '部分完成',
|
||||||
failed: '失败',
|
failed: '失败',
|
||||||
cancelled: '已取消',
|
|
||||||
cancelling: '正在取消…',
|
|
||||||
},
|
},
|
||||||
progress: {
|
progress: {
|
||||||
preparing: '准备中…',
|
preparing: '准备中…',
|
||||||
|
|||||||
@ -1,18 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copy text to clipboard with fallback for non-HTTPS contexts
|
|
||||||
* where navigator.clipboard is undefined.
|
|
||||||
*/
|
|
||||||
export async function copyToClipboard(text: string): Promise<void> {
|
|
||||||
if (navigator.clipboard) {
|
|
||||||
return navigator.clipboard.writeText(text)
|
|
||||||
}
|
|
||||||
// Fallback: textarea + execCommand('copy')
|
|
||||||
const ta = document.createElement('textarea')
|
|
||||||
ta.value = text
|
|
||||||
ta.style.position = 'fixed'
|
|
||||||
ta.style.left = '-9999px'
|
|
||||||
document.body.appendChild(ta)
|
|
||||||
ta.select()
|
|
||||||
document.execCommand('copy')
|
|
||||||
document.body.removeChild(ta)
|
|
||||||
}
|
|
||||||
@ -208,7 +208,6 @@ import { useI18n } from 'vue-i18n'
|
|||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { mcConfirm } from '@/components/common/useConfirm'
|
import { mcConfirm } from '@/components/common/useConfirm'
|
||||||
import { agentApi, agentContextApi } from '@/api/index'
|
import { agentApi, agentContextApi } from '@/api/index'
|
||||||
import { copyToClipboard } from '@/utils/clipboard'
|
|
||||||
import type { Agent, WorkspaceFile } from '@/types/index'
|
import type { Agent, WorkspaceFile } from '@/types/index'
|
||||||
import { useMarkdownRenderer } from '@/composables/useMarkdownRenderer'
|
import { useMarkdownRenderer } from '@/composables/useMarkdownRenderer'
|
||||||
import { plainTextIcon } from '@/composables/usePixelarticons'
|
import { plainTextIcon } from '@/composables/usePixelarticons'
|
||||||
@ -331,7 +330,7 @@ function handlePreviewClick(e: MouseEvent) {
|
|||||||
const encoded = btn.getAttribute('data-code')
|
const encoded = btn.getAttribute('data-code')
|
||||||
if (!encoded) return
|
if (!encoded) return
|
||||||
const code = decodeURIComponent(encoded)
|
const code = decodeURIComponent(encoded)
|
||||||
copyToClipboard(code).then(() => {
|
navigator.clipboard.writeText(code).then(() => {
|
||||||
btn.classList.add('copied')
|
btn.classList.add('copied')
|
||||||
const textEl = btn.querySelector('.code-block__copy-text')
|
const textEl = btn.querySelector('.code-block__copy-text')
|
||||||
if (textEl) textEl.textContent = t('chat.copied') || 'Copied'
|
if (textEl) textEl.textContent = t('chat.copied') || 'Copied'
|
||||||
|
|||||||
@ -317,7 +317,6 @@ import { mcConfirm } from '@/components/common/useConfirm'
|
|||||||
import { ChatDotRound, Delete, Plus, Setting, UploadFilled } from '@element-plus/icons-vue'
|
import { ChatDotRound, Delete, Plus, Setting, UploadFilled } from '@element-plus/icons-vue'
|
||||||
import { conversationApi, agentApi, modelApi, chatApi, cronJobApi } from '@/api/index'
|
import { conversationApi, agentApi, modelApi, chatApi, cronJobApi } from '@/api/index'
|
||||||
import { channelIconUrl } from '@/utils/channelSource'
|
import { channelIconUrl } from '@/utils/channelSource'
|
||||||
import { copyToClipboard } from '@/utils/clipboard'
|
|
||||||
import { useChat } from '@/composables/chat/useChat'
|
import { useChat } from '@/composables/chat/useChat'
|
||||||
import { reconstructErrorInfo } from '@/types/chatError'
|
import { reconstructErrorInfo } from '@/types/chatError'
|
||||||
import { reconcileMessages, extractMessages } from '@/utils/messageReconcile'
|
import { reconcileMessages, extractMessages } from '@/utils/messageReconcile'
|
||||||
@ -1742,7 +1741,7 @@ function handleCodeCopy(e: MouseEvent) {
|
|||||||
const encoded = btn.getAttribute('data-code')
|
const encoded = btn.getAttribute('data-code')
|
||||||
if (!encoded) return
|
if (!encoded) return
|
||||||
const code = decodeURIComponent(encoded)
|
const code = decodeURIComponent(encoded)
|
||||||
copyToClipboard(code).then(() => {
|
navigator.clipboard.writeText(code).then(() => {
|
||||||
btn.classList.add('copied')
|
btn.classList.add('copied')
|
||||||
const textEl = btn.querySelector('.code-block__copy-text')
|
const textEl = btn.querySelector('.code-block__copy-text')
|
||||||
if (textEl) textEl.textContent = t('chat.copied')
|
if (textEl) textEl.textContent = t('chat.copied')
|
||||||
|
|||||||
@ -41,7 +41,6 @@
|
|||||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { copyToClipboard } from '@/utils/clipboard'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
visible: boolean
|
visible: boolean
|
||||||
@ -84,7 +83,7 @@ onBeforeUnmount(() => {
|
|||||||
|
|
||||||
async function copyCode() {
|
async function copyCode() {
|
||||||
try {
|
try {
|
||||||
await copyToClipboard(props.userCode)
|
await navigator.clipboard.writeText(props.userCode)
|
||||||
copied.value = true
|
copied.value = true
|
||||||
setTimeout(() => { copied.value = false }, 2000)
|
setTimeout(() => { copied.value = false }, 2000)
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@ -15,15 +15,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stage-labels">
|
<div class="stage-labels">
|
||||||
<div
|
<span v-for="stage in stages" :key="stage.key" class="stage-label" :class="{ active: stage.key === currentStage && !isTerminal, done: stage.key === currentStage && isTerminal }">
|
||||||
v-for="(stage, idx) in stages"
|
{{ t(`wiki.jobStage.${stage.key}`) }}
|
||||||
:key="stage.key"
|
</span>
|
||||||
class="stage-label-cell"
|
|
||||||
>
|
|
||||||
<span class="stage-label" :class="{ active: stage.key === currentStage && !isTerminal, done: stage.key === currentStage && isTerminal }">
|
|
||||||
{{ t(`wiki.jobStage.${stage.key}`) }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Model & progress info -->
|
<!-- Model & progress info -->
|
||||||
@ -180,16 +174,6 @@ const elapsed = computed(() => {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The last cell only contains a dot (no trailing connector line). With
|
|
||||||
* flex:1 it would reserve a full segment of empty space after the dot,
|
|
||||||
* which makes the final stage look stranded — the connector going into
|
|
||||||
* it appears to stop short and there's a visible blank gap on the right.
|
|
||||||
* Pin the last cell to dot-width so the 6 connector lines distribute
|
|
||||||
* evenly between the 7 dots and the final dot anchors to the right edge. */
|
|
||||||
.stage-dot-group:last-child {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stage-dot {
|
.stage-dot {
|
||||||
width: 10px;
|
width: 10px;
|
||||||
height: 10px;
|
height: 10px;
|
||||||
@ -217,33 +201,15 @@ const elapsed = computed(() => {
|
|||||||
}
|
}
|
||||||
.stage-line.done { background: var(--mc-primary); }
|
.stage-line.done { background: var(--mc-primary); }
|
||||||
|
|
||||||
/* Labels mirror the dot-row's flex structure so each label cell is the
|
|
||||||
* same width as its matching dot cell. The last cell pins to dot-width
|
|
||||||
* (matching .stage-dot-group:last-child above), and inside every cell the
|
|
||||||
* label is centered horizontally over the dot at the cell's left edge by
|
|
||||||
* shifting it half the dot width and translating back by half its own
|
|
||||||
* width — keeping label text centered above each dot at any container
|
|
||||||
* width. */
|
|
||||||
.stage-labels {
|
.stage-labels {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
justify-content: space-between;
|
||||||
}
|
|
||||||
.stage-label-cell {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-start;
|
|
||||||
overflow: visible;
|
|
||||||
}
|
|
||||||
.stage-label-cell:last-child {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
}
|
}
|
||||||
.stage-label {
|
.stage-label {
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
color: var(--mc-text-tertiary);
|
color: var(--mc-text-tertiary);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
white-space: nowrap;
|
flex: 1;
|
||||||
margin-left: 5px;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
}
|
}
|
||||||
.stage-label.active { color: var(--mc-primary); font-weight: 600; }
|
.stage-label.active { color: var(--mc-primary); font-weight: 600; }
|
||||||
.stage-label.done { color: var(--mc-success, #5a8a5a); font-weight: 600; }
|
.stage-label.done { color: var(--mc-success, #5a8a5a); font-weight: 600; }
|
||||||
|
|||||||
@ -138,26 +138,15 @@
|
|||||||
<span class="raw-item-type">{{ raw.sourceType }}</span>
|
<span class="raw-item-type">{{ raw.sourceType }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="raw-item-meta">
|
<div class="raw-item-meta">
|
||||||
<span
|
<span class="status-badge" :class="raw.processingStatus">
|
||||||
class="status-badge"
|
{{ t(`wiki.status.${raw.processingStatus}`) }}
|
||||||
:class="cancellingIds.has(raw.id) && raw.processingStatus === 'processing' ? 'cancelling' : raw.processingStatus"
|
|
||||||
>
|
|
||||||
{{ cancellingIds.has(raw.id) && raw.processingStatus === 'processing'
|
|
||||||
? t('wiki.status.cancelling')
|
|
||||||
: t(`wiki.status.${raw.processingStatus}`) }}
|
|
||||||
</span>
|
</span>
|
||||||
<span v-if="raw.pageCount != null && raw.pageCount > 0" class="page-count-chip">
|
<span v-if="raw.pageCount != null && raw.pageCount > 0" class="page-count-chip">
|
||||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
|
||||||
{{ raw.pageCount }}
|
{{ raw.pageCount }}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
v-if="raw.processingStatus === 'cancelled'"
|
v-if="raw.errorMessage && (raw.processingStatus === 'failed' || raw.processingStatus === 'partial')"
|
||||||
class="error-hint" :title="raw.errorMessage || ''"
|
|
||||||
>
|
|
||||||
{{ t('wiki.cancelledHint') }}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
v-else-if="raw.errorMessage && (raw.processingStatus === 'failed' || raw.processingStatus === 'partial')"
|
|
||||||
class="error-hint" :title="raw.errorMessage"
|
class="error-hint" :title="raw.errorMessage"
|
||||||
>
|
>
|
||||||
{{ raw.errorMessage }}
|
{{ raw.errorMessage }}
|
||||||
@ -165,25 +154,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="raw-item-actions">
|
<div class="raw-item-actions">
|
||||||
<button
|
<button
|
||||||
v-if="raw.processingStatus === 'processing' && !cancellingIds.has(raw.id)"
|
v-if="raw.processingStatus === 'partial'"
|
||||||
class="btn-icon btn-icon-danger" :title="t('wiki.cancel')"
|
|
||||||
@click="cancelRaw(raw.id)"
|
|
||||||
>
|
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
|
|
||||||
<line x1="6" y1="6" x2="18" y2="18"/>
|
|
||||||
<line x1="6" y1="18" x2="18" y2="6"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
v-else-if="raw.processingStatus === 'processing' && cancellingIds.has(raw.id)"
|
|
||||||
class="btn-icon btn-icon-cancelling" :title="t('wiki.cancelling')" disabled
|
|
||||||
>
|
|
||||||
<svg class="spinner" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
|
|
||||||
<path d="M21 12a9 9 0 1 1-6.22-8.56"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
v-else-if="raw.processingStatus === 'partial'"
|
|
||||||
class="btn-icon btn-icon-resume" :title="t('wiki.resume')"
|
class="btn-icon btn-icon-resume" :title="t('wiki.resume')"
|
||||||
@click="reprocess(raw.id)"
|
@click="reprocess(raw.id)"
|
||||||
>
|
>
|
||||||
@ -192,7 +163,7 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
v-else-if="raw.processingStatus === 'failed' || raw.processingStatus === 'completed' || raw.processingStatus === 'cancelled'"
|
v-else-if="raw.processingStatus === 'failed' || raw.processingStatus === 'completed'"
|
||||||
class="btn-icon" :title="t('wiki.reprocess')"
|
class="btn-icon" :title="t('wiki.reprocess')"
|
||||||
@click="reprocess(raw.id)"
|
@click="reprocess(raw.id)"
|
||||||
>
|
>
|
||||||
@ -417,14 +388,6 @@ let jobPoller: ReturnType<typeof setTimeout> | null = null
|
|||||||
|
|
||||||
const TERMINAL_STATUSES = new Set(['completed', 'failed', 'partial', 'cancelled'])
|
const TERMINAL_STATUSES = new Set(['completed', 'failed', 'partial', 'cancelled'])
|
||||||
|
|
||||||
// Local optimistic state: rows the user has just clicked "cancel" on.
|
|
||||||
// Backend cancellation is observed at the next abort checkpoint (which can
|
|
||||||
// take 10+ seconds while a route-phase LLM call is in flight), so without
|
|
||||||
// this set the click looks unresponsive — the button stays the same and
|
|
||||||
// the badge keeps reading "处理中". Cleared as soon as the row's status
|
|
||||||
// transitions out of "processing" via the next fetchRawMaterials.
|
|
||||||
const cancellingIds = ref(new Set<number>())
|
|
||||||
|
|
||||||
async function pollJobs() {
|
async function pollJobs() {
|
||||||
if (!store.currentKB) return
|
if (!store.currentKB) return
|
||||||
const kbId = store.currentKB.id
|
const kbId = store.currentKB.id
|
||||||
@ -463,22 +426,6 @@ watch(hasProcessing, (active) => {
|
|||||||
else if (jobPoller) { clearTimeout(jobPoller); jobPoller = null }
|
else if (jobPoller) { clearTimeout(jobPoller); jobPoller = null }
|
||||||
}, { immediate: true })
|
}, { immediate: true })
|
||||||
|
|
||||||
// Clear the optimistic "cancelling" flag for any row that has left the
|
|
||||||
// 'processing' state — the backend has written the terminal status, so
|
|
||||||
// the badge and action buttons can now reflect reality.
|
|
||||||
watch(() => store.rawMaterials, (rows) => {
|
|
||||||
if (cancellingIds.value.size === 0) return
|
|
||||||
const next = new Set(cancellingIds.value)
|
|
||||||
for (const r of rows) {
|
|
||||||
if (r.processingStatus !== 'processing' && next.has(r.id)) {
|
|
||||||
next.delete(r.id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (next.size !== cancellingIds.value.size) {
|
|
||||||
cancellingIds.value = next
|
|
||||||
}
|
|
||||||
}, { deep: true })
|
|
||||||
|
|
||||||
async function handleLocalRepair(rawId: number) {
|
async function handleLocalRepair(rawId: number) {
|
||||||
if (!store.currentKB) return
|
if (!store.currentKB) return
|
||||||
// For local repair, we'd need a page slug. For now, reprocess the raw material.
|
// For local repair, we'd need a page slug. For now, reprocess the raw material.
|
||||||
@ -609,30 +556,6 @@ async function deleteRaw(rawId: number) {
|
|||||||
await store.fetchRawMaterials(store.currentKB.id)
|
await store.fetchRawMaterials(store.currentKB.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function cancelRaw(rawId: number) {
|
|
||||||
if (!store.currentKB) return
|
|
||||||
const kbId = store.currentKB.id
|
|
||||||
// Optimistic flag — drives the spinner button and "正在取消…" badge text
|
|
||||||
// so the click is visibly registered even if the pipeline is currently
|
|
||||||
// mid-LLM call and won't reach its next abort checkpoint for several seconds.
|
|
||||||
cancellingIds.value.add(rawId)
|
|
||||||
try {
|
|
||||||
await wikiApi.cancelRaw(kbId, rawId)
|
|
||||||
} catch (e) {
|
|
||||||
// Roll back the optimistic state if the call itself failed (auth /
|
|
||||||
// network error). Without this the button would stay stuck in the
|
|
||||||
// cancelling state forever.
|
|
||||||
cancellingIds.value.delete(rawId)
|
|
||||||
throw e
|
|
||||||
}
|
|
||||||
// Re-fetch periodically so the row's status flips from 'processing' to
|
|
||||||
// 'cancelled' as soon as the pipeline observes the flag and writes its
|
|
||||||
// terminal status — at which point the watch below clears the flag.
|
|
||||||
await store.fetchRawMaterials(kbId)
|
|
||||||
setTimeout(() => { store.fetchRawMaterials(kbId) }, 5000)
|
|
||||||
setTimeout(() => { store.fetchRawMaterials(kbId) }, 15000)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function downloadRaw(raw: { id: number; title?: string }) {
|
async function downloadRaw(raw: { id: number; title?: string }) {
|
||||||
if (!store.currentKB) return
|
if (!store.currentKB) return
|
||||||
try {
|
try {
|
||||||
@ -815,12 +738,6 @@ async function handleScanDir() {
|
|||||||
.status-badge.completed { background: rgba(90, 138, 90, 0.15); color: var(--mc-success); }
|
.status-badge.completed { background: rgba(90, 138, 90, 0.15); color: var(--mc-success); }
|
||||||
.status-badge.partial { background: rgba(217, 119, 87, 0.15); color: var(--mc-primary); }
|
.status-badge.partial { background: rgba(217, 119, 87, 0.15); color: var(--mc-primary); }
|
||||||
.status-badge.failed { background: var(--mc-danger-bg); color: var(--mc-danger); }
|
.status-badge.failed { background: var(--mc-danger-bg); color: var(--mc-danger); }
|
||||||
.status-badge.cancelled { background: var(--mc-bg-sunken); color: var(--mc-text-tertiary); }
|
|
||||||
.status-badge.cancelling { background: var(--mc-bg-sunken); color: var(--mc-text-secondary); }
|
|
||||||
|
|
||||||
.btn-icon.btn-icon-cancelling { cursor: default; opacity: 0.7; }
|
|
||||||
.btn-icon.btn-icon-cancelling .spinner { animation: rmp-spin 0.9s linear infinite; }
|
|
||||||
@keyframes rmp-spin { to { transform: rotate(360deg); } }
|
|
||||||
|
|
||||||
/* Process button */
|
/* Process button */
|
||||||
.process-btn { width: 100%; justify-content: center; margin-top: 16px; }
|
.process-btn { width: 100%; justify-content: center; margin-top: 16px; }
|
||||||
|
|||||||
BIN
outputs/北京公交票务评估/__pycache__/build_svg_diagrams.cpython-312.pyc
Normal file
BIN
outputs/北京公交票务评估/_archive/北京公交集团票务综合管理平台系统升级-详细设计与Codex实施方案.docx
Normal file
367
outputs/北京公交票务评估/build_design_doc.py
Normal file
@ -0,0 +1,367 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Iterable
|
||||||
|
|
||||||
|
from docx import Document
|
||||||
|
from docx.enum.section import WD_SECTION
|
||||||
|
from docx.enum.table import WD_ALIGN_VERTICAL, WD_TABLE_ALIGNMENT
|
||||||
|
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
||||||
|
from docx.oxml import OxmlElement
|
||||||
|
from docx.oxml.ns import qn
|
||||||
|
from docx.shared import Inches, Pt, RGBColor
|
||||||
|
|
||||||
|
|
||||||
|
OUT = Path("/Users/mate/Codes/mate/mateclaw/outputs/北京公交票务评估/北京公交集团票务综合管理平台系统升级-详细设计与Codex实施方案.docx")
|
||||||
|
|
||||||
|
BLUE = RGBColor(46, 116, 181)
|
||||||
|
DARK_BLUE = RGBColor(31, 77, 120)
|
||||||
|
INK = RGBColor(11, 37, 69)
|
||||||
|
MUTED = RGBColor(96, 108, 122)
|
||||||
|
LIGHT = "F2F4F7"
|
||||||
|
LIGHT_BLUE = "E8EEF5"
|
||||||
|
CALLOUT = "F4F6F9"
|
||||||
|
|
||||||
|
|
||||||
|
def set_cell_shading(cell, fill: str) -> None:
|
||||||
|
tc_pr = cell._tc.get_or_add_tcPr()
|
||||||
|
shd = tc_pr.find(qn("w:shd"))
|
||||||
|
if shd is None:
|
||||||
|
shd = OxmlElement("w:shd")
|
||||||
|
tc_pr.append(shd)
|
||||||
|
shd.set(qn("w:fill"), fill)
|
||||||
|
|
||||||
|
|
||||||
|
def set_cell_margins(cell, top=80, start=120, bottom=80, end=120) -> None:
|
||||||
|
tc = cell._tc
|
||||||
|
tc_pr = tc.get_or_add_tcPr()
|
||||||
|
tc_mar = tc_pr.first_child_found_in("w:tcMar")
|
||||||
|
if tc_mar is None:
|
||||||
|
tc_mar = OxmlElement("w:tcMar")
|
||||||
|
tc_pr.append(tc_mar)
|
||||||
|
for m, v in [("top", top), ("start", start), ("bottom", bottom), ("end", end)]:
|
||||||
|
node = tc_mar.find(qn(f"w:{m}"))
|
||||||
|
if node is None:
|
||||||
|
node = OxmlElement(f"w:{m}")
|
||||||
|
tc_mar.append(node)
|
||||||
|
node.set(qn("w:w"), str(v))
|
||||||
|
node.set(qn("w:type"), "dxa")
|
||||||
|
|
||||||
|
|
||||||
|
def set_table_width(table, widths: list[float]) -> None:
|
||||||
|
table.autofit = False
|
||||||
|
table.alignment = WD_TABLE_ALIGNMENT.CENTER
|
||||||
|
for row in table.rows:
|
||||||
|
for idx, cell in enumerate(row.cells):
|
||||||
|
cell.width = Inches(widths[idx])
|
||||||
|
set_cell_margins(cell)
|
||||||
|
cell.vertical_alignment = WD_ALIGN_VERTICAL.CENTER
|
||||||
|
|
||||||
|
|
||||||
|
def set_run_font(run, size=None, bold=None, color=None, name="Calibri"):
|
||||||
|
run.font.name = name
|
||||||
|
run._element.rPr.rFonts.set(qn("w:ascii"), name)
|
||||||
|
run._element.rPr.rFonts.set(qn("w:hAnsi"), name)
|
||||||
|
run._element.rPr.rFonts.set(qn("w:eastAsia"), "Microsoft YaHei")
|
||||||
|
if size is not None:
|
||||||
|
run.font.size = Pt(size)
|
||||||
|
if bold is not None:
|
||||||
|
run.bold = bold
|
||||||
|
if color is not None:
|
||||||
|
run.font.color.rgb = color
|
||||||
|
|
||||||
|
|
||||||
|
def add_para(doc: Document, text: str = "", style: str | None = None, bold=False, color=None, size=None, align=None):
|
||||||
|
p = doc.add_paragraph(style=style)
|
||||||
|
if align is not None:
|
||||||
|
p.alignment = align
|
||||||
|
p.paragraph_format.space_after = Pt(6)
|
||||||
|
p.paragraph_format.line_spacing = 1.1
|
||||||
|
if text:
|
||||||
|
r = p.add_run(text)
|
||||||
|
set_run_font(r, size=size, bold=bold, color=color)
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def add_bullets(doc: Document, items: Iterable[str]) -> None:
|
||||||
|
for item in items:
|
||||||
|
p = doc.add_paragraph(style="List Bullet")
|
||||||
|
p.paragraph_format.space_after = Pt(4)
|
||||||
|
p.paragraph_format.line_spacing = 1.167
|
||||||
|
r = p.add_run(item)
|
||||||
|
set_run_font(r, size=10.5)
|
||||||
|
|
||||||
|
|
||||||
|
def add_numbered(doc: Document, items: Iterable[str]) -> None:
|
||||||
|
for item in items:
|
||||||
|
p = doc.add_paragraph(style="List Number")
|
||||||
|
p.paragraph_format.space_after = Pt(4)
|
||||||
|
p.paragraph_format.line_spacing = 1.167
|
||||||
|
r = p.add_run(item)
|
||||||
|
set_run_font(r, size=10.5)
|
||||||
|
|
||||||
|
|
||||||
|
def add_heading(doc: Document, text: str, level: int):
|
||||||
|
p = doc.add_heading(text, level=level)
|
||||||
|
if level == 1:
|
||||||
|
p.paragraph_format.space_before = Pt(16)
|
||||||
|
p.paragraph_format.space_after = Pt(8)
|
||||||
|
elif level == 2:
|
||||||
|
p.paragraph_format.space_before = Pt(12)
|
||||||
|
p.paragraph_format.space_after = Pt(6)
|
||||||
|
else:
|
||||||
|
p.paragraph_format.space_before = Pt(8)
|
||||||
|
p.paragraph_format.space_after = Pt(4)
|
||||||
|
for r in p.runs:
|
||||||
|
set_run_font(r, size={1: 16, 2: 13, 3: 12}.get(level, 11), bold=True, color=BLUE if level < 3 else DARK_BLUE)
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def add_table(doc: Document, headers: list[str], rows: list[list[str]], widths: list[float], header_fill=LIGHT) -> None:
|
||||||
|
table = doc.add_table(rows=1, cols=len(headers))
|
||||||
|
table.style = "Table Grid"
|
||||||
|
hdr = table.rows[0].cells
|
||||||
|
for i, h in enumerate(headers):
|
||||||
|
hdr[i].text = h
|
||||||
|
set_cell_shading(hdr[i], header_fill)
|
||||||
|
set_cell_margins(hdr[i])
|
||||||
|
for p in hdr[i].paragraphs:
|
||||||
|
p.paragraph_format.space_after = Pt(0)
|
||||||
|
for r in p.runs:
|
||||||
|
set_run_font(r, size=9.5, bold=True, color=INK)
|
||||||
|
for row in rows:
|
||||||
|
cells = table.add_row().cells
|
||||||
|
for i, value in enumerate(row):
|
||||||
|
cells[i].text = value
|
||||||
|
set_cell_margins(cells[i])
|
||||||
|
for p in cells[i].paragraphs:
|
||||||
|
p.paragraph_format.space_after = Pt(0)
|
||||||
|
p.paragraph_format.line_spacing = 1.08
|
||||||
|
for r in p.runs:
|
||||||
|
set_run_font(r, size=9)
|
||||||
|
set_table_width(table, widths)
|
||||||
|
doc.add_paragraph()
|
||||||
|
|
||||||
|
|
||||||
|
def add_callout(doc: Document, title: str, body: str) -> None:
|
||||||
|
table = doc.add_table(rows=1, cols=1)
|
||||||
|
table.style = "Table Grid"
|
||||||
|
cell = table.cell(0, 0)
|
||||||
|
set_cell_shading(cell, CALLOUT)
|
||||||
|
set_cell_margins(cell, top=120, bottom=120, start=160, end=160)
|
||||||
|
p = cell.paragraphs[0]
|
||||||
|
p.paragraph_format.space_after = Pt(4)
|
||||||
|
r = p.add_run(title)
|
||||||
|
set_run_font(r, size=10.5, bold=True, color=DARK_BLUE)
|
||||||
|
p2 = cell.add_paragraph()
|
||||||
|
p2.paragraph_format.space_after = Pt(0)
|
||||||
|
r2 = p2.add_run(body)
|
||||||
|
set_run_font(r2, size=10)
|
||||||
|
set_table_width(table, [6.5])
|
||||||
|
doc.add_paragraph()
|
||||||
|
|
||||||
|
|
||||||
|
def configure_document(doc: Document) -> None:
|
||||||
|
section = doc.sections[0]
|
||||||
|
section.top_margin = Inches(1)
|
||||||
|
section.bottom_margin = Inches(1)
|
||||||
|
section.left_margin = Inches(1)
|
||||||
|
section.right_margin = Inches(1)
|
||||||
|
section.header_distance = Inches(0.492)
|
||||||
|
section.footer_distance = Inches(0.492)
|
||||||
|
|
||||||
|
styles = doc.styles
|
||||||
|
normal = styles["Normal"]
|
||||||
|
normal.font.name = "Calibri"
|
||||||
|
normal._element.rPr.rFonts.set(qn("w:eastAsia"), "Microsoft YaHei")
|
||||||
|
normal.font.size = Pt(11)
|
||||||
|
normal.paragraph_format.space_after = Pt(6)
|
||||||
|
normal.paragraph_format.line_spacing = 1.1
|
||||||
|
|
||||||
|
for name, size, color in [("Heading 1", 16, BLUE), ("Heading 2", 13, BLUE), ("Heading 3", 12, DARK_BLUE)]:
|
||||||
|
st = styles[name]
|
||||||
|
st.font.name = "Calibri"
|
||||||
|
st._element.rPr.rFonts.set(qn("w:eastAsia"), "Microsoft YaHei")
|
||||||
|
st.font.size = Pt(size)
|
||||||
|
st.font.color.rgb = color
|
||||||
|
st.font.bold = True
|
||||||
|
|
||||||
|
header = section.header.paragraphs[0]
|
||||||
|
header.text = "北京公交集团票务综合管理平台系统升级 | 详细设计与 Codex 实施方案"
|
||||||
|
header.alignment = WD_ALIGN_PARAGRAPH.LEFT
|
||||||
|
for r in header.runs:
|
||||||
|
set_run_font(r, size=9, color=MUTED)
|
||||||
|
|
||||||
|
footer = section.footer.paragraphs[0]
|
||||||
|
footer.alignment = WD_ALIGN_PARAGRAPH.RIGHT
|
||||||
|
footer.text = "内部评估文件"
|
||||||
|
for r in footer.runs:
|
||||||
|
set_run_font(r, size=9, color=MUTED)
|
||||||
|
|
||||||
|
|
||||||
|
def build() -> None:
|
||||||
|
doc = Document()
|
||||||
|
configure_document(doc)
|
||||||
|
|
||||||
|
add_para(doc, "技术方案", bold=True, color=MUTED, size=11)
|
||||||
|
title = doc.add_paragraph()
|
||||||
|
title.paragraph_format.space_after = Pt(4)
|
||||||
|
r = title.add_run("北京公交集团票务综合管理平台系统升级")
|
||||||
|
set_run_font(r, size=24, bold=True, color=INK)
|
||||||
|
subtitle = doc.add_paragraph()
|
||||||
|
subtitle.paragraph_format.space_after = Pt(16)
|
||||||
|
r = subtitle.add_run("详细设计与 Codex 协作实施方案")
|
||||||
|
set_run_font(r, size=15, color=MUTED)
|
||||||
|
|
||||||
|
meta = [
|
||||||
|
["文档用途", "用于项目立项、报价拆分、研发实施和 Codex 协作开发规划"],
|
||||||
|
["编制日期", date.today().isoformat()],
|
||||||
|
["需求来源", "《北京公交集团票务综合管理平台系统升级-需求分析说明书(初稿0506)》"],
|
||||||
|
["版本", "V1.0"],
|
||||||
|
]
|
||||||
|
add_table(doc, ["字段", "内容"], meta, [1.3, 5.2], header_fill=LIGHT_BLUE)
|
||||||
|
|
||||||
|
add_callout(
|
||||||
|
doc,
|
||||||
|
"总体判断",
|
||||||
|
"本项目属于存量核心业务系统重构升级,重点不是页面生成,而是票据库存流水、票单结算、报表口径、数据权限、历史数据迁移和国产化适配。Codex 可显著提升需求整理、重复代码生成、测试补齐和文档产出效率,但架构边界、业务规则确认和上线割接仍需人工主导。",
|
||||||
|
)
|
||||||
|
|
||||||
|
add_heading(doc, "1. 项目范围与建设目标", 1)
|
||||||
|
add_para(doc, "本次建设目标是在继承现有生产系统主要能力的基础上,完成业务流程重构、国产化数据库迁移、权限和数据范围治理、报表整合、接口标准化和运维能力提升。系统面向集团、分公司和票务室多级用户,覆盖有人售票、无人售票、票库、票柜、票袋、票单、充值卡、特殊业务和统计报表等票务经营管理场景。")
|
||||||
|
add_bullets(doc, [
|
||||||
|
"完整保留并优化核心票务业务闭环:印制、调拨、核销、配票、结算、退票、库存查询和流水追溯。",
|
||||||
|
"整合同类报表,按集团、分公司、票务室三级数据权限展示统计结果。",
|
||||||
|
"完成 Oracle 历史数据到达梦数据库的迁移、校验和兼容适配。",
|
||||||
|
"预留并分阶段实现与 IC 卡系统、数据湖、二维码系统、票款清分系统、电子签章系统的对接。",
|
||||||
|
"建立可审计、可测试、可持续迭代的工程体系,支持 Codex 辅助开发和自动化校验。",
|
||||||
|
])
|
||||||
|
|
||||||
|
add_heading(doc, "2. 总体技术架构", 1)
|
||||||
|
add_table(doc, ["层级", "建议技术", "设计说明"], [
|
||||||
|
["前端展示层", "Vue 3 + TypeScript + Vite + Element Plus", "承载菜单导航、列表查询、业务表单、报表展示、打印和导入导出入口。"],
|
||||||
|
["后端服务层", "Java 17 + Spring Boot 3 + MyBatis-Plus", "实现业务编排、事务控制、权限校验、库存流水、报表聚合和接口服务。"],
|
||||||
|
["数据存储层", "达梦 DM8 + Redis + OBS/MinIO", "达梦作为主业务库,Redis 用于会话/缓存/短期任务状态,文件对象存储用于模板、导入文件和导出文件。"],
|
||||||
|
["集成层", "REST/OpenAPI + 批量文件导入 + 定时任务", "对外提供标准接口;对无网络银行清分场景保留线下 Excel/CSV 导入;定时同步基础信息和报表数据。"],
|
||||||
|
["运维层", "Nginx + 应用服务 + 日志审计 + 监控告警", "满足国产化部署、日志追溯、性能监控和上线回滚要求。"],
|
||||||
|
], [1.15, 1.75, 3.6], header_fill=LIGHT_BLUE)
|
||||||
|
|
||||||
|
add_heading(doc, "3. 系统模块设计", 1)
|
||||||
|
modules = [
|
||||||
|
["基础信息", "票务室、线路、票价、车辆、司售、普票类型、充值类型、机具、多样化运营类型、IC 卡类型", "以 CRUD 和同步查询为主,需统一数据权限和停用机制。"],
|
||||||
|
["票库管理", "印制、调拨、核销、调账、票库库存、票库出入库查询", "核心在审批状态、库存流水、票号段合法性和跨公司调拨。"],
|
||||||
|
["票柜管理", "票柜库存、入库、出库、配票申请、票务室调票", "承接票库到票务室的库存流转,需要单张/批量调票能力。"],
|
||||||
|
["票单管理", "票袋、票单配票、票单结算、票单查询、票袋调票、重新结算、票号查询", "项目最复杂模块,需要严格状态机和结算口径。"],
|
||||||
|
["无人售与特殊业务", "异物、大额币、残币、无人售线路结算、多样化收入、特殊票", "重点是异常状态、待销毁/领取/入账流程和批量导入。"],
|
||||||
|
["退票管理", "票袋退票、票柜退票", "支持连续退票和单张退票,必须回写库存和流水。"],
|
||||||
|
["充值管理", "IC 卡库存、入库、出库、网点库存、售出、充值收入", "需要处理网点维度库存、售出导入和收入记录。"],
|
||||||
|
["统计报表", "集团、分公司、票务室多级报表、趋势图、Excel 导出", "需先冻结报表口径,避免开发完成后重复返工。"],
|
||||||
|
["系统管理", "用户、角色、权限、菜单、阈值、日志", "为全系统提供认证、授权、数据范围和审计能力。"],
|
||||||
|
]
|
||||||
|
add_table(doc, ["模块", "主要功能", "设计重点"], modules, [1.25, 2.9, 2.35])
|
||||||
|
|
||||||
|
add_heading(doc, "4. 核心业务状态与流水设计", 1)
|
||||||
|
add_para(doc, "票据类业务必须采用“当前库存 + 流水台账 + 状态机”的组合模型。只存库存余额无法满足审计、调账、退票、票号查询和异常追溯要求。")
|
||||||
|
add_table(doc, ["业务对象", "关键状态", "关键校验"], [
|
||||||
|
["印制申请", "待审核、不同意、待确认、完成", "待审核/不同意可修改;完成后生成票库入库流水。"],
|
||||||
|
["调拨申请", "待审核、不同意、待确认、完成", "校验调出库存、调入公司、票号段不重叠。"],
|
||||||
|
["核销申请", "待审核、完成、驳回", "核销后票号段不可再配出,保留原因和审批记录。"],
|
||||||
|
["配票申请", "待处理、已配票、退回、完成", "票柜库存扣减,票袋或票单库存增加。"],
|
||||||
|
["票单结算", "已配票、已结算、重新结算中、已更正", "销售张数和金额由剩余票号段反推,保留旧结算记录。"],
|
||||||
|
["退票", "申请、确认、完成、驳回", "退票票号不能已核销、不能重复退票,完成后回写对应库存。"],
|
||||||
|
], [1.3, 2.1, 3.1])
|
||||||
|
|
||||||
|
add_heading(doc, "5. 数据模型设计原则", 1)
|
||||||
|
add_bullets(doc, [
|
||||||
|
"基础主数据表:组织、分公司、票务室、线路、车辆、司售、票价、票种、充值类型、机具等。",
|
||||||
|
"库存余额表:按库存主体、票种、票价、票组、起止号、状态聚合当前可用数量。",
|
||||||
|
"库存流水表:记录每一次入库、出库、调拨、退票、核销、调账、配票、结算的来源单据和操作人。",
|
||||||
|
"业务单据表:每类申请、审核、确认、结算均独立建单,避免把审批状态混入库存余额。",
|
||||||
|
"报表宽表/汇总表:对日结、月结、趋势报表建立可重算的汇总层,减少实时聚合压力。",
|
||||||
|
"审计表:记录登录、菜单访问、增删改、导入导出、审批、结账等关键操作。",
|
||||||
|
])
|
||||||
|
|
||||||
|
add_heading(doc, "6. 权限与数据范围设计", 1)
|
||||||
|
add_table(doc, ["角色", "功能权限", "数据范围"], [
|
||||||
|
["集团管理员", "最高权限,可维护菜单、角色、用户和全局参数", "全集团数据,可分配集团/分公司/票务室数据权限。"],
|
||||||
|
["集团操作员", "按角色授权使用业务功能", "可查看被授权的分公司和票务室数据。"],
|
||||||
|
["分公司管理员", "维护本分公司及下属票务室用户和权限", "默认本分公司数据,可细分到票务室。"],
|
||||||
|
["分公司操作员", "使用分公司层面业务和报表功能", "本分公司及授权票务室数据。"],
|
||||||
|
["票务室操作员", "操作票柜、票袋、票单、结算等票务室业务", "仅本票务室数据。"],
|
||||||
|
], [1.25, 2.55, 2.7], header_fill=LIGHT_BLUE)
|
||||||
|
add_callout(doc, "权限实现建议", "权限必须拆成菜单权限、按钮权限、数据范围权限和字段/导出权限。所有查询接口必须统一经过数据范围拦截器,避免前端隐藏菜单但后端接口越权。")
|
||||||
|
|
||||||
|
add_heading(doc, "7. 接口与集成设计", 1)
|
||||||
|
add_table(doc, ["系统/对象", "对接方式", "一期处理建议"], [
|
||||||
|
["IC 卡系统", "接口同步或定时任务", "优先同步线路、车辆、司售、IC 卡基础信息;接口不稳定时保留手动导入兜底。"],
|
||||||
|
["数据湖/二维码系统", "标准 REST/API 或数据推送", "先定义统一数据服务接口、鉴权、频率、字段口径;具体联调作为独立里程碑。"],
|
||||||
|
["银行清分清点系统", "线下 Excel/CSV 导入", "按银行无网环境处理,建立模板、校验、导入日志和失败明细下载。"],
|
||||||
|
["票款清分/胆款系统", "批量同步或接口推送", "以票务系统生成的清分日报为主数据源,明确对账字段。"],
|
||||||
|
["电子签章系统", "第三方接口", "建议一期预留签章对象、签章状态和附件字段,实际签章联调单独报价/排期。"],
|
||||||
|
], [1.55, 1.55, 3.4])
|
||||||
|
|
||||||
|
add_heading(doc, "8. 数据迁移方案", 1)
|
||||||
|
add_numbered(doc, [
|
||||||
|
"盘点 Oracle 现有 79 张表、序列、视图、函数、触发器和历史附件,建立字段映射表。",
|
||||||
|
"完成达梦库结构设计,明确字段类型、索引、主键、唯一约束和历史兼容字段。",
|
||||||
|
"开发迁移脚本,先迁移基础主数据,再迁移库存、单据、结算、报表历史数据。",
|
||||||
|
"执行数据清洗:重复票号、缺失组织、异常状态、金额不平、非法日期等问题生成清洗报告。",
|
||||||
|
"进行三轮迁移演练:开发环境、测试环境、准生产环境,每轮输出数量核对和金额核对报告。",
|
||||||
|
"上线割接时冻结旧系统写入,完成增量迁移、业务抽样验证和回滚预案确认。",
|
||||||
|
])
|
||||||
|
|
||||||
|
add_heading(doc, "9. Codex 协作开发流程", 1)
|
||||||
|
add_table(doc, ["阶段", "Codex 可承担工作", "人工控制点"], [
|
||||||
|
["需求结构化", "从 Word/Excel 提取功能项、生成需求池、补充初版验收点", "业务人员确认字段、流程、报表口径和删除/保留范围。"],
|
||||||
|
["设计阶段", "生成表结构草案、接口草案、状态机草案、测试场景清单", "架构师确认边界、事务、权限模型和关键业务规则。"],
|
||||||
|
["编码阶段", "生成 CRUD、Service、Mapper、Controller、前端页面、导入导出、测试用例", "开发负责人审查代码风格、事务完整性和异常处理。"],
|
||||||
|
["测试阶段", "补接口测试、构造 Mock 数据、生成回归脚本、修复构建问题", "测试负责人确认场景覆盖和真实业务数据抽样。"],
|
||||||
|
["交付阶段", "生成部署文档、接口文档、用户手册和变更说明", "项目组确认上线窗口、培训材料、应急预案。"],
|
||||||
|
], [1.2, 2.8, 2.5], header_fill=LIGHT_BLUE)
|
||||||
|
|
||||||
|
add_heading(doc, "10. 实施计划", 1)
|
||||||
|
add_table(doc, ["阶段", "周期", "主要产出"], [
|
||||||
|
["阶段 0:需求深化", "3-5 周", "最终需求清单、原型、报表口径、接口清单、迁移清单。"],
|
||||||
|
["阶段 1:平台底座", "4-6 周", "登录、权限、菜单、日志、字典、导入导出、消息中心。"],
|
||||||
|
["阶段 2:基础与库存", "8-10 周", "基础资料、票库、票柜、库存流水、审批流程。"],
|
||||||
|
["阶段 3:票单核心", "8-12 周", "票袋、配票、结算、重新结算、票号查询、退票。"],
|
||||||
|
["阶段 4:扩展业务与报表", "8-10 周", "无人售、充值、特殊业务、集团/分公司/票务室报表。"],
|
||||||
|
["阶段 5:迁移联调与上线", "8-10 周", "数据迁移、接口联调、UAT、性能测试、安全整改、上线割接。"],
|
||||||
|
], [1.55, 1.15, 3.8])
|
||||||
|
|
||||||
|
add_heading(doc, "11. 测试与验收策略", 1)
|
||||||
|
add_bullets(doc, [
|
||||||
|
"单元测试:覆盖票号段计算、金额计算、库存扣减、状态流转、权限过滤等纯业务逻辑。",
|
||||||
|
"接口测试:覆盖所有新增、修改、删除、审核、确认、结账、导入导出接口。",
|
||||||
|
"场景测试:按印制到配票、配票到结算、结算到报表、退票回库等业务闭环执行。",
|
||||||
|
"数据迁移测试:按表数量、记录数、金额合计、票号段覆盖、异常数据清单进行核对。",
|
||||||
|
"权限测试:集团、分公司、票务室分别验证菜单、按钮、数据、导出范围。",
|
||||||
|
"性能测试:以峰值 150 在线用户为基准,重点压测报表查询、导入、结账和库存查询。",
|
||||||
|
])
|
||||||
|
|
||||||
|
add_heading(doc, "12. 风险与控制措施", 1)
|
||||||
|
add_table(doc, ["风险", "影响", "控制措施"], [
|
||||||
|
["报表口径未冻结", "开发完成后反复返工", "每张报表先签字确认指标、维度、过滤条件和样例数据。"],
|
||||||
|
["历史数据质量差", "迁移延期、上线后账不平", "提前做数据体检,问题数据单独出清洗规则和责任确认。"],
|
||||||
|
["接口资料不完整", "联调延期", "接口作为独立里程碑,未提供资料的内容不阻塞核心业务开发。"],
|
||||||
|
["票据状态规则遗漏", "库存错误、审计风险", "核心单据全部状态机化,关键状态变更必须有流水和测试用例。"],
|
||||||
|
["Codex 生成代码缺少业务约束", "隐藏缺陷", "Codex 只产出初稿,核心业务必须人工 review 和场景测试。"],
|
||||||
|
["国产化兼容问题", "部署和性能不稳定", "早期引入达梦、国产 OS、中间件环境,不到上线前再适配。"],
|
||||||
|
], [1.65, 1.8, 3.05])
|
||||||
|
|
||||||
|
add_heading(doc, "13. 结论建议", 1)
|
||||||
|
add_para(doc, "建议采用“核心闭环优先、接口分阶段、报表先定口径、迁移提前演练”的实施策略。Codex 应作为研发加速工具嵌入需求整理、代码生成、测试补齐和文档交付过程,但不能替代架构决策、业务确认和验收责任。")
|
||||||
|
add_bullets(doc, [
|
||||||
|
"一期优先交付票务核心闭环:基础信息、票库、票柜、票袋、票单、退票、无人售、充值、报表、权限和迁移。",
|
||||||
|
"电子签章、复杂实时接口、等保/密评配合建议单独列项,降低一期范围失控风险。",
|
||||||
|
"开发组织建议按 10-12 人、8-10 个月主计划推进,使用 Codex 后可将重复开发和文档测试工作压缩 30%-50%。",
|
||||||
|
"正式实施前应先输出功能清单 Excel、原型稿、数据库初设和迁移体检报告,作为报价和排期基线。",
|
||||||
|
])
|
||||||
|
|
||||||
|
doc.save(OUT)
|
||||||
|
print(OUT)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
build()
|
||||||
340
outputs/北京公交票务评估/build_svg_diagrams.py
Normal file
@ -0,0 +1,340 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
OUT = Path("/Users/mate/Codes/mate/mateclaw/outputs/北京公交票务评估/svg设计图")
|
||||||
|
OUT.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
COLORS = {
|
||||||
|
"red": "#D71920",
|
||||||
|
"wall_red": "#B63A2E",
|
||||||
|
"gray": "#6F7378",
|
||||||
|
"dark": "#1F2933",
|
||||||
|
"light": "#F5F7FA",
|
||||||
|
"line": "#D8DEE6",
|
||||||
|
"blue": "#2E86C1",
|
||||||
|
"green": "#3FA45B",
|
||||||
|
"yellow": "#F2C94C",
|
||||||
|
"ink": "#0B2545",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def write(name: str, body: str) -> None:
|
||||||
|
(OUT / name).write_text(body, encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def header(title: str, subtitle: str, width=1440, height=960) -> str:
|
||||||
|
return f'''<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}" viewBox="0 0 {width} {height}">
|
||||||
|
<defs>
|
||||||
|
<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
|
||||||
|
<feDropShadow dx="0" dy="8" stdDeviation="10" flood-color="#1F2933" flood-opacity="0.12"/>
|
||||||
|
</filter>
|
||||||
|
<marker id="arrow-red" markerWidth="10" markerHeight="10" refX="8" refY="5" orient="auto">
|
||||||
|
<path d="M0,0 L10,5 L0,10 Z" fill="{COLORS['red']}"/>
|
||||||
|
</marker>
|
||||||
|
<marker id="arrow-gray" markerWidth="10" markerHeight="10" refX="8" refY="5" orient="auto">
|
||||||
|
<path d="M0,0 L10,5 L0,10 Z" fill="{COLORS['gray']}"/>
|
||||||
|
</marker>
|
||||||
|
<style>
|
||||||
|
.title {{ font: 700 34px "Microsoft YaHei", "PingFang SC", Arial, sans-serif; fill: {COLORS['ink']}; }}
|
||||||
|
.subtitle {{ font: 400 17px "Microsoft YaHei", "PingFang SC", Arial, sans-serif; fill: {COLORS['gray']}; }}
|
||||||
|
.h {{ font: 700 20px "Microsoft YaHei", "PingFang SC", Arial, sans-serif; fill: {COLORS['ink']}; }}
|
||||||
|
.t {{ font: 400 15px "Microsoft YaHei", "PingFang SC", Arial, sans-serif; fill: {COLORS['dark']}; }}
|
||||||
|
.s {{ font: 400 13px "Microsoft YaHei", "PingFang SC", Arial, sans-serif; fill: {COLORS['gray']}; }}
|
||||||
|
.tiny {{ font: 400 12px "Microsoft YaHei", "PingFang SC", Arial, sans-serif; fill: {COLORS['gray']}; }}
|
||||||
|
.card {{ fill: white; stroke: {COLORS['line']}; stroke-width: 1.2; filter: url(#shadow); }}
|
||||||
|
.band {{ fill: {COLORS['light']}; stroke: {COLORS['line']}; stroke-width: 1; }}
|
||||||
|
.redline {{ stroke: {COLORS['red']}; stroke-width: 3; fill: none; marker-end: url(#arrow-red); }}
|
||||||
|
.grayline {{ stroke: {COLORS['gray']}; stroke-width: 2.2; fill: none; marker-end: url(#arrow-gray); }}
|
||||||
|
.dash {{ stroke: {COLORS['gray']}; stroke-width: 2; stroke-dasharray: 8 8; fill: none; marker-end: url(#arrow-gray); }}
|
||||||
|
</style>
|
||||||
|
</defs>
|
||||||
|
<rect width="100%" height="100%" fill="#FFFFFF"/>
|
||||||
|
<path d="M0 0 H1440 V10 H0 Z" fill="{COLORS['red']}"/>
|
||||||
|
<path d="M0 10 H1440 V15 H0 Z" fill="{COLORS['gray']}" opacity="0.55"/>
|
||||||
|
<text x="64" y="72" class="title">{title}</text>
|
||||||
|
<text x="64" y="102" class="subtitle">{subtitle}</text>
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
def footer() -> str:
|
||||||
|
return f'''
|
||||||
|
<text x="64" y="925" class="tiny">北京公交集团票务综合管理平台系统升级 | SDD(Spec-Driven Development,规范驱动开发)实施方案配套设计图</text>
|
||||||
|
<circle cx="1358" cy="904" r="18" fill="{COLORS['red']}" opacity="0.95"/>
|
||||||
|
<circle cx="1395" cy="904" r="18" fill="{COLORS['gray']}" opacity="0.78"/>
|
||||||
|
</svg>
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
def card(x, y, w, h, title, lines, color="#D71920"):
|
||||||
|
parts = [f'<rect x="{x}" y="{y}" width="{w}" height="{h}" rx="10" class="card"/>',
|
||||||
|
f'<rect x="{x}" y="{y}" width="{w}" height="8" rx="4" fill="{color}"/>',
|
||||||
|
f'<text x="{x+22}" y="{y+42}" class="h">{title}</text>']
|
||||||
|
yy = y + 72
|
||||||
|
for line in lines:
|
||||||
|
parts.append(f'<text x="{x+22}" y="{yy}" class="t">{line}</text>')
|
||||||
|
yy += 26
|
||||||
|
return "\n".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
architecture = header("总体技术架构图", "从用户访问、应用服务、数据存储、外部集成到运维保障的分层架构")
|
||||||
|
architecture += f'''
|
||||||
|
<rect x="58" y="138" width="1324" height="680" rx="18" class="band"/>
|
||||||
|
<text x="92" y="178" class="h">访问与用户层</text>
|
||||||
|
{card(88, 206, 248, 138, "集团用户", ["集团管理员 / 操作员", "全局管理、报表、审批"], COLORS['red'])}
|
||||||
|
{card(376, 206, 248, 138, "分公司用户", ["分公司管理员 / 操作员", "本公司业务、审核、统计"], COLORS['blue'])}
|
||||||
|
{card(664, 206, 248, 138, "票务室用户", ["票柜、票袋、票单", "配票、结算、退票"], COLORS['green'])}
|
||||||
|
{card(952, 206, 248, 138, "移动/内网终端", ["浏览器访问", "打印、导入、导出"], COLORS['yellow'])}
|
||||||
|
|
||||||
|
<path d="M212 356 V410" class="redline"/>
|
||||||
|
<path d="M500 356 V410" class="redline"/>
|
||||||
|
<path d="M788 356 V410" class="redline"/>
|
||||||
|
<path d="M1076 356 V410" class="redline"/>
|
||||||
|
|
||||||
|
<text x="92" y="438" class="h">应用与业务服务层</text>
|
||||||
|
{card(88, 466, 248, 168, "前端应用", ["Vue3 / TypeScript", "菜单导航、表单、报表", "打印与导入导出"], COLORS['red'])}
|
||||||
|
{card(376, 466, 248, 168, "网关与认证", ["统一登录认证", "菜单 / 按钮权限", "数据范围拦截"], COLORS['gray'])}
|
||||||
|
{card(664, 466, 248, 168, "业务服务", ["票库、票柜、票单", "充值、无人售、退票", "审批与状态机"], COLORS['blue'])}
|
||||||
|
{card(952, 466, 248, 168, "报表服务", ["日结、月结、趋势", "Excel 导出", "汇总表加速"], COLORS['green'])}
|
||||||
|
|
||||||
|
<path d="M336 550 H370" class="grayline"/>
|
||||||
|
<path d="M624 550 H658" class="grayline"/>
|
||||||
|
<path d="M912 550 H946" class="grayline"/>
|
||||||
|
<path d="M788 646 V698" class="redline"/>
|
||||||
|
|
||||||
|
<text x="92" y="724" class="h">数据、集成与运维层</text>
|
||||||
|
{card(88, 752, 220, 120, "达梦数据库", ["主业务库 / 主备", "库存、流水、报表"], COLORS['red'])}
|
||||||
|
{card(342, 752, 220, 120, "Redis / 缓存", ["会话、字典、短期任务", "热点查询缓存"], COLORS['gray'])}
|
||||||
|
{card(596, 752, 220, 120, "文件与对象存储", ["模板、导入文件", "导出报表、附件"], COLORS['yellow'])}
|
||||||
|
{card(850, 752, 220, 120, "外部系统集成", ["IC卡 / 数据湖 / 胆款", "银行清分线下导入"], COLORS['blue'])}
|
||||||
|
{card(1104, 752, 220, 120, "运维保障", ["日志、监控、备份", "灰度、回滚、巡检"], COLORS['green'])}
|
||||||
|
'''
|
||||||
|
architecture += footer()
|
||||||
|
write("01-总体技术架构图.svg", architecture)
|
||||||
|
|
||||||
|
|
||||||
|
flow = header("核心票务业务流转图", "围绕票库、票柜、票袋、票单、结算、退票和报表的主业务闭环")
|
||||||
|
flow += f'''
|
||||||
|
<rect x="74" y="150" width="1290" height="700" rx="18" class="band"/>
|
||||||
|
{card(104, 190, 220, 126, "1. 印制入库", ["分公司发起印制申请", "集团审核", "确认后进入票库"], COLORS['red'])}
|
||||||
|
{card(384, 190, 220, 126, "2. 票库管理", ["票号段入库", "库存余额与流水", "调拨 / 核销 / 调账"], COLORS['gray'])}
|
||||||
|
{card(664, 190, 220, 126, "3. 配票申请", ["票务室申请", "分公司确认", "票库到票柜"], COLORS['blue'])}
|
||||||
|
{card(944, 190, 220, 126, "4. 票柜库存", ["票务室库存", "入库 / 出库查询", "票务室调票"], COLORS['green'])}
|
||||||
|
|
||||||
|
<path d="M324 253 H378" class="redline"/>
|
||||||
|
<path d="M604 253 H658" class="redline"/>
|
||||||
|
<path d="M884 253 H938" class="redline"/>
|
||||||
|
|
||||||
|
{card(104, 400, 220, 126, "5. 票袋管理", ["绑定线路、售票员", "更换线路", "票袋库存归属"], COLORS['green'])}
|
||||||
|
{card(384, 400, 220, 126, "6. 票单配票", ["加载票袋数据", "录入起号止号", "票柜出库到票袋"], COLORS['red'])}
|
||||||
|
{card(664, 400, 220, 126, "7. 票单结算", ["录入剩余票号", "自动计算张数金额", "生成结算记录"], COLORS['blue'])}
|
||||||
|
{card(944, 400, 220, 126, "8. 票单查询", ["配票单 / 结算单", "打印、导出", "重新结算入口"], COLORS['gray'])}
|
||||||
|
|
||||||
|
<path d="M1054 328 C1054 362 260 362 214 394" class="grayline"/>
|
||||||
|
<path d="M324 463 H378" class="redline"/>
|
||||||
|
<path d="M604 463 H658" class="redline"/>
|
||||||
|
<path d="M884 463 H938" class="redline"/>
|
||||||
|
|
||||||
|
{card(104, 610, 220, 126, "9. 退票处理", ["票袋退票", "票柜退票", "库存回写"], COLORS['wall_red'])}
|
||||||
|
{card(384, 610, 220, 126, "10. 无人售/充值", ["无人售线路结算", "IC卡库存与收入", "导入与异常处理"], COLORS['yellow'])}
|
||||||
|
{card(664, 610, 220, 126, "11. 报表汇总", ["日结、月结", "收入、清分、趋势", "三级数据权限"], COLORS['blue'])}
|
||||||
|
{card(944, 610, 220, 126, "12. 审计追溯", ["库存流水", "操作日志", "票号查询"], COLORS['gray'])}
|
||||||
|
|
||||||
|
<path d="M214 538 V604" class="grayline"/>
|
||||||
|
<path d="M774 538 V604" class="redline"/>
|
||||||
|
<path d="M884 673 H938" class="grayline"/>
|
||||||
|
<path d="M1054 598 C1054 568 1054 560 1054 538" class="dash"/>
|
||||||
|
<text x="1178" y="557" class="s">异常更正 / 重新结算</text>
|
||||||
|
'''
|
||||||
|
flow += footer()
|
||||||
|
write("02-核心票务业务流转图.svg", flow)
|
||||||
|
|
||||||
|
|
||||||
|
deployment = header("部署与可运维性架构图", "面向国产化环境的部署、监控、备份、回滚和运维交接设计")
|
||||||
|
deployment += f'''
|
||||||
|
<rect x="70" y="148" width="1300" height="704" rx="18" class="band"/>
|
||||||
|
<text x="102" y="188" class="h">网络与访问区</text>
|
||||||
|
{card(104, 214, 230, 116, "用户浏览器", ["集团 / 分公司 / 票务室", "内网访问、统一认证"], COLORS['red'])}
|
||||||
|
{card(394, 214, 230, 116, "Nginx / 网关", ["HTTPS 终止", "反向代理、限流"], COLORS['gray'])}
|
||||||
|
{card(684, 214, 230, 116, "应用服务 A", ["业务 API", "前端静态资源"], COLORS['blue'])}
|
||||||
|
{card(974, 214, 230, 116, "应用双节点预留", ["建议位 / 二期可选", "原需求为 1 台业务主机"], COLORS['gray'])}
|
||||||
|
<path d="M334 272 H388" class="redline"/>
|
||||||
|
<path d="M624 272 H678" class="redline"/>
|
||||||
|
<path d="M914 272 H968" class="grayline"/>
|
||||||
|
|
||||||
|
<text x="102" y="398" class="h">数据与文件区</text>
|
||||||
|
{card(104, 426, 230, 128, "达梦主库", ["业务数据写入", "库存与单据主数据"], COLORS['red'])}
|
||||||
|
{card(394, 426, 230, 128, "达梦备库", ["主备同步", "故障切换准备"], COLORS['gray'])}
|
||||||
|
{card(684, 426, 230, 128, "Redis", ["会话、缓存", "任务状态、热点字典"], COLORS['green'])}
|
||||||
|
{card(974, 426, 230, 128, "对象存储", ["模板、导入文件", "导出报表、附件"], COLORS['yellow'])}
|
||||||
|
<rect x="1226" y="426" width="112" height="128" rx="10" fill="#FFFFFF" stroke="{COLORS['line']}" stroke-dasharray="6 6"/>
|
||||||
|
<text x="1246" y="464" class="s">Oracle</text>
|
||||||
|
<text x="1246" y="488" class="s">旧库</text>
|
||||||
|
<text x="1246" y="512" class="tiny">迁移演练</text>
|
||||||
|
<text x="1246" y="534" class="tiny">回滚备份</text>
|
||||||
|
<path d="M1220 490 H630" class="dash"/>
|
||||||
|
<path d="M334 490 H388" class="grayline"/>
|
||||||
|
<path d="M799 342 V420" class="redline"/>
|
||||||
|
<path d="M1089 342 V420" class="grayline"/>
|
||||||
|
|
||||||
|
<text x="102" y="620" class="h">运维保障区</text>
|
||||||
|
{card(104, 648, 216, 124, "日志中心", ["业务日志、接口日志", "登录与操作审计"], COLORS['gray'])}
|
||||||
|
{card(360, 648, 216, 124, "监控告警", ["存活、慢SQL、磁盘", "异常率、任务失败"], COLORS['red'])}
|
||||||
|
{card(616, 648, 216, 124, "备份恢复", ["数据库全量/增量", "文件备份、恢复演练"], COLORS['green'])}
|
||||||
|
{card(872, 648, 216, 124, "CI/CD", ["构建、测试、制品", "部署、版本标记"], COLORS['blue'])}
|
||||||
|
{card(1128, 648, 216, 124, "回滚预案", ["旧版本保留", "割接窗口、冒烟检查"], COLORS['wall_red'])}
|
||||||
|
<path d="M684 592 C550 610 465 620 468 642" class="dash"/>
|
||||||
|
<path d="M914 592 C790 610 724 622 724 642" class="dash"/>
|
||||||
|
<path d="M624 272 C520 340 475 380 480 420" class="dash"/>
|
||||||
|
'''
|
||||||
|
deployment += footer()
|
||||||
|
write("03-部署与可运维性架构图.svg", deployment)
|
||||||
|
|
||||||
|
|
||||||
|
permissions = header("权限与数据范围设计图", "菜单权限、按钮权限、接口权限、数据权限和导出权限的统一控制")
|
||||||
|
permissions += f'''
|
||||||
|
<rect x="70" y="148" width="1300" height="704" rx="18" class="band"/>
|
||||||
|
{card(96, 198, 240, 134, "集团管理员", ["配置菜单、角色、用户", "分配分公司数据范围", "查看全集团数据"], COLORS['red'])}
|
||||||
|
{card(396, 198, 240, 134, "集团操作员", ["按角色使用功能", "查看授权公司数据", "集团级报表"], COLORS['gray'])}
|
||||||
|
{card(696, 198, 240, 134, "分公司管理员", ["维护本公司用户", "配置票务室权限", "本公司业务管理"], COLORS['blue'])}
|
||||||
|
{card(996, 198, 240, 134, "票务室操作员", ["票柜、票袋、票单", "结算、退票、导出", "仅本票务室数据"], COLORS['green'])}
|
||||||
|
|
||||||
|
<rect x="170" y="414" width="1100" height="100" rx="12" fill="#FFFFFF" stroke="{COLORS['line']}" filter="url(#shadow)"/>
|
||||||
|
<text x="212" y="456" class="h">统一权限拦截层</text>
|
||||||
|
<text x="212" y="486" class="t">菜单权限 → 按钮权限 → 接口权限 → 数据范围权限 → 导出/打印权限</text>
|
||||||
|
<path d="M216 344 V408" class="redline"/>
|
||||||
|
<path d="M516 344 V408" class="grayline"/>
|
||||||
|
<path d="M816 344 V408" class="grayline"/>
|
||||||
|
<path d="M1116 344 V408" class="grayline"/>
|
||||||
|
|
||||||
|
{card(116, 604, 230, 126, "组织维度", ["集团", "分公司", "票务室 / 车队 / 线路"], COLORS['red'])}
|
||||||
|
{card(386, 604, 230, 126, "业务维度", ["票库 / 票柜 / 票袋", "充值网点", "无人售线路"], COLORS['blue'])}
|
||||||
|
{card(656, 604, 230, 126, "数据维度", ["单据、库存、流水", "报表汇总", "历史迁移数据"], COLORS['green'])}
|
||||||
|
{card(926, 604, 230, 126, "审计维度", ["登录日志", "操作日志", "导入导出日志"], COLORS['gray'])}
|
||||||
|
<path d="M720 524 V598" class="redline"/>
|
||||||
|
<path d="M720 524 C530 548 500 570 500 598" class="grayline"/>
|
||||||
|
<path d="M720 524 C902 548 1040 570 1040 598" class="grayline"/>
|
||||||
|
'''
|
||||||
|
permissions += footer()
|
||||||
|
write("04-权限与数据范围设计图.svg", permissions)
|
||||||
|
|
||||||
|
|
||||||
|
delivery = header("SDD 协同交付流程图", "把大系统拆成可验证的小任务,形成需求、编码、测试、评审、交付闭环")
|
||||||
|
delivery += f'''
|
||||||
|
<rect x="74" y="148" width="1290" height="704" rx="18" class="band"/>
|
||||||
|
{card(102, 202, 230, 128, "1. 需求卡片化", ["按页面/动作拆分", "角色、输入、输出", "验收标准"], COLORS['red'])}
|
||||||
|
{card(382, 202, 230, 128, "2. 领域建模", ["状态机、票号段", "库存流水、报表口径", "人工确认"], COLORS['gray'])}
|
||||||
|
{card(662, 202, 230, 128, "3. 任务生成", ["表结构、接口", "页面、测试", "小批量执行"], COLORS['blue'])}
|
||||||
|
{card(942, 202, 230, 128, "4. 编码实现", ["前后端协同", "导入导出", "权限与日志"], COLORS['green'])}
|
||||||
|
<path d="M332 266 H376" class="redline"/>
|
||||||
|
<path d="M612 266 H656" class="redline"/>
|
||||||
|
<path d="M892 266 H936" class="redline"/>
|
||||||
|
|
||||||
|
{card(102, 470, 230, 128, "8. 模块验收", ["业务演示", "样例数据核对", "问题闭环"], COLORS['wall_red'])}
|
||||||
|
{card(382, 470, 230, 128, "7. 人工评审", ["核心规则 review", "安全与权限检查", "SQL 与事务检查"], COLORS['gray'])}
|
||||||
|
{card(662, 470, 230, 128, "6. 自动化测试", ["单元 / 接口", "场景 / 回归", "构建检查"], COLORS['blue'])}
|
||||||
|
{card(942, 470, 230, 128, "5. 本地自测", ["页面联调", "异常分支", "导入导出校验"], COLORS['green'])}
|
||||||
|
<path d="M1057 342 V464" class="redline"/>
|
||||||
|
<path d="M942 534 H898" class="grayline"/>
|
||||||
|
<path d="M662 534 H618" class="grayline"/>
|
||||||
|
<path d="M382 534 H338" class="grayline"/>
|
||||||
|
|
||||||
|
<rect x="208" y="690" width="944" height="74" rx="12" fill="#FFFFFF" stroke="{COLORS['line']}" filter="url(#shadow)"/>
|
||||||
|
<text x="238" y="726" class="h">交付原则</text>
|
||||||
|
<text x="362" y="724" class="t">小任务、强测试、人工把关、持续演示;常规功能提效,核心规则不省评审。</text>
|
||||||
|
<path d="M217 608 C217 670 678 650 678 684" class="dash"/>
|
||||||
|
'''
|
||||||
|
delivery += footer()
|
||||||
|
write("05-SDD协同交付流程图.svg", delivery)
|
||||||
|
|
||||||
|
|
||||||
|
lake = header("数据湖与外部系统对接架构图", "明确票务系统向数据湖、二维码、胆款、IC卡和银行清分场景的数据供给边界")
|
||||||
|
lake += f'''
|
||||||
|
<rect x="72" y="148" width="1292" height="704" rx="18" class="band"/>
|
||||||
|
<text x="104" y="190" class="h">票务系统数据源</text>
|
||||||
|
{card(104, 222, 222, 124, "业务库", ["单据、库存、流水", "结算、退票、充值"], COLORS['red'])}
|
||||||
|
{card(370, 222, 222, 124, "报表汇总库", ["日结、月结、趋势", "集团 / 分公司 / 票务室"], COLORS['blue'])}
|
||||||
|
{card(636, 222, 222, 124, "文件与附件", ["导入文件", "导出报表、签章文件"], COLORS['yellow'])}
|
||||||
|
|
||||||
|
<text x="104" y="426" class="h">数据服务与交换层</text>
|
||||||
|
{card(104, 458, 222, 136, "数据目录", ["数据集清单", "字段字典", "版本管理"], COLORS['gray'])}
|
||||||
|
{card(370, 458, 222, 136, "接口服务", ["REST / 批量文件", "鉴权、限流、加密"], COLORS['red'])}
|
||||||
|
{card(636, 458, 222, 136, "同步任务", ["全量 / 增量", "重试、补偿、对账"], COLORS['green'])}
|
||||||
|
{card(902, 458, 222, 136, "接口审计", ["调用日志", "失败告警", "手工补传"], COLORS['blue'])}
|
||||||
|
|
||||||
|
<path d="M215 358 V452" class="redline"/>
|
||||||
|
<path d="M481 358 V452" class="redline"/>
|
||||||
|
<path d="M747 358 V452" class="grayline"/>
|
||||||
|
<path d="M326 526 H364" class="grayline"/>
|
||||||
|
<path d="M592 526 H630" class="grayline"/>
|
||||||
|
<path d="M858 526 H896" class="grayline"/>
|
||||||
|
|
||||||
|
<text x="104" y="670" class="h">外部消费与对接对象</text>
|
||||||
|
{card(104, 700, 188, 116, "数据湖", ["统一取数", "数据质量校验"], COLORS['red'])}
|
||||||
|
{card(326, 700, 188, 116, "二维码系统", ["是否保留供数", "历史数据确认"], COLORS['gray'])}
|
||||||
|
{card(548, 700, 188, 116, "胆款系统", ["清分日报同步", "对账与补偿"], COLORS['blue'])}
|
||||||
|
{card(770, 700, 188, 116, "IC卡系统", ["主数据同步", "增量补偿"], COLORS['green'])}
|
||||||
|
{card(992, 700, 168, 116, "银行清分", ["线下文件导入", "异常明细"], COLORS['yellow'])}
|
||||||
|
{card(1190, 700, 150, 116, "电子签章", ["状态预留", "签章归档"], COLORS['wall_red'])}
|
||||||
|
|
||||||
|
<path d="M481 606 C400 638 220 650 198 694" class="redline"/>
|
||||||
|
<path d="M481 606 C455 646 430 666 420 694" class="grayline"/>
|
||||||
|
<path d="M747 606 C705 646 662 666 642 694" class="grayline"/>
|
||||||
|
<path d="M747 606 C820 640 850 662 864 694" class="grayline"/>
|
||||||
|
<path d="M1013 606 C1070 642 1085 666 1086 694" class="grayline"/>
|
||||||
|
<path d="M1013 606 C1180 642 1240 666 1265 694" class="grayline"/>
|
||||||
|
'''
|
||||||
|
lake += footer()
|
||||||
|
write("06-数据湖与外部系统对接架构图.svg", lake)
|
||||||
|
|
||||||
|
|
||||||
|
map_svg = header("系统功能地图", "按集团、分公司、票务室三级用户视角展示业务入口和能力分布")
|
||||||
|
map_svg += f'''
|
||||||
|
<rect x="72" y="148" width="1292" height="704" rx="18" class="band"/>
|
||||||
|
<text x="104" y="190" class="h">集团视角</text>
|
||||||
|
{card(104, 220, 210, 116, "集团管理", ["用户、权限、菜单", "阈值、日志、配置"], COLORS['red'])}
|
||||||
|
{card(344, 220, 210, 116, "集团审批", ["印制、调拨、核销", "调账审核"], COLORS['gray'])}
|
||||||
|
{card(584, 220, 210, 116, "集团报表", ["存售、收入、清分", "趋势、月报"], COLORS['blue'])}
|
||||||
|
{card(824, 220, 210, 116, "数据服务", ["数据湖、二维码", "接口审计"], COLORS['green'])}
|
||||||
|
|
||||||
|
<text x="104" y="414" class="h">分公司视角</text>
|
||||||
|
{card(104, 444, 210, 116, "基础资料", ["票务室、线路", "车辆、司售、票价"], COLORS['blue'])}
|
||||||
|
{card(344, 444, 210, 116, "票库票柜", ["印制、调拨、核销", "配票申请、调票"], COLORS['red'])}
|
||||||
|
{card(584, 444, 210, 116, "充值与特殊", ["IC卡库存、售出", "异物、大额币、残币"], COLORS['yellow'])}
|
||||||
|
{card(824, 444, 210, 116, "分公司报表", ["日报、月报", "收入核对、导出"], COLORS['green'])}
|
||||||
|
|
||||||
|
<text x="104" y="638" class="h">票务室视角</text>
|
||||||
|
{card(104, 668, 210, 116, "票柜库存", ["入库、出库", "库存查询"], COLORS['green'])}
|
||||||
|
{card(344, 668, 210, 116, "票袋票单", ["票袋、配票", "结算、重新结算"], COLORS['red'])}
|
||||||
|
{card(584, 668, 210, 116, "退票打印", ["票袋/票柜退票", "配票单、结算单"], COLORS['gray'])}
|
||||||
|
{card(824, 668, 210, 116, "票务室报表", ["日结、存售", "交班、银行登记"], COLORS['blue'])}
|
||||||
|
|
||||||
|
<rect x="1090" y="220" width="190" height="564" rx="12" fill="#FFFFFF" stroke="{COLORS['line']}" filter="url(#shadow)"/>
|
||||||
|
<rect x="1090" y="220" width="190" height="8" rx="4" fill="{COLORS['red']}"/>
|
||||||
|
<text x="1116" y="262" class="h">共用能力</text>
|
||||||
|
<text x="1116" y="306" class="t">消息中心</text>
|
||||||
|
<text x="1116" y="338" class="t">阈值预警</text>
|
||||||
|
<text x="1116" y="370" class="t">Excel 导入导出</text>
|
||||||
|
<text x="1116" y="402" class="t">打印模板</text>
|
||||||
|
<text x="1116" y="434" class="t">操作审计</text>
|
||||||
|
<text x="1116" y="466" class="t">数据权限</text>
|
||||||
|
<text x="1116" y="498" class="t">外部接口</text>
|
||||||
|
<text x="1116" y="530" class="t">备份恢复</text>
|
||||||
|
<path d="M1038 278 H1084" class="grayline"/>
|
||||||
|
<path d="M1038 502 H1084" class="grayline"/>
|
||||||
|
<path d="M1038 726 H1084" class="grayline"/>
|
||||||
|
'''
|
||||||
|
map_svg += footer()
|
||||||
|
write("07-系统功能地图.svg", map_svg)
|
||||||
|
|
||||||
|
index = "\n".join([
|
||||||
|
"# SVG 设计图清单",
|
||||||
|
"",
|
||||||
|
"- 01-总体技术架构图.svg",
|
||||||
|
"- 02-核心票务业务流转图.svg",
|
||||||
|
"- 03-部署与可运维性架构图.svg",
|
||||||
|
"- 04-权限与数据范围设计图.svg",
|
||||||
|
"- 05-SDD协同交付流程图.svg",
|
||||||
|
"- 06-数据湖与外部系统对接架构图.svg",
|
||||||
|
"- 07-系统功能地图.svg",
|
||||||
|
])
|
||||||
|
(OUT / "README.md").write_text(index, encoding="utf-8")
|
||||||
|
print(OUT)
|
||||||
883
outputs/北京公交票务评估/build_vibe_design_doc.py
Normal file
@ -0,0 +1,883 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections import defaultdict
|
||||||
|
from datetime import date
|
||||||
|
from pathlib import Path
|
||||||
|
from re import sub
|
||||||
|
from typing import Iterable
|
||||||
|
|
||||||
|
from docx import Document
|
||||||
|
from docx.enum.section import WD_ORIENT, WD_SECTION
|
||||||
|
from docx.enum.table import WD_ALIGN_VERTICAL, WD_TABLE_ALIGNMENT
|
||||||
|
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
||||||
|
from docx.oxml import OxmlElement
|
||||||
|
from docx.oxml.ns import qn
|
||||||
|
from docx.shared import Inches, Pt, RGBColor
|
||||||
|
|
||||||
|
|
||||||
|
SOURCE = Path("/Users/mate/Docs/Work/北京公交集团票务综合管理平台系统升级-需求分析说明书(初稿0506).docx")
|
||||||
|
OUT = Path("/Users/mate/Codes/mate/mateclaw/outputs/北京公交票务评估/北京公交集团票务综合管理平台系统升级-SDD实施方案与工作量清单.docx")
|
||||||
|
|
||||||
|
BLUE = RGBColor(46, 116, 181)
|
||||||
|
DARK_BLUE = RGBColor(31, 77, 120)
|
||||||
|
INK = RGBColor(11, 37, 69)
|
||||||
|
MUTED = RGBColor(96, 108, 122)
|
||||||
|
LIGHT = "F2F4F7"
|
||||||
|
LIGHT_BLUE = "E8EEF5"
|
||||||
|
CALLOUT = "F4F6F9"
|
||||||
|
|
||||||
|
|
||||||
|
def clean(text: str) -> str:
|
||||||
|
text = text.replace("\u200c", "").replace("\u200b", "").replace("\ufeff", "")
|
||||||
|
text = sub(r"\s+", " ", text).strip()
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def module_name(text: str) -> str:
|
||||||
|
text = clean(text)
|
||||||
|
text = sub(r"^[\d.、\s]+", "", text)
|
||||||
|
return text.strip(" ")
|
||||||
|
|
||||||
|
|
||||||
|
def set_run_font(run, size=None, bold=None, color=None, name="Calibri"):
|
||||||
|
run.font.name = name
|
||||||
|
run._element.rPr.rFonts.set(qn("w:ascii"), name)
|
||||||
|
run._element.rPr.rFonts.set(qn("w:hAnsi"), name)
|
||||||
|
run._element.rPr.rFonts.set(qn("w:eastAsia"), "Microsoft YaHei")
|
||||||
|
if size is not None:
|
||||||
|
run.font.size = Pt(size)
|
||||||
|
if bold is not None:
|
||||||
|
run.bold = bold
|
||||||
|
if color is not None:
|
||||||
|
run.font.color.rgb = color
|
||||||
|
|
||||||
|
|
||||||
|
def shade(cell, fill: str):
|
||||||
|
tc_pr = cell._tc.get_or_add_tcPr()
|
||||||
|
shd = tc_pr.find(qn("w:shd"))
|
||||||
|
if shd is None:
|
||||||
|
shd = OxmlElement("w:shd")
|
||||||
|
tc_pr.append(shd)
|
||||||
|
shd.set(qn("w:fill"), fill)
|
||||||
|
|
||||||
|
|
||||||
|
def margins(cell, top=80, start=120, bottom=80, end=120):
|
||||||
|
tc_pr = cell._tc.get_or_add_tcPr()
|
||||||
|
tc_mar = tc_pr.first_child_found_in("w:tcMar")
|
||||||
|
if tc_mar is None:
|
||||||
|
tc_mar = OxmlElement("w:tcMar")
|
||||||
|
tc_pr.append(tc_mar)
|
||||||
|
for key, val in [("top", top), ("start", start), ("bottom", bottom), ("end", end)]:
|
||||||
|
node = tc_mar.find(qn(f"w:{key}"))
|
||||||
|
if node is None:
|
||||||
|
node = OxmlElement(f"w:{key}")
|
||||||
|
tc_mar.append(node)
|
||||||
|
node.set(qn("w:w"), str(val))
|
||||||
|
node.set(qn("w:type"), "dxa")
|
||||||
|
|
||||||
|
|
||||||
|
def table_width(table, widths: list[float]):
|
||||||
|
table.autofit = False
|
||||||
|
table.alignment = WD_TABLE_ALIGNMENT.CENTER
|
||||||
|
for row in table.rows:
|
||||||
|
for idx, cell in enumerate(row.cells):
|
||||||
|
cell.width = Inches(widths[idx])
|
||||||
|
cell.vertical_alignment = WD_ALIGN_VERTICAL.CENTER
|
||||||
|
margins(cell)
|
||||||
|
|
||||||
|
|
||||||
|
def add_para(doc, text="", bold=False, color=None, size=10.5, align=None):
|
||||||
|
p = doc.add_paragraph()
|
||||||
|
p.paragraph_format.space_after = Pt(6)
|
||||||
|
p.paragraph_format.line_spacing = 1.12
|
||||||
|
if align is not None:
|
||||||
|
p.alignment = align
|
||||||
|
if text:
|
||||||
|
r = p.add_run(text)
|
||||||
|
set_run_font(r, size=size, bold=bold, color=color)
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def heading(doc, text, level):
|
||||||
|
p = doc.add_heading(text, level=level)
|
||||||
|
p.paragraph_format.space_before = Pt(16 if level == 1 else 10)
|
||||||
|
p.paragraph_format.space_after = Pt(8 if level == 1 else 5)
|
||||||
|
for r in p.runs:
|
||||||
|
set_run_font(r, size={1: 16, 2: 13, 3: 12}.get(level, 11), bold=True, color=BLUE if level < 3 else DARK_BLUE)
|
||||||
|
|
||||||
|
|
||||||
|
def bullets(doc, items: Iterable[str]):
|
||||||
|
for item in items:
|
||||||
|
p = doc.add_paragraph(style="List Bullet")
|
||||||
|
p.paragraph_format.space_after = Pt(3)
|
||||||
|
p.paragraph_format.line_spacing = 1.12
|
||||||
|
r = p.add_run(item)
|
||||||
|
set_run_font(r, size=10)
|
||||||
|
|
||||||
|
|
||||||
|
def numbered(doc, items: Iterable[str]):
|
||||||
|
for item in items:
|
||||||
|
p = doc.add_paragraph(style="List Number")
|
||||||
|
p.paragraph_format.space_after = Pt(3)
|
||||||
|
p.paragraph_format.line_spacing = 1.12
|
||||||
|
r = p.add_run(item)
|
||||||
|
set_run_font(r, size=10)
|
||||||
|
|
||||||
|
|
||||||
|
def add_table(doc, headers: list[str], rows: list[list[str]], widths: list[float], fill=LIGHT, font_size=8.8):
|
||||||
|
table = doc.add_table(rows=1, cols=len(headers))
|
||||||
|
table.style = "Table Grid"
|
||||||
|
for i, h in enumerate(headers):
|
||||||
|
c = table.rows[0].cells[i]
|
||||||
|
c.text = h
|
||||||
|
shade(c, fill)
|
||||||
|
margins(c)
|
||||||
|
for p in c.paragraphs:
|
||||||
|
p.paragraph_format.space_after = Pt(0)
|
||||||
|
for r in p.runs:
|
||||||
|
set_run_font(r, size=font_size, bold=True, color=INK)
|
||||||
|
for row in rows:
|
||||||
|
cells = table.add_row().cells
|
||||||
|
for i, value in enumerate(row):
|
||||||
|
cells[i].text = str(value)
|
||||||
|
margins(cells[i])
|
||||||
|
for p in cells[i].paragraphs:
|
||||||
|
p.paragraph_format.space_after = Pt(0)
|
||||||
|
p.paragraph_format.line_spacing = 1.02
|
||||||
|
for r in p.runs:
|
||||||
|
set_run_font(r, size=font_size)
|
||||||
|
table_width(table, widths)
|
||||||
|
doc.add_paragraph()
|
||||||
|
|
||||||
|
|
||||||
|
def callout(doc, title, body):
|
||||||
|
table = doc.add_table(rows=1, cols=1)
|
||||||
|
table.style = "Table Grid"
|
||||||
|
cell = table.cell(0, 0)
|
||||||
|
shade(cell, CALLOUT)
|
||||||
|
margins(cell, top=120, bottom=120, start=160, end=160)
|
||||||
|
p = cell.paragraphs[0]
|
||||||
|
p.paragraph_format.space_after = Pt(4)
|
||||||
|
r = p.add_run(title)
|
||||||
|
set_run_font(r, size=10.5, bold=True, color=DARK_BLUE)
|
||||||
|
p2 = cell.add_paragraph()
|
||||||
|
p2.paragraph_format.space_after = Pt(0)
|
||||||
|
r2 = p2.add_run(body)
|
||||||
|
set_run_font(r2, size=10)
|
||||||
|
table_width(table, [6.5])
|
||||||
|
doc.add_paragraph()
|
||||||
|
|
||||||
|
|
||||||
|
def configure(doc: Document):
|
||||||
|
section = doc.sections[0]
|
||||||
|
section.top_margin = Inches(0.85)
|
||||||
|
section.bottom_margin = Inches(0.85)
|
||||||
|
section.left_margin = Inches(0.9)
|
||||||
|
section.right_margin = Inches(0.9)
|
||||||
|
section.header_distance = Inches(0.45)
|
||||||
|
section.footer_distance = Inches(0.45)
|
||||||
|
styles = doc.styles
|
||||||
|
normal = styles["Normal"]
|
||||||
|
normal.font.name = "Calibri"
|
||||||
|
normal._element.rPr.rFonts.set(qn("w:eastAsia"), "Microsoft YaHei")
|
||||||
|
normal.font.size = Pt(10.5)
|
||||||
|
normal.paragraph_format.space_after = Pt(6)
|
||||||
|
normal.paragraph_format.line_spacing = 1.12
|
||||||
|
for style_name, size, color in [("Heading 1", 16, BLUE), ("Heading 2", 13, BLUE), ("Heading 3", 12, DARK_BLUE)]:
|
||||||
|
st = styles[style_name]
|
||||||
|
st.font.name = "Calibri"
|
||||||
|
st._element.rPr.rFonts.set(qn("w:eastAsia"), "Microsoft YaHei")
|
||||||
|
st.font.size = Pt(size)
|
||||||
|
st.font.bold = True
|
||||||
|
st.font.color.rgb = color
|
||||||
|
header = section.header.paragraphs[0]
|
||||||
|
header.text = "北京公交集团票务综合管理平台系统升级 | SDD 实施与工作量评估"
|
||||||
|
for r in header.runs:
|
||||||
|
set_run_font(r, size=8.5, color=MUTED)
|
||||||
|
footer = section.footer.paragraphs[0]
|
||||||
|
footer.text = "内部评估文件"
|
||||||
|
footer.alignment = WD_ALIGN_PARAGRAPH.RIGHT
|
||||||
|
for r in footer.runs:
|
||||||
|
set_run_font(r, size=8.5, color=MUTED)
|
||||||
|
|
||||||
|
|
||||||
|
def extract_items():
|
||||||
|
doc = Document(SOURCE)
|
||||||
|
table = doc.tables[38]
|
||||||
|
items = []
|
||||||
|
current = ""
|
||||||
|
for row in table.rows[1:]:
|
||||||
|
cells = [clean(c.text) for c in row.cells[:5]]
|
||||||
|
mod, page, func, change, note = cells
|
||||||
|
if mod:
|
||||||
|
current = module_name(mod)
|
||||||
|
if not page:
|
||||||
|
continue
|
||||||
|
items.append({
|
||||||
|
"module": current,
|
||||||
|
"page": page,
|
||||||
|
"function": func,
|
||||||
|
"change": change,
|
||||||
|
"note": note,
|
||||||
|
})
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
DELETE_OR_MERGE_PAGES = {"模版下载", "票单打印", "包专管理", "无人售管理(车辆)"}
|
||||||
|
GROUP_COMPANY_REPORTS = {
|
||||||
|
"配出统计",
|
||||||
|
"存售日报表",
|
||||||
|
"普票收入日报表",
|
||||||
|
"票款清分日报",
|
||||||
|
"IC卡充值日报表",
|
||||||
|
"IC卡充值走势报表",
|
||||||
|
"收入走势报表",
|
||||||
|
"大额币走势报表",
|
||||||
|
"残币入账走势报表",
|
||||||
|
"分公司票务管理月报表",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_requirement_items(items):
|
||||||
|
normalized = []
|
||||||
|
for item in items:
|
||||||
|
item = dict(item)
|
||||||
|
if item["page"] in DELETE_OR_MERGE_PAGES:
|
||||||
|
item["change"] = "删除/整合"
|
||||||
|
item["note"] = f"{item['note']} 原需求标注删除或合并,按菜单下线/能力整合处理。".strip()
|
||||||
|
if item["module"] == "统计报表(分公司)" and item["page"] in GROUP_COMPANY_REPORTS:
|
||||||
|
continue
|
||||||
|
if item["module"] == "统计报表(集团)" and item["page"] in GROUP_COMPANY_REPORTS:
|
||||||
|
item["module"] = "统计报表(集团/分公司整合)"
|
||||||
|
item["note"] = f"{item['note']} 集团/分公司同名报表合并建设,按数据权限切换展示范围。".strip()
|
||||||
|
normalized.append(item)
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def add_external_and_pending_items(items):
|
||||||
|
additions = [
|
||||||
|
{
|
||||||
|
"module": "外部系统对接",
|
||||||
|
"page": "公交集团数据湖标准数据接口",
|
||||||
|
"function": "统一数据接口、数据格式、传输频率、安全保障、数据加密",
|
||||||
|
"change": "待补充接口文档、数据范围、推拉模式、频率和鉴权要求",
|
||||||
|
"note": "需求文档 3.3.1 明确用于数据湖获取票务系统数据。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"module": "外部系统对接",
|
||||||
|
"page": "二维码系统数据接口",
|
||||||
|
"function": "统一标准数据接口、二维码相关历史/统计数据供给",
|
||||||
|
"change": "需求文档中现有二维码报表拟删除,但对外接口仍需确认是否保留数据输出",
|
||||||
|
"note": "需确认二维码普票进出站、售检统计是否仅删除页面,还是同步取消数据服务。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"module": "外部系统对接",
|
||||||
|
"page": "银行清分清点系统线下对接",
|
||||||
|
"function": "线下报表传递、手动导入、清分日报入库",
|
||||||
|
"change": "银行无网环境运行,需通过文件模板、导入校验和人工确认完成",
|
||||||
|
"note": "涉及无人售票与票款清分,需失败明细、重复导入控制和导入日志。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"module": "外部系统对接",
|
||||||
|
"page": "票款清分/胆款系统同步",
|
||||||
|
"function": "票务系统导入数据后同步胆款系统,涉及票款清分日报",
|
||||||
|
"change": "拓展部分待明确,需确认同步字段、触发时点、失败重试和对账机制",
|
||||||
|
"note": "建议作为独立联调里程碑,未提供接口前只做适配层和模拟接口。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"module": "外部系统对接",
|
||||||
|
"page": "IC卡/一卡通系统数据对接",
|
||||||
|
"function": "线路、车辆、司售、IC卡类型、充值相关数据同步",
|
||||||
|
"change": "需确认接口方式、同步频率、主数据归属、异常补偿",
|
||||||
|
"note": "需求文档多处说明线路、车辆、司售信息来自 IC 卡系统接口同步。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"module": "外部系统对接",
|
||||||
|
"page": "电子签章系统预留/对接",
|
||||||
|
"function": "特殊业务在线签字确认、签章状态、签章文件归档",
|
||||||
|
"change": "根据业务后续评估再升级",
|
||||||
|
"note": "一期建议预留字段和附件能力;正式联调、CA/签章授权另列。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"module": "待定事项",
|
||||||
|
"page": "安全性与合规要求确认",
|
||||||
|
"function": "安全策略、审计要求、加密要求、等保/密评/信创测评配合",
|
||||||
|
"change": "需求文档标注待集团补充确认",
|
||||||
|
"note": "会影响部署架构、日志留存、账号策略、接口加密和报价边界。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"module": "待定事项",
|
||||||
|
"page": "调账管理业务场景确认",
|
||||||
|
"function": "调账申请、审核、库存/金额调整、差异流水",
|
||||||
|
"change": "具体业务场景待明确",
|
||||||
|
"note": "需明确可调对象、审批层级、是否影响历史报表和会计口径。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"module": "待定事项",
|
||||||
|
"page": "特殊票管理业务场景确认",
|
||||||
|
"function": "特殊票新增、修改、删除、领取、核销或结算关系",
|
||||||
|
"change": "具体业务场景待明确",
|
||||||
|
"note": "需确认是否进入库存流水、是否纳入收入和报表。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"module": "待定事项",
|
||||||
|
"page": "无人售票自动对账与数据清洗",
|
||||||
|
"function": "无人售票清分数据自动对账、异常识别、数据清洗",
|
||||||
|
"change": "文档提出新增能力,但规则和数据来源需补充",
|
||||||
|
"note": "需确认银行文件格式、对账容差、异常处理流程和人工复核机制。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"module": "专项工作",
|
||||||
|
"page": "数据迁移与上线割接",
|
||||||
|
"function": "Oracle 到达梦迁移、三轮迁移演练、增量割接、抽样核验、回滚预案",
|
||||||
|
"change": "专项工作量,不并入普通功能页面",
|
||||||
|
"note": "原需求明确 79 张表、序列、视图、函数、约 200GB 历史数据和 10 年存储。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"module": "专项工作",
|
||||||
|
"page": "培训与运维交接",
|
||||||
|
"function": "管理员培训、业务培训、报表核对培训、运维培训、用户手册和培训课件",
|
||||||
|
"change": "专项工作量",
|
||||||
|
"note": "建议至少上线前集中培训和上线后复盘培训两轮。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"module": "平台底座专项",
|
||||||
|
"page": "消息中心与审批待办",
|
||||||
|
"function": "阈值预警、申请/审核消息、未读已读、弹窗提醒、待办跳转",
|
||||||
|
"change": "原需求明确消息中心和阈值预警",
|
||||||
|
"note": "作为平台底座能力单列,不混入单个业务模块。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"module": "平台底座专项",
|
||||||
|
"page": "打印模板与浏览器打印",
|
||||||
|
"function": "配票单、结算单、打印模板、纸张规格、浏览器打印兼容",
|
||||||
|
"change": "票单打印菜单删除,但打印能力保留",
|
||||||
|
"note": "打印能力整合至配票、结算、查询入口。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"module": "平台底座专项",
|
||||||
|
"page": "Excel异步导出中心",
|
||||||
|
"function": "大数据量导出、异步任务、下载中心、导出权限、水印/字段控制",
|
||||||
|
"change": "报表和查询列表共用能力",
|
||||||
|
"note": "避免大文件导出阻塞浏览器请求。",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
return items + additions
|
||||||
|
|
||||||
|
|
||||||
|
def classify(item):
|
||||||
|
text = f"{item['module']} {item['page']} {item['function']} {item['change']} {item['note']}"
|
||||||
|
if item["module"] == "专项工作":
|
||||||
|
if "数据迁移" in text:
|
||||||
|
return "专项工作", 50, 80, "三轮迁移演练、数据治理、增量割接和回滚验证不可省略。"
|
||||||
|
if "培训" in text:
|
||||||
|
return "专项工作", 15, 25, "培训材料、分角色培训和上线后复盘需单列交付。"
|
||||||
|
if item["module"] == "平台底座专项":
|
||||||
|
if "消息" in text:
|
||||||
|
return "平台能力", 8, 14, "消息中心、待办提醒和阈值预警属于跨模块底座能力。"
|
||||||
|
if "打印" in text:
|
||||||
|
return "平台能力", 8, 14, "打印菜单删除不代表打印能力删除,需统一模板和兼容性验证。"
|
||||||
|
if "导出" in text:
|
||||||
|
return "平台能力", 8, 14, "大数据量导出需异步任务、权限控制和下载中心。"
|
||||||
|
if item["module"] == "外部系统对接":
|
||||||
|
if "数据湖" in text:
|
||||||
|
return "外部对接", 18, 35, "需确认数据范围、接口协议、鉴权加密、传输频率、推拉模式和验收样例。"
|
||||||
|
if "胆款" in text or "清分" in text:
|
||||||
|
return "外部对接", 15, 32, "需确认字段口径、文件/接口格式、重试补偿、对账机制和失败处理。"
|
||||||
|
if "IC卡" in text or "一卡通" in text:
|
||||||
|
return "外部对接", 18, 36, "需确认主数据归属、同步频率、增量机制、异常补偿和历史数据映射。"
|
||||||
|
if "电子签章" in text:
|
||||||
|
return "外部对接", 8, 20, "一期建议预留签章对象、签章状态和附件归档;正式联调视接口材料另估。"
|
||||||
|
return "外部对接", 12, 28, "需以接口文档、联调环境、样例数据和验收规则为准。"
|
||||||
|
if item["module"] == "待定事项":
|
||||||
|
if "无人售票自动对账" in text:
|
||||||
|
return "待定补充", 12, 24, "自动对账规则未冻结,需按银行清分护栏预留分析、校验和复核工时。"
|
||||||
|
if "调账管理" in text:
|
||||||
|
return "待定补充", 8, 24, "调账业务会影响库存、金额、报表和审计追溯,需高于普通待定事项估算。"
|
||||||
|
return "待定补充", 6, 24, "当前需求材料不足,需业务或集团侧补充确认后才能冻结范围。"
|
||||||
|
if "可删除" in text or "删除" in item["page"] or "删除" in item["change"]:
|
||||||
|
return "拟删除/整合", 0, 1, "确认下线、菜单隐藏、历史数据保留和跳转清理。"
|
||||||
|
if any(k in text for k in ["结算", "重新结算", "配票", "调票", "调账", "退票", "库存", "入库", "出库", "核销", "调拨", "印制", "票号"]):
|
||||||
|
lo, hi = 8, 18
|
||||||
|
if any(k in text for k in ["票单结算", "重新结算", "票袋调票", "票柜退票", "票袋退票"]):
|
||||||
|
lo, hi = 14, 26
|
||||||
|
return "核心业务", lo, hi, "需要状态机、库存流水、票号段校验、事务一致性和回归测试。"
|
||||||
|
if any(k in item["module"] for k in ["统计报表"]) or "报表" in text or "统计" in text:
|
||||||
|
return "报表统计", 6, 14, "需确认统计口径、权限范围、导出格式和历史数据性能。"
|
||||||
|
if "导入" in text or "上传" in text or "下载" in text:
|
||||||
|
return "导入导出", 5, 10, "需模板、字段校验、失败明细、导入日志和重复处理。"
|
||||||
|
if any(k in item["module"] for k in ["系统管理"]) or any(k in item["page"] for k in ["用户", "权限", "菜单", "日志", "阈值"]):
|
||||||
|
return "平台能力", 6, 14, "需统一认证授权、按钮权限、数据范围和审计记录。"
|
||||||
|
if any(k in text for k in ["查询、新建、修改、删除", "添加、编辑、删除", "添加、编辑", "新增", "管理"]):
|
||||||
|
return "常规功能", 4, 8, "以列表、表单、校验、权限按钮和操作日志为主。"
|
||||||
|
return "查询维护", 3, 6, "以查询、筛选、详情、导出或基础维护为主。"
|
||||||
|
|
||||||
|
|
||||||
|
def build_rows(items):
|
||||||
|
rows = []
|
||||||
|
for idx, item in enumerate(items, start=1):
|
||||||
|
kind, lo, hi, detail = classify(item)
|
||||||
|
vibe_lo = round(lo * 0.32, 1)
|
||||||
|
vibe_hi = round(hi * 0.40, 1)
|
||||||
|
vibe_lo, vibe_hi = apply_protection_floor(item, kind, vibe_lo, vibe_hi)
|
||||||
|
vibe_display = build_vibe_display(item, vibe_lo, vibe_hi)
|
||||||
|
dev = build_dev_scope(item, kind)
|
||||||
|
rows.append({
|
||||||
|
**item,
|
||||||
|
"idx": idx,
|
||||||
|
"kind": kind,
|
||||||
|
"lo": lo,
|
||||||
|
"hi": hi,
|
||||||
|
"vibe_lo": vibe_lo,
|
||||||
|
"vibe_hi": vibe_hi,
|
||||||
|
"vibe_display": vibe_display,
|
||||||
|
"dev": dev,
|
||||||
|
"detail": detail,
|
||||||
|
})
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def build_vibe_display(item, vibe_lo, vibe_hi):
|
||||||
|
text = f"{item['module']} {item['page']} {item['function']} {item['change']} {item['note']}"
|
||||||
|
if "二维码系统数据接口" in text:
|
||||||
|
return "0-1 / 12-18"
|
||||||
|
return f"{vibe_lo}-{vibe_hi}"
|
||||||
|
|
||||||
|
|
||||||
|
def apply_protection_floor(item, kind, vibe_lo, vibe_hi):
|
||||||
|
text = f"{item['module']} {item['page']} {item['function']} {item['change']} {item['note']}"
|
||||||
|
if kind == "拟删除/整合":
|
||||||
|
return 0, 1
|
||||||
|
if item["module"] == "专项工作" and "数据迁移" in text:
|
||||||
|
return 30, 55
|
||||||
|
if item["module"] == "专项工作" and "培训" in text:
|
||||||
|
return 10, 18
|
||||||
|
if any(k in text for k in ["票单结算", "票单重新结算", "重新结算", "月终结算", "票袋退票", "票柜退票", "票袋调票", "调账"]):
|
||||||
|
vibe_lo = max(vibe_lo, 8)
|
||||||
|
vibe_hi = max(vibe_hi, 12)
|
||||||
|
if any(k in text for k in ["数据湖", "IC卡/一卡通", "IC 卡/一卡通"]):
|
||||||
|
vibe_lo = max(vibe_lo, 15)
|
||||||
|
vibe_hi = max(vibe_hi, 20)
|
||||||
|
if "胆款系统" in text:
|
||||||
|
vibe_lo = max(vibe_lo, 15)
|
||||||
|
vibe_hi = max(vibe_hi, 18)
|
||||||
|
if "二维码系统数据接口" in text:
|
||||||
|
vibe_lo = max(vibe_lo, 12)
|
||||||
|
vibe_hi = max(vibe_hi, 18)
|
||||||
|
if any(k in text for k in ["银行清分", "自动对账"]):
|
||||||
|
vibe_lo = max(vibe_lo, 12)
|
||||||
|
vibe_hi = max(vibe_hi, 18)
|
||||||
|
if any(k in text for k in ["票款实交银行登记", "票务室交班对账"]):
|
||||||
|
vibe_lo = max(vibe_lo, 6)
|
||||||
|
vibe_hi = max(vibe_hi, 12)
|
||||||
|
if "配票应急导入" in text:
|
||||||
|
vibe_lo = max(vibe_lo, 6)
|
||||||
|
vibe_hi = max(vibe_hi, 10)
|
||||||
|
if kind == "报表统计":
|
||||||
|
vibe_lo = max(vibe_lo, 4)
|
||||||
|
vibe_hi = max(vibe_hi, 8)
|
||||||
|
if vibe_hi < vibe_lo:
|
||||||
|
vibe_hi = vibe_lo
|
||||||
|
return round(vibe_lo, 1), round(vibe_hi, 1)
|
||||||
|
|
||||||
|
|
||||||
|
def build_dev_scope(item, kind):
|
||||||
|
page = item["page"]
|
||||||
|
if kind == "拟删除/整合":
|
||||||
|
return f"确认 {page} 下线或合并路径,处理菜单、权限、历史查询入口和数据保留。"
|
||||||
|
if kind == "核心业务":
|
||||||
|
return f"建设 {page} 的单据、审批/确认、库存流水、票号段校验、查询导出和异常回滚。"
|
||||||
|
if kind == "报表统计":
|
||||||
|
return f"建设 {page} 查询、统计聚合、权限过滤、Excel 导出、必要趋势展示和口径校验。"
|
||||||
|
if kind == "导入导出":
|
||||||
|
return f"建设 {page} 模板下载、文件上传、字段校验、入库处理、失败明细和导入日志。"
|
||||||
|
if kind == "平台能力":
|
||||||
|
return f"建设 {page} 的配置管理、权限控制、审计记录、查询筛选和维护页面。"
|
||||||
|
return f"建设 {page} 的列表查询、详情、新增/编辑/停用或删除、权限按钮、日志和导出能力。"
|
||||||
|
|
||||||
|
|
||||||
|
def module_summary(rows):
|
||||||
|
result = []
|
||||||
|
grouped = defaultdict(list)
|
||||||
|
for row in rows:
|
||||||
|
grouped[row["module"]].append(row)
|
||||||
|
for mod, vals in grouped.items():
|
||||||
|
lo = sum(v["lo"] for v in vals)
|
||||||
|
hi = sum(v["hi"] for v in vals)
|
||||||
|
vlo = sum(v["vibe_lo"] for v in vals)
|
||||||
|
vhi = sum(v["vibe_hi"] for v in vals)
|
||||||
|
focus = module_focus(mod)
|
||||||
|
if mod == "外部系统对接":
|
||||||
|
focus = "含数据湖、二维码、银行清分、胆款、IC 卡、电子签章 6 项。二维码若业务下线,模块协同后下调至 60-84 人日;若仍需对外供数,维持 72-102 人日。开工前第 1 周以集团业务部书面冻结结果为准。"
|
||||||
|
result.append([mod, str(len(vals)), f"{lo}-{hi}", f"{round(vlo)}-{round(vhi)}", focus])
|
||||||
|
report_vals = []
|
||||||
|
for mod, vals in grouped.items():
|
||||||
|
if "统计报表" in mod:
|
||||||
|
report_vals.extend(vals)
|
||||||
|
if report_vals:
|
||||||
|
lo = sum(v["lo"] for v in report_vals)
|
||||||
|
hi = sum(v["hi"] for v in report_vals)
|
||||||
|
vlo = sum(v["vibe_lo"] for v in report_vals)
|
||||||
|
vhi = sum(v["vibe_hi"] for v in report_vals)
|
||||||
|
deleted = sum(1 for v in report_vals if v["kind"] == "拟删除/整合")
|
||||||
|
actual = len(report_vals) - deleted
|
||||||
|
result.append([
|
||||||
|
"报表合计(含整合/集团/分公司/票务室)",
|
||||||
|
f"{len(report_vals)}",
|
||||||
|
f"{lo}-{hi}",
|
||||||
|
f"{round(vlo)}-{round(vhi)}",
|
||||||
|
f"报表实际开发 {actual} 项,拟删除/整合 {deleted} 项,便于报价沟通。",
|
||||||
|
])
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def module_focus(mod):
|
||||||
|
if "专项工作" in mod:
|
||||||
|
return "数据迁移、上线割接、培训和运维交接专项,不应混入普通功能项。"
|
||||||
|
if "平台底座专项" in mod:
|
||||||
|
return "消息中心、打印模板、异步导出等跨模块共用能力。"
|
||||||
|
if "外部系统" in mod:
|
||||||
|
return "数据湖、二维码、银行清分、胆款、IC卡、电子签章等接口联调。"
|
||||||
|
if "待定事项" in mod:
|
||||||
|
return "需求边界未冻结,需补材料后确定开发、测试和报价口径。"
|
||||||
|
if "票单" in mod:
|
||||||
|
return "票袋、配票、结算、重新结算、票号追溯,是核心复杂域。"
|
||||||
|
if "票柜" in mod or "票库" in mod or "申请" in mod or "审核" in mod:
|
||||||
|
return "围绕库存流转、审批状态和票号段控制。"
|
||||||
|
if "统计报表" in mod:
|
||||||
|
return "以口径确认、聚合性能、导出格式和权限过滤为重点。"
|
||||||
|
if "系统" in mod:
|
||||||
|
return "全局权限、菜单、日志、阈值,是平台底座。"
|
||||||
|
if "基础" in mod:
|
||||||
|
return "主数据维护和外部同步,是后续业务依赖。"
|
||||||
|
return "按业务场景完成查询、维护、导入导出和日志审计。"
|
||||||
|
|
||||||
|
|
||||||
|
def make_landscape(doc):
|
||||||
|
section = doc.add_section(WD_SECTION.NEW_PAGE)
|
||||||
|
section.orientation = WD_ORIENT.LANDSCAPE
|
||||||
|
section.page_width, section.page_height = section.page_height, section.page_width
|
||||||
|
section.top_margin = Inches(0.6)
|
||||||
|
section.bottom_margin = Inches(0.6)
|
||||||
|
section.left_margin = Inches(0.55)
|
||||||
|
section.right_margin = Inches(0.55)
|
||||||
|
section.header_distance = Inches(0.35)
|
||||||
|
section.footer_distance = Inches(0.35)
|
||||||
|
return section
|
||||||
|
|
||||||
|
|
||||||
|
def build():
|
||||||
|
items = add_external_and_pending_items(normalize_requirement_items(extract_items()))
|
||||||
|
rows = build_rows(items)
|
||||||
|
normal_lo = sum(r["lo"] for r in rows)
|
||||||
|
normal_hi = sum(r["hi"] for r in rows)
|
||||||
|
vibe_lo = round(sum(r["vibe_lo"] for r in rows))
|
||||||
|
vibe_hi = round(sum(r["vibe_hi"] for r in rows))
|
||||||
|
|
||||||
|
doc = Document()
|
||||||
|
configure(doc)
|
||||||
|
|
||||||
|
add_para(doc, "项目实施方案", bold=True, color=MUTED, size=11)
|
||||||
|
p = doc.add_paragraph()
|
||||||
|
r = p.add_run("北京公交集团票务综合管理平台系统升级")
|
||||||
|
set_run_font(r, size=23, bold=True, color=INK)
|
||||||
|
p.paragraph_format.space_after = Pt(4)
|
||||||
|
p = doc.add_paragraph()
|
||||||
|
r = p.add_run("SDD 实施方案与工作量清单")
|
||||||
|
set_run_font(r, size=15, color=MUTED)
|
||||||
|
p.paragraph_format.space_after = Pt(14)
|
||||||
|
add_table(doc, ["字段", "内容"], [
|
||||||
|
["文档用途", "用于项目立项、排期、报价拆分、研发组织和验收范围确认"],
|
||||||
|
["文档版本", "v1.0"],
|
||||||
|
["编制日期", date.today().isoformat()],
|
||||||
|
["编制人", "项目经理 / 解决方案团队"],
|
||||||
|
["审核人", "架构师 / 技术负责人"],
|
||||||
|
["变更记录", "v0.9 初稿;v1.0 修订工作量护栏、删除项口径、外部接口区间、报表整合、部署运维披露、目标环境矩阵和 SDD 术语展开"],
|
||||||
|
["需求来源", "北京公交集团票务综合管理平台系统升级需求分析说明书(初稿0506)"],
|
||||||
|
["估算口径", "包含需求深化、设计、开发、自测、联调、迁移、测试配合和上线支持"],
|
||||||
|
], [1.35, 5.15], fill=LIGHT_BLUE, font_size=9)
|
||||||
|
|
||||||
|
callout(doc, "简要结论", f"本项目按当前修订口径约 {len(rows)} 个工作条目,正常工作量约 {normal_lo}-{normal_hi} 人日。SDD 对常规页面、脚手架、测试样例、文档和部分接口适配按 60% 以上提效目标组织;但核心业务、外部对接、报表口径、数据迁移和上线割接设置最低保护工时,因此全口径协同后工作量约 {vibe_lo}-{vibe_hi} 人日。该口径要求需求冻结、接口材料同步到位、自动化测试和人工评审强约束。")
|
||||||
|
|
||||||
|
heading(doc, "一、简要评估", 1)
|
||||||
|
add_table(doc, ["项目", "建议口径"], [
|
||||||
|
["功能规模", f"{len(rows)} 个功能项,覆盖原需求 14 个一级模块,并补充外部系统对接、数据湖接口和待定事项工作量。"],
|
||||||
|
["传统开发团队", "10-12 人,8-10 个月完成;包含产品、前端、后端、数据迁移、测试、实施。"],
|
||||||
|
["SDD 激进团队", "8-10 人,4-5 个月完成;前提是需求冻结、接口材料及时、测试自动化、代码评审、业务验收和并行联调节奏全部到位。"],
|
||||||
|
["SDD 稳妥团队", "8-10 人,5-6 个月完成;适用于接口材料存在补充、报表口径需要多轮确认、迁移数据质量需要治理的情况。"],
|
||||||
|
["外部条件受阻场景", "6 个月以上;适用于数据湖、IC卡、胆款、电子签章等接口资料或联调环境延期,或安全合规要求后置补充。"],
|
||||||
|
["核心难点", "票号段连续性、库存流水、票单结算、报表口径、数据权限、历史数据迁移、数据湖/外部系统对接、国产化适配。"],
|
||||||
|
["报价建议", "完整建设建议按 450-650 万区间,稳妥报价点 520-580 万;效率提升主要压缩周期和重复劳动,不等于按同等比例降低报价;电子签章、等保测评、第三方授权另列。"],
|
||||||
|
], [1.4, 5.1], fill=LIGHT_BLUE, font_size=9)
|
||||||
|
|
||||||
|
heading(doc, "二、模块工作量简表", 1)
|
||||||
|
add_table(doc, ["模块", "功能项数", "正常人日", "协同后人日", "重点说明"], module_summary(rows), [1.25, 0.65, 0.85, 0.9, 2.85], fill=LIGHT)
|
||||||
|
|
||||||
|
heading(doc, "三、SDD 实施方式", 1)
|
||||||
|
add_para(doc, "本方案中的 SDD(Spec-Driven Development,规范驱动开发)不是直接让工具一次性生成完整系统,而是把需求、设计、编码、测试、文档拆成小批量可验证任务。每个功能都按照“需求卡片 → 数据结构 → 接口 → 页面 → 测试 → 评审 → 合并”的节奏推进。本文 SDD 一律指本节定义的开发方法论,与软件工程语境中作为交付物的 Software Design Description(软件设计说明书)不同。")
|
||||||
|
numbered(doc, [
|
||||||
|
"需求卡片化:每个页面或业务动作形成独立任务,明确角色、数据范围、输入输出、状态和验收标准。",
|
||||||
|
"先底座后业务:先完成认证、菜单、权限、字典、日志、导入导出、文件存储等平台能力。",
|
||||||
|
"核心域手工建模:票据库存、票号段、结算、退票、调账等领域模型由架构师和业务人员先确认,再进入编码。",
|
||||||
|
"批量生成常规代码:常规列表、表单、接口、DTO、Mapper、权限按钮、导入模板可以批量生成并统一 review。",
|
||||||
|
"测试前置:每个核心业务先写状态流转和金额/张数计算测试,避免只实现页面不验证账务逻辑。",
|
||||||
|
"小步交付:每个模块独立演示、独立验收、独立回归,不把问题积压到总体验收阶段。",
|
||||||
|
])
|
||||||
|
heading(doc, "3.1 可压缩与不可压缩工作", 2)
|
||||||
|
add_table(doc, ["类别", "可压缩内容", "不可压缩/低压缩内容", "管理要求"], [
|
||||||
|
["常规 CRUD 与页面", "列表、表单、详情、权限按钮、DTO、Mapper、基础接口、前端页面可批量生成。", "业务字段确认、页面原型确认、用户体验验收。", "适合按 60% 以上提效估算。"],
|
||||||
|
["核心票据业务", "单据骨架、状态枚举、接口样板、测试样例可提效。", "票号连续性、库存流水、结算口径、退票回写、调账规则。", "设置最低保护工时,必须人工评审。"],
|
||||||
|
["报表统计", "查询页面、导出模板、聚合 SQL 初稿可提效。", "指标口径、历史数据比对、金额/张数验收、旧系统样例核对。", "口径确认和验收时间不按编码效率压缩。"],
|
||||||
|
["外部系统对接", "适配层、Mock 服务、接口日志、重试框架可提效。", "接口文档等待、网络开通、联调排期、外部系统问题定位。", "拆分内部开发工时和外部等待周期。"],
|
||||||
|
["数据迁移", "映射脚本、校验脚本、异常报告生成可提效。", "历史脏数据处理、业务确认、割接窗口、抽样核验。", "至少三轮演练,不压缩关键验证。"],
|
||||||
|
["部署运维", "部署脚本、巡检脚本、监控配置模板可提效。", "生产网络、安全审查、备份恢复演练、回滚演练。", "纳入上线准入条件。"],
|
||||||
|
], [1.0, 1.8, 2.05, 1.65], fill=LIGHT_BLUE, font_size=8)
|
||||||
|
heading(doc, "3.2 核心工作最低保护工时", 2)
|
||||||
|
add_table(doc, ["工作类型", "最低保护工时建议", "原因"], [
|
||||||
|
["票单结算、重新结算", "单项不低于 8-12 人日", "涉及金额、张数、票号段、重新结算和历史追溯,不能只按页面复杂度估算。"],
|
||||||
|
["退票、调票、调账", "单项不低于 8-12 人日", "会影响库存回写、流水一致性和报表口径。"],
|
||||||
|
["数据湖、IC 卡接口", "单接口不低于 15-20 人日", "需字段确认、鉴权、安全、日志、联调、失败补偿和验收样例。"],
|
||||||
|
["胆款接口", "单接口不低于 15-18 人日", "以清分日报同步、补偿、对账为主,按中等接口复杂度预留。"],
|
||||||
|
["银行清分导入/自动对账", "不低于 12-18 人日", "需文件模板、重复导入控制、异常明细、人工复核和对账规则。"],
|
||||||
|
["核心报表", "单张不低于 4-8 人日", "报表开发本身可提效,但口径和数据核对需要保留时间。"],
|
||||||
|
["数据迁移与割接", "专项不低于 50-80 人日", "迁移脚本、三轮演练、异常治理、割接和回滚不可省略。"],
|
||||||
|
], [1.7, 1.2, 3.6], fill=LIGHT)
|
||||||
|
add_para(doc, "下表给出 SDD 协同后的实施护栏,适用于详细功能项清单。它不是传统正常工作量,而是协同开发后仍需保留的区间口径;数据迁移一行的 30-55 人日为协同后专项口径,与上一表传统 50-80 人日并存不冲突。", size=9.5)
|
||||||
|
add_table(doc, ["工作类别", "协同后人日护栏", "适用功能项", "说明"], [
|
||||||
|
["票单结算/重新结算", "8-12 人日", "票单结算、票单重新结算", "按保护工时修正表 19,不再直接套用统一压缩系数。"],
|
||||||
|
["退票/调票/调账", "8-12 人日", "票袋退票、票柜退票、票袋调票、调账管理", "涉及库存回写、流水一致性和报表回写。"],
|
||||||
|
["数据湖/IC 卡接口", "15-20 人日", "数据湖、IC 卡/一卡通", "含字段确认、鉴权、安全、日志、联调和失败补偿。"],
|
||||||
|
["胆款接口", "15-18 人日", "票款清分/胆款", "以清分日报同步、补偿、对账为主,按中等接口复杂度预留。"],
|
||||||
|
["银行清分/自动对账", "12-18 人日", "银行清分清点、无人售票自动对账", "含文件模板、重复导入控制、异常明细和人工复核。"],
|
||||||
|
["核心报表", "4-8 人日", "日结、月结、清分、收入、趋势等核心报表", "报表口径和旧系统样例核对不按编码效率压缩。"],
|
||||||
|
["数据迁移与割接", "30-55 人日", "Oracle 到达梦迁移、三轮演练、增量割接", "协同可提效脚本和报告生成,但数据治理和演练需保留周期。"],
|
||||||
|
], [1.4, 1.05, 2.0, 2.05], fill=LIGHT_BLUE, font_size=8)
|
||||||
|
|
||||||
|
heading(doc, "四、实施步骤简表", 1)
|
||||||
|
add_table(doc, ["阶段", "周期", "主要工作", "交付物"], [
|
||||||
|
["0. 需求冻结与切片", "1.5-2 周", "需求清单、业务流程、角色权限、报表口径、接口范围、迁移对象确认;同步拆成可并行任务卡。", "需求矩阵、原型、报表口径表、接口清单、迁移清单、任务切片清单。"],
|
||||||
|
["1. 平台底座", "2.5-3 周", "登录、用户、角色、菜单、数据权限、日志、字典、阈值、消息、文件导入导出。", "可登录可授权的基础系统。"],
|
||||||
|
["2. 基础与库存", "4-5 周", "基础资料、票库、票柜、审批、库存余额和流水。", "库存闭环 MVP。"],
|
||||||
|
["3. 票单核心", "5-6 周", "票袋、配票、结算、重新结算、退票、票号查询。", "票务室核心业务闭环。"],
|
||||||
|
["4. 扩展业务与报表", "4-5 周", "无人售、充值、特殊业务、集团/分公司/票务室报表。", "完整业务与统计能力。"],
|
||||||
|
["5. 迁移联调上线", "4-6 周", "数据迁移、接口联调、UAT、性能、安全整改、上线演练。", "生产上线版本与运维交接材料。"],
|
||||||
|
], [0.9, 0.8, 3.05, 1.75], fill=LIGHT)
|
||||||
|
add_callout = callout
|
||||||
|
add_callout(doc, "周期口径说明", "上表阶段存在并行关系,不应简单相加。平台底座完成 60%-70% 后,基础信息、库存、报表模板、接口 Mock、迁移脚本可并行推进。若甲方接口材料、报表口径、历史样例数据无法同步提供,周期应切换到 5-6 个月或 6 个月以上口径。")
|
||||||
|
|
||||||
|
heading(doc, "五、关键设计细节", 1)
|
||||||
|
heading(doc, "5.1 库存与票号设计", 2)
|
||||||
|
bullets(doc, [
|
||||||
|
"采用“库存余额表 + 库存流水表 + 业务单据表”三层模型,不以单一库存字段承载全部业务历史。",
|
||||||
|
"票号段字段统一包含票价、票种、票组、起号、止号、张数、状态、库存主体、来源单据。",
|
||||||
|
"所有出入库动作必须写流水,流水记录来源、目标、数量、金额、操作人、操作时间和业务原因。",
|
||||||
|
"连续票号段需校验重叠、断号、越界、已核销、已退票、已结算等状态。",
|
||||||
|
"重新结算不得覆盖原记录,应形成更正单据和差异流水,保证历史可追溯。",
|
||||||
|
])
|
||||||
|
heading(doc, "5.2 权限与数据范围", 2)
|
||||||
|
bullets(doc, [
|
||||||
|
"权限拆分为菜单权限、按钮权限、接口权限、数据范围权限和导出权限。",
|
||||||
|
"集团管理员可分配全局权限;集团操作员按授权查看部分分公司;分公司角色默认本分公司;票务室角色只看本票务室。",
|
||||||
|
"后端查询必须统一注入数据范围条件,不能只依赖前端隐藏菜单。",
|
||||||
|
"导出、打印、报表接口必须复用同一套数据权限规则。",
|
||||||
|
])
|
||||||
|
heading(doc, "5.3 报表设计", 2)
|
||||||
|
bullets(doc, [
|
||||||
|
"每张报表上线前先确认指标定义、筛选条件、统计维度、取数来源、金额精度、导出格式。",
|
||||||
|
"集团和分公司同类报表尽量合并,用数据权限和维度字段控制展示结果。",
|
||||||
|
"高频报表建立日汇总/月汇总表,避免每次查询实时扫大量流水。",
|
||||||
|
"报表验收必须使用旧系统样例数据做金额和张数比对。",
|
||||||
|
])
|
||||||
|
heading(doc, "5.3.1 消息中心、阈值预警与打印模板", 3)
|
||||||
|
add_table(doc, ["能力", "设计内容", "待确认事项"], [
|
||||||
|
["消息中心", "统一承载阈值预警、申请待审核、审核结果、导入失败、接口失败、结账提醒等站内消息。支持未读/已读、弹窗提醒、按角色过滤。", "是否需要短信、企业微信、邮件等外部通知;提醒频率和免打扰规则。"],
|
||||||
|
["阈值预警", "阈值管理配置指标、阈值类型、适用组织、提醒对象;达到阈值时进入消息中心并在用户打开系统时提醒。", "具体阈值指标、计算频率、是否需要定时任务预计算。"],
|
||||||
|
["审批待办", "印制、调拨、核销、调账、退票、销毁等流程统一生成待办消息,支持跳转到对应单据。", "各流程审批层级和超时提醒规则。"],
|
||||||
|
["打印模板", "票单配票、票单结算、票单查询整合配票单/结算单打印;票单打印菜单删除但打印能力保留。", "纸张规格、打印机环境、页眉页脚、签字栏、是否套打。"],
|
||||||
|
["Excel 导出", "报表、库存、流水、查询列表提供导出;大数据量导出采用异步任务和下载中心。", "导出字段、文件格式、最大导出量、是否水印。"],
|
||||||
|
], [1.0, 3.45, 2.05], fill=LIGHT_BLUE, font_size=8)
|
||||||
|
heading(doc, "5.4 数据迁移", 2)
|
||||||
|
bullets(doc, [
|
||||||
|
"迁移对象包括 Oracle 表、序列、视图、函数、历史业务数据和必要附件。",
|
||||||
|
"先做数据体检,输出重复票号、缺失组织、异常状态、金额不平、非法日期等问题清单。",
|
||||||
|
"至少执行三轮迁移演练:开发、测试、准生产,每轮生成记录数、金额、票号覆盖和异常清单。",
|
||||||
|
"上线割接需要冻结旧系统写入、执行增量迁移、业务抽样核验,并保留回滚方案。",
|
||||||
|
])
|
||||||
|
heading(doc, "5.5 数据湖与外部系统对接", 2)
|
||||||
|
add_para(doc, "重新审查需求文档后,外部系统对接不能只作为技术预留处理。数据湖、二维码系统、银行清分、票款清分/胆款系统、IC 卡系统、电子签章均会影响接口设计、数据模型、测试样例、部署网络和验收边界。")
|
||||||
|
add_table(doc, ["对接对象", "需求依据", "设计内容", "工作量建议", "待确认事项"], [
|
||||||
|
["公交集团数据湖", "3.3.1 要求对外提供统一标准数据接口,用于数据湖获取票务系统数据。", "建设数据服务接口、字段字典、鉴权加密、调用日志、频率控制、失败重试、样例数据。", "18-35 人日", "数据范围、推送/拉取模式、接口协议、传输频率、加密算法、验收样例。"],
|
||||||
|
["二维码系统", "3.3.1 与数据湖并列;附录中二维码报表页面拟删除。", "确认页面删除后是否仍需保留二维码相关数据服务;如保留,需建立接口或数据集。", "12-28 人日", "页面删除是否等于业务下线;历史二维码数据是否迁移;是否还要对外供数。"],
|
||||||
|
["银行清分清点系统", "3.3.2 明确银行无网环境,通过线下报表、手动导入方式对接。", "设计导入模板、字段校验、重复导入控制、失败明细、导入日志、人工确认流程。", "15-32 人日", "银行文件格式、清分日报字段、异常处理、是否需要自动对账。"],
|
||||||
|
["票款清分/胆款系统", "3.3.3 由票务系统导入数据后同步胆款系统,拓展部分待明确。", "设计清分日报同步接口、对账字段、同步状态、失败重试、补偿任务和接口日志。", "15-32 人日", "同步方向、字段口径、触发时点、接口环境、失败重传和对账责任。"],
|
||||||
|
["IC 卡/一卡通系统", "3.3.4;线路、车辆、司售等基础信息来自 IC 卡系统同步。", "建设主数据同步、增量更新、异常补偿、数据映射、同步日志和人工修正入口。", "18-36 人日", "接口文档、同步频率、主数据归属、历史数据映射、异常数据处理。"],
|
||||||
|
["电子签章系统", "功能需求中说明根据业务后续评估再升级,特殊业务提供在线签字确认。", "一期预留签章对象、签章状态、附件归档和业务钩子;正式接口联调可单列。", "8-20 人日", "签章厂商、CA/证书、签章流程、法律效力、附件存储、费用授权。"],
|
||||||
|
], [1.05, 1.35, 1.75, 0.75, 1.6], fill=LIGHT_BLUE, font_size=7.8)
|
||||||
|
heading(doc, "5.5.1 数据湖接口细化设计", 3)
|
||||||
|
add_table(doc, ["设计项", "建议方案", "需要确认"], [
|
||||||
|
["数据目录", "建立票务主数据、库存流水、单据审批、票单结算、充值收入、无人售清分、报表汇总等数据集目录。", "数据湖实际需要哪些主题域,是否需要历史全量数据。"],
|
||||||
|
["同步模式", "支持全量初始化 + 增量同步;增量可按更新时间、业务流水号或同步批次号识别。", "数据湖是主动拉取还是票务系统主动推送。"],
|
||||||
|
["接口形态", "优先 REST/OpenAPI;大批量历史数据可采用文件交换或定时批量导出。", "接口协议、网络通道、文件格式、字符集和压缩方式。"],
|
||||||
|
["安全与加密", "接口鉴权、签名、时间戳、防重放、传输加密、敏感字段脱敏、调用白名单。", "集团安全要求、密钥管理方式、是否要求国密算法。"],
|
||||||
|
["数据质量", "提供记录数、金额合计、张数合计、批次状态、失败明细和重传机制。", "数据湖验收规则、质量阈值、错误处理责任边界。"],
|
||||||
|
["版本管理", "字段字典和接口版本化,新增字段向后兼容,重大变更需变更单。", "数据湖侧是否有统一接口规范和版本发布流程。"],
|
||||||
|
["审计监控", "记录调用方、接口、时间、参数摘要、返回状态、耗时、错误信息和重试次数。", "日志留存周期、审计字段、告警推送方式。"],
|
||||||
|
], [1.1, 3.25, 2.15], fill=LIGHT)
|
||||||
|
heading(doc, "5.6 待定事项与范围冻结", 2)
|
||||||
|
add_table(doc, ["待定事项", "需求文档表述", "影响", "冻结前处理建议"], [
|
||||||
|
["第三方对接模块", "待提供具体接口材料。", "影响接口工作量、排期、联调环境和报价边界。", "把接口文档、样例数据、联调账号、验收规则作为开工前置条件。"],
|
||||||
|
["安全性要求", "待集团补充确认。", "影响账号策略、日志留存、接口加密、部署网络和等保/密评。", "形成安全需求清单,明确是否含等保、密评、信创测评配合。"],
|
||||||
|
["调账管理", "具体业务场景待明确。", "影响库存、金额、报表和审计追溯。", "明确可调对象、审批层级、是否影响历史报表、是否需要会计凭证。"],
|
||||||
|
["特殊票管理", "具体业务场景待明确。", "影响是否纳入库存流水、收入统计和报表。", "明确特殊票类型、领取/核销/结算流程和数据口径。"],
|
||||||
|
["无人售票自动对账", "功能范围中提出新增无人售票自动对账、数据清洗。", "影响银行清分导入、异常处理和对账算法。", "明确银行文件格式、对账口径、容差规则、人工复核流程。"],
|
||||||
|
["电子签章", "后续根据业务评估再升级。", "影响特殊业务闭环和第三方费用。", "一期只做预留还是完整接入需在合同中明确。"],
|
||||||
|
], [1.2, 1.75, 1.65, 1.9], fill=LIGHT, font_size=8)
|
||||||
|
heading(doc, "5.6.1 范围边界与报价口径", 3)
|
||||||
|
add_table(doc, ["事项", "建议纳入一期", "建议单列/待确认", "原因"], [
|
||||||
|
["数据湖接口", "接口框架、字段字典、调用日志、Mock 联调、已确认数据集。", "数据湖侧未确认的数据集、特殊加密、历史全量大批量交换。", "数据范围和集团安全规范会直接影响工作量。"],
|
||||||
|
["二维码系统", "保留必要数据接口占位和历史数据确认。", "若页面删除后仍要求完整二维码统计供数,应单列确认。", "需求中页面拟删除,但 3.3.1 仍提到二维码系统。"],
|
||||||
|
["电子签章", "签章状态、附件字段、业务钩子、归档能力预留。", "完整电子签章厂商联调、CA 授权、法律效力流程。", "需求明确后续评估再升级。"],
|
||||||
|
["等保/密评/信创测评", "基础安全设计、日志审计、国产化适配。", "正式测评整改、测评材料、第三方平台对接。", "需求中安全性待集团补充确认。"],
|
||||||
|
["调账/特殊票", "基础单据与菜单预留。", "完整业务规则、报表影响、会计口径调整。", "原文标注具体业务场景待明确。"],
|
||||||
|
["无人售自动对账", "导入、清分日报、异常记录基础能力。", "自动对账算法、容差规则、异常闭环和清洗策略。", "原文提出新增能力但规则未展开。"],
|
||||||
|
], [1.05, 2.0, 2.0, 1.45], fill=LIGHT_BLUE, font_size=8)
|
||||||
|
add_table(doc, ["序号", "冻结对象", "冻结物", "责任方", "截止节点", "未冻结后果"], [
|
||||||
|
["1", "二维码业务边界", "业务下线/仅删菜单/仍需对外供数三选一书面确认", "集团业务部", "第 1 周", "进入稳妥计划"],
|
||||||
|
["2", "调账管理业务场景", "可调对象、审批层级、是否影响历史报表、是否需会计凭证", "集团财务/业务", "第 2 周", "单列变更"],
|
||||||
|
["3", "特殊票管理业务场景", "特殊票类型、领取/核销/结算流程", "集团业务", "第 2 周", "单列变更"],
|
||||||
|
["4", "银行清分文件格式", "字段、分隔符、字符集、对账容差", "银行/集团清算", "第 3 周", "银行清分项延后"],
|
||||||
|
["5", "安全合规等级", "等保二级/三级、是否含密评/信创测评", "集团信息安全", "第 4 周", "安全整改后置或单列"],
|
||||||
|
["6", "电子签章范围", "一期预留/一期完整接入 + CA 厂商", "集团法务/业务", "第 8 周", "一期仅做预留"],
|
||||||
|
["7", "数据湖数据集与协议", "主题域、推/拉、加密算法、验收样例", "数据湖团队", "第 4 周", "数据湖仅交付框架"],
|
||||||
|
["8", "IC 卡同步主数据归属", "线路/车辆/司售数据源、增量字段", "一卡通公司", "第 4 周", "协同人日上调"],
|
||||||
|
["9", "报表口径样例", "集团/分公司/票务室各类报表旧系统样例数据", "集团业务", "第 6 周", "报表验收延后"],
|
||||||
|
["10", "达梦双套许可", "生产主备 + 开发测试主备", "集团采购", "第 6 周", "联调延期 1-2 周或本地用 H2/MySQL 临时联调"],
|
||||||
|
], [0.35, 1.05, 2.0, 1.0, 0.75, 1.35], fill=LIGHT, font_size=7.5)
|
||||||
|
|
||||||
|
heading(doc, "六、部署与可运维性设计", 1)
|
||||||
|
add_para(doc, "本项目属于票务核心管理系统,部署和运维能力应在一期同步设计,不能等到开发完成后补。运维设计目标是:可部署、可监控、可审计、可备份、可恢复、可灰度、可回滚。")
|
||||||
|
heading(doc, "6.0 基础资源与容量规划", 2)
|
||||||
|
add_table(doc, ["资源项", "原始需求口径", "设计建议"], [
|
||||||
|
["用户规模", "总量 >200 名,峰值 150 名在线。", "按 150 在线用户进行压力测试,报表、导入、结账等高负载场景单独压测。"],
|
||||||
|
["服务器资源", "3 台服务器:2 台数据库服务器,1 台应用服务器;总计 24vCPU、96G 内存。", "基础方案按 1 台应用主机交付;应用双节点仅作为建议位预留或二期可选,不纳入基础资源口径。"],
|
||||||
|
["数据库", "达梦数据库 2 套:生产 1 套主备 + 开发测试 1 套主备。", "达梦许可由甲方采购或在合同中单列;SQL 兼容性需前置验证,重点关注 Oracle 迁移函数、序列、视图和报表 SQL。"],
|
||||||
|
["历史数据", "Oracle 历史数据约 200GB,涉及 79 张表、序列、视图、函数等。", "先做数据体检和迁移映射,历史数据全量迁移后再做增量割接。"],
|
||||||
|
["对象存储", "10 年存储需求约 800-1000G OBS。", "导入文件、导出报表、签章附件、迁移中间文件需制定生命周期和归档策略。"],
|
||||||
|
["网络资源", "业务应用、数据库、数据中心服务器、线网中心内网需互通;涉及 192.168.99.x、10.129.87.x 和外网映射。", "上线前完成网络连通性、端口、白名单、接口访问、文件交换路径和外网 NAT 映射确认。"],
|
||||||
|
], [1.0, 2.25, 3.25], fill=LIGHT_BLUE, font_size=8)
|
||||||
|
callout(doc, "资源与许可边界", "基础部署严格对齐原始需求 3 台服务器口径。应用双节点、双活容灾、第三方运维平台对接、OBS 异地副本属于建议项或二期可选。达梦数据库授权、国产服务器与操作系统、国产中间件与浏览器、电子签章 CA 与硬件介质、等保/密评/信创测评费用,由甲方采购或在合同中单列。")
|
||||||
|
add_table(doc, ["运维领域", "设计要求", "工作量建议"], [
|
||||||
|
["环境规划", "至少规划开发、测试、预生产、生产四类环境;生产采用应用服务与数据库分离部署,数据库主备。", "12-18 人日"],
|
||||||
|
["国产化适配", "适配达梦数据库、国产服务器操作系统、国产中间件或 Nginx/应用服务部署要求,提前验证驱动和 SQL 兼容性。", "20-35 人日"],
|
||||||
|
["配置管理", "数据库连接、文件存储、外部接口、导入目录、日志级别、任务开关全部配置化,避免写死在代码中。", "8-12 人日"],
|
||||||
|
["CI/CD", "建立构建、单元测试、打包、制品归档、部署脚本流程;生产部署需支持版本标记和回滚。", "15-25 人日"],
|
||||||
|
["日志审计", "区分业务操作日志、登录日志、接口日志、导入导出日志、系统错误日志;关键单据状态变化必须可追溯。", "15-25 人日"],
|
||||||
|
["监控告警", "监控应用存活、接口耗时、数据库连接池、慢 SQL、磁盘空间、导入任务、定时任务和异常错误率。", "15-25 人日"],
|
||||||
|
["备份恢复", "数据库全量/增量备份,导入文件和导出附件备份,定期恢复演练,形成恢复时间目标。", "12-20 人日"],
|
||||||
|
["上线回滚", "上线前冻结窗口、迁移脚本、冒烟检查、业务抽样、失败回滚、旧系统只读策略均需预案。", "15-25 人日"],
|
||||||
|
["运维交接", "提供部署手册、配置手册、巡检手册、常见问题、数据恢复手册和接口联调手册。", "10-18 人日"],
|
||||||
|
["外部接口运维", "对数据湖、IC卡、胆款、银行清分导入等接口建立调用日志、失败告警、重试补偿和手工补传工具。", "20-35 人日"],
|
||||||
|
], [1.15, 4.2, 1.15], fill=LIGHT_BLUE)
|
||||||
|
heading(doc, "6.1 运维验收指标建议", 2)
|
||||||
|
add_table(doc, ["指标", "建议值/要求", "说明"], [
|
||||||
|
["RPO", "核心业务数据建议 ≤ 15 分钟;最终以甲方灾备要求和上线前验证为准。", "通过达梦主备同步和定时归档备份兜底;如要求 RPO ≤ 5 分钟需开启同步复制并单独验收。"],
|
||||||
|
["RTO", "普通故障 2 小时内恢复;重大故障按应急预案切换或回滚。", "需在上线前完成恢复演练和回滚演练。"],
|
||||||
|
["日志留存", "操作日志、登录日志、接口日志建议不少于 180 天;审计类日志可按集团要求延长。", "满足操作追溯、接口追踪和安全审计。"],
|
||||||
|
["接口重试", "外部接口失败建议自动重试 3 次,仍失败进入补偿队列并告警。", "适用于数据湖、IC 卡、胆款等接口。"],
|
||||||
|
["导入失败处理", "导入失败必须生成失败明细,可下载、可修正、可重新导入。", "适用于银行清分、无人售、残币、充值等文件导入场景。"],
|
||||||
|
["慢 SQL 阈值", "建议 2 秒以上记录慢 SQL,5 秒以上告警或进入优化清单。", "重点关注报表查询、库存查询和迁移后 SQL。"],
|
||||||
|
["备份恢复演练", "上线前至少 1 次,正式运行后建议每季度 1 次。", "演练结果纳入运维交接材料。"],
|
||||||
|
["生产配置管理", "密钥、数据库连接、接口地址、文件路径、任务开关全部外置配置。", "避免生产环境硬编码和手工改包。"],
|
||||||
|
], [1.0, 2.45, 3.05], fill=LIGHT)
|
||||||
|
heading(doc, "6.2 性能、可靠性与兼容性验收", 2)
|
||||||
|
add_table(doc, ["验收项", "建议指标", "说明"], [
|
||||||
|
["在线用户", "按峰值 150 在线用户设计压测场景。", "覆盖登录、列表查询、库存查询、票单结算、报表查询。"],
|
||||||
|
["普通查询", "常规列表查询建议 2 秒内返回。", "大数据量查询需分页、索引和权限过滤优化。"],
|
||||||
|
["报表查询", "常用日报/月报建议 5-10 秒内返回;复杂历史报表可异步生成。", "高频报表建议使用汇总表或预计算。"],
|
||||||
|
["Excel 导出", "大文件导出使用异步任务,完成后下载,避免浏览器长时间等待。", "需限制单次最大导出量和导出权限。"],
|
||||||
|
["文件导入", "导入任务异步执行,失败明细可下载,可修正后重导。", "适用于银行清分、无人售、残币、充值等模板。"],
|
||||||
|
["可靠性", "关键业务异常必须事务回滚,不产生半库存、半流水状态。", "票据库存、结算、退票、调账为重点。"],
|
||||||
|
["浏览器兼容", "优先支持 Chrome、Edge 及甲方内网指定国产浏览器。", "打印、上传、下载需在目标浏览器验证。"],
|
||||||
|
["打印兼容", "配票单、结算单需验证纸张、页边距、分页和打印机兼容。", "票单打印菜单删除,但打印能力保留到查询/结算入口。"],
|
||||||
|
], [1.05, 2.25, 3.2], fill=LIGHT)
|
||||||
|
add_table(doc, ["压测场景", "建议并发/数据量", "通过判据", "工具建议"], [
|
||||||
|
["登录与菜单加载", "150 在线用户,分批登录", "登录成功率稳定,菜单加载无明显超时", "JMeter/Locust 或甲方指定工具"],
|
||||||
|
["库存查询", "50 并发,按分公司/票务室/票号段查询", "常规查询 2 秒内,大查询分页返回", "JMeter/Locust"],
|
||||||
|
["票单结算", "20 并发,含票号段计算和保存", "事务一致,无半库存/半流水状态", "接口压测 + 场景脚本"],
|
||||||
|
["报表查询", "20 并发,日报/月报/清分报表", "常用报表 5-10 秒内,复杂报表异步生成", "接口压测"],
|
||||||
|
["Excel 导出", "10 并发,大数据量导出", "进入异步任务,不阻塞浏览器请求", "接口压测 + 文件校验"],
|
||||||
|
["文件导入", "5 并发,银行清分/残币/充值模板", "失败明细准确,可修正后重导", "批量导入脚本"],
|
||||||
|
], [1.1, 1.4, 2.55, 1.45], fill=LIGHT_BLUE, font_size=8)
|
||||||
|
add_para(doc, "压测完成后需执行数据一致性核验脚本,至少校验:库存余额等于流水汇总、票号段数量等于起号至止号枚举数量、单据金额等于明细金额汇总。核验结果应纳入性能测试报告。", size=9.5)
|
||||||
|
heading(doc, "6.3 交付物、培训与运维交接", 2)
|
||||||
|
add_table(doc, ["交付类别", "交付物", "说明"], [
|
||||||
|
["需求与设计", "需求矩阵、功能清单、原型、数据库设计、接口设计、报表口径表。", "作为验收范围和变更管理基线。"],
|
||||||
|
["开发交付", "源代码、构建脚本、部署包、数据库脚本、迁移脚本、导入模板、打印模板。", "版本需可追溯,生产发布包需归档。"],
|
||||||
|
["测试交付", "测试用例、接口测试报告、性能测试报告、UAT 问题清单、回归测试报告。", "重点覆盖库存流水、结算、权限、报表、迁移。"],
|
||||||
|
["数据迁移", "迁移方案、字段映射、迁移脚本、数据体检报告、迁移核对报告、割接记录。", "至少三轮迁移演练后上线。"],
|
||||||
|
["运维文档", "部署手册、配置手册、巡检手册、备份恢复手册、接口补偿手册、应急回滚预案。", "交付给甲方运维团队。"],
|
||||||
|
["用户材料", "用户操作手册、管理员手册、培训课件、常见问题清单。", "按集团、分公司、票务室角色分别组织培训。"],
|
||||||
|
["培训安排", "管理员培训、业务操作培训、报表核对培训、运维培训。", "建议至少 2 轮:上线前集中培训,上线后问题复盘培训。"],
|
||||||
|
], [1.05, 2.65, 2.8], fill=LIGHT_BLUE, font_size=8)
|
||||||
|
heading(doc, "6.4 目标环境矩阵", 2)
|
||||||
|
add_table(doc, ["维度", "必测项", "选测项", "备注"], [
|
||||||
|
["浏览器", "Chrome 100+、Edge 100+", "奇安信浏览器、UOS 浏览器、360 安全浏览器", "甲方信创浏览器需提供具体版本。"],
|
||||||
|
["操作系统", "Windows 10/11", "统信 UOS、麒麟 V10", "服务端和客户端分别声明。"],
|
||||||
|
["打印机", "1 款激光 + 1 款针打(甲方提供型号)", "热敏打印机(如有)", "票务打印多用针打,纸张需现场调试。"],
|
||||||
|
["屏幕", "1920×1080 桌面", "1366×768 笔记本、4K 屏", "UI 适配最小宽度建议 1366。"],
|
||||||
|
["网络", "内网 100M/1G", "跨区域内网带宽 ≥ 50M", "报表和导出场景按 50M 估算。"],
|
||||||
|
], [0.85, 1.55, 1.75, 2.35], fill=LIGHT, font_size=8)
|
||||||
|
callout(doc, "部署工作量口径", "部署与运维建设建议单独预留 140-235 人日,其中外部接口运维、失败补偿和告警监控需纳入一期。如果甲方要求等保测评、密评配合、信创测评、双活容灾或第三方运维平台对接,应另行增加专项工作量和报价。")
|
||||||
|
|
||||||
|
heading(doc, "七、详细功能项清单", 1)
|
||||||
|
add_para(doc, "以下清单按需求文档附录逐项展开。正常人日为完整开发、自测、联调配合口径;协同后人日为采用 SDD 方式后的执行估算,不包含甲方等待、接口资料延迟和重大需求变更。")
|
||||||
|
make_landscape(doc)
|
||||||
|
heading(doc, "详细功能项与工作量估算", 1)
|
||||||
|
detail_rows = []
|
||||||
|
for r in rows:
|
||||||
|
detail_rows.append([
|
||||||
|
r["idx"],
|
||||||
|
r["module"],
|
||||||
|
r["page"],
|
||||||
|
r["kind"],
|
||||||
|
r["function"] or "按页面说明",
|
||||||
|
r["dev"],
|
||||||
|
f"{r['lo']}-{r['hi']}",
|
||||||
|
r["vibe_display"],
|
||||||
|
r["detail"],
|
||||||
|
])
|
||||||
|
add_table(
|
||||||
|
doc,
|
||||||
|
["序号", "模块", "功能页面", "类型", "现有功能", "开发内容", "正常人日", "协同后", "难点/备注"],
|
||||||
|
detail_rows,
|
||||||
|
[0.35, 0.95, 1.35, 0.75, 1.25, 2.1, 0.58, 0.58, 1.6],
|
||||||
|
fill=LIGHT_BLUE,
|
||||||
|
font_size=7.2,
|
||||||
|
)
|
||||||
|
|
||||||
|
doc.add_section(WD_SECTION.NEW_PAGE)
|
||||||
|
heading(doc, "八、风险与边界", 1)
|
||||||
|
add_table(doc, ["风险点", "表现", "建议处理"], [
|
||||||
|
["需求边界不清", "文档中存在“待明确”“删除”“整合”项,容易反复变更。", "在开发前形成冻结版功能清单,变更走审批和工期调整。"],
|
||||||
|
["接口资料缺失", "数据湖、二维码、胆款、电子签章等接口材料未完全明确。", "一期只做接口框架和已确认接口,未确认部分单独列二期。"],
|
||||||
|
["报表口径争议", "同一报表集团/分公司/票务室口径不同,数字验收困难。", "先用旧系统样例数据签字确认,再开发。"],
|
||||||
|
["历史数据异常", "旧系统长期运行导致脏数据和状态不一致。", "迁移前做数据体检,异常数据由业务确认处理规则。"],
|
||||||
|
["国产化兼容风险", "达梦 SQL、数据库驱动、国产操作系统、中间件和浏览器兼容问题可能后置暴露。", "开发早期即接入达梦和目标浏览器,迁移函数、序列、视图和报表 SQL 前置验证。"],
|
||||||
|
["网络连通延期", "云平台、数据中心、线网中心、外部接口白名单和端口开通不及时。", "把网络连通、端口、白名单和 NAT 映射列为上线前置条件。"],
|
||||||
|
["安全合规后补", "等保、密评、信创测评要求若后置补充,会影响架构、日志、加密和部署。", "安全等级和测评范围在第 4 周前冻结,正式测评配合单列报价。"],
|
||||||
|
["打印与浏览器兼容", "配票单、结算单在目标浏览器、打印机、纸张和页边距下可能存在偏差。", "上线前用甲方实际浏览器和打印机做专项验证。"],
|
||||||
|
["旧系统并行窗口", "上线割接后旧系统是否只读、并行多久、如何回滚未明确。", "制定割接窗口、只读策略、增量迁移、回滚分支和抽样验收流程。"],
|
||||||
|
["AI 生成代码质量波动", "常规代码效率高,但核心业务可能遗漏隐性规则。", "核心交易类代码必须人工 review,并要求测试覆盖状态流转和金额计算。"],
|
||||||
|
], [1.35, 2.45, 2.7], fill=LIGHT)
|
||||||
|
|
||||||
|
heading(doc, "九、结论", 1)
|
||||||
|
add_para(doc, f"本项目按当前修订口径共 {len(rows)} 个工作条目,已将原需求明确删除/整合的功能按 0-1 人日处理,并合并集团/分公司同名报表。传统完整实施工作量约 {normal_lo}-{normal_hi} 人日,SDD 协同后约 {vibe_lo}-{vibe_hi} 人日;核心业务、外部接口、报表口径、数据迁移和上线割接设置最低保护工时,不随 60% 提效目标等比例压缩。")
|
||||||
|
add_para(doc, "周期建议采用双口径:激进计划为 8-10 人、4-5 个月,前提是需求冻结、接口资料、历史样例数据、达梦许可和联调环境在约定节点全部到位;稳妥计划为 8-10 人、5-6 个月,适用于接口材料补充、报表口径多轮确认或迁移数据质量治理场景。任一关键前置条件未达成,应自动切换至稳妥计划或单列变更。上述协同后人日不含部署与运维建设的 140-235 人日;后者已包含在基础报价中,仅当甲方追加等保/密评/信创测评、双活容灾或第三方运维平台对接时另行增加专项报价。")
|
||||||
|
add_para(doc, "报价建议维持 450-650 万区间,稳妥报价点 520-580 万。该报价不含达梦数据库授权、国产服务器与操作系统、国产中间件、第三方电子签章、CA/硬件介质、等保/密评/信创测评、第三方运维平台和双活容灾等单列项目。效率提升主要压缩周期和重复劳动,不等于按同等比例降低项目责任、质保、联调和验收成本。")
|
||||||
|
|
||||||
|
doc.save(OUT)
|
||||||
|
print(OUT)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
build()
|
||||||
72
outputs/北京公交票务评估/insert_diagrams_into_doc.py
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from docx import Document
|
||||||
|
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
||||||
|
from docx.shared import Inches, Pt
|
||||||
|
|
||||||
|
|
||||||
|
SRC = Path("/Users/mate/Codes/mate/mateclaw/outputs/北京公交票务评估/北京公交集团票务综合管理平台系统升级-SDD实施方案与工作量清单.docx")
|
||||||
|
OUT = Path("/Users/mate/Codes/mate/mateclaw/outputs/北京公交票务评估/北京公交集团票务综合管理平台系统升级-SDD实施方案与工作量清单-含设计图.docx")
|
||||||
|
PNG = Path("/Users/mate/Codes/mate/mateclaw/outputs/北京公交票务评估/svg设计图/png预览")
|
||||||
|
|
||||||
|
|
||||||
|
DIAGRAMS = {
|
||||||
|
"三、SDD 实施方式": ("05-SDD协同交付流程图.png", "图 1 SDD 协同交付流程图"),
|
||||||
|
"四、实施步骤简表": ("07-系统功能地图.png", "图 2 系统功能地图"),
|
||||||
|
"五、关键设计细节": ("02-核心票务业务流转图.png", "图 3 核心票务业务流转图"),
|
||||||
|
"5.2 权限与数据范围": ("04-权限与数据范围设计图.png", "图 4 权限与数据范围设计图"),
|
||||||
|
"六、部署与可运维性设计": ("03-部署与可运维性架构图.png", "图 5 部署与可运维性架构图"),
|
||||||
|
"5.5 数据湖与外部系统对接": ("06-数据湖与外部系统对接架构图.png", "图 6 数据湖与外部系统对接架构图"),
|
||||||
|
"七、详细功能项清单": ("01-总体技术架构图.png", "图 7 总体技术架构图"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def insert_after(paragraph, image_path: Path, caption: str, width=Inches(6.7)):
|
||||||
|
caption_p = paragraph.insert_paragraph_before("")
|
||||||
|
# Move caption and picture after the heading by inserting before next paragraph is not directly
|
||||||
|
# supported in python-docx. Use low-level insertion after heading.
|
||||||
|
pic_p = paragraph._p.addnext(paragraph._p.__class__())
|
||||||
|
|
||||||
|
|
||||||
|
def add_picture_after(paragraph, image_path: Path, caption: str, width=Inches(6.7)):
|
||||||
|
doc = paragraph.part.document
|
||||||
|
p_pic = doc.add_paragraph()
|
||||||
|
p_pic.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||||
|
run = p_pic.add_run()
|
||||||
|
run.add_picture(str(image_path), width=width)
|
||||||
|
p_pic.paragraph_format.space_before = Pt(6)
|
||||||
|
p_pic.paragraph_format.space_after = Pt(3)
|
||||||
|
|
||||||
|
p_cap = doc.add_paragraph()
|
||||||
|
p_cap.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||||
|
p_cap.paragraph_format.space_after = Pt(8)
|
||||||
|
r = p_cap.add_run(caption)
|
||||||
|
r.font.size = Pt(9)
|
||||||
|
r.font.name = "Microsoft YaHei"
|
||||||
|
|
||||||
|
# Move the newly appended picture + caption immediately after the target paragraph.
|
||||||
|
paragraph._p.addnext(p_cap._p)
|
||||||
|
paragraph._p.addnext(p_pic._p)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
doc = Document(SRC)
|
||||||
|
inserted = 0
|
||||||
|
for para in list(doc.paragraphs):
|
||||||
|
text = para.text.strip()
|
||||||
|
if text in DIAGRAMS:
|
||||||
|
file_name, caption = DIAGRAMS[text]
|
||||||
|
image = PNG / file_name
|
||||||
|
if not image.exists():
|
||||||
|
raise FileNotFoundError(image)
|
||||||
|
width = Inches(8.9) if "详细功能项" in text else Inches(6.7)
|
||||||
|
add_picture_after(para, image, caption, width=width)
|
||||||
|
inserted += 1
|
||||||
|
if inserted != len(DIAGRAMS):
|
||||||
|
raise RuntimeError(f"Inserted {inserted}, expected {len(DIAGRAMS)}")
|
||||||
|
doc.save(OUT)
|
||||||
|
print(OUT)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
After Width: | Height: | Size: 511 KiB |
|
After Width: | Height: | Size: 433 KiB |
|
After Width: | Height: | Size: 480 KiB |
121
outputs/北京公交票务评估/svg设计图/01-总体技术架构图.svg
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1440" height="960" viewBox="0 0 1440 960">
|
||||||
|
<defs>
|
||||||
|
<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
|
||||||
|
<feDropShadow dx="0" dy="8" stdDeviation="10" flood-color="#1F2933" flood-opacity="0.12"/>
|
||||||
|
</filter>
|
||||||
|
<marker id="arrow-red" markerWidth="10" markerHeight="10" refX="8" refY="5" orient="auto">
|
||||||
|
<path d="M0,0 L10,5 L0,10 Z" fill="#D71920"/>
|
||||||
|
</marker>
|
||||||
|
<marker id="arrow-gray" markerWidth="10" markerHeight="10" refX="8" refY="5" orient="auto">
|
||||||
|
<path d="M0,0 L10,5 L0,10 Z" fill="#6F7378"/>
|
||||||
|
</marker>
|
||||||
|
<style>
|
||||||
|
.title { font: 700 34px "Microsoft YaHei", "PingFang SC", Arial, sans-serif; fill: #0B2545; }
|
||||||
|
.subtitle { font: 400 17px "Microsoft YaHei", "PingFang SC", Arial, sans-serif; fill: #6F7378; }
|
||||||
|
.h { font: 700 20px "Microsoft YaHei", "PingFang SC", Arial, sans-serif; fill: #0B2545; }
|
||||||
|
.t { font: 400 15px "Microsoft YaHei", "PingFang SC", Arial, sans-serif; fill: #1F2933; }
|
||||||
|
.s { font: 400 13px "Microsoft YaHei", "PingFang SC", Arial, sans-serif; fill: #6F7378; }
|
||||||
|
.tiny { font: 400 12px "Microsoft YaHei", "PingFang SC", Arial, sans-serif; fill: #6F7378; }
|
||||||
|
.card { fill: white; stroke: #D8DEE6; stroke-width: 1.2; filter: url(#shadow); }
|
||||||
|
.band { fill: #F5F7FA; stroke: #D8DEE6; stroke-width: 1; }
|
||||||
|
.redline { stroke: #D71920; stroke-width: 3; fill: none; marker-end: url(#arrow-red); }
|
||||||
|
.grayline { stroke: #6F7378; stroke-width: 2.2; fill: none; marker-end: url(#arrow-gray); }
|
||||||
|
.dash { stroke: #6F7378; stroke-width: 2; stroke-dasharray: 8 8; fill: none; marker-end: url(#arrow-gray); }
|
||||||
|
</style>
|
||||||
|
</defs>
|
||||||
|
<rect width="100%" height="100%" fill="#FFFFFF"/>
|
||||||
|
<path d="M0 0 H1440 V10 H0 Z" fill="#D71920"/>
|
||||||
|
<path d="M0 10 H1440 V15 H0 Z" fill="#6F7378" opacity="0.55"/>
|
||||||
|
<text x="64" y="72" class="title">总体技术架构图</text>
|
||||||
|
<text x="64" y="102" class="subtitle">从用户访问、应用服务、数据存储、外部集成到运维保障的分层架构</text>
|
||||||
|
|
||||||
|
<rect x="58" y="138" width="1324" height="680" rx="18" class="band"/>
|
||||||
|
<text x="92" y="178" class="h">访问与用户层</text>
|
||||||
|
<rect x="88" y="206" width="248" height="138" rx="10" class="card"/>
|
||||||
|
<rect x="88" y="206" width="248" height="8" rx="4" fill="#D71920"/>
|
||||||
|
<text x="110" y="248" class="h">集团用户</text>
|
||||||
|
<text x="110" y="278" class="t">集团管理员 / 操作员</text>
|
||||||
|
<text x="110" y="304" class="t">全局管理、报表、审批</text>
|
||||||
|
<rect x="376" y="206" width="248" height="138" rx="10" class="card"/>
|
||||||
|
<rect x="376" y="206" width="248" height="8" rx="4" fill="#2E86C1"/>
|
||||||
|
<text x="398" y="248" class="h">分公司用户</text>
|
||||||
|
<text x="398" y="278" class="t">分公司管理员 / 操作员</text>
|
||||||
|
<text x="398" y="304" class="t">本公司业务、审核、统计</text>
|
||||||
|
<rect x="664" y="206" width="248" height="138" rx="10" class="card"/>
|
||||||
|
<rect x="664" y="206" width="248" height="8" rx="4" fill="#3FA45B"/>
|
||||||
|
<text x="686" y="248" class="h">票务室用户</text>
|
||||||
|
<text x="686" y="278" class="t">票柜、票袋、票单</text>
|
||||||
|
<text x="686" y="304" class="t">配票、结算、退票</text>
|
||||||
|
<rect x="952" y="206" width="248" height="138" rx="10" class="card"/>
|
||||||
|
<rect x="952" y="206" width="248" height="8" rx="4" fill="#F2C94C"/>
|
||||||
|
<text x="974" y="248" class="h">移动/内网终端</text>
|
||||||
|
<text x="974" y="278" class="t">浏览器访问</text>
|
||||||
|
<text x="974" y="304" class="t">打印、导入、导出</text>
|
||||||
|
|
||||||
|
<path d="M212 356 V410" class="redline"/>
|
||||||
|
<path d="M500 356 V410" class="redline"/>
|
||||||
|
<path d="M788 356 V410" class="redline"/>
|
||||||
|
<path d="M1076 356 V410" class="redline"/>
|
||||||
|
|
||||||
|
<text x="92" y="438" class="h">应用与业务服务层</text>
|
||||||
|
<rect x="88" y="466" width="248" height="168" rx="10" class="card"/>
|
||||||
|
<rect x="88" y="466" width="248" height="8" rx="4" fill="#D71920"/>
|
||||||
|
<text x="110" y="508" class="h">前端应用</text>
|
||||||
|
<text x="110" y="538" class="t">Vue3 / TypeScript</text>
|
||||||
|
<text x="110" y="564" class="t">菜单导航、表单、报表</text>
|
||||||
|
<text x="110" y="590" class="t">打印与导入导出</text>
|
||||||
|
<rect x="376" y="466" width="248" height="168" rx="10" class="card"/>
|
||||||
|
<rect x="376" y="466" width="248" height="8" rx="4" fill="#6F7378"/>
|
||||||
|
<text x="398" y="508" class="h">网关与认证</text>
|
||||||
|
<text x="398" y="538" class="t">统一登录认证</text>
|
||||||
|
<text x="398" y="564" class="t">菜单 / 按钮权限</text>
|
||||||
|
<text x="398" y="590" class="t">数据范围拦截</text>
|
||||||
|
<rect x="664" y="466" width="248" height="168" rx="10" class="card"/>
|
||||||
|
<rect x="664" y="466" width="248" height="8" rx="4" fill="#2E86C1"/>
|
||||||
|
<text x="686" y="508" class="h">业务服务</text>
|
||||||
|
<text x="686" y="538" class="t">票库、票柜、票单</text>
|
||||||
|
<text x="686" y="564" class="t">充值、无人售、退票</text>
|
||||||
|
<text x="686" y="590" class="t">审批与状态机</text>
|
||||||
|
<rect x="952" y="466" width="248" height="168" rx="10" class="card"/>
|
||||||
|
<rect x="952" y="466" width="248" height="8" rx="4" fill="#3FA45B"/>
|
||||||
|
<text x="974" y="508" class="h">报表服务</text>
|
||||||
|
<text x="974" y="538" class="t">日结、月结、趋势</text>
|
||||||
|
<text x="974" y="564" class="t">Excel 导出</text>
|
||||||
|
<text x="974" y="590" class="t">汇总表加速</text>
|
||||||
|
|
||||||
|
<path d="M336 550 H370" class="grayline"/>
|
||||||
|
<path d="M624 550 H658" class="grayline"/>
|
||||||
|
<path d="M912 550 H946" class="grayline"/>
|
||||||
|
<path d="M788 646 V698" class="redline"/>
|
||||||
|
|
||||||
|
<text x="92" y="724" class="h">数据、集成与运维层</text>
|
||||||
|
<rect x="88" y="752" width="220" height="120" rx="10" class="card"/>
|
||||||
|
<rect x="88" y="752" width="220" height="8" rx="4" fill="#D71920"/>
|
||||||
|
<text x="110" y="794" class="h">达梦数据库</text>
|
||||||
|
<text x="110" y="824" class="t">主业务库 / 主备</text>
|
||||||
|
<text x="110" y="850" class="t">库存、流水、报表</text>
|
||||||
|
<rect x="342" y="752" width="220" height="120" rx="10" class="card"/>
|
||||||
|
<rect x="342" y="752" width="220" height="8" rx="4" fill="#6F7378"/>
|
||||||
|
<text x="364" y="794" class="h">Redis / 缓存</text>
|
||||||
|
<text x="364" y="824" class="t">会话、字典、短期任务</text>
|
||||||
|
<text x="364" y="850" class="t">热点查询缓存</text>
|
||||||
|
<rect x="596" y="752" width="220" height="120" rx="10" class="card"/>
|
||||||
|
<rect x="596" y="752" width="220" height="8" rx="4" fill="#F2C94C"/>
|
||||||
|
<text x="618" y="794" class="h">文件与对象存储</text>
|
||||||
|
<text x="618" y="824" class="t">模板、导入文件</text>
|
||||||
|
<text x="618" y="850" class="t">导出报表、附件</text>
|
||||||
|
<rect x="850" y="752" width="220" height="120" rx="10" class="card"/>
|
||||||
|
<rect x="850" y="752" width="220" height="8" rx="4" fill="#2E86C1"/>
|
||||||
|
<text x="872" y="794" class="h">外部系统集成</text>
|
||||||
|
<text x="872" y="824" class="t">IC卡 / 数据湖 / 胆款</text>
|
||||||
|
<text x="872" y="850" class="t">银行清分线下导入</text>
|
||||||
|
<rect x="1104" y="752" width="220" height="120" rx="10" class="card"/>
|
||||||
|
<rect x="1104" y="752" width="220" height="8" rx="4" fill="#3FA45B"/>
|
||||||
|
<text x="1126" y="794" class="h">运维保障</text>
|
||||||
|
<text x="1126" y="824" class="t">日志、监控、备份</text>
|
||||||
|
<text x="1126" y="850" class="t">灰度、回滚、巡检</text>
|
||||||
|
|
||||||
|
<text x="64" y="925" class="tiny">北京公交集团票务综合管理平台系统升级 | SDD(Spec-Driven Development,规范驱动开发)实施方案配套设计图</text>
|
||||||
|
<circle cx="1358" cy="904" r="18" fill="#D71920" opacity="0.95"/>
|
||||||
|
<circle cx="1395" cy="904" r="18" fill="#6F7378" opacity="0.78"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 7.3 KiB |
126
outputs/北京公交票务评估/svg设计图/02-核心票务业务流转图.svg
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1440" height="960" viewBox="0 0 1440 960">
|
||||||
|
<defs>
|
||||||
|
<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
|
||||||
|
<feDropShadow dx="0" dy="8" stdDeviation="10" flood-color="#1F2933" flood-opacity="0.12"/>
|
||||||
|
</filter>
|
||||||
|
<marker id="arrow-red" markerWidth="10" markerHeight="10" refX="8" refY="5" orient="auto">
|
||||||
|
<path d="M0,0 L10,5 L0,10 Z" fill="#D71920"/>
|
||||||
|
</marker>
|
||||||
|
<marker id="arrow-gray" markerWidth="10" markerHeight="10" refX="8" refY="5" orient="auto">
|
||||||
|
<path d="M0,0 L10,5 L0,10 Z" fill="#6F7378"/>
|
||||||
|
</marker>
|
||||||
|
<style>
|
||||||
|
.title { font: 700 34px "Microsoft YaHei", "PingFang SC", Arial, sans-serif; fill: #0B2545; }
|
||||||
|
.subtitle { font: 400 17px "Microsoft YaHei", "PingFang SC", Arial, sans-serif; fill: #6F7378; }
|
||||||
|
.h { font: 700 20px "Microsoft YaHei", "PingFang SC", Arial, sans-serif; fill: #0B2545; }
|
||||||
|
.t { font: 400 15px "Microsoft YaHei", "PingFang SC", Arial, sans-serif; fill: #1F2933; }
|
||||||
|
.s { font: 400 13px "Microsoft YaHei", "PingFang SC", Arial, sans-serif; fill: #6F7378; }
|
||||||
|
.tiny { font: 400 12px "Microsoft YaHei", "PingFang SC", Arial, sans-serif; fill: #6F7378; }
|
||||||
|
.card { fill: white; stroke: #D8DEE6; stroke-width: 1.2; filter: url(#shadow); }
|
||||||
|
.band { fill: #F5F7FA; stroke: #D8DEE6; stroke-width: 1; }
|
||||||
|
.redline { stroke: #D71920; stroke-width: 3; fill: none; marker-end: url(#arrow-red); }
|
||||||
|
.grayline { stroke: #6F7378; stroke-width: 2.2; fill: none; marker-end: url(#arrow-gray); }
|
||||||
|
.dash { stroke: #6F7378; stroke-width: 2; stroke-dasharray: 8 8; fill: none; marker-end: url(#arrow-gray); }
|
||||||
|
</style>
|
||||||
|
</defs>
|
||||||
|
<rect width="100%" height="100%" fill="#FFFFFF"/>
|
||||||
|
<path d="M0 0 H1440 V10 H0 Z" fill="#D71920"/>
|
||||||
|
<path d="M0 10 H1440 V15 H0 Z" fill="#6F7378" opacity="0.55"/>
|
||||||
|
<text x="64" y="72" class="title">核心票务业务流转图</text>
|
||||||
|
<text x="64" y="102" class="subtitle">围绕票库、票柜、票袋、票单、结算、退票和报表的主业务闭环</text>
|
||||||
|
|
||||||
|
<rect x="74" y="150" width="1290" height="700" rx="18" class="band"/>
|
||||||
|
<rect x="104" y="190" width="220" height="126" rx="10" class="card"/>
|
||||||
|
<rect x="104" y="190" width="220" height="8" rx="4" fill="#D71920"/>
|
||||||
|
<text x="126" y="232" class="h">1. 印制入库</text>
|
||||||
|
<text x="126" y="262" class="t">分公司发起印制申请</text>
|
||||||
|
<text x="126" y="288" class="t">集团审核</text>
|
||||||
|
<text x="126" y="314" class="t">确认后进入票库</text>
|
||||||
|
<rect x="384" y="190" width="220" height="126" rx="10" class="card"/>
|
||||||
|
<rect x="384" y="190" width="220" height="8" rx="4" fill="#6F7378"/>
|
||||||
|
<text x="406" y="232" class="h">2. 票库管理</text>
|
||||||
|
<text x="406" y="262" class="t">票号段入库</text>
|
||||||
|
<text x="406" y="288" class="t">库存余额与流水</text>
|
||||||
|
<text x="406" y="314" class="t">调拨 / 核销 / 调账</text>
|
||||||
|
<rect x="664" y="190" width="220" height="126" rx="10" class="card"/>
|
||||||
|
<rect x="664" y="190" width="220" height="8" rx="4" fill="#2E86C1"/>
|
||||||
|
<text x="686" y="232" class="h">3. 配票申请</text>
|
||||||
|
<text x="686" y="262" class="t">票务室申请</text>
|
||||||
|
<text x="686" y="288" class="t">分公司确认</text>
|
||||||
|
<text x="686" y="314" class="t">票库到票柜</text>
|
||||||
|
<rect x="944" y="190" width="220" height="126" rx="10" class="card"/>
|
||||||
|
<rect x="944" y="190" width="220" height="8" rx="4" fill="#3FA45B"/>
|
||||||
|
<text x="966" y="232" class="h">4. 票柜库存</text>
|
||||||
|
<text x="966" y="262" class="t">票务室库存</text>
|
||||||
|
<text x="966" y="288" class="t">入库 / 出库查询</text>
|
||||||
|
<text x="966" y="314" class="t">票务室调票</text>
|
||||||
|
|
||||||
|
<path d="M324 253 H378" class="redline"/>
|
||||||
|
<path d="M604 253 H658" class="redline"/>
|
||||||
|
<path d="M884 253 H938" class="redline"/>
|
||||||
|
|
||||||
|
<rect x="104" y="400" width="220" height="126" rx="10" class="card"/>
|
||||||
|
<rect x="104" y="400" width="220" height="8" rx="4" fill="#3FA45B"/>
|
||||||
|
<text x="126" y="442" class="h">5. 票袋管理</text>
|
||||||
|
<text x="126" y="472" class="t">绑定线路、售票员</text>
|
||||||
|
<text x="126" y="498" class="t">更换线路</text>
|
||||||
|
<text x="126" y="524" class="t">票袋库存归属</text>
|
||||||
|
<rect x="384" y="400" width="220" height="126" rx="10" class="card"/>
|
||||||
|
<rect x="384" y="400" width="220" height="8" rx="4" fill="#D71920"/>
|
||||||
|
<text x="406" y="442" class="h">6. 票单配票</text>
|
||||||
|
<text x="406" y="472" class="t">加载票袋数据</text>
|
||||||
|
<text x="406" y="498" class="t">录入起号止号</text>
|
||||||
|
<text x="406" y="524" class="t">票柜出库到票袋</text>
|
||||||
|
<rect x="664" y="400" width="220" height="126" rx="10" class="card"/>
|
||||||
|
<rect x="664" y="400" width="220" height="8" rx="4" fill="#2E86C1"/>
|
||||||
|
<text x="686" y="442" class="h">7. 票单结算</text>
|
||||||
|
<text x="686" y="472" class="t">录入剩余票号</text>
|
||||||
|
<text x="686" y="498" class="t">自动计算张数金额</text>
|
||||||
|
<text x="686" y="524" class="t">生成结算记录</text>
|
||||||
|
<rect x="944" y="400" width="220" height="126" rx="10" class="card"/>
|
||||||
|
<rect x="944" y="400" width="220" height="8" rx="4" fill="#6F7378"/>
|
||||||
|
<text x="966" y="442" class="h">8. 票单查询</text>
|
||||||
|
<text x="966" y="472" class="t">配票单 / 结算单</text>
|
||||||
|
<text x="966" y="498" class="t">打印、导出</text>
|
||||||
|
<text x="966" y="524" class="t">重新结算入口</text>
|
||||||
|
|
||||||
|
<path d="M1054 328 C1054 362 260 362 214 394" class="grayline"/>
|
||||||
|
<path d="M324 463 H378" class="redline"/>
|
||||||
|
<path d="M604 463 H658" class="redline"/>
|
||||||
|
<path d="M884 463 H938" class="redline"/>
|
||||||
|
|
||||||
|
<rect x="104" y="610" width="220" height="126" rx="10" class="card"/>
|
||||||
|
<rect x="104" y="610" width="220" height="8" rx="4" fill="#B63A2E"/>
|
||||||
|
<text x="126" y="652" class="h">9. 退票处理</text>
|
||||||
|
<text x="126" y="682" class="t">票袋退票</text>
|
||||||
|
<text x="126" y="708" class="t">票柜退票</text>
|
||||||
|
<text x="126" y="734" class="t">库存回写</text>
|
||||||
|
<rect x="384" y="610" width="220" height="126" rx="10" class="card"/>
|
||||||
|
<rect x="384" y="610" width="220" height="8" rx="4" fill="#F2C94C"/>
|
||||||
|
<text x="406" y="652" class="h">10. 无人售/充值</text>
|
||||||
|
<text x="406" y="682" class="t">无人售线路结算</text>
|
||||||
|
<text x="406" y="708" class="t">IC卡库存与收入</text>
|
||||||
|
<text x="406" y="734" class="t">导入与异常处理</text>
|
||||||
|
<rect x="664" y="610" width="220" height="126" rx="10" class="card"/>
|
||||||
|
<rect x="664" y="610" width="220" height="8" rx="4" fill="#2E86C1"/>
|
||||||
|
<text x="686" y="652" class="h">11. 报表汇总</text>
|
||||||
|
<text x="686" y="682" class="t">日结、月结</text>
|
||||||
|
<text x="686" y="708" class="t">收入、清分、趋势</text>
|
||||||
|
<text x="686" y="734" class="t">三级数据权限</text>
|
||||||
|
<rect x="944" y="610" width="220" height="126" rx="10" class="card"/>
|
||||||
|
<rect x="944" y="610" width="220" height="8" rx="4" fill="#6F7378"/>
|
||||||
|
<text x="966" y="652" class="h">12. 审计追溯</text>
|
||||||
|
<text x="966" y="682" class="t">库存流水</text>
|
||||||
|
<text x="966" y="708" class="t">操作日志</text>
|
||||||
|
<text x="966" y="734" class="t">票号查询</text>
|
||||||
|
|
||||||
|
<path d="M214 538 V604" class="grayline"/>
|
||||||
|
<path d="M774 538 V604" class="redline"/>
|
||||||
|
<path d="M884 673 H938" class="grayline"/>
|
||||||
|
<path d="M1054 598 C1054 568 1054 560 1054 538" class="dash"/>
|
||||||
|
<text x="1178" y="557" class="s">异常更正 / 重新结算</text>
|
||||||
|
|
||||||
|
<text x="64" y="925" class="tiny">北京公交集团票务综合管理平台系统升级 | SDD(Spec-Driven Development,规范驱动开发)实施方案配套设计图</text>
|
||||||
|
<circle cx="1358" cy="904" r="18" fill="#D71920" opacity="0.95"/>
|
||||||
|
<circle cx="1395" cy="904" r="18" fill="#6F7378" opacity="0.78"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 7.3 KiB |
122
outputs/北京公交票务评估/svg设计图/03-部署与可运维性架构图.svg
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1440" height="960" viewBox="0 0 1440 960">
|
||||||
|
<defs>
|
||||||
|
<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
|
||||||
|
<feDropShadow dx="0" dy="8" stdDeviation="10" flood-color="#1F2933" flood-opacity="0.12"/>
|
||||||
|
</filter>
|
||||||
|
<marker id="arrow-red" markerWidth="10" markerHeight="10" refX="8" refY="5" orient="auto">
|
||||||
|
<path d="M0,0 L10,5 L0,10 Z" fill="#D71920"/>
|
||||||
|
</marker>
|
||||||
|
<marker id="arrow-gray" markerWidth="10" markerHeight="10" refX="8" refY="5" orient="auto">
|
||||||
|
<path d="M0,0 L10,5 L0,10 Z" fill="#6F7378"/>
|
||||||
|
</marker>
|
||||||
|
<style>
|
||||||
|
.title { font: 700 34px "Microsoft YaHei", "PingFang SC", Arial, sans-serif; fill: #0B2545; }
|
||||||
|
.subtitle { font: 400 17px "Microsoft YaHei", "PingFang SC", Arial, sans-serif; fill: #6F7378; }
|
||||||
|
.h { font: 700 20px "Microsoft YaHei", "PingFang SC", Arial, sans-serif; fill: #0B2545; }
|
||||||
|
.t { font: 400 15px "Microsoft YaHei", "PingFang SC", Arial, sans-serif; fill: #1F2933; }
|
||||||
|
.s { font: 400 13px "Microsoft YaHei", "PingFang SC", Arial, sans-serif; fill: #6F7378; }
|
||||||
|
.tiny { font: 400 12px "Microsoft YaHei", "PingFang SC", Arial, sans-serif; fill: #6F7378; }
|
||||||
|
.card { fill: white; stroke: #D8DEE6; stroke-width: 1.2; filter: url(#shadow); }
|
||||||
|
.band { fill: #F5F7FA; stroke: #D8DEE6; stroke-width: 1; }
|
||||||
|
.redline { stroke: #D71920; stroke-width: 3; fill: none; marker-end: url(#arrow-red); }
|
||||||
|
.grayline { stroke: #6F7378; stroke-width: 2.2; fill: none; marker-end: url(#arrow-gray); }
|
||||||
|
.dash { stroke: #6F7378; stroke-width: 2; stroke-dasharray: 8 8; fill: none; marker-end: url(#arrow-gray); }
|
||||||
|
</style>
|
||||||
|
</defs>
|
||||||
|
<rect width="100%" height="100%" fill="#FFFFFF"/>
|
||||||
|
<path d="M0 0 H1440 V10 H0 Z" fill="#D71920"/>
|
||||||
|
<path d="M0 10 H1440 V15 H0 Z" fill="#6F7378" opacity="0.55"/>
|
||||||
|
<text x="64" y="72" class="title">部署与可运维性架构图</text>
|
||||||
|
<text x="64" y="102" class="subtitle">面向国产化环境的部署、监控、备份、回滚和运维交接设计</text>
|
||||||
|
|
||||||
|
<rect x="70" y="148" width="1300" height="704" rx="18" class="band"/>
|
||||||
|
<text x="102" y="188" class="h">网络与访问区</text>
|
||||||
|
<rect x="104" y="214" width="230" height="116" rx="10" class="card"/>
|
||||||
|
<rect x="104" y="214" width="230" height="8" rx="4" fill="#D71920"/>
|
||||||
|
<text x="126" y="256" class="h">用户浏览器</text>
|
||||||
|
<text x="126" y="286" class="t">集团 / 分公司 / 票务室</text>
|
||||||
|
<text x="126" y="312" class="t">内网访问、统一认证</text>
|
||||||
|
<rect x="394" y="214" width="230" height="116" rx="10" class="card"/>
|
||||||
|
<rect x="394" y="214" width="230" height="8" rx="4" fill="#6F7378"/>
|
||||||
|
<text x="416" y="256" class="h">Nginx / 网关</text>
|
||||||
|
<text x="416" y="286" class="t">HTTPS 终止</text>
|
||||||
|
<text x="416" y="312" class="t">反向代理、限流</text>
|
||||||
|
<rect x="684" y="214" width="230" height="116" rx="10" class="card"/>
|
||||||
|
<rect x="684" y="214" width="230" height="8" rx="4" fill="#2E86C1"/>
|
||||||
|
<text x="706" y="256" class="h">应用服务 A</text>
|
||||||
|
<text x="706" y="286" class="t">业务 API</text>
|
||||||
|
<text x="706" y="312" class="t">前端静态资源</text>
|
||||||
|
<rect x="974" y="214" width="230" height="116" rx="10" class="card"/>
|
||||||
|
<rect x="974" y="214" width="230" height="8" rx="4" fill="#6F7378"/>
|
||||||
|
<text x="996" y="256" class="h">应用双节点预留</text>
|
||||||
|
<text x="996" y="286" class="t">建议位 / 二期可选</text>
|
||||||
|
<text x="996" y="312" class="t">原需求为 1 台业务主机</text>
|
||||||
|
<path d="M334 272 H388" class="redline"/>
|
||||||
|
<path d="M624 272 H678" class="redline"/>
|
||||||
|
<path d="M914 272 H968" class="grayline"/>
|
||||||
|
|
||||||
|
<text x="102" y="398" class="h">数据与文件区</text>
|
||||||
|
<rect x="104" y="426" width="230" height="128" rx="10" class="card"/>
|
||||||
|
<rect x="104" y="426" width="230" height="8" rx="4" fill="#D71920"/>
|
||||||
|
<text x="126" y="468" class="h">达梦主库</text>
|
||||||
|
<text x="126" y="498" class="t">业务数据写入</text>
|
||||||
|
<text x="126" y="524" class="t">库存与单据主数据</text>
|
||||||
|
<rect x="394" y="426" width="230" height="128" rx="10" class="card"/>
|
||||||
|
<rect x="394" y="426" width="230" height="8" rx="4" fill="#6F7378"/>
|
||||||
|
<text x="416" y="468" class="h">达梦备库</text>
|
||||||
|
<text x="416" y="498" class="t">主备同步</text>
|
||||||
|
<text x="416" y="524" class="t">故障切换准备</text>
|
||||||
|
<rect x="684" y="426" width="230" height="128" rx="10" class="card"/>
|
||||||
|
<rect x="684" y="426" width="230" height="8" rx="4" fill="#3FA45B"/>
|
||||||
|
<text x="706" y="468" class="h">Redis</text>
|
||||||
|
<text x="706" y="498" class="t">会话、缓存</text>
|
||||||
|
<text x="706" y="524" class="t">任务状态、热点字典</text>
|
||||||
|
<rect x="974" y="426" width="230" height="128" rx="10" class="card"/>
|
||||||
|
<rect x="974" y="426" width="230" height="8" rx="4" fill="#F2C94C"/>
|
||||||
|
<text x="996" y="468" class="h">对象存储</text>
|
||||||
|
<text x="996" y="498" class="t">模板、导入文件</text>
|
||||||
|
<text x="996" y="524" class="t">导出报表、附件</text>
|
||||||
|
<rect x="1226" y="426" width="112" height="128" rx="10" fill="#FFFFFF" stroke="#D8DEE6" stroke-dasharray="6 6"/>
|
||||||
|
<text x="1246" y="464" class="s">Oracle</text>
|
||||||
|
<text x="1246" y="488" class="s">旧库</text>
|
||||||
|
<text x="1246" y="512" class="tiny">迁移演练</text>
|
||||||
|
<text x="1246" y="534" class="tiny">回滚备份</text>
|
||||||
|
<path d="M1220 490 H630" class="dash"/>
|
||||||
|
<path d="M334 490 H388" class="grayline"/>
|
||||||
|
<path d="M799 342 V420" class="redline"/>
|
||||||
|
<path d="M1089 342 V420" class="grayline"/>
|
||||||
|
|
||||||
|
<text x="102" y="620" class="h">运维保障区</text>
|
||||||
|
<rect x="104" y="648" width="216" height="124" rx="10" class="card"/>
|
||||||
|
<rect x="104" y="648" width="216" height="8" rx="4" fill="#6F7378"/>
|
||||||
|
<text x="126" y="690" class="h">日志中心</text>
|
||||||
|
<text x="126" y="720" class="t">业务日志、接口日志</text>
|
||||||
|
<text x="126" y="746" class="t">登录与操作审计</text>
|
||||||
|
<rect x="360" y="648" width="216" height="124" rx="10" class="card"/>
|
||||||
|
<rect x="360" y="648" width="216" height="8" rx="4" fill="#D71920"/>
|
||||||
|
<text x="382" y="690" class="h">监控告警</text>
|
||||||
|
<text x="382" y="720" class="t">存活、慢SQL、磁盘</text>
|
||||||
|
<text x="382" y="746" class="t">异常率、任务失败</text>
|
||||||
|
<rect x="616" y="648" width="216" height="124" rx="10" class="card"/>
|
||||||
|
<rect x="616" y="648" width="216" height="8" rx="4" fill="#3FA45B"/>
|
||||||
|
<text x="638" y="690" class="h">备份恢复</text>
|
||||||
|
<text x="638" y="720" class="t">数据库全量/增量</text>
|
||||||
|
<text x="638" y="746" class="t">文件备份、恢复演练</text>
|
||||||
|
<rect x="872" y="648" width="216" height="124" rx="10" class="card"/>
|
||||||
|
<rect x="872" y="648" width="216" height="8" rx="4" fill="#2E86C1"/>
|
||||||
|
<text x="894" y="690" class="h">CI/CD</text>
|
||||||
|
<text x="894" y="720" class="t">构建、测试、制品</text>
|
||||||
|
<text x="894" y="746" class="t">部署、版本标记</text>
|
||||||
|
<rect x="1128" y="648" width="216" height="124" rx="10" class="card"/>
|
||||||
|
<rect x="1128" y="648" width="216" height="8" rx="4" fill="#B63A2E"/>
|
||||||
|
<text x="1150" y="690" class="h">回滚预案</text>
|
||||||
|
<text x="1150" y="720" class="t">旧版本保留</text>
|
||||||
|
<text x="1150" y="746" class="t">割接窗口、冒烟检查</text>
|
||||||
|
<path d="M684 592 C550 610 465 620 468 642" class="dash"/>
|
||||||
|
<path d="M914 592 C790 610 724 622 724 642" class="dash"/>
|
||||||
|
<path d="M624 272 C520 340 475 380 480 420" class="dash"/>
|
||||||
|
|
||||||
|
<text x="64" y="925" class="tiny">北京公交集团票务综合管理平台系统升级 | SDD(Spec-Driven Development,规范驱动开发)实施方案配套设计图</text>
|
||||||
|
<circle cx="1358" cy="904" r="18" fill="#D71920" opacity="0.95"/>
|
||||||
|
<circle cx="1395" cy="904" r="18" fill="#6F7378" opacity="0.78"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 7.4 KiB |
97
outputs/北京公交票务评估/svg设计图/04-权限与数据范围设计图.svg
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1440" height="960" viewBox="0 0 1440 960">
|
||||||
|
<defs>
|
||||||
|
<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
|
||||||
|
<feDropShadow dx="0" dy="8" stdDeviation="10" flood-color="#1F2933" flood-opacity="0.12"/>
|
||||||
|
</filter>
|
||||||
|
<marker id="arrow-red" markerWidth="10" markerHeight="10" refX="8" refY="5" orient="auto">
|
||||||
|
<path d="M0,0 L10,5 L0,10 Z" fill="#D71920"/>
|
||||||
|
</marker>
|
||||||
|
<marker id="arrow-gray" markerWidth="10" markerHeight="10" refX="8" refY="5" orient="auto">
|
||||||
|
<path d="M0,0 L10,5 L0,10 Z" fill="#6F7378"/>
|
||||||
|
</marker>
|
||||||
|
<style>
|
||||||
|
.title { font: 700 34px "Microsoft YaHei", "PingFang SC", Arial, sans-serif; fill: #0B2545; }
|
||||||
|
.subtitle { font: 400 17px "Microsoft YaHei", "PingFang SC", Arial, sans-serif; fill: #6F7378; }
|
||||||
|
.h { font: 700 20px "Microsoft YaHei", "PingFang SC", Arial, sans-serif; fill: #0B2545; }
|
||||||
|
.t { font: 400 15px "Microsoft YaHei", "PingFang SC", Arial, sans-serif; fill: #1F2933; }
|
||||||
|
.s { font: 400 13px "Microsoft YaHei", "PingFang SC", Arial, sans-serif; fill: #6F7378; }
|
||||||
|
.tiny { font: 400 12px "Microsoft YaHei", "PingFang SC", Arial, sans-serif; fill: #6F7378; }
|
||||||
|
.card { fill: white; stroke: #D8DEE6; stroke-width: 1.2; filter: url(#shadow); }
|
||||||
|
.band { fill: #F5F7FA; stroke: #D8DEE6; stroke-width: 1; }
|
||||||
|
.redline { stroke: #D71920; stroke-width: 3; fill: none; marker-end: url(#arrow-red); }
|
||||||
|
.grayline { stroke: #6F7378; stroke-width: 2.2; fill: none; marker-end: url(#arrow-gray); }
|
||||||
|
.dash { stroke: #6F7378; stroke-width: 2; stroke-dasharray: 8 8; fill: none; marker-end: url(#arrow-gray); }
|
||||||
|
</style>
|
||||||
|
</defs>
|
||||||
|
<rect width="100%" height="100%" fill="#FFFFFF"/>
|
||||||
|
<path d="M0 0 H1440 V10 H0 Z" fill="#D71920"/>
|
||||||
|
<path d="M0 10 H1440 V15 H0 Z" fill="#6F7378" opacity="0.55"/>
|
||||||
|
<text x="64" y="72" class="title">权限与数据范围设计图</text>
|
||||||
|
<text x="64" y="102" class="subtitle">菜单权限、按钮权限、接口权限、数据权限和导出权限的统一控制</text>
|
||||||
|
|
||||||
|
<rect x="70" y="148" width="1300" height="704" rx="18" class="band"/>
|
||||||
|
<rect x="96" y="198" width="240" height="134" rx="10" class="card"/>
|
||||||
|
<rect x="96" y="198" width="240" height="8" rx="4" fill="#D71920"/>
|
||||||
|
<text x="118" y="240" class="h">集团管理员</text>
|
||||||
|
<text x="118" y="270" class="t">配置菜单、角色、用户</text>
|
||||||
|
<text x="118" y="296" class="t">分配分公司数据范围</text>
|
||||||
|
<text x="118" y="322" class="t">查看全集团数据</text>
|
||||||
|
<rect x="396" y="198" width="240" height="134" rx="10" class="card"/>
|
||||||
|
<rect x="396" y="198" width="240" height="8" rx="4" fill="#6F7378"/>
|
||||||
|
<text x="418" y="240" class="h">集团操作员</text>
|
||||||
|
<text x="418" y="270" class="t">按角色使用功能</text>
|
||||||
|
<text x="418" y="296" class="t">查看授权公司数据</text>
|
||||||
|
<text x="418" y="322" class="t">集团级报表</text>
|
||||||
|
<rect x="696" y="198" width="240" height="134" rx="10" class="card"/>
|
||||||
|
<rect x="696" y="198" width="240" height="8" rx="4" fill="#2E86C1"/>
|
||||||
|
<text x="718" y="240" class="h">分公司管理员</text>
|
||||||
|
<text x="718" y="270" class="t">维护本公司用户</text>
|
||||||
|
<text x="718" y="296" class="t">配置票务室权限</text>
|
||||||
|
<text x="718" y="322" class="t">本公司业务管理</text>
|
||||||
|
<rect x="996" y="198" width="240" height="134" rx="10" class="card"/>
|
||||||
|
<rect x="996" y="198" width="240" height="8" rx="4" fill="#3FA45B"/>
|
||||||
|
<text x="1018" y="240" class="h">票务室操作员</text>
|
||||||
|
<text x="1018" y="270" class="t">票柜、票袋、票单</text>
|
||||||
|
<text x="1018" y="296" class="t">结算、退票、导出</text>
|
||||||
|
<text x="1018" y="322" class="t">仅本票务室数据</text>
|
||||||
|
|
||||||
|
<rect x="170" y="414" width="1100" height="100" rx="12" fill="#FFFFFF" stroke="#D8DEE6" filter="url(#shadow)"/>
|
||||||
|
<text x="212" y="456" class="h">统一权限拦截层</text>
|
||||||
|
<text x="212" y="486" class="t">菜单权限 → 按钮权限 → 接口权限 → 数据范围权限 → 导出/打印权限</text>
|
||||||
|
<path d="M216 344 V408" class="redline"/>
|
||||||
|
<path d="M516 344 V408" class="grayline"/>
|
||||||
|
<path d="M816 344 V408" class="grayline"/>
|
||||||
|
<path d="M1116 344 V408" class="grayline"/>
|
||||||
|
|
||||||
|
<rect x="116" y="604" width="230" height="126" rx="10" class="card"/>
|
||||||
|
<rect x="116" y="604" width="230" height="8" rx="4" fill="#D71920"/>
|
||||||
|
<text x="138" y="646" class="h">组织维度</text>
|
||||||
|
<text x="138" y="676" class="t">集团</text>
|
||||||
|
<text x="138" y="702" class="t">分公司</text>
|
||||||
|
<text x="138" y="728" class="t">票务室 / 车队 / 线路</text>
|
||||||
|
<rect x="386" y="604" width="230" height="126" rx="10" class="card"/>
|
||||||
|
<rect x="386" y="604" width="230" height="8" rx="4" fill="#2E86C1"/>
|
||||||
|
<text x="408" y="646" class="h">业务维度</text>
|
||||||
|
<text x="408" y="676" class="t">票库 / 票柜 / 票袋</text>
|
||||||
|
<text x="408" y="702" class="t">充值网点</text>
|
||||||
|
<text x="408" y="728" class="t">无人售线路</text>
|
||||||
|
<rect x="656" y="604" width="230" height="126" rx="10" class="card"/>
|
||||||
|
<rect x="656" y="604" width="230" height="8" rx="4" fill="#3FA45B"/>
|
||||||
|
<text x="678" y="646" class="h">数据维度</text>
|
||||||
|
<text x="678" y="676" class="t">单据、库存、流水</text>
|
||||||
|
<text x="678" y="702" class="t">报表汇总</text>
|
||||||
|
<text x="678" y="728" class="t">历史迁移数据</text>
|
||||||
|
<rect x="926" y="604" width="230" height="126" rx="10" class="card"/>
|
||||||
|
<rect x="926" y="604" width="230" height="8" rx="4" fill="#6F7378"/>
|
||||||
|
<text x="948" y="646" class="h">审计维度</text>
|
||||||
|
<text x="948" y="676" class="t">登录日志</text>
|
||||||
|
<text x="948" y="702" class="t">操作日志</text>
|
||||||
|
<text x="948" y="728" class="t">导入导出日志</text>
|
||||||
|
<path d="M720 524 V598" class="redline"/>
|
||||||
|
<path d="M720 524 C530 548 500 570 500 598" class="grayline"/>
|
||||||
|
<path d="M720 524 C902 548 1040 570 1040 598" class="grayline"/>
|
||||||
|
|
||||||
|
<text x="64" y="925" class="tiny">北京公交集团票务综合管理平台系统升级 | SDD(Spec-Driven Development,规范驱动开发)实施方案配套设计图</text>
|
||||||
|
<circle cx="1358" cy="904" r="18" fill="#D71920" opacity="0.95"/>
|
||||||
|
<circle cx="1395" cy="904" r="18" fill="#6F7378" opacity="0.78"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 6.0 KiB |
98
outputs/北京公交票务评估/svg设计图/05-SDD协同交付流程图.svg
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1440" height="960" viewBox="0 0 1440 960">
|
||||||
|
<defs>
|
||||||
|
<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
|
||||||
|
<feDropShadow dx="0" dy="8" stdDeviation="10" flood-color="#1F2933" flood-opacity="0.12"/>
|
||||||
|
</filter>
|
||||||
|
<marker id="arrow-red" markerWidth="10" markerHeight="10" refX="8" refY="5" orient="auto">
|
||||||
|
<path d="M0,0 L10,5 L0,10 Z" fill="#D71920"/>
|
||||||
|
</marker>
|
||||||
|
<marker id="arrow-gray" markerWidth="10" markerHeight="10" refX="8" refY="5" orient="auto">
|
||||||
|
<path d="M0,0 L10,5 L0,10 Z" fill="#6F7378"/>
|
||||||
|
</marker>
|
||||||
|
<style>
|
||||||
|
.title { font: 700 34px "Microsoft YaHei", "PingFang SC", Arial, sans-serif; fill: #0B2545; }
|
||||||
|
.subtitle { font: 400 17px "Microsoft YaHei", "PingFang SC", Arial, sans-serif; fill: #6F7378; }
|
||||||
|
.h { font: 700 20px "Microsoft YaHei", "PingFang SC", Arial, sans-serif; fill: #0B2545; }
|
||||||
|
.t { font: 400 15px "Microsoft YaHei", "PingFang SC", Arial, sans-serif; fill: #1F2933; }
|
||||||
|
.s { font: 400 13px "Microsoft YaHei", "PingFang SC", Arial, sans-serif; fill: #6F7378; }
|
||||||
|
.tiny { font: 400 12px "Microsoft YaHei", "PingFang SC", Arial, sans-serif; fill: #6F7378; }
|
||||||
|
.card { fill: white; stroke: #D8DEE6; stroke-width: 1.2; filter: url(#shadow); }
|
||||||
|
.band { fill: #F5F7FA; stroke: #D8DEE6; stroke-width: 1; }
|
||||||
|
.redline { stroke: #D71920; stroke-width: 3; fill: none; marker-end: url(#arrow-red); }
|
||||||
|
.grayline { stroke: #6F7378; stroke-width: 2.2; fill: none; marker-end: url(#arrow-gray); }
|
||||||
|
.dash { stroke: #6F7378; stroke-width: 2; stroke-dasharray: 8 8; fill: none; marker-end: url(#arrow-gray); }
|
||||||
|
</style>
|
||||||
|
</defs>
|
||||||
|
<rect width="100%" height="100%" fill="#FFFFFF"/>
|
||||||
|
<path d="M0 0 H1440 V10 H0 Z" fill="#D71920"/>
|
||||||
|
<path d="M0 10 H1440 V15 H0 Z" fill="#6F7378" opacity="0.55"/>
|
||||||
|
<text x="64" y="72" class="title">SDD 协同交付流程图</text>
|
||||||
|
<text x="64" y="102" class="subtitle">把大系统拆成可验证的小任务,形成需求、编码、测试、评审、交付闭环</text>
|
||||||
|
|
||||||
|
<rect x="74" y="148" width="1290" height="704" rx="18" class="band"/>
|
||||||
|
<rect x="102" y="202" width="230" height="128" rx="10" class="card"/>
|
||||||
|
<rect x="102" y="202" width="230" height="8" rx="4" fill="#D71920"/>
|
||||||
|
<text x="124" y="244" class="h">1. 需求卡片化</text>
|
||||||
|
<text x="124" y="274" class="t">按页面/动作拆分</text>
|
||||||
|
<text x="124" y="300" class="t">角色、输入、输出</text>
|
||||||
|
<text x="124" y="326" class="t">验收标准</text>
|
||||||
|
<rect x="382" y="202" width="230" height="128" rx="10" class="card"/>
|
||||||
|
<rect x="382" y="202" width="230" height="8" rx="4" fill="#6F7378"/>
|
||||||
|
<text x="404" y="244" class="h">2. 领域建模</text>
|
||||||
|
<text x="404" y="274" class="t">状态机、票号段</text>
|
||||||
|
<text x="404" y="300" class="t">库存流水、报表口径</text>
|
||||||
|
<text x="404" y="326" class="t">人工确认</text>
|
||||||
|
<rect x="662" y="202" width="230" height="128" rx="10" class="card"/>
|
||||||
|
<rect x="662" y="202" width="230" height="8" rx="4" fill="#2E86C1"/>
|
||||||
|
<text x="684" y="244" class="h">3. 任务生成</text>
|
||||||
|
<text x="684" y="274" class="t">表结构、接口</text>
|
||||||
|
<text x="684" y="300" class="t">页面、测试</text>
|
||||||
|
<text x="684" y="326" class="t">小批量执行</text>
|
||||||
|
<rect x="942" y="202" width="230" height="128" rx="10" class="card"/>
|
||||||
|
<rect x="942" y="202" width="230" height="8" rx="4" fill="#3FA45B"/>
|
||||||
|
<text x="964" y="244" class="h">4. 编码实现</text>
|
||||||
|
<text x="964" y="274" class="t">前后端协同</text>
|
||||||
|
<text x="964" y="300" class="t">导入导出</text>
|
||||||
|
<text x="964" y="326" class="t">权限与日志</text>
|
||||||
|
<path d="M332 266 H376" class="redline"/>
|
||||||
|
<path d="M612 266 H656" class="redline"/>
|
||||||
|
<path d="M892 266 H936" class="redline"/>
|
||||||
|
|
||||||
|
<rect x="102" y="470" width="230" height="128" rx="10" class="card"/>
|
||||||
|
<rect x="102" y="470" width="230" height="8" rx="4" fill="#B63A2E"/>
|
||||||
|
<text x="124" y="512" class="h">8. 模块验收</text>
|
||||||
|
<text x="124" y="542" class="t">业务演示</text>
|
||||||
|
<text x="124" y="568" class="t">样例数据核对</text>
|
||||||
|
<text x="124" y="594" class="t">问题闭环</text>
|
||||||
|
<rect x="382" y="470" width="230" height="128" rx="10" class="card"/>
|
||||||
|
<rect x="382" y="470" width="230" height="8" rx="4" fill="#6F7378"/>
|
||||||
|
<text x="404" y="512" class="h">7. 人工评审</text>
|
||||||
|
<text x="404" y="542" class="t">核心规则 review</text>
|
||||||
|
<text x="404" y="568" class="t">安全与权限检查</text>
|
||||||
|
<text x="404" y="594" class="t">SQL 与事务检查</text>
|
||||||
|
<rect x="662" y="470" width="230" height="128" rx="10" class="card"/>
|
||||||
|
<rect x="662" y="470" width="230" height="8" rx="4" fill="#2E86C1"/>
|
||||||
|
<text x="684" y="512" class="h">6. 自动化测试</text>
|
||||||
|
<text x="684" y="542" class="t">单元 / 接口</text>
|
||||||
|
<text x="684" y="568" class="t">场景 / 回归</text>
|
||||||
|
<text x="684" y="594" class="t">构建检查</text>
|
||||||
|
<rect x="942" y="470" width="230" height="128" rx="10" class="card"/>
|
||||||
|
<rect x="942" y="470" width="230" height="8" rx="4" fill="#3FA45B"/>
|
||||||
|
<text x="964" y="512" class="h">5. 本地自测</text>
|
||||||
|
<text x="964" y="542" class="t">页面联调</text>
|
||||||
|
<text x="964" y="568" class="t">异常分支</text>
|
||||||
|
<text x="964" y="594" class="t">导入导出校验</text>
|
||||||
|
<path d="M1057 342 V464" class="redline"/>
|
||||||
|
<path d="M942 534 H898" class="grayline"/>
|
||||||
|
<path d="M662 534 H618" class="grayline"/>
|
||||||
|
<path d="M382 534 H338" class="grayline"/>
|
||||||
|
|
||||||
|
<rect x="208" y="690" width="944" height="74" rx="12" fill="#FFFFFF" stroke="#D8DEE6" filter="url(#shadow)"/>
|
||||||
|
<text x="238" y="726" class="h">交付原则</text>
|
||||||
|
<text x="362" y="724" class="t">小任务、强测试、人工把关、持续演示;常规功能提效,核心规则不省评审。</text>
|
||||||
|
<path d="M217 608 C217 670 678 650 678 684" class="dash"/>
|
||||||
|
|
||||||
|
<text x="64" y="925" class="tiny">北京公交集团票务综合管理平台系统升级 | SDD(Spec-Driven Development,规范驱动开发)实施方案配套设计图</text>
|
||||||
|
<circle cx="1358" cy="904" r="18" fill="#D71920" opacity="0.95"/>
|
||||||
|
<circle cx="1395" cy="904" r="18" fill="#6F7378" opacity="0.78"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 5.9 KiB |
123
outputs/北京公交票务评估/svg设计图/06-数据湖与外部系统对接架构图.svg
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1440" height="960" viewBox="0 0 1440 960">
|
||||||
|
<defs>
|
||||||
|
<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
|
||||||
|
<feDropShadow dx="0" dy="8" stdDeviation="10" flood-color="#1F2933" flood-opacity="0.12"/>
|
||||||
|
</filter>
|
||||||
|
<marker id="arrow-red" markerWidth="10" markerHeight="10" refX="8" refY="5" orient="auto">
|
||||||
|
<path d="M0,0 L10,5 L0,10 Z" fill="#D71920"/>
|
||||||
|
</marker>
|
||||||
|
<marker id="arrow-gray" markerWidth="10" markerHeight="10" refX="8" refY="5" orient="auto">
|
||||||
|
<path d="M0,0 L10,5 L0,10 Z" fill="#6F7378"/>
|
||||||
|
</marker>
|
||||||
|
<style>
|
||||||
|
.title { font: 700 34px "Microsoft YaHei", "PingFang SC", Arial, sans-serif; fill: #0B2545; }
|
||||||
|
.subtitle { font: 400 17px "Microsoft YaHei", "PingFang SC", Arial, sans-serif; fill: #6F7378; }
|
||||||
|
.h { font: 700 20px "Microsoft YaHei", "PingFang SC", Arial, sans-serif; fill: #0B2545; }
|
||||||
|
.t { font: 400 15px "Microsoft YaHei", "PingFang SC", Arial, sans-serif; fill: #1F2933; }
|
||||||
|
.s { font: 400 13px "Microsoft YaHei", "PingFang SC", Arial, sans-serif; fill: #6F7378; }
|
||||||
|
.tiny { font: 400 12px "Microsoft YaHei", "PingFang SC", Arial, sans-serif; fill: #6F7378; }
|
||||||
|
.card { fill: white; stroke: #D8DEE6; stroke-width: 1.2; filter: url(#shadow); }
|
||||||
|
.band { fill: #F5F7FA; stroke: #D8DEE6; stroke-width: 1; }
|
||||||
|
.redline { stroke: #D71920; stroke-width: 3; fill: none; marker-end: url(#arrow-red); }
|
||||||
|
.grayline { stroke: #6F7378; stroke-width: 2.2; fill: none; marker-end: url(#arrow-gray); }
|
||||||
|
.dash { stroke: #6F7378; stroke-width: 2; stroke-dasharray: 8 8; fill: none; marker-end: url(#arrow-gray); }
|
||||||
|
</style>
|
||||||
|
</defs>
|
||||||
|
<rect width="100%" height="100%" fill="#FFFFFF"/>
|
||||||
|
<path d="M0 0 H1440 V10 H0 Z" fill="#D71920"/>
|
||||||
|
<path d="M0 10 H1440 V15 H0 Z" fill="#6F7378" opacity="0.55"/>
|
||||||
|
<text x="64" y="72" class="title">数据湖与外部系统对接架构图</text>
|
||||||
|
<text x="64" y="102" class="subtitle">明确票务系统向数据湖、二维码、胆款、IC卡和银行清分场景的数据供给边界</text>
|
||||||
|
|
||||||
|
<rect x="72" y="148" width="1292" height="704" rx="18" class="band"/>
|
||||||
|
<text x="104" y="190" class="h">票务系统数据源</text>
|
||||||
|
<rect x="104" y="222" width="222" height="124" rx="10" class="card"/>
|
||||||
|
<rect x="104" y="222" width="222" height="8" rx="4" fill="#D71920"/>
|
||||||
|
<text x="126" y="264" class="h">业务库</text>
|
||||||
|
<text x="126" y="294" class="t">单据、库存、流水</text>
|
||||||
|
<text x="126" y="320" class="t">结算、退票、充值</text>
|
||||||
|
<rect x="370" y="222" width="222" height="124" rx="10" class="card"/>
|
||||||
|
<rect x="370" y="222" width="222" height="8" rx="4" fill="#2E86C1"/>
|
||||||
|
<text x="392" y="264" class="h">报表汇总库</text>
|
||||||
|
<text x="392" y="294" class="t">日结、月结、趋势</text>
|
||||||
|
<text x="392" y="320" class="t">集团 / 分公司 / 票务室</text>
|
||||||
|
<rect x="636" y="222" width="222" height="124" rx="10" class="card"/>
|
||||||
|
<rect x="636" y="222" width="222" height="8" rx="4" fill="#F2C94C"/>
|
||||||
|
<text x="658" y="264" class="h">文件与附件</text>
|
||||||
|
<text x="658" y="294" class="t">导入文件</text>
|
||||||
|
<text x="658" y="320" class="t">导出报表、签章文件</text>
|
||||||
|
|
||||||
|
<text x="104" y="426" class="h">数据服务与交换层</text>
|
||||||
|
<rect x="104" y="458" width="222" height="136" rx="10" class="card"/>
|
||||||
|
<rect x="104" y="458" width="222" height="8" rx="4" fill="#6F7378"/>
|
||||||
|
<text x="126" y="500" class="h">数据目录</text>
|
||||||
|
<text x="126" y="530" class="t">数据集清单</text>
|
||||||
|
<text x="126" y="556" class="t">字段字典</text>
|
||||||
|
<text x="126" y="582" class="t">版本管理</text>
|
||||||
|
<rect x="370" y="458" width="222" height="136" rx="10" class="card"/>
|
||||||
|
<rect x="370" y="458" width="222" height="8" rx="4" fill="#D71920"/>
|
||||||
|
<text x="392" y="500" class="h">接口服务</text>
|
||||||
|
<text x="392" y="530" class="t">REST / 批量文件</text>
|
||||||
|
<text x="392" y="556" class="t">鉴权、限流、加密</text>
|
||||||
|
<rect x="636" y="458" width="222" height="136" rx="10" class="card"/>
|
||||||
|
<rect x="636" y="458" width="222" height="8" rx="4" fill="#3FA45B"/>
|
||||||
|
<text x="658" y="500" class="h">同步任务</text>
|
||||||
|
<text x="658" y="530" class="t">全量 / 增量</text>
|
||||||
|
<text x="658" y="556" class="t">重试、补偿、对账</text>
|
||||||
|
<rect x="902" y="458" width="222" height="136" rx="10" class="card"/>
|
||||||
|
<rect x="902" y="458" width="222" height="8" rx="4" fill="#2E86C1"/>
|
||||||
|
<text x="924" y="500" class="h">接口审计</text>
|
||||||
|
<text x="924" y="530" class="t">调用日志</text>
|
||||||
|
<text x="924" y="556" class="t">失败告警</text>
|
||||||
|
<text x="924" y="582" class="t">手工补传</text>
|
||||||
|
|
||||||
|
<path d="M215 358 V452" class="redline"/>
|
||||||
|
<path d="M481 358 V452" class="redline"/>
|
||||||
|
<path d="M747 358 V452" class="grayline"/>
|
||||||
|
<path d="M326 526 H364" class="grayline"/>
|
||||||
|
<path d="M592 526 H630" class="grayline"/>
|
||||||
|
<path d="M858 526 H896" class="grayline"/>
|
||||||
|
|
||||||
|
<text x="104" y="670" class="h">外部消费与对接对象</text>
|
||||||
|
<rect x="104" y="700" width="188" height="116" rx="10" class="card"/>
|
||||||
|
<rect x="104" y="700" width="188" height="8" rx="4" fill="#D71920"/>
|
||||||
|
<text x="126" y="742" class="h">数据湖</text>
|
||||||
|
<text x="126" y="772" class="t">统一取数</text>
|
||||||
|
<text x="126" y="798" class="t">数据质量校验</text>
|
||||||
|
<rect x="326" y="700" width="188" height="116" rx="10" class="card"/>
|
||||||
|
<rect x="326" y="700" width="188" height="8" rx="4" fill="#6F7378"/>
|
||||||
|
<text x="348" y="742" class="h">二维码系统</text>
|
||||||
|
<text x="348" y="772" class="t">是否保留供数</text>
|
||||||
|
<text x="348" y="798" class="t">历史数据确认</text>
|
||||||
|
<rect x="548" y="700" width="188" height="116" rx="10" class="card"/>
|
||||||
|
<rect x="548" y="700" width="188" height="8" rx="4" fill="#2E86C1"/>
|
||||||
|
<text x="570" y="742" class="h">胆款系统</text>
|
||||||
|
<text x="570" y="772" class="t">清分日报同步</text>
|
||||||
|
<text x="570" y="798" class="t">对账与补偿</text>
|
||||||
|
<rect x="770" y="700" width="188" height="116" rx="10" class="card"/>
|
||||||
|
<rect x="770" y="700" width="188" height="8" rx="4" fill="#3FA45B"/>
|
||||||
|
<text x="792" y="742" class="h">IC卡系统</text>
|
||||||
|
<text x="792" y="772" class="t">主数据同步</text>
|
||||||
|
<text x="792" y="798" class="t">增量补偿</text>
|
||||||
|
<rect x="992" y="700" width="168" height="116" rx="10" class="card"/>
|
||||||
|
<rect x="992" y="700" width="168" height="8" rx="4" fill="#F2C94C"/>
|
||||||
|
<text x="1014" y="742" class="h">银行清分</text>
|
||||||
|
<text x="1014" y="772" class="t">线下文件导入</text>
|
||||||
|
<text x="1014" y="798" class="t">异常明细</text>
|
||||||
|
<rect x="1190" y="700" width="150" height="116" rx="10" class="card"/>
|
||||||
|
<rect x="1190" y="700" width="150" height="8" rx="4" fill="#B63A2E"/>
|
||||||
|
<text x="1212" y="742" class="h">电子签章</text>
|
||||||
|
<text x="1212" y="772" class="t">状态预留</text>
|
||||||
|
<text x="1212" y="798" class="t">签章归档</text>
|
||||||
|
|
||||||
|
<path d="M481 606 C400 638 220 650 198 694" class="redline"/>
|
||||||
|
<path d="M481 606 C455 646 430 666 420 694" class="grayline"/>
|
||||||
|
<path d="M747 606 C705 646 662 666 642 694" class="grayline"/>
|
||||||
|
<path d="M747 606 C820 640 850 662 864 694" class="grayline"/>
|
||||||
|
<path d="M1013 606 C1070 642 1085 666 1086 694" class="grayline"/>
|
||||||
|
<path d="M1013 606 C1180 642 1240 666 1265 694" class="grayline"/>
|
||||||
|
|
||||||
|
<text x="64" y="925" class="tiny">北京公交集团票务综合管理平台系统升级 | SDD(Spec-Driven Development,规范驱动开发)实施方案配套设计图</text>
|
||||||
|
<circle cx="1358" cy="904" r="18" fill="#D71920" opacity="0.95"/>
|
||||||
|
<circle cx="1395" cy="904" r="18" fill="#6F7378" opacity="0.78"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 7.3 KiB |
117
outputs/北京公交票务评估/svg设计图/07-系统功能地图.svg
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1440" height="960" viewBox="0 0 1440 960">
|
||||||
|
<defs>
|
||||||
|
<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
|
||||||
|
<feDropShadow dx="0" dy="8" stdDeviation="10" flood-color="#1F2933" flood-opacity="0.12"/>
|
||||||
|
</filter>
|
||||||
|
<marker id="arrow-red" markerWidth="10" markerHeight="10" refX="8" refY="5" orient="auto">
|
||||||
|
<path d="M0,0 L10,5 L0,10 Z" fill="#D71920"/>
|
||||||
|
</marker>
|
||||||
|
<marker id="arrow-gray" markerWidth="10" markerHeight="10" refX="8" refY="5" orient="auto">
|
||||||
|
<path d="M0,0 L10,5 L0,10 Z" fill="#6F7378"/>
|
||||||
|
</marker>
|
||||||
|
<style>
|
||||||
|
.title { font: 700 34px "Microsoft YaHei", "PingFang SC", Arial, sans-serif; fill: #0B2545; }
|
||||||
|
.subtitle { font: 400 17px "Microsoft YaHei", "PingFang SC", Arial, sans-serif; fill: #6F7378; }
|
||||||
|
.h { font: 700 20px "Microsoft YaHei", "PingFang SC", Arial, sans-serif; fill: #0B2545; }
|
||||||
|
.t { font: 400 15px "Microsoft YaHei", "PingFang SC", Arial, sans-serif; fill: #1F2933; }
|
||||||
|
.s { font: 400 13px "Microsoft YaHei", "PingFang SC", Arial, sans-serif; fill: #6F7378; }
|
||||||
|
.tiny { font: 400 12px "Microsoft YaHei", "PingFang SC", Arial, sans-serif; fill: #6F7378; }
|
||||||
|
.card { fill: white; stroke: #D8DEE6; stroke-width: 1.2; filter: url(#shadow); }
|
||||||
|
.band { fill: #F5F7FA; stroke: #D8DEE6; stroke-width: 1; }
|
||||||
|
.redline { stroke: #D71920; stroke-width: 3; fill: none; marker-end: url(#arrow-red); }
|
||||||
|
.grayline { stroke: #6F7378; stroke-width: 2.2; fill: none; marker-end: url(#arrow-gray); }
|
||||||
|
.dash { stroke: #6F7378; stroke-width: 2; stroke-dasharray: 8 8; fill: none; marker-end: url(#arrow-gray); }
|
||||||
|
</style>
|
||||||
|
</defs>
|
||||||
|
<rect width="100%" height="100%" fill="#FFFFFF"/>
|
||||||
|
<path d="M0 0 H1440 V10 H0 Z" fill="#D71920"/>
|
||||||
|
<path d="M0 10 H1440 V15 H0 Z" fill="#6F7378" opacity="0.55"/>
|
||||||
|
<text x="64" y="72" class="title">系统功能地图</text>
|
||||||
|
<text x="64" y="102" class="subtitle">按集团、分公司、票务室三级用户视角展示业务入口和能力分布</text>
|
||||||
|
|
||||||
|
<rect x="72" y="148" width="1292" height="704" rx="18" class="band"/>
|
||||||
|
<text x="104" y="190" class="h">集团视角</text>
|
||||||
|
<rect x="104" y="220" width="210" height="116" rx="10" class="card"/>
|
||||||
|
<rect x="104" y="220" width="210" height="8" rx="4" fill="#D71920"/>
|
||||||
|
<text x="126" y="262" class="h">集团管理</text>
|
||||||
|
<text x="126" y="292" class="t">用户、权限、菜单</text>
|
||||||
|
<text x="126" y="318" class="t">阈值、日志、配置</text>
|
||||||
|
<rect x="344" y="220" width="210" height="116" rx="10" class="card"/>
|
||||||
|
<rect x="344" y="220" width="210" height="8" rx="4" fill="#6F7378"/>
|
||||||
|
<text x="366" y="262" class="h">集团审批</text>
|
||||||
|
<text x="366" y="292" class="t">印制、调拨、核销</text>
|
||||||
|
<text x="366" y="318" class="t">调账审核</text>
|
||||||
|
<rect x="584" y="220" width="210" height="116" rx="10" class="card"/>
|
||||||
|
<rect x="584" y="220" width="210" height="8" rx="4" fill="#2E86C1"/>
|
||||||
|
<text x="606" y="262" class="h">集团报表</text>
|
||||||
|
<text x="606" y="292" class="t">存售、收入、清分</text>
|
||||||
|
<text x="606" y="318" class="t">趋势、月报</text>
|
||||||
|
<rect x="824" y="220" width="210" height="116" rx="10" class="card"/>
|
||||||
|
<rect x="824" y="220" width="210" height="8" rx="4" fill="#3FA45B"/>
|
||||||
|
<text x="846" y="262" class="h">数据服务</text>
|
||||||
|
<text x="846" y="292" class="t">数据湖、二维码</text>
|
||||||
|
<text x="846" y="318" class="t">接口审计</text>
|
||||||
|
|
||||||
|
<text x="104" y="414" class="h">分公司视角</text>
|
||||||
|
<rect x="104" y="444" width="210" height="116" rx="10" class="card"/>
|
||||||
|
<rect x="104" y="444" width="210" height="8" rx="4" fill="#2E86C1"/>
|
||||||
|
<text x="126" y="486" class="h">基础资料</text>
|
||||||
|
<text x="126" y="516" class="t">票务室、线路</text>
|
||||||
|
<text x="126" y="542" class="t">车辆、司售、票价</text>
|
||||||
|
<rect x="344" y="444" width="210" height="116" rx="10" class="card"/>
|
||||||
|
<rect x="344" y="444" width="210" height="8" rx="4" fill="#D71920"/>
|
||||||
|
<text x="366" y="486" class="h">票库票柜</text>
|
||||||
|
<text x="366" y="516" class="t">印制、调拨、核销</text>
|
||||||
|
<text x="366" y="542" class="t">配票申请、调票</text>
|
||||||
|
<rect x="584" y="444" width="210" height="116" rx="10" class="card"/>
|
||||||
|
<rect x="584" y="444" width="210" height="8" rx="4" fill="#F2C94C"/>
|
||||||
|
<text x="606" y="486" class="h">充值与特殊</text>
|
||||||
|
<text x="606" y="516" class="t">IC卡库存、售出</text>
|
||||||
|
<text x="606" y="542" class="t">异物、大额币、残币</text>
|
||||||
|
<rect x="824" y="444" width="210" height="116" rx="10" class="card"/>
|
||||||
|
<rect x="824" y="444" width="210" height="8" rx="4" fill="#3FA45B"/>
|
||||||
|
<text x="846" y="486" class="h">分公司报表</text>
|
||||||
|
<text x="846" y="516" class="t">日报、月报</text>
|
||||||
|
<text x="846" y="542" class="t">收入核对、导出</text>
|
||||||
|
|
||||||
|
<text x="104" y="638" class="h">票务室视角</text>
|
||||||
|
<rect x="104" y="668" width="210" height="116" rx="10" class="card"/>
|
||||||
|
<rect x="104" y="668" width="210" height="8" rx="4" fill="#3FA45B"/>
|
||||||
|
<text x="126" y="710" class="h">票柜库存</text>
|
||||||
|
<text x="126" y="740" class="t">入库、出库</text>
|
||||||
|
<text x="126" y="766" class="t">库存查询</text>
|
||||||
|
<rect x="344" y="668" width="210" height="116" rx="10" class="card"/>
|
||||||
|
<rect x="344" y="668" width="210" height="8" rx="4" fill="#D71920"/>
|
||||||
|
<text x="366" y="710" class="h">票袋票单</text>
|
||||||
|
<text x="366" y="740" class="t">票袋、配票</text>
|
||||||
|
<text x="366" y="766" class="t">结算、重新结算</text>
|
||||||
|
<rect x="584" y="668" width="210" height="116" rx="10" class="card"/>
|
||||||
|
<rect x="584" y="668" width="210" height="8" rx="4" fill="#6F7378"/>
|
||||||
|
<text x="606" y="710" class="h">退票打印</text>
|
||||||
|
<text x="606" y="740" class="t">票袋/票柜退票</text>
|
||||||
|
<text x="606" y="766" class="t">配票单、结算单</text>
|
||||||
|
<rect x="824" y="668" width="210" height="116" rx="10" class="card"/>
|
||||||
|
<rect x="824" y="668" width="210" height="8" rx="4" fill="#2E86C1"/>
|
||||||
|
<text x="846" y="710" class="h">票务室报表</text>
|
||||||
|
<text x="846" y="740" class="t">日结、存售</text>
|
||||||
|
<text x="846" y="766" class="t">交班、银行登记</text>
|
||||||
|
|
||||||
|
<rect x="1090" y="220" width="190" height="564" rx="12" fill="#FFFFFF" stroke="#D8DEE6" filter="url(#shadow)"/>
|
||||||
|
<rect x="1090" y="220" width="190" height="8" rx="4" fill="#D71920"/>
|
||||||
|
<text x="1116" y="262" class="h">共用能力</text>
|
||||||
|
<text x="1116" y="306" class="t">消息中心</text>
|
||||||
|
<text x="1116" y="338" class="t">阈值预警</text>
|
||||||
|
<text x="1116" y="370" class="t">Excel 导入导出</text>
|
||||||
|
<text x="1116" y="402" class="t">打印模板</text>
|
||||||
|
<text x="1116" y="434" class="t">操作审计</text>
|
||||||
|
<text x="1116" y="466" class="t">数据权限</text>
|
||||||
|
<text x="1116" y="498" class="t">外部接口</text>
|
||||||
|
<text x="1116" y="530" class="t">备份恢复</text>
|
||||||
|
<path d="M1038 278 H1084" class="grayline"/>
|
||||||
|
<path d="M1038 502 H1084" class="grayline"/>
|
||||||
|
<path d="M1038 726 H1084" class="grayline"/>
|
||||||
|
|
||||||
|
<text x="64" y="925" class="tiny">北京公交集团票务综合管理平台系统升级 | SDD(Spec-Driven Development,规范驱动开发)实施方案配套设计图</text>
|
||||||
|
<circle cx="1358" cy="904" r="18" fill="#D71920" opacity="0.95"/>
|
||||||
|
<circle cx="1395" cy="904" r="18" fill="#6F7378" opacity="0.78"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 7.0 KiB |
9
outputs/北京公交票务评估/svg设计图/README.md
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
# SVG 设计图清单
|
||||||
|
|
||||||
|
- 01-总体技术架构图.svg
|
||||||
|
- 02-核心票务业务流转图.svg
|
||||||
|
- 03-部署与可运维性架构图.svg
|
||||||
|
- 04-权限与数据范围设计图.svg
|
||||||
|
- 05-SDD协同交付流程图.svg
|
||||||
|
- 06-数据湖与外部系统对接架构图.svg
|
||||||
|
- 07-系统功能地图.svg
|
||||||
BIN
outputs/北京公交票务评估/svg设计图/png预览/01-总体技术架构图.png
Normal file
|
After Width: | Height: | Size: 169 KiB |
BIN
outputs/北京公交票务评估/svg设计图/png预览/02-核心票务业务流转图.png
Normal file
|
After Width: | Height: | Size: 158 KiB |
BIN
outputs/北京公交票务评估/svg设计图/png预览/03-部署与可运维性架构图.png
Normal file
|
After Width: | Height: | Size: 169 KiB |
BIN
outputs/北京公交票务评估/svg设计图/png预览/04-权限与数据范围设计图.png
Normal file
|
After Width: | Height: | Size: 134 KiB |
BIN
outputs/北京公交票务评估/svg设计图/png预览/05-SDD协同交付流程图.png
Normal file
|
After Width: | Height: | Size: 139 KiB |
BIN
outputs/北京公交票务评估/svg设计图/png预览/06-数据湖与外部系统对接架构图.png
Normal file
|
After Width: | Height: | Size: 174 KiB |
BIN
outputs/北京公交票务评估/svg设计图/png预览/07-系统功能地图.png
Normal file
|
After Width: | Height: | Size: 149 KiB |
BIN
outputs/北京公交票务评估/svg设计图/quicklook/01-总体技术架构图.svg.png
Normal file
|
After Width: | Height: | Size: 279 KiB |