Commit Graph
2125 Commits
Author SHA1 Message Date
Vickey cf2ffb0194 docs: update thing-model design with Phase 1 implementation plan 2026-05-23 08:20:48 +08:00
Vickey c6b70c1665 docs: update point-command.md status to reflect P0-P3 implementation
Correct stale [TODO] markers in §0, §1.3, §3, §4.1, §4.5, §4.7
to [DONE] matching the actual code delivered.
v2026.5.22
2026-05-22 23:49:10 +08:00
Vickey c556c6a0b8 feat: complete point command P0-P2 rebuild — DTO, lock, validation, API
P0: Replace content JSON-string with sealed PointCommandPayload + record DTOs.
P0: Accept write() boolean return, suppress echo on failure.
P1: Return commandId from POST endpoints, add RESTful GET /{commandId}.
P1: Add driver-online check via EntityStateMapper, PointCommandValidator.
P2: Add DeviceLockManager for per-device serialized command execution.
P3: Add POST /list API with pagination, commandId idempotency support.
2026-05-22 23:36:38 +08:00
Vickey 47760888bd feat: unify DeviceStatusEnum and DriverStatusEnum into EntityStatusEnum
- Replace two duplicate enums with single EntityStatusEnum (same index/code)
- Rename DB columns: state_flag→entity_state_flag, state_ext→entity_state_ext
- Add stateDescription field to DeviceStateDTO/DriverStateDTO for diagnostics
- Add description field to DeviceHealthState/DriverHealthState
- UPSERT SQL now writes stateDescription into entity_state_ext on heartbeat
- Remove EntityStateStatus interface (no longer needed with single enum)
- Update MQ layer, heartbeat services, status queries, gRPC, and all tests
2026-05-22 22:35:02 +08:00
pnoker 436b3b01bb feat: refine device and driver health state handling 2026-05-22 18:29:24 +08:00
pnoker 0471bd904e feat: add device health timeout state handling 2026-05-22 16:44:58 +08:00
pnoker 2e75061c70 style: clean up RabbitConstant, fix imports and update docs formatting 2026-05-22 09:16:27 +08:00
pnoker 6b4cefc74d feat(command): add DLX, result receipt, dedup cache, and validation
Phase 2-4 of the point command chain redesign (point-command.md):
- Add DLX (dead letter exchange/queue) for failed commands
- Add result exchange/queue and PointCommandResultReceiver
- Add PointCommandDeadReceiver for DLX message processing
- Add CommandDedupCache (Caffeine 5-min TTL) for driver-side idempotency
- Add PointCommandResultDTO for driver-to-center result receipts
- Persist commands (PENDING->SENT) with commandId/CorrelationData
- Enhance validateCommandScope: device/point enable flag, rwFlag for write
- Add GET /point_command/get_by_command_id query endpoint
- Update PointCommandDTO to carry commandId and tenantId
- Update PointCommandReceiver: dedup check, result sending, bounded retry
2026-05-22 08:56:59 +08:00
pnoker 10b09d2656 feat(data): add point command persistence model and status enums
Phase 2 of the point command chain redesign (point-command.md):
- Add PointCommandStatusEnum (PENDING/SENT/SUCCESS/FAILED/TIMEOUT/EXPIRED/DEAD)
- Add PointCommandSourceEnum (HTTP/GRPC/AGENTIC/SCHEDULED)
- Add PointCommandDO persistence object for dc3_point_command table
- Add PointCommandManager/Impl and PointCommandMapper (MyBatis-Plus)
2026-05-22 08:56:30 +08:00
pnoker 2617565e72 refactor(command): rename PointValueCommand/DeviceCommand to PointCommand
Phase 1 of the point command chain redesign (point-command.md):
- Delete dead code: DriverCommandDTO, DriverCommandTypeEnum
- Rename DeviceCommandDTO to PointCommandDTO (inner: DeviceRead->PointRead, DeviceWrite->PointWrite)
- Rename DeviceCommandTypeEnum to PointCommandTypeEnum
- Rename PointValueCommand* to PointCommand* (Controller, Service, ServiceImpl, Facade, VOs)
- Rename facade methods: dispatchRead->submitRead, dispatchWrite->submitWrite
- Rename HTTP path: /point_value_command -> /point_command
- Rename RabbitMQ exchange/queue/routing: dc3.e.command -> dc3.e.point_command
- Update all config, driver, agentic, and test files to new naming
- Update frontend API paths to /point_command/*
2026-05-22 08:55:51 +08:00
pnoker 8772149298 feat(data): implement RabbitMQ TTL+DLX timeout for driver and device state
Replace Caffeine LocalCacheService + @Scheduled scanner with
lease-based timeout using RabbitMQ TTL + DLX:
- Driver: each heartbeat publishes a 45s delayed check message;
  DriverTimeoutCheckReceiver performs secondary lease_version and
  expire_time verification before marking offline.
- Device: a self-sustaining 10s tick queue triggers batch scanning
  of expired device leases in EntityStateExpiryScanner.
- Remove OfflineExpiryListener and LocalCacheService from state chain.
- Heartbeat services now only write dc3_entity_state (source of truth).
2026-05-22 01:36:14 +08:00
pnoker 439330f3ff refactor(data): align entity state table with lease-based timeout design doc
Rename driver_id to parent_entity_id, ttl_seconds to timeout_seconds.
Add last_state_flag, last_heartbeat_time, last_alarm_id,
timeout_source_flag, and state_ext fields to EntityStateDO.
Add TimeoutSourceFlagEnum for timeout source classification.
2026-05-22 01:35:41 +08:00
pnoker 9007cbc22c style: apply consistent code formatting across all modules and docs 2026-05-22 00:24:22 +08:00
Vickey 0583fbee06 fix(data): harden entity state design and add test coverage
Design fixes:
- EntityStateExpiryScanner: use atomic lambdaUpdate with lease_version
  WHERE condition instead of read-then-write, preventing duplicate
  alarm writes across multiple Data Center instances.
- EntityStateExpiryScanner: add LIMIT 500 to scan query to bound
  memory usage after extended downtime.
- OfflineExpiryListener: atomically mark DB state as offline before
  writing alarm, preventing the scanner from writing a duplicate.
- EntityStateDO: fix Javadoc to reflect actual EntityTypeFlagEnum
  values (DRIVER=3, DEVICE=6).

Tests (32 total, all passing):
- EntityStateExpiryScannerTest: no expired rows, already-offline skip,
  online driver/device expiry with alarm, claim failure when another
  instance wins, multiple expired rows processed in order.
- DriverStateServiceImplTest: null guard, new DB row creation with
  correct fields, lease version increment, status flip alarm trigger.
- DeviceStateServiceImplTest: null guard, custom TTL, lease version
  increment, null driverId defaults to 0, status flip alarm.
- DriverStatusServiceImplTest: empty page, offline when DB missing,
  online from DB, offline when expired, device online/offline counts.
- DeviceStatusServiceImplTest: empty page, offline when DB missing,
  online from DB, offline when expired, profile query.
2026-05-22 00:11:11 +08:00
Vickey 04d0d5342f feat(data): add persistent state lease table for driver/device status
Replace local Caffeine cache as the source of truth for driver and
device online status with dc3_entity_state, a persistent state lease
table backed by PostgreSQL.

- Add EntityStateDO/Mapper/Manager persistence stack.
- Add EntityStateExpiryScanner that scans expire_time every 15 seconds
  with lease_version recheck to avoid stale alarm writes.
- Dual-write heartbeat path: DriverStateServiceImpl and
  DeviceStateServiceImpl now write dc3_entity_state first, then local
  cache.
- All status queries (controllers, gRPC server, local facade,
  SystemHealthServiceImpl) read from dc3_entity_state. Rows missing or
  past expire_time return offline.
- OfflineExpiryListener gains a DB dedup check to skip expiry events
  already handled by the scanner.
- Add @EnableScheduling to DataApplication.
- Add design doc docs/design/entity-state.md with full DDL.

This resolves restart state loss, multi-instance inconsistency, and
races between expiry checks and fresh heartbeats.
v2026.5.21
2026-05-21 23:57:45 +08:00
Vickey e85dd9ba7e feat(alarm): batch rule pipeline with processBatch dispatch 2026-05-21 23:26:44 +08:00
Vickey db14585cb8 refactor: replace manual constructors with @RequiredArgsConstructor
Replace hand-written all-assignment constructors with Lombok
@RequiredArgsConstructor across controllers, services, config classes,
event listeners, and tool classes. No behavioral change.
2026-05-21 23:18:08 +08:00
Vickey a0b392c3aa feat: rule alarm optimization 2026-05-21 21:39:13 +08:00
Vickey c7d927bec4 feat(alarm): hybrid window aggregator and evaluator
Wires the windowed evaluation path that the previous P4 commits set up.
With this commit AVG/MIN/MAX/SUM/COUNT/ALL/ANY rules actually run end-
to-end:

- ConditionEvaluator: utility extracted out of RuleEvaluatorImpl so
  both the LAST path and the windowed path apply identical operator
  semantics. evaluate(condition, value), recoveryConditionOf(...) and
  toBigDecimal(...) are the only public surfaces.

- WindowDataSource: small interface with aggregate() (scalar fold for
  AVG/MIN/MAX/SUM/COUNT) and samples() (raw rows for ALL/ANY).
  AggregateOutcome carries (value, sampleCount) so callers can enforce
  minSamples.

- LocalWindowDataSource: reads WindowSampleBuffer.snapshot(...) and
  folds in Java; AVG uses scale=6 / HALF_UP rounding to match
  PostgreSQL's AVG(numeric). Skips null numValue samples for numeric
  aggregates; COUNT counts every sample.

- RepositoryWindowDataSource: pushes the aggregate to the time-series
  store via RepositoryService.aggregateInWindow / samplesInWindow
  introduced in the previous commit. Only POINT facts are supported.

- HybridWindowDataSource (@Primary): routes by spec.duration —
  duration <= dc3.alarm.window.local-cutoff goes to local, larger
  goes to repository.

- WindowedRuleEvaluator: dispatch by mode. AVG/MIN/MAX/SUM/COUNT pull
  the scalar and feed it to ConditionEvaluator; ALL/ANY pull samples
  and apply the rule's per-sample condition. minSamples gates both
  paths. Recovery uses the synthesized condition (Recovery.operator
  + Recovery.threshold) over the same window, mirroring the LAST path.

- RuleEvaluatorImpl: rewritten to dispatch by parsed WindowSpec. LAST
  stays inline (no data-source plumbing needed); everything else
  delegates to the windowed evaluator. The previous warn-once gate
  for non-LAST modes is removed; invalid window specs (which should
  already be rejected by the save validator) are skipped with a
  single log line.

Tests:
- RuleEvaluatorImplTest is rewritten to pass a mock
  WindowedRuleEvaluator and verify dispatch.
- WindowedRuleEvaluatorTest covers AVG/COUNT/ALL/ANY/recovery paths
  + minSamples gate + LAST defensive guard.
- All 193 tests in dc3-common-data pass.
2026-05-21 21:30:07 +08:00
Vickey 3b01a16ad1 feat(repository): time-windowed point-value aggregation
Long-window alarm rules need a way to fold AVG/MIN/MAX/SUM/COUNT over
a time bracket of dc3_point_value, plus a raw-sample pull for ALL/ANY
where the rule condition has to run per row. The existing
RepositoryService had neither — listHistoryPointValue is count-bound
without a from/to, listPagePointValue only takes a lower bound.

Add two methods on RepositoryService:

- aggregateInWindow(WindowAggregateRequest) -> WindowAggregateResult
  pushes AVG/MIN/MAX/SUM/COUNT to PostgreSQL via a fixed-set <choose>
  in the new mapper XML so the function name can't be a SQL-injection
  vector. Numeric aggregates filter num_value IS NOT NULL to align
  with the partial index already in place; COUNT counts every row in
  the window.
- samplesInWindow(tenantId, deviceId, pointId, from, to)
  pulls (num_value, cal_value, create_time) ordered by create_time.
  Used by ALL/ANY long-window paths — slow by design, the doc warns
  against this combination.

Both live on PointValueMapper + PointValueMapper.xml so a future TSDB
adapter can swap the implementation without touching the alarm side.
PostgresRepositoryServiceImpl wires the calls through and guards
against null arguments. RepositoryStrategyFactoryTest's stub gets the
two new no-op methods so it still satisfies the interface.

Tests: PostgresRepositoryServiceImplWindowTest covers delegation +
null guards. The actual SQL is verified by inspection — adding a real
DAL slice (Testcontainers) for the alarm side is left for later.
2026-05-21 21:23:55 +08:00
Vickey c5fcc0b4fd feat(alarm): in-memory window sample buffer
Short windows (≤ dc3.alarm.window.local-cutoff, default PT5M) need a
side store the rule engine can read to fold AVG/MIN/MAX/SUM/COUNT/
ALL/ANY over recent samples. PointValueLocalCacheService is latest-
only and can't satisfy this, so introduce a dedicated buffer.

Shape:

- WindowSample (record): {numValue, calValue, timestamp}. numValue is
  null for non-numeric payloads — aggregators must filter null.
- WindowSampleKey (record): (tenantId, targetType, entityId). Per-
  entity rather than per-rule so multiple rules on the same entity
  share one time-ordered series.
- WindowSampleBuffer: Caffeine map of key -> ConcurrentLinkedDeque,
  trimmed inline on each append by both retention time
  (local-cutoff) and per-key sample count (max-samples-per-buffer,
  default 1000). Idle keys are evicted by Caffeine after
  buffer-idle-expiry (default 30min).
- AlarmWindowProperties (`@ConfigurationProperties("dc3.alarm.window")`):
  exposes localCutoff / maxBufferKeys / maxSamplesPerBuffer /
  bufferIdleExpiry.

AlarmRuleTriggerServiceImpl.processPointValue now appends to the
buffer before dispatching the fact, so when the engine eventually
reads a snapshot it sees the triggering sample inside the window.

Doesn't connect to the evaluator yet — that's P4-4. P4-3 lands the
long-window repository path next.
2026-05-21 21:10:59 +08:00
Vickey cd94180d2b feat(alarm): parse ISO-8601 window duration and accept all modes at save
Phase 4 lifts the LAST-only gate so users can save AVG/MIN/MAX/SUM/
COUNT/ALL/ANY rules ahead of the runtime evaluator landing. RuleExt's
window block already declares a mode + ISO-8601 duration + minSamples,
but nothing parsed those fields — save validation rejected anything
non-LAST and runtime ignored the duration entirely.

Add three pieces:

- WindowMode enum (dc3-common-model) — typed mode value with case-
  insensitive ofCode(), plus reducesToScalar() / requiresDuration()
  classifiers used by the future evaluator.
- WindowSpec record — parsed view {mode, duration, minSamples, valid,
  reason} with last() / ok() / invalid() factories.
- WindowSpecParser — turns RuleExt.Window into WindowSpec, applying
  the rules consistently (null window = LAST, blank mode = LAST,
  duration parse failure = invalid, non-positive duration on
  aggregation modes = invalid). Future runtime evaluator + this
  validator both call through the same parser.

RuleServiceImpl.validateWindowMode now routes through the parser:
unknown modes, malformed durations, and zero/negative durations on
aggregation modes get UnSupportException at save time. AVG with PT3M
is now accepted at save (the runtime path still no-ops for non-LAST
until the evaluator wiring lands).

Existing rejection tests flipped: AVG with PT3M is now accepted, the
rejection paths are exercised through unknown mode, zero duration,
and malformed duration. WindowSpecParserTest covers parser behavior
in isolation.
2026-05-21 21:06:27 +08:00
Vickey fa6243f011 feat(data): cache notify policy and message templates
RuleNotificationServiceImpl was issuing four DB lookups per alarm
fan-out: notify by id, message by id, enabled-bind list by tenant +
notify, and channel by id. Notify/message/channel/bind rows mutate at
configuration time and are read on every notification, so the read-amp
is significant under sustained alarm load.

Add NotifyConfigCache, a Caffeine-backed wrapper that owns:

- notify (NotifyBO by id)
- message (MessageBO by id)
- channel (NotifyChannelBO by id), with tenant scope enforced at the
  call site so cross-tenant lookups still return null
- bind list (List<NotifyChannelBindBO> by (tenantId, notifyId))

Invalidation:

- NotifyServiceImpl, MessageServiceImpl, NotifyChannelServiceImpl,
  NotifyChannelBindServiceImpl call the matching invalidate hook on
  add/update/delete.
- Bind updates invalidate both old and new (tenantId, notifyId) tuples
  in case the parent notify changes.

RuleNotificationServiceImpl is dramatically slimmed: the four
loadNotify/loadMessage/loadEnabledBinds/loadChannel helpers now route
through the cache, and the previously-injected manager/builder fields
that only existed for those calls are dropped.

Configurable via dc3.alarm.cache.notify.{max-size,ttl-seconds};
defaults hold 5000 entries for 60 seconds across each section.
2026-05-21 20:52:32 +08:00
pnoker 308df030c2 feat(data): batch alarm rule trigger entrypoint
PointValueServiceImpl.save(List) used to do
pointValueBOList.forEach(alarmRuleTriggerService::processPointValue),
forcing every batch caller to know that the trigger is per-fact. With
RuleRegistry now caching candidate rules, the per-fact dispatch is
already cheap, but the call site still leaks the loop.

Add AlarmRuleTriggerService.processPointValues(List<PointValueBO>) so
batch ingest hands off the whole list in one call. The default impl
fans out per-fact internally — rule semantics (firing, recovery, dedup)
are still defined sample-by-sample, so the engine itself stays unchanged.

Tests: PointValueServiceImplTest now verifies the bulk handoff
(times(1) processPointValues), and a new AlarmRuleTriggerServiceImplTest
covers the trigger's per-fact fan-out + invalid-entry filtering for
both the single and batch entrypoints.
2026-05-21 20:39:38 +08:00
pnoker 8404ad3623 feat(data): cache active rules in RuleRegistry
RuleEngineImpl previously hit dc3_rule on every fact via
RuleCandidateLookup + RuleBuilder.buildBOListByDOList. With per-second
point ingestion, that means database QPS = point upload rate.

Add RuleRegistry as a Caffeine-backed cache fronting both the lookup
and the BO conversion. Cache key is (tenantId, alarmTargetTypeFlag,
entityId), which is exactly the dimension RuleCandidateLookup already
filters on, so the cached list is reusable across repeated facts for
the same entity. Cached values are pre-built RuleBOs — no repeat
DO -> BO work either.

Invalidation:
- RuleServiceImpl.add/update/delete drops the per-tenant cache so the
  next evaluation reflects the change.
- A 60-second write TTL is a safety net for missed invalidations
  (e.g., a manual SQL update).

Configurable via dc3.alarm.cache.rule.{max-size,ttl-seconds}; defaults
hold 10000 keys for 60 seconds.
2026-05-21 20:14:40 +08:00
pnoker 2ea09124f7 feat(data): async notify dispatch via RabbitMQ worker
Outbound notification used to call adapter.send synchronously inside
the @Transactional rule pipeline, so a slow webhook or feishu adapter
would stretch the rule transaction and starve the rabbit consumers
that feed it. Move the dispatch onto its own queue:

- New dc3.q.notify.task queue bound to the alarm exchange with routing
  key dc3.r.notify.task.<channel-type>; 24h TTL on the queue caps any
  runaway backlog when an adapter is fully wedged.
- NotifyTaskDTO carries the rendered MessagePayload plus the PENDING
  notify_history row id so the worker can mutate it in place.
- NotifyTaskSender publishes tasks; RuleNotificationServiceImpl writes
  the PENDING row, advances last_notify_time for rate-limit debouncing,
  and hands the task to the sender — no blocking I/O remains in the
  notification entrypoint.
- NotifyWorker consumes the queue, looks up the channel/adapter, and
  stamps the history row with SUCCESS / FAILED / SKIPPED. Failures
  re-publish the task with retryCount++ until MAX_ATTEMPTS (3); the
  history row carries RETRYING in between. We deliberately don't use
  basicNack(requeue=true) — RabbitMQ would put the message back at the
  head of the queue and the worker would tight-loop on it.
- No DLQ wired; terminal FAILED rows are the operator audit trail.

Add tests for both the sender (routing-key shape, history-id required)
and the worker (success/failed/retrying/terminal/skipped paths). Tests
now total 138 in dc3-common-data.
2026-05-21 19:03:04 +08:00
pnoker f2f14558be feat(data): introduce notify task pending state in history writes
dc3_notify_history previously transitioned directly from non-existent
to its terminal status (SUCCESS / FAILED / SKIPPED) at the end of the
synchronous send path. The async dispatch refactor in commit 7 needs
the history row to exist *before* the channel send so the worker has
something to update.

Refactor the writer into three pieces with no external behavior change:

- buildHistory(...) factors out the column population shared by both
  paths.
- persistPendingHistory(...) writes a PENDING row before adapter.send
  so the row id is stable for the subsequent finalize update (and for
  the upcoming worker).
- finalizeHistory(...) updates the same row to SUCCESS / FAILED with
  response_ext / error_message / target.
- historySkipped(...) keeps writing SKIPPED rows directly — those rows
  describe a *decision* to not send, so they have no PENDING phase.

This change keeps existing tests green and isolates the storage shape
needed for the queue-based worker that lands next.
2026-05-21 18:46:13 +08:00
pnoker 80aac4b4bf refactor(data): rename eventId to alarmId across rule/notify domain
The dc3_rule_state.event_id and dc3_notify_history.event_id columns
both reference dc3_entity_alarm.id. The "event" naming was a holdover
from before the entity_alarm table existed; it leaves readers wondering
whether event_id means an event identifier in some other table or the
alarm record id.

Rename the field across the full Java surface so the column semantics
are obvious from the type:

- DO @TableField("event_id") -> @TableField("alarm_id")
- BO/VO/Query Long eventId -> Long alarmId
- RuleFact.eventId -> alarmId; RuleMatchVariables.Snapshot.eventId ->
  alarmId so message templates expose ${alarmId} (no template in the
  seed data references the old name).
- RuleStateLookup.findFiringEventId -> findFiringAlarmId
- AlarmEventRecordServiceImpl, RuleNotificationServiceImpl,
  RuleStateServiceImpl, NotifyHistoryServiceImpl mirror the rename
- Existing tests follow the rename

The schema column rename and the corresponding index renames live in a
separate dc3-docker commit so the database migration can be applied at
the same time as this code rolls out.
2026-05-21 18:42:40 +08:00
pnoker c39fa95fd1 feat(data): persist alarm_level_flag and unify severity source
dc3_entity_alarm.alarm_level_flag was a SMALLINT P0..P3 column wired
into idx_entity_alarm_alert and exposed by EntityAlarmDO, but every
writer (DeviceAlarmServiceImpl / DriverAlarmServiceImpl /
OfflineExpiryListener / AlarmEventRecordServiceImpl) skipped
setAlarmLevelFlag, leaving every alarm at the schema default of P2 and
hiding rule-driven severity from the dashboard query path.

- Add AlarmLevelResolver to map RuleExt.Content.severity ("P0".."P3",
  case-insensitive) into AlarmMessageLevelFlagEnum, falling back when
  the value is blank or unrecognized.
- Wire severity into AlarmEventRecordServiceImpl so rule-driven alarms
  carry their declared level forward to the entity_alarm row.
- Default heartbeat-timeout offline alarms to P1 and device/driver
  self-reported alarms to P2.
- Document on NotifyPolicyEngineImpl.levelAllowed that severity is
  always sourced from the rule, never from message_level — the
  template-level column is for rendering only.

As part of this, fold the existing RuleStateManager lookup in
AlarmEventRecordServiceImpl into RuleStateLookup so the service is
fully testable with Mockito (no MyBatis-Plus chain wrapper inside).
2026-05-21 18:28:41 +08:00
pnoker e2c86f8d70 fix(data): reject non-LAST window modes at evaluation and save time
RuleExt.Window.mode permits LAST/AVG/MIN/MAX/SUM/COUNT/ALL/ANY but only
LAST is implemented; the evaluator previously ignored the mode field
entirely so a rule configured for AVG would silently be evaluated as
LAST.

Reject the unimplemented modes in two places:

- RuleServiceImpl.add/update raises UnSupportException so the user gets
  immediate feedback when saving a rule the engine cannot honor.
- RuleEvaluatorImpl skips matches/recovers (returns false) when the
  rule's window mode is anything other than LAST, with a one-time WARN
  per rule id so repeated facts on a misconfigured rule don't flood
  the logs.

Add WINDOW_MODE_LAST to AlarmConstant so both call sites resolve the
single supported mode through a shared symbol.
2026-05-21 18:19:27 +08:00
pnoker 9531861c44 fix(data): require firing state before evaluating recovery
RuleEngineImpl.evaluate previously emitted recovery matches purely from
the current fact + RuleExt.Recovery condition, with no check that the
rule actually had a FIRING row in dc3_rule_state. After a cold start,
or any time a routine fact happened to satisfy the recovery threshold,
the system would manufacture a phantom RECOVERED state and fire a
recovery notification for a rule that had never been triggered.

Treat recovery as a state transition: only emit RuleMatch.recovery when
a FIRING row exists for the (tenant, rule, target type, entity) tuple.
The fingerprint dimension is intentionally not part of the predicate
since it is only resolved at notification time.

Extract RuleCandidateLookup and RuleStateLookup so the engine can be
unit-tested without standing up MyBatis-Plus chain wrappers.
RuleNotificationServiceImpl.persistRuleState gets a defensive secondary
check on the fingerprinted state row, returning null when the recovery
target is not actually FIRING; the caller short-circuits the channel
fan-out in that case.
2026-05-21 18:08:55 +08:00
pnoker 69afed88a6 fix(data): backfill tenantId on device/driver alarm path
DeviceAlarmServiceImpl / DriverAlarmServiceImpl previously persisted
dc3_entity_alarm rows with tenant_id=0 when the inbound DTO lacked a
tenant id, after which AlarmRuleTriggerService silently dropped the
event because the trigger requires a valid tenant id. The alarm was
recorded in the database but the user never saw a notification.

Backfill the tenant id (and driver id, on the device path) via
DeviceFacade / DriverFacade lookup when the DTO is missing it. If the
upstream metadata also cannot supply a tenant id, drop the alarm at
the entrypoint rather than persist a tenant_id=0 row.

OfflineExpiryListener gets the same hardening so cache-expiry-derived
offline alarms cannot bypass the tenant guard either.
2026-05-21 17:53:01 +08:00
pnoker 2a6619b9a6 fix(driver): harden metadata cache against lost updates and orphan ids
- Switch loadCache from fire-and-forget on ForkJoinPool.commonPool to a
  synchronous Caffeine refresh. gRPC failures now surface as
  ServiceException so the RabbitMQ consumer nack-requeues the event
  instead of acking after a silent drop.
- When the upstream loader returns null, drop the orphan id from
  driverMetadata.deviceIds so the Quartz read scan stops re-fetching
  a device the manager has already deleted.
- Reorder DEVICE ADD/UPDATE (add deviceId before loadCache) and DELETE
  (remove deviceId before invalidateCache) so a Quartz scan racing the
  consumer cannot trigger redundant gRPC loads.
- Guard DriverStatusScheduleJob against driver==null so a DRIVER DELETE
  clear no longer NPEs the heartbeat job.
- Unify getDriverConfig / getPointConfig error returns to Map.of()
  across all three helpers; previously the same "config incomplete"
  case threw, returned empty, or silently filtered depending on which
  overload you hit.
- Extract AbstractMetadataCache base class so the device and point
  caches share one implementation; recordStats and the
  Caffeine-managed loader executor come for free.
- Make cache size, load timeout, and stats recording configurable
  under dc3.driver.metadata.cache.*; defaults preserve current
  behavior (5000 / 5s / off).
2026-05-21 16:13:52 +08:00
pnoker 9fd0034039 refactor(constant,data,driver): consolidate scattered ad-hoc constants
Pull a few constants that were duplicated (or pinning a contract value)
across classes into the canonical homes that the project already uses
for shared values.

- DefaultConstant.DEFAULT_ID = 0L: replaces the private DEFAULT_ID = 0L
  that AlarmEventRecordServiceImpl and RuleNotificationServiceImpl each
  defined themselves to mark "absent / unset Long identifier".
  RuleEngineImpl.GLOBAL_ENTITY_ID stays put — same numeric value but a
  domain-meaningful "rule applies to every entity" sentinel, not a
  missing-id placeholder.
- DataConstant.Health.STATUS_UP / STATUS_DOWN: replaces the private
  UP / DOWN string literals in SystemHealthServiceImpl. These are the
  contract values the dashboard banner reads; SystemHealthVO already
  documents the strings, now they're sourced from one place instead of
  being typed out at every probe.
- MetadataCacheConstants: new utility class colocated with the driver
  metadata caches so DeviceMetadata and PointMetadata share the same
  CACHE_LOAD_TIMEOUT_SECONDS (5L) and MAX_CACHE_SIZE (5000L). Tuning
  one cache without forgetting the other was the bug-shaped bit of the
  previous duplication.
2026-05-21 15:18:23 +08:00
pnoker 524a9cb1de feat(dal): allow filtering groups by position in list query
Add a Position equals predicate to GroupServiceImpl.fuzzyQuery so the
existing GroupQuery.position field is honored when listing groups,
mirroring the other optional filters on the wrapper.
2026-05-21 15:02:18 +08:00
pnoker e38c7d432a feat(driver,manager): broadcast driver metadata events and let drivers refresh on demand
Extends the existing point/device metadata refresh flow to drivers:

- MetadataEvent gains an optional targetServices set so manager-side
  publishers can scope a metadata event to specific driver services
  instead of broadcasting to every listener.
- New DRIVER value on MetadataTypeEnum, plus a new GetById RPC on
  driver_driver.proto so drivers can re-fetch their registered metadata
  without going through the full registration handshake again.
- DriverClient.refreshMetadata(driverId) calls the new RPC and reapplies
  the returned device ids and attribute maps via a shared applyMetadata
  helper.
- MetadataReceiver now handles MetadataTypeEnum.DRIVER:
    DELETE  -> clear DriverMetadata, DeviceMetadata, and PointMetadata
              caches and flip status to OFFLINE
    ADD/UPDATE -> driverClient.refreshMetadata(id)
- Manager-side MetadataEventListener forwards events to RabbitMQ keyed by
  the target services in the event, falling back to broadcast when none
  are specified.
- DeviceServiceImpl, DriverAttributeServiceImpl, PointAttributeServiceImpl,
  and PointServiceImpl populate targetServices using the relevant driver
  service names so changes only wake the drivers that own the affected
  entity.
- DriverDriverServer.getById exposes the new RPC; new DriverDriverServerTest
  covers it.
- MetadataReceiverTest gains driverUpdate / driverDelete cases;
  MetadataEventListenerTest covers the per-service routing.
2026-05-21 15:02:09 +08:00
pnoker 91eef8cbcf refactor(drivers,tests): switch to constructor injection via @RequiredArgsConstructor
Last batch of the cross-module refactor away from @Resource field
injection.

Production code:
- dc3-driver/* (8 driver modules): listening-virtual (4 files: Netty
  handlers + driver custom impl), virtual, mqtt (2), coap (2),
  modbus-tcp, plcs7, opc-da, opc-ua. Fields become private final;
  jakarta.annotation.Resource imports are removed.
- NettyTcpServer/NettyUdpServer pass null to NettyTcpServerHandler /
  NettyUdpServerHandler when adding the manually-created handler to the
  Netty pipeline. Those handler instances are pure stubs that delegate
  to the Spring-managed instance via the existing static self-reference,
  so a null collaborator field on the stub is functionally equivalent to
  the prior no-op default-constructor instance.

Test adaptations:
- 26 tests across dc3-common-mqtt, dc3-common-data, dc3-common-auth,
  dc3-common-manager, dc3-common-facade-{local-manager,local-auth,
  local-data}, and dc3-driver/* swap the no-args + reflection setup for
  a direct constructor call that passes the Lombok-generated parameter
  list. Reflection setters and the `injectField` helper are removed
  where they only existed for refactor-affected fields; reflection
  helpers that touch unrelated state (static factories, private cache
  maps used as test seams) are kept.
- 3 dc3-common-driver tests (DeviceCommandReceiverTest,
  MetadataReceiverTest, DriverRegisterServiceImplTest) get the same
  treatment. MetadataReceiverTest also gains a DriverClient mock to
  match the now-five-arg constructor.
- VirtualDriverCustomServiceImplTest and SessionServiceImplTest drop
  their reflection-only @BeforeEach blocks: with final fields, Mockito
  @InjectMocks now uses constructor injection automatically and matches
  collaborators by type.
- PointServiceImplTest gains a missing DriverService @Mock so that
  Mockito constructor injection can populate every parameter.
2026-05-21 14:57:13 +08:00
pnoker 15ecdadeac refactor(agentic,web,gateway): switch to constructor injection via @RequiredArgsConstructor
Replace @Resource field injection with Lombok @RequiredArgsConstructor
on SessionServiceImpl, WebFilterConfig, and FilterServiceImpl. Fields
become private final, dependencies are now expressed in the
constructor signature, and the jakarta.annotation.Resource import is
removed.

SessionServiceImplTest no longer needs the reflection-based wireResources
@BeforeEach hook: with final fields, Mockito @InjectMocks now uses
constructor injection automatically and matches the four @Mock
collaborators by type.
2026-05-21 14:18:09 +08:00
pnoker 092c63e84f refactor(auth): switch to constructor injection via @RequiredArgsConstructor
Replace @Resource field injection with Lombok @RequiredArgsConstructor
across the dc3-common-auth module: user/role/tenant/menu/api/resource
service implementations, role-resource and role-user binding services,
auth biz services (token, dictionary, resource registry sync, user
password), tenant-bind service, and gRPC servers (User, UserLogin,
Token, Tenant, ResourceRegistry). Fields become private final,
dependencies are now expressed in the constructor signature, and the
jakarta.annotation.Resource import is removed.
2026-05-21 14:08:56 +08:00
pnoker 44d6d845a2 refactor(manager): switch to constructor injection via @RequiredArgsConstructor
Replace @Resource field injection with Lombok @RequiredArgsConstructor
across the dc3-common-manager module: device/driver/point/profile
service implementations, attribute configurators, gRPC servers
(driver-side and manager-side), schedule, dictionary, dashboard,
import service, and metadata event listener. Fields become private
final, dependencies are now expressed in the constructor signature,
and the jakarta.annotation.Resource import is removed.
2026-05-21 12:10:47 +08:00
pnoker 6f6a15e528 refactor(data): switch to constructor injection via @RequiredArgsConstructor
Replace @Resource field injection with Lombok @RequiredArgsConstructor
across the dc3-common-data module: rule engine and alarm services,
notify and message services, repository, schedule jobs, rabbit
receivers, dashboards, and offline expiry listener. Fields become
private final, dependencies are now expressed in the constructor
signature, and the jakarta.annotation.Resource import is removed.

PointValueReceiver, ScheduleForDataServiceImpl, and PointValueJob had
hand-written constructors that took only PointBatchProperties — those
have been collapsed into Lombok-generated @RequiredArgsConstructor that
accepts every dependency in one go.
2026-05-21 12:07:11 +08:00
pnoker d27572f426 refactor(facade): switch to constructor injection via @RequiredArgsConstructor
Replace @Resource field injection with Lombok @RequiredArgsConstructor
across the facade modules: facade-grpc (12 facades), facade-local-manager
(4), facade-local-auth (5), and facade-local-data (3). Fields become
private final, dependencies are now expressed in the constructor
signature, and the jakarta.annotation.Resource import is removed.
2026-05-21 11:59:38 +08:00
pnoker 8f8e2b1c95 refactor(driver): switch to constructor injection via @RequiredArgsConstructor
Replace @Resource field injection with Lombok @RequiredArgsConstructor
across the dc3-common-driver module: gRPC clients, RabbitMQ receivers,
schedule jobs, init runner, register/read/write services, and metadata
caches. Fields become private final, dependencies are now expressed in
the constructor signature, and the jakarta.annotation.Resource import is
removed.

DeviceMetadata keeps a hand-written constructor because its async cache
must be initialized inside the constructor body — the buildAsync lambda
captures deviceClient. The constructor is now public and accepts the two
collaborators as parameters, replacing the prior private no-arg
constructor that relied on field injection.
2026-05-21 11:22:42 +08:00
pnoker 5b5deb63c7 refactor(mqtt): switch to constructor injection via @RequiredArgsConstructor
Replace @Resource field injection with Lombok @RequiredArgsConstructor on
MqttScheduleJob (QuartzJobBean), MqttReceiveHandler, and
MqttScheduleServiceImpl. Fields become private final, dependencies are now
expressed in the constructor signature, and the jakarta.annotation.Resource
import is removed. SpringBeanJobFactory continues to satisfy the new
constructor for the Quartz job.
2026-05-21 10:27:14 +08:00
pnoker 62d8a2d403 refactor(dal): switch to constructor injection via @RequiredArgsConstructor
Replace @Resource field injection with Lombok @RequiredArgsConstructor on
GroupServiceImpl, GroupBindServiceImpl, LabelServiceImpl, and
LabelBindServiceImpl. Fields become private final, dependencies are now
expressed in the constructor signature, and the jakarta.annotation.Resource
import is removed.
2026-05-21 10:10:26 +08:00
pnoker 60cebd179e docs(gateway,api,quartz,repository): improve class-level Javadoc 2026-05-21 01:25:51 +08:00
pnoker 604786b495 docs(exception): add missing class-level Javadoc 2026-05-21 01:25:46 +08:00
pnoker 945f731a29 docs(dal): improve class-level Javadoc descriptions 2026-05-21 01:25:42 +08:00
Vickey b4ef026880 docs(model,constant): improve class-level Javadoc descriptions 2026-05-21 01:25:39 +08:00
Vickey 1c2252fcde docs(agentic): improve class-level Javadoc descriptions 2026-05-21 01:25:35 +08:00