Files
ExperionCrawler/docs/측류추출식-통합유량설정공식-구현코딩-PhaseII.md

515 lines
26 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 측류추출 통합유량 — Phase II UI 구현 코딩 (Tab 18: 설정 + 권장 SP 대시보드)
> **성격**: Phase I advisory 엔진(`...-PhaseI.md`)의 **Web UI 코딩 명세 + 검증 절차**.
> 감독자가 `diagnosis-checklist.md` 8단계로 진단한 뒤 반영. **advisory 불변식 유지** — 제어 레지스터 쓰기 0건.
> Phase II는 **운전원이 경험상수를 공급**하고 **권장 SP를 화면에서 본다**(수동 인가). 자동 쓰기는 Phase III.
**Phase II 범위 분리**:
- **본 문서 = UI 코딩**: ① 설정 CRUD API(admin) ② Tab 18 = 설정 에디터 + 권장 SP 대시보드.
- **Phase II-분석(별도)**: θ 자동튜닝·PCT/차온·front-position·confidence 자동강등·느린 바이어스(= PhaseI §6 P-1~P-5). 본 문서 §6에 인터페이스 훅만.
---
## 0. 기존 UI 아키텍처 전제 (확인됨)
| 요소 | 사실 |
|:-----|:-----|
| 탭 라우터 | `core.js``paneInit` 맵 + `activateTab(tab)``data-src`(`/panes/<tab>.html`) HTML을 fetch해 주입 후 `paneInit[tab]?.()` 호출 |
| 탭 등록 | ① `index.html` `<li class="nav-item" data-tab="ff">``<section class="pane" id="pane-ff" data-src="/panes/ff.html">``<script src="/js/ff.js">``ff.js`에서 `paneInit.ff = ffInit` |
| API 헬퍼 | `core.js` `api(method, path, body)` (camelCase JSON), `esc()` XSS 이스케이프, `fmtVal/fmtTs` |
| admin 인증 | KB 토큰 — 헤더 `X-Kb-Token`, 백엔드 `IKbAuthService.ValidateAsync`, 프론트 `sessionStorage.kbToken` (docs.js 패턴) |
| JSON 바인딩 | `PropertyNamingPolicy=null` + **`PropertyNameCaseInsensitive=true`** → 프론트 camelCase 바디가 PascalCase DTO에 바인딩됨. 응답은 **camelCase 명시 익명객체**(CODING_CONVENTIONS) |
| 라이브 폴링 | trend.js의 `setInterval` 패턴 |
---
## 1. 파일 배치 (신규/변경)
```
변경:
src/Core/Application/Feedforward/IFeedforwardStores.cs # IFeedforwardConfigStore에 CRUD 추가
src/Infrastructure/Control/FeedforwardConfigStore.cs # Save/Delete (파라미터화)
src/Web/Controllers/FeedforwardController.cs # config CRUD(admin) 추가
src/Web/wwwroot/index.html # nav-item + pane + script 3줄
신규:
src/Web/wwwroot/panes/ff.html # 대시보드 + 설정 에디터 마크업
src/Web/wwwroot/js/ff.js # paneInit.ff — 폴링·렌더·에디터
src/Web/wwwroot/css/ff.css # (또는 style.css에 추가)
```
> Phase I과 동일하게 **단일 csproj**(`src/Web/ExperionCrawler.csproj`). C# 빌드: `dotnet build src/Web/ExperionCrawler.csproj`.
---
## 2. 백엔드 — 설정 CRUD (admin)
### 2.1 Store 인터페이스 확장 (`IFeedforwardStores.cs`)
```csharp
public interface IFeedforwardConfigStore
{
Task<IReadOnlyList<ColumnConfig>> LoadAllAsync(CancellationToken ct = default);
Task<int> SaveColumnAsync(ColumnConfig cfg, CancellationToken ct = default); // Id==0 INSERT, else UPDATE. 반환=id
Task DeleteColumnAsync(int columnId, CancellationToken ct = default);
}
```
### 2.2 Store 구현 추가 (`FeedforwardConfigStore.cs`)
> **★ 사용자 입력이 들어오므로 전 컬럼 파라미터 바인딩**(인젝션 차단). 컬럼+스트림은 트랜잭션으로 원자적 교체.
> `AdvisoryOnly`는 항상 TRUE 강제(불변식).
```csharp
// using System.Data.Common; 추가
private static DbParameter P(DbCommand cmd, string name, object? val)
{
var p = cmd.CreateParameter();
p.ParameterName = name;
p.Value = val ?? DBNull.Value;
cmd.Parameters.Add(p);
return p;
}
public async Task<int> SaveColumnAsync(ColumnConfig cfg, CancellationToken ct = default)
{
var conn = _ctx.Database.GetDbConnection();
if (conn.State != ConnectionState.Open) await conn.OpenAsync(ct);
await using var tx = await conn.BeginTransactionAsync(ct);
int id = cfg.Id;
var levelTags = string.Join(',', cfg.LevelTags);
if (id == 0)
{
await using var cmd = conn.CreateCommand();
cmd.Transaction = tx;
cmd.CommandText = """
INSERT INTO ff_column_config
(name, enabled, feed_tag, pressure_tag, level_tags, scan_sec,
feed_filter_tau_sec, feed_move_thr_per_min, press_filter_tau_sec,
pressure_band, settle_sec, stale_sec, product_key, advisory_only)
VALUES (@name,@en,@feed,@pres,@lvl,@scan,@fft,@fmt,@pft,@pb,@settle,@stale,@pk,TRUE)
RETURNING id
""";
P(cmd,"@name",cfg.Name); P(cmd,"@en",cfg.Enabled); P(cmd,"@feed",cfg.FeedTag.ToLowerInvariant());
P(cmd,"@pres",(object?)cfg.PressureTag?.ToLowerInvariant()); P(cmd,"@lvl",levelTags.ToLowerInvariant());
P(cmd,"@scan",cfg.ScanSec); P(cmd,"@fft",cfg.FeedFilterTauSec); P(cmd,"@fmt",cfg.FeedMoveThresholdPerMin);
P(cmd,"@pft",cfg.PressFilterTauSec); P(cmd,"@pb",cfg.PressureBand); P(cmd,"@settle",cfg.SettleSec);
P(cmd,"@stale",cfg.StaleSec); P(cmd,"@pk",cfg.ProductKey ?? "P");
id = Convert.ToInt32(await cmd.ExecuteScalarAsync(ct));
}
else
{
await using var cmd = conn.CreateCommand();
cmd.Transaction = tx;
cmd.CommandText = """
UPDATE ff_column_config SET
name=@name, enabled=@en, feed_tag=@feed, pressure_tag=@pres, level_tags=@lvl,
scan_sec=@scan, feed_filter_tau_sec=@fft, feed_move_thr_per_min=@fmt,
press_filter_tau_sec=@pft, pressure_band=@pb, settle_sec=@settle,
stale_sec=@stale, product_key=@pk, advisory_only=TRUE
WHERE id=@id
""";
P(cmd,"@id",id); P(cmd,"@name",cfg.Name); P(cmd,"@en",cfg.Enabled);
P(cmd,"@feed",cfg.FeedTag.ToLowerInvariant()); P(cmd,"@pres",(object?)cfg.PressureTag?.ToLowerInvariant());
P(cmd,"@lvl",levelTags.ToLowerInvariant()); P(cmd,"@scan",cfg.ScanSec); P(cmd,"@fft",cfg.FeedFilterTauSec);
P(cmd,"@fmt",cfg.FeedMoveThresholdPerMin); P(cmd,"@pft",cfg.PressFilterTauSec); P(cmd,"@pb",cfg.PressureBand);
P(cmd,"@settle",cfg.SettleSec); P(cmd,"@stale",cfg.StaleSec); P(cmd,"@pk",cfg.ProductKey ?? "P");
await cmd.ExecuteNonQueryAsync(ct);
}
// 스트림 원자적 교체
await using (var del = conn.CreateCommand())
{
del.Transaction = tx; del.CommandText = "DELETE FROM ff_stream_config WHERE column_id=@id";
P(del,"@id",id); await del.ExecuteNonQueryAsync(ct);
}
foreach (var s in cfg.Streams)
{
await using var ins = conn.CreateCommand();
ins.Transaction = tx;
ins.CommandText = """
INSERT INTO ff_stream_config
(column_id, key, flow_tag, role, target_coeff, theta_up_sec, theta_dn_sec, tau_sec,
sp_min, sp_max, rate_up_per_min, rate_dn_per_min, reflux_from_product, grade)
VALUES (@cid,@key,@flow,@role,@k,@tup,@tdn,@tau,@smin,@smax,@rup,@rdn,@rfp,@grade)
""";
P(ins,"@cid",id); P(ins,"@key",s.Key); P(ins,"@flow",s.FlowTag.ToLowerInvariant());
P(ins,"@role",s.Role.ToString()); P(ins,"@k",s.TargetCoeff); P(ins,"@tup",s.ThetaUpSec);
P(ins,"@tdn",s.ThetaDnSec); P(ins,"@tau",s.TauSec); P(ins,"@smin",s.SpMin); P(ins,"@smax",s.SpMax);
P(ins,"@rup",s.RateUpPerMin); P(ins,"@rdn",s.RateDnPerMin); P(ins,"@rfp",s.RefluxFromProduct);
P(ins,"@grade",s.Grade.ToString());
await ins.ExecuteNonQueryAsync(ct);
}
await tx.CommitAsync(ct);
return id;
}
public async Task DeleteColumnAsync(int columnId, CancellationToken ct = default)
{
var conn = _ctx.Database.GetDbConnection();
if (conn.State != ConnectionState.Open) await conn.OpenAsync(ct);
await using var cmd = conn.CreateCommand();
cmd.CommandText = "DELETE FROM ff_column_config WHERE id=@id"; // ON DELETE CASCADE → 스트림 동반 삭제
P(cmd,"@id",columnId);
await cmd.ExecuteNonQueryAsync(ct);
}
```
> **누적 상태 정리(PhaseI 진단 잔여 #2)**: 컬럼 삭제 시 `AdvisoryStore`/`Supervisor._states`도 정리하려면
> `IFeedforwardAdvisoryStore.Remove(int id)`를 추가하고 컨트롤러 DELETE에서 호출(아래 §2.3 주석). Supervisor는
> 다음 Tick에서 enabled 목록에 없으면 자연 미갱신 — `_states` 잔존만 남으나 미미.
### 2.3 컨트롤러 확장 (`FeedforwardController.cs`)
```csharp
// 생성자에 IKbAuthService 주입 (DocsController 패턴)
private readonly IFeedforwardAdvisoryStore _store;
private readonly IFeedforwardConfigStore _config;
private readonly IKbAuthService _auth;
public FeedforwardController(IFeedforwardAdvisoryStore store, IFeedforwardConfigStore config, IKbAuthService auth)
{ _store = store; _config = config; _auth = auth; }
private Task<bool> IsAdminAsync(CancellationToken ct)
=> _auth.ValidateAsync(Request.Headers["X-Kb-Token"].ToString(), ct);
// ── 설정 조회 (admin) ──────────────────────────────────────────
[HttpGet("config")]
public async Task<IActionResult> GetConfig(CancellationToken ct)
{
if (!await IsAdminAsync(ct)) return Unauthorized();
var cols = await _config.LoadAllAsync(ct);
return Ok(new { columns = cols.Select(MapConfig) });
}
[HttpPost("config")]
public async Task<IActionResult> SaveConfig([FromBody] ColumnConfig body, CancellationToken ct)
{
if (!await IsAdminAsync(ct)) return Unauthorized();
var id = await _config.SaveColumnAsync(body, ct);
return Ok(new { success = true, id });
}
[HttpDelete("config/{id:int}")]
public async Task<IActionResult> DeleteConfig(int id, CancellationToken ct)
{
if (!await IsAdminAsync(ct)) return Unauthorized();
await _config.DeleteColumnAsync(id, ct);
// _store.Remove(id); // IFeedforwardAdvisoryStore.Remove 구현 시 활성화
return Ok(new { success = true });
}
// camelCase 매핑 (설정 — 응답)
private static object MapConfig(ColumnConfig c) => new
{
id = c.Id, name = c.Name, enabled = c.Enabled, advisoryOnly = c.AdvisoryOnly,
feedTag = c.FeedTag, pressureTag = c.PressureTag, levelTags = c.LevelTags,
scanSec = c.ScanSec, feedFilterTauSec = c.FeedFilterTauSec,
feedMoveThresholdPerMin = c.FeedMoveThresholdPerMin, pressFilterTauSec = c.PressFilterTauSec,
pressureBand = c.PressureBand, settleSec = c.SettleSec, staleSec = c.StaleSec, productKey = c.ProductKey,
streams = c.Streams.Select(s => new
{
key = s.Key, flowTag = s.FlowTag, role = s.Role.ToString(), targetCoeff = s.TargetCoeff,
thetaUpSec = s.ThetaUpSec, thetaDnSec = s.ThetaDnSec, tauSec = s.TauSec,
spMin = s.SpMin, spMax = s.SpMax, rateUpPerMin = s.RateUpPerMin, rateDnPerMin = s.RateDnPerMin,
refluxFromProduct = s.RefluxFromProduct, grade = s.Grade.ToString()
})
};
```
> `[FromBody] ColumnConfig`는 record라도 `PropertyNameCaseInsensitive=true`로 camelCase 바디가 바인딩됨.
> `double.MaxValue`(1e9 DDL 기본) 같은 큰 수는 그대로 직렬화/역직렬화. `Enum.Parse`는 Role/Grade 문자열로 처리(StreamConfig가 enum이라 JSON 문자열 "Commanded"/"A" 그대로 바인딩).
---
## 3. 프론트엔드 — Tab 18
### 3.1 index.html 와이어링 (3곳)
```html
<!-- (1) nav: data-tab="trend" 다음 -->
<li class="nav-item" data-tab="ff">
<span class="nav-ico">⚖️</span><span class="nav-txt">유량 권장(FF)</span>
</li>
<!-- (2) pane: pane-trend 다음 -->
<section class="pane" id="pane-ff" data-src="/panes/ff.html"></section>
<!-- (3) script: /js/trend.js 다음 -->
<script src="/js/ff.js"></script>
<link rel="stylesheet" href="/css/ff.css"> <!-- 또는 style.css에 병합 -->
```
### 3.2 panes/ff.html
```html
<div class="ff-wrap">
<div class="ff-head">
<h2>측류추출 유량 권장 (Advisory · 보조지표)</h2>
<span class="ff-badge">읽기 전용 — 권장값. 인가는 운전원</span>
<button id="ff-cfg-toggle" class="btn">설정 ▾</button>
</div>
<!-- 권장 SP 대시보드 (공개 읽기) -->
<div id="ff-dash" class="ff-dash"><div class="ff-empty">불러오는 중…</div></div>
<!-- 설정 에디터 (admin) -->
<div id="ff-cfg" class="ff-cfg" style="display:none">
<div class="ff-cfg-bar">
<input id="ff-token" type="password" placeholder="admin 토큰" class="inp">
<button id="ff-unlock" class="btn">잠금해제</button>
<button id="ff-new" class="btn" disabled>+ 컬럼</button>
<span id="ff-cfg-msg" class="ff-msg"></span>
</div>
<div id="ff-cfg-list"></div>
</div>
</div>
```
### 3.3 js/ff.js (paneInit.ff)
```javascript
/* ff.js — 측류추출 유량 권장(FF) 대시보드 + 설정 에디터.
대시보드는 공개 읽기(/api/ff/advisory), 설정은 admin(X-Kb-Token). */
paneInit.ff = ffInit;
let ffTimer = null;
function ffToken() { return sessionStorage.getItem('kbToken') || ''; }
async function ffApiAdmin(method, path, body) {
const h = { 'Content-Type': 'application/json' };
const t = ffToken(); if (t) h['X-Kb-Token'] = t;
const res = await fetch(path, { method, headers: h, body: body ? JSON.stringify(body) : undefined });
if (res.status === 401) throw new Error('UNAUTH');
if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
return res.status === 204 ? null : res.json();
}
async function ffInit() {
// 재진입 시 폴링 중복 방지
if (ffTimer) { clearInterval(ffTimer); ffTimer = null; }
await ffLoadDash();
ffTimer = setInterval(ffLoadDash, 3000);
document.getElementById('ff-cfg-toggle').onclick = () => {
const c = document.getElementById('ff-cfg');
c.style.display = c.style.display === 'none' ? 'block' : 'none';
};
document.getElementById('ff-unlock').onclick = ffUnlock;
document.getElementById('ff-new').onclick = () => ffEditColumn(null);
if (ffToken()) ffEnableAdmin();
}
// ── 대시보드 (공개) ──────────────────────────────────────────────
async function ffLoadDash() {
let data;
try { data = await api('GET', '/api/ff/advisory'); }
catch (e) { return; } // 일시 오류 무시(다음 폴링)
const host = document.getElementById('ff-dash');
if (!host) { clearInterval(ffTimer); ffTimer = null; return; } // 탭 떠남
const cols = data.columns || [];
if (!cols.length) { host.innerHTML = '<div class="ff-empty">활성 컬럼 없음</div>'; return; }
host.innerHTML = cols.map(ffCard).join('');
}
function ffTrendIco(t) { return t > 0 ? '▲' : t < 0 ? '▼' : ''; }
function ffCard(c) {
const rows = (c.streams || []).map(s => `
<tr class="${s.valid ? '' : 'ff-stale'}">
<td>${esc(s.key)}</td><td class="ff-tag">${esc(s.flowTag)}</td>
<td><span class="ff-role ff-role-${esc(s.role)}">${esc(s.role)}</span></td>
<td class="ff-num">${fmtVal(s.pv)}</td>
<td class="ff-num ff-rec">${s.recommendedSp==null?'':fmtVal(s.recommendedSp)}</td>
<td class="ff-num">${s.gap==null?'':fmtVal(s.gap)}</td>
<td>${ffTrendIco(s.trend)}</td>
<td><span class="ff-grade ff-grade-${esc(s.grade)}">${esc(s.grade)}</span></td>
</tr>`).join('');
const banner = c.transient
? `<div class="ff-transient">과도상태: ${esc(c.transientReason)} — 권장값 정착 대기</div>` : '';
const mb = `물질수지: ${esc(c.massBalanceState)}` +
(c.vLoss!=null ? ` · V_loss ${fmtVal(c.vLoss)}` : '') +
(c.yield!=null ? ` · 수율 ${fmtVal(c.yield)}%` : '');
return `
<div class="ff-col-card ${c.enabled?'':'ff-disabled'}">
<div class="ff-col-head"><b>${esc(c.columnName)}</b>
<span class="ff-feed">FEED ${fmtVal(c.feedFiltered)}</span>
<span class="ff-time">${fmtTs(c.computedAt)}</span></div>
${banner}
<table class="ff-tbl"><thead><tr>
<th>스트림</th><th>태그</th><th>역할</th><th>PV</th><th>권장 SP</th><th>Δ</th><th>추세</th><th>신뢰</th>
</tr></thead><tbody>${rows}</tbody></table>
<div class="ff-mb">${esc(mb)}</div>
<div class="ff-note">D·B는 레벨 제어가 구동(기대치). 권장값은 참고 — 인가는 운전원.</div>
</div>`;
}
// ── 설정 에디터 (admin) ──────────────────────────────────────────
async function ffUnlock() {
const tok = document.getElementById('ff-token').value.trim();
if (!tok) return;
// KB 로그인 재사용: 토큰 유효성은 첫 admin 호출에서 검증
sessionStorage.setItem('kbToken', tok);
try { await ffLoadConfig(); ffEnableAdmin(); ffMsg('잠금 해제됨'); }
catch (e) { sessionStorage.removeItem('kbToken'); ffMsg('토큰 무효', true); }
}
function ffEnableAdmin() { document.getElementById('ff-new').disabled = false; ffLoadConfig().catch(()=>{}); }
function ffMsg(m, err) { const e=document.getElementById('ff-cfg-msg'); e.textContent=m; e.className='ff-msg'+(err?' err':''); }
async function ffLoadConfig() {
const data = await ffApiAdmin('GET', '/api/ff/config');
const host = document.getElementById('ff-cfg-list');
host.innerHTML = (data.columns||[]).map(ffCfgRow).join('') || '<div class="ff-empty">설정 없음</div>';
host.querySelectorAll('[data-edit]').forEach(b => b.onclick = () =>
ffEditColumn(data.columns.find(c => c.id == b.dataset.edit)));
host.querySelectorAll('[data-del]').forEach(b => b.onclick = () => ffDelete(b.dataset.del));
}
function ffCfgRow(c) {
return `<div class="ff-cfg-item"><b>${esc(c.name)}</b> (id ${c.id}) — feed ${esc(c.feedTag)},
스트림 ${c.streams.length}개, ${c.enabled?'활성':'비활성'}
<button class="btn sm" data-edit="${c.id}">편집</button>
<button class="btn sm danger" data-del="${c.id}">삭제</button></div>`;
}
async function ffDelete(id) {
if (!confirm(`컬럼 ${id} 삭제?`)) return;
try { await ffApiAdmin('DELETE', `/api/ff/config/${id}`); await ffLoadConfig(); ffMsg('삭제됨'); }
catch (e) { ffMsg(e.message==='UNAUTH'?'권한 없음':'삭제 실패', true); }
}
// 간단 JSON 편집(턴키 최소형). Phase II-b에서 폼 위젯화 권장.
function ffEditColumn(c) {
const tmpl = c || { name:'', enabled:false, feedTag:'', pressureTag:null, levelTags:[],
scanSec:2, feedFilterTauSec:300, feedMoveThresholdPerMin:5, pressFilterTauSec:60,
pressureBand:3, settleSec:1800, staleSec:120, productKey:'P',
streams:[{key:'P',flowTag:'',role:'Commanded',targetCoeff:0.95,thetaUpSec:60,thetaDnSec:60,
tauSec:900,spMin:0,spMax:1e9,rateUpPerMin:30,rateDnPerMin:60,refluxFromProduct:false,grade:'A'}] };
const json = prompt('컬럼 설정(JSON) 편집:', JSON.stringify(tmpl));
if (!json) return;
let body; try { body = JSON.parse(json); } catch { return ffMsg('JSON 파싱 오류', true); }
if (c) body.id = c.id;
ffApiAdmin('POST', '/api/ff/config', body)
.then(() => { ffLoadConfig(); ffMsg('저장됨'); })
.catch(e => ffMsg(e.message==='UNAUTH'?'권한 없음':'저장 실패: '+e.message, true));
}
```
> 에디터는 **턴키 최소형(JSON prompt)** 으로 제공. 운전원용 폼 위젯(스트림 행 추가/삭제, 검증)은 **Phase II-b**에서
> 고도화 권장(본 문서 범위 밖). 대시보드는 완성형.
### 3.4 css/ff.css (요지)
```css
.ff-wrap{padding:16px;color:var(--t1)}
.ff-head{display:flex;align-items:center;gap:12px;margin-bottom:12px}
.ff-badge{font-size:12px;color:var(--t2);border:1px solid var(--bd);border-radius:10px;padding:2px 8px}
.ff-dash{display:grid;grid-template-columns:repeat(auto-fill,minmax(420px,1fr));gap:12px}
.ff-col-card{background:var(--bg2);border:1px solid var(--bd);border-radius:8px;padding:12px}
.ff-col-card.ff-disabled{opacity:.5}
.ff-col-head{display:flex;justify-content:space-between;align-items:center;margin-bottom:6px}
.ff-transient{background:#3a2e00;color:#ffd24d;padding:4px 8px;border-radius:4px;font-size:13px;margin:4px 0}
.ff-tbl{width:100%;border-collapse:collapse;font-size:13px}
.ff-tbl th,.ff-tbl td{padding:3px 6px;border-bottom:1px solid var(--bd);text-align:left}
.ff-num{text-align:right;font-variant-numeric:tabular-nums}
.ff-rec{font-weight:600;color:#7fd1ff}
.ff-stale{opacity:.45}
.ff-role-LevelDriven{color:#9aa}.ff-role-Monitor{color:#777}.ff-role-Commanded{color:#7fd1ff}
.ff-grade-A{color:#4caf50}.ff-grade-B{color:#ffb300}.ff-grade-C{color:#ff5252}
.ff-mb,.ff-note{font-size:12px;color:var(--t2);margin-top:6px}
.ff-msg.err{color:#ff5252}
```
---
## 4. 검증 절차 (diagnosis-checklist.md 8단계)
### STEP 1~2 맥락·구조
- 레이어: Web 컨트롤러(읽기 공개 + 설정 admin), Infra 스토어(파라미터화 CRUD), 프론트 탭.
- 변경 파일 4 / 신규 3. 제어 레지스터 무관.
### STEP 3 코드 읽기
순서: IFeedforwardStores(인터페이스) → ConfigStore(Save/Delete) → Controller → ff.js → ff.html/css.
### STEP 4 호출계층 지도
```
[대시보드] 브라우저 setInterval(3s) → GET /api/ff/advisory (공개) → AdvisoryStore (read)
[설정] ff.js (X-Kb-Token) → GET/POST/DELETE /api/ff/config → IsAdminAsync 게이트
→ ConfigStore.Save/Delete (트랜잭션·파라미터화) → ff_* 테이블
─ 제어 SP/OP 쓰기 경로 없음 (WriteTagAsync/SetModeAsync 미참조) ─
```
### STEP 5 패턴 매칭 (자가 사전점검)
| 체크 | 상태 |
|:-----|:-----|
| **SQL 인젝션** | CRUD 전 컬럼 **파라미터 바인딩**(`P()` 헬퍼) — f-string/concat 없음 ✅ |
| **인증** | 모든 변경/설정조회 엔드포인트 `IsAdminAsync` 선검사, 401 반환 ✅ |
| **XSS** | 렌더는 `esc()` 경유, 숫자는 `fmtVal` ✅ |
| **폴링 누수** | `ffInit` 재진입 시 `clearInterval` 선행, host 없으면(탭 이탈) 타이머 정지 ✅ |
| **camelCase 응답** | `MapConfig`/`MapColumn` 명시 익명객체 ✅ |
| **제어 쓰기 0건** | Control/Controllers grep `WriteTagAsync|SetModeAsync` = 0 (불변식 유지) ★ |
| 트랜잭션 | Save는 컬럼+스트림 원자적(`BeginTransactionAsync`/`Commit`) ✅ |
| 커넥션 | EF 소유 — 열기만 보장, 닫지 않음(스코프 종료 시 정리) |
### STEP 6 교차검증 (Q1~Q4)
- 설정 조회를 admin으로? Q3: 엔지니어링 값이라 의도. (대시보드 읽기는 공개)
- JSON prompt 에디터: Q4 장애? 잘못된 JSON은 파싱 catch로 무해. 운영 편의는 Phase II-b 폼으로.
- 토큰 미검증 unlock: 토큰을 sessionStorage에 넣고 **첫 admin 호출에서 검증**(401 시 제거) — docs.js와 동일.
### STEP 7 심각도 가이드
- HIGH: 인젝션/인증우회/빌드실패(없어야 함).
- MED: 트랜잭션 누락(부분 저장), 폴링 중복.
- LOW: 에디터 UX, 미사용 CSS.
### STEP 8 보고서 양식 — 감독자 채움(파일:줄 인용).
---
## 5. 빌드/런타임 검증 (감독자 승인 후)
- `dotnet build src/Web/ExperionCrawler.csproj` 경고0/에러0.
- **제어 쓰기 불변식**: `grep -rn "WriteTagAsync\|SetModeAsync" src/Infrastructure/Control src/Web/Controllers/FeedforwardController.cs` → 0건.
- admin 미인증 `POST /api/ff/config` → 401, 인증 후 저장→`GET /api/ff/config` 반영.
- 인젝션: `name``'); DROP TABLE ff_stream_config;--` 넣어도 **리터럴 저장**(파라미터화) 확인.
- 브라우저: Tab 18 진입 → 대시보드 3초 폴링, 과도 배너/신뢰등급 색/레벨주석 표시, 다른 탭 이동 시 폴링 정지(타이머 누수 없음).
- camelCase: 응답 필드가 `recommendedSp`·`massBalanceState` 등으로 옴(undefined 없음).
---
## 6. Phase II-분석 훅 (별도 진행 — 본 UI 문서 범위 밖)
PhaseI §6 P-1~P-5(θ 자동튜닝·PCT/차온·front-position·confidence 자동강등·느린 바이어스)는
**분석 엔진 확장**이라 UI와 분리. 본 UI는 그 산출을 표시할 **자리만** 둔다:
- `StreamAdvisory.Grade`(이미 표시) ← confidence 자동강등(P-5) 연결점.
- 컬럼 카드 `ff-note`/배너 ← sweet-spot 드리프트 경고(P-3) 표시 위치.
- 설정에 `tempTags`·`analyzerTag`·`dTdP`·`pRef` 필드 추가 시 ColumnConfig 확장(P-2) — DDL ALTER + 로더/CRUD 컬럼 추가로 후속.
---
## 7. 턴키 상태 & 잔여
**턴키**: 백엔드 CRUD(파라미터화)·컨트롤러·프론트(대시보드 완성, 에디터 최소형)·index 와이어링·검증절차 모두 포함.
**구현 순서**: ① 인터페이스+ConfigStore CRUD → ② 컨트롤러(IKbAuthService 주입) → ③ index.html 3줄 + ff.html/ff.js/ff.css → ④ build → ⑤ admin 토큰으로 CRUD·인젝션·폴링 검증.
**잔여(판단/후속)**: 에디터 폼 위젯화(II-b), `AdvisoryStore.Remove`로 삭제 컬럼 정리, 분석 훅 P-1~P-5.
---
## 8. Phase I 회고 — 인증 제거 (2026-05-31 적용)
### 배경
Phase I 엔진은 **어디에도 Experion SP/OP 쓰기 코드가 없다**(`WriteTagAsync`/`SetModeAsync` 0건).
그런데 FF 설정 CRUD API에 `IKbAuthService`(KB admin 토큰) 인증이 붙어 있어,
운전원이 대시보드를 보기 위해 RAG 관리 탭 로그인이 필요하거나 별도 토큰을 입력해야 했다.
advisory(보조지표)는 늘상 운전원이 봐야 하는 페이지인데, 보지 못하게 막는 진입장벽이 불합리했다.
### 적용 변경
| 레이어 | 변경 내용 |
|:-------|:----------|
| `FeedforwardController.cs` | `IKbAuthService` 의존성 및 `IsAdminAsync()` 가드 제거. config CRUD 엔드포인트 인증 없이 동작 |
| `ff.js` | `ffApiAdmin`(토큰 헤더) → `ffApi`(인증 없음). `ffUnlock`/`ffEnableAdmin` 제거. `ffInit`에서 바로 설정 로드 |
| `ff.html` | `#ff-token` input + `#ff-unlock` 버튼 제거. `#ff-new` disabled 해제 |
### 향후 재도입 시점 (Phase III)
RSP 쓰기(Experion SP/OP write)가 실제로 구현되는 **Phase III**에서 `IKbAuthService`를 다시 주입하고
프론트에 토큰 입력 UI를 복원한다. 그때는 쓰기 동작이 있으므로 인증이 정당화된다.