Complete Phase 1 B: encrypted drift DB, FCM notify, session revoke

- Flutter: drift + SQLCipher local store for tone samples/KV with
  secure-storage passphrase; migrate legacy SharedPreferences
- Core: FCM notifyUser on escalation, admin push-test, push metrics,
  DELETE session for multi-device logout
- Docs/roadmap B checkboxes updated; Linux SQLCipher apt notes

Co-authored-by: okuma <o0kuma@users.noreply.github.com>
This commit is contained in:
Cursor Agent 2026-07-30 05:28:39 +00:00
parent 6b06f1fde6
commit 9b02c59dd6
No known key found for this signature in database
20 changed files with 1830 additions and 43 deletions

View File

@ -40,6 +40,9 @@ Phase 1 B (베타 품질) 추가분:
- `GET /admin/dashboard` — 최소 HTML 대시보드
- `POST|GET /users/:id/device-tokens` — FCM 토큰 등록(전송은 후속)
- `GET /users/:id/sessions` — 활성 세션 목록(멀티 디바이스 1차)
- `DELETE /users/:id/sessions/:sessionId` — 세션 종료
- `POST /admin/push-test` — FCM 스모크 테스트 (`FCM_SERVER_KEY` 필요, 없으면 skipped)
- 에스컬레이션 시 사용자 디바이스로 푸시 시도 (`notifyUser`)
## 테스트

View File

@ -120,6 +120,61 @@ func registerBRoutes(r *gin.Engine, db *gorm.DB) {
}
c.JSON(http.StatusOK, gin.H{"sessions": out})
})
// Revoke another (or current) session — multi-device control.
r.DELETE("/users/:id/sessions/:sessionId", func(c *gin.Context) {
userID, ok := parseUintParam(c, "id")
if !ok {
return
}
sessionID, ok := parseUintParam(c, "sessionId")
if !ok {
return
}
actor, ok := currentUser(c, db, true)
if !ok {
return
}
if actor.ID != userID {
c.JSON(http.StatusForbidden, gin.H{"detail": "can only revoke your own sessions"})
return
}
var session Session
if err := db.Where("id = ? AND user_id = ?", sessionID, userID).First(&session).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"detail": "session not found"})
return
}
db.Delete(&session)
c.JSON(http.StatusOK, gin.H{"deleted": true, "id": sessionID})
})
// Admin smoke-test push to a user (requires FCM_SERVER_KEY for real delivery).
r.POST("/admin/push-test", func(c *gin.Context) {
if !requireAdmin(c) {
return
}
var req struct {
UserID uint `json:"user_id" binding:"required"`
Title string `json:"title"`
Body string `json:"body"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
return
}
if req.Title == "" {
req.Title = "분신 테스트"
}
if req.Body == "" {
req.Body = "push test"
}
sent, reason, err := notifyUser(db, req.UserID, req.Title, req.Body, map[string]string{"type": "test"})
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"detail": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"sent": sent, "skipped_reason": reason})
})
}
func trimToken(token string) string {

View File

@ -72,6 +72,49 @@ func TestDeviceTokenAndSessions(t *testing.T) {
}
}
func TestRevokeSessionAndPushTestWithoutFCM(t *testing.T) {
server, _ := setupTestServer(t)
userID, token := mustSignup(t, server.URL, "revoke")
// Second login via invite re-use is blocked; mint another session by signup of peer then
// just revoke the only session after listing.
req, _ := http.NewRequest(http.MethodGet, server.URL+"/users/"+strconv.FormatUint(uint64(userID), 10)+"/sessions", nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
var sout map[string]interface{}
json.NewDecoder(resp.Body).Decode(&sout)
resp.Body.Close()
sessions := sout["sessions"].([]interface{})
sid := uint(sessions[0].(map[string]interface{})["id"].(float64))
del := deleteJSONAuth(t, server.URL+"/users/"+strconv.FormatUint(uint64(userID), 10)+"/sessions/"+strconv.FormatUint(uint64(sid), 10), token)
if del.StatusCode != http.StatusOK {
t.Fatalf("revoke: %d", del.StatusCode)
}
// Push test without FCM_SERVER_KEY should soft-skip.
push := postJSONAuth(t, server.URL+"/admin/push-test", "test-admin-token", map[string]interface{}{
"user_id": userID,
"title": "t",
"body": "b",
})
if push.StatusCode != http.StatusOK {
t.Fatalf("push-test: %d", push.StatusCode)
}
var pout map[string]interface{}
json.NewDecoder(push.Body).Decode(&pout)
// Without device tokens / FCM key: sent==0 and a skipped_reason is expected.
if pout["sent"].(float64) != 0 {
t.Fatalf("expected zero sent without FCM tokens, got %v", pout)
}
if pout["skipped_reason"] == nil || pout["skipped_reason"] == "" {
t.Fatalf("expected skipped_reason, got %v", pout)
}
}
func TestAdminMetricsIncludesDraftLatencyFields(t *testing.T) {
server, _ := setupTestServer(t)
req, _ := http.NewRequest(http.MethodGet, server.URL+"/admin/metrics", nil)

View File

@ -136,6 +136,9 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi
"escalate_errors": rt.EscalateErrors,
"escalate_error_rate": rt.EscalateErrorRate,
"twin_sends_blocked": rt.TwinSendsBlocked,
"push_attempts": rt.PushAttempts,
"push_skipped": rt.PushSkipped,
"push_delivered": rt.PushDelivered,
"invites_minted": invitesMinted,
"invites_used": invitesUsed,
})
@ -240,6 +243,12 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi
Reason: result.Reason,
MessageSnippet: req.Text,
})
// Best-effort push (no-op without FCM_SERVER_KEY / real tokens).
_, _, _ = notifyUser(db, req.SenderID, "분신 확인 필요", result.Reason, map[string]string{
"type": "escalation",
"conversation_id": strconv.FormatUint(uint64(convID), 10),
"reason": result.Reason,
})
c.JSON(http.StatusForbidden, gin.H{"detail": "escalated", "reason": result.Reason})
return
}

View File

@ -105,6 +105,19 @@ func postJSONAuth(t *testing.T, url, token string, body interface{}) *http.Respo
return resp
}
func deleteJSONAuth(t *testing.T, url, token string) *http.Response {
t.Helper()
req, _ := http.NewRequest(http.MethodDelete, url, nil)
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("delete %s: %v", url, err)
}
return resp
}
func mintInvite(t *testing.T, serverURL string) string {
t.Helper()
resp := postJSONAuth(t, serverURL+"/invites", "test-admin-token", nil)

View File

@ -11,12 +11,15 @@ import (
type RuntimeMetrics struct {
mu sync.Mutex
DraftRequests int64
DraftErrors int64
DraftLatencies []time.Duration // capped ring of recent samples
EscalateChecks int64
EscalateErrors int64
DraftRequests int64
DraftErrors int64
DraftLatencies []time.Duration // capped ring of recent samples
EscalateChecks int64
EscalateErrors int64
TwinSendsBlocked int64
PushAttempts int64
PushSkipped int64
PushDelivered int64
}
const maxLatencySamples = 200
@ -51,6 +54,16 @@ func (m *RuntimeMetrics) recordTwinBlocked() {
m.TwinSendsBlocked++
}
func (m *RuntimeMetrics) recordPush(delivered int, skipped bool) {
m.mu.Lock()
defer m.mu.Unlock()
m.PushAttempts++
if skipped {
m.PushSkipped++
}
m.PushDelivered += int64(delivered)
}
func (m *RuntimeMetrics) snapshot() ginHMetrics {
m.mu.Lock()
defer m.mu.Unlock()
@ -76,16 +89,19 @@ func (m *RuntimeMetrics) snapshot() ginHMetrics {
escErrRate = float64(m.EscalateErrors) / float64(m.EscalateChecks)
}
return ginHMetrics{
DraftRequests: m.DraftRequests,
DraftErrors: m.DraftErrors,
DraftErrorRate: errRate,
DraftLatencyAvgMs: avgMs,
DraftLatencyMaxMs: float64(max.Milliseconds()),
DraftLatencySamples: len(m.DraftLatencies),
EscalateChecks: m.EscalateChecks,
EscalateErrors: m.EscalateErrors,
EscalateErrorRate: escErrRate,
TwinSendsBlocked: m.TwinSendsBlocked,
DraftRequests: m.DraftRequests,
DraftErrors: m.DraftErrors,
DraftErrorRate: errRate,
DraftLatencyAvgMs: avgMs,
DraftLatencyMaxMs: float64(max.Milliseconds()),
DraftLatencySamples: len(m.DraftLatencies),
EscalateChecks: m.EscalateChecks,
EscalateErrors: m.EscalateErrors,
EscalateErrorRate: escErrRate,
TwinSendsBlocked: m.TwinSendsBlocked,
PushAttempts: m.PushAttempts,
PushSkipped: m.PushSkipped,
PushDelivered: m.PushDelivered,
}
}
@ -100,4 +116,7 @@ type ginHMetrics struct {
EscalateErrors int64
EscalateErrorRate float64
TwinSendsBlocked int64
PushAttempts int64
PushSkipped int64
PushDelivered int64
}

82
core-backend/push.go Normal file
View File

@ -0,0 +1,82 @@
package main
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"os"
"strings"
"time"
"gorm.io/gorm"
)
// FCM legacy HTTP API (server key). When FCM_SERVER_KEY is unset, notifyUser
// records a metric and returns without error so product flows stay usable.
func fcmServerKey() string {
return strings.TrimSpace(os.Getenv("FCM_SERVER_KEY"))
}
type fcmLegacyPayload struct {
To string `json:"to,omitempty"`
Registration []string `json:"registration_ids,omitempty"`
Priority string `json:"priority"`
Notification map[string]string `json:"notification"`
Data map[string]string `json:"data,omitempty"`
}
func notifyUser(db *gorm.DB, userID uint, title, body string, data map[string]string) (sent int, skippedReason string, err error) {
var tokens []DeviceToken
db.Where("user_id = ?", userID).Find(&tokens)
if len(tokens) == 0 {
return 0, "no_device_tokens", nil
}
key := fcmServerKey()
if key == "" {
runtimeMetrics.recordPush(0, true)
return 0, "fcm_not_configured", nil
}
// Skip placeholder install:* tokens — they are not real FCM registration IDs.
regIDs := make([]string, 0, len(tokens))
for _, t := range tokens {
if strings.HasPrefix(t.Token, "install:") {
continue
}
regIDs = append(regIDs, t.Token)
}
if len(regIDs) == 0 {
runtimeMetrics.recordPush(0, true)
return 0, "only_placeholder_tokens", nil
}
payload := fcmLegacyPayload{
Registration: regIDs,
Priority: "high",
Notification: map[string]string{"title": title, "body": body},
Data: data,
}
raw, _ := json.Marshal(payload)
req, err := http.NewRequest(http.MethodPost, "https://fcm.googleapis.com/fcm/send", bytes.NewReader(raw))
if err != nil {
return 0, "", err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "key="+key)
client := &http.Client{Timeout: 8 * time.Second}
resp, err := client.Do(req)
if err != nil {
runtimeMetrics.recordPush(0, true)
return 0, "", err
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
runtimeMetrics.recordPush(0, true)
return 0, "", fmt.Errorf("fcm returned %d", resp.StatusCode)
}
runtimeMetrics.recordPush(len(regIDs), false)
return len(regIDs), "", nil
}

View File

@ -185,13 +185,14 @@ Master 합의 착수 순서: **A → B → C → D(맨 마지막)**. E는 Phase
동시 통과. Android 에뮬레이터 UI 탭은 이 환경에 SDK가 없어 체크리스트는 `mobile/README.md`에 유지
##### B. 그다음 — 베타 품질
- [~] FCM 푸시 연동 — 디바이스 토큰 등록 API(`POST /users/:id/device-tokens`)까지.
실제 FCM 전송은 Firebase 프로젝트 키 연동 후
- [~] 멀티 디바이스 동기화 — 활성 세션 목록(`GET /users/:id/sessions`) + Flutter 화면.
강제 로그아웃·메시지 동기화 고도화는 후속
- [ ] drift + SQLCipher 로컬 저장 — 다음 슬라이스(코드젠). 현재 말투는 SharedPreferences
- [x] 말투 이력 기기 내 저장 + 서버 최소 전송 — 온보딩 샘플 로컬 저장, draft에 샘플만 전달,
데이터 흐름 UI로 원칙 노출
- [~] FCM 푸시 연동 — 토큰 등록 + 에스컬레이션 시 `notifyUser` + `POST /admin/push-test`.
`FCM_SERVER_KEY`와 실제 FCM registration token이 있으면 전송, 없으면 soft-skip.
Flutter는 install-id 플레이스홀더 등록(Firebase Messaging 앱 키는 배포 환경에서 교체)
- [~] 멀티 디바이스 동기화 — 세션 목록 + 세션 종료(`DELETE /users/:id/sessions/:id`).
메시지 히스토리 서버 동기화는 이미 REST/WS; 오프라인 큐는 후속
- [x] drift + SQLCipher 로컬 저장 — `mobile/lib/db/` (말투 샘플·KV). 키는
`flutter_secure_storage`. Linux CI는 SQLCipher SO 없으면 메모리 폴백
- [x] 말투 이력 기기 내 저장 + 서버 최소 전송 — drift 암호화 저장, draft에 샘플만 전달
- [x] 데이터 흐름 표시 UI — `DataFlowScreen`
- [x] 생성 지연시간·오류율 계측 — process-local `RuntimeMetrics``/admin/metrics`
- [x] 모니터링 대시보드(최소) — `GET /admin/dashboard`

View File

@ -130,7 +130,7 @@ Python으로 남긴다.
| 서비스 간 통신 | Go 코어 → Python AI 서비스, 내부망 HTTP(REST) | 처음부터 gRPC 등으로 과설계하지 않음 — 필요해지면 그때 전환 |
| 메시지 릴레이 | Go 코어 서비스 내 WebSocket | 자체 서버로 충분한 규모. Kafka·관리형 pub-sub은 지금 시점에 과한 인프라 |
| 데이터베이스 | PostgreSQL | users/contacts/conversations/messages/escalation_logs/whitelist_rules 관계형 스키마에 적합. Go 쪽 접근은 `pgx`나 GORM |
| 온디바이스 저장소 | Flutter `drift`(SQLite) + `sqlcipher_flutter_libs` 암호화 | 말투 이력·설정을 기기 내 암호화 저장한다는 §2/§5 원칙을 그대로 구현 |
| 온디바이스 저장소 | Flutter `drift` + `sqlcipher_flutter_libs` (`PRAGMA key`, 패스프레이즈는 `flutter_secure_storage`) | 말투 이력·설정을 기기 내 암호화 저장한다는 §2/§5 원칙. 구현: `mobile/lib/db/` |
| Gemini API 키 관리 | Python AI 서비스에서만 보관 (Go 코어는 키를 안 가짐) | `poc/tone-corpus/.env`는 PoC 전용 — 프로덕션 키·쿼터는 AI 서비스 환경에서만 분리 관리 |
이 표 밖의 결정(자율성 기본값, 화이트리스트 기본 주제, 신뢰 UX 문구)은 `roadmap.md` Phase 1 §3에

View File

@ -52,8 +52,20 @@ HTTP로 검증한다.
6. 분신 메시지 되돌리기 · 거부권
7. 자율성 L0~L2 + 화이트리스트 추가/삭제
## 아직 없는 것 (B 이후)
## 로컬 DB (drift + SQLCipher)
- FCM 푸시 · drift + SQLCipher · 멀티 디바이스
말투 샘플·온보딩 플래그는 `lib/db/` 암호화 SQLite에 저장한다. 패스프레이즈는
`flutter_secure_storage`. Linux 개발 호스트에 `libsqlcipher.so`가 없으면 메모리 폴백으로
기동한다(Android 릴리즈 경로에서는 SQLCipher 사용).
```bash
# Linux에서 drift 테스트/암호화 DB를 쓰려면
sudo apt-get install -y libsqlite3-dev libsqlcipher1
```
## 아직 없는 것
- Firebase 프로젝트의 실제 FCM registration token 연동 (`google-services.json`)
- 오프라인 메시지 큐 / 멀티디바이스 실시간 설정 동기화 고도화
- 온보딩 말투 UX 디테일 (PoC #1 결과는 맨 마지막에 반영)
- iOS 빌드 (v1 범위 밖)

View File

@ -0,0 +1,128 @@
import 'dart:ffi';
import 'dart:io';
import 'dart:math';
import 'package:drift/drift.dart';
import 'package:drift/native.dart';
import 'package:flutter/services.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:sqlcipher_flutter_libs/sqlcipher_flutter_libs.dart';
import 'package:sqlite3/open.dart';
import 'tables.dart';
part 'app_database.g.dart';
@DriftDatabase(tables: [ToneSamples, LocalKv])
class AppDatabase extends _$AppDatabase {
AppDatabase(super.e);
@override
int get schemaVersion => 1;
/// In-memory DB for unit tests (no SQLCipher / filesystem).
factory AppDatabase.memory() => AppDatabase(NativeDatabase.memory());
/// Production opener: encrypted on-device file (tech-design.md §8).
static Future<AppDatabase> open() async {
final db = AppDatabase(_openEncryptedExecutor());
// Force open so missing SQLCipher SO fails here (caller can fall back).
await db.customSelect('SELECT 1').get();
return db;
}
Future<List<String>> loadToneSamples() async {
final rows = await (select(toneSamples)..orderBy([(t) => OrderingTerm.asc(t.sortOrder)])).get();
return rows.map((r) => r.sampleText).toList();
}
Future<void> replaceToneSamples(List<String> samples) async {
final now = DateTime.now().millisecondsSinceEpoch;
await transaction(() async {
await delete(toneSamples).go();
for (var i = 0; i < samples.length; i++) {
await into(toneSamples).insert(
ToneSamplesCompanion.insert(
id: 'tone_${i}_$now',
sampleText: samples[i],
createdAtMs: now,
sortOrder: i,
),
);
}
});
}
Future<String?> getKv(String key) async {
final row = await (select(localKv)..where((t) => t.key.equals(key))).getSingleOrNull();
return row?.value;
}
Future<void> setKv(String key, String value) async {
await into(localKv).insertOnConflictUpdate(LocalKvCompanion.insert(key: key, value: value));
}
Future<bool> getBoolKv(String key, {bool defaultValue = false}) async {
final v = await getKv(key);
if (v == null) return defaultValue;
return v == '1' || v.toLowerCase() == 'true';
}
Future<void> setBoolKv(String key, bool value) async {
await setKv(key, value ? '1' : '0');
}
}
const _kDbPassphrase = 'db_passphrase_v1';
QueryExecutor _openEncryptedExecutor() {
return LazyDatabase(() async {
final dir = await getApplicationDocumentsDirectory();
final file = File(p.join(dir.path, 'bunsin_encrypted.db'));
final passphrase = await _loadOrCreatePassphrase();
// Background isolate does not inherit open.overrideFor re-apply there.
final token = RootIsolateToken.instance;
return NativeDatabase.createInBackground(
file,
isolateSetup: () async {
if (token != null) {
BackgroundIsolateBinaryMessenger.ensureInitialized(token);
}
await _configureSqlCipherOpen();
},
setup: (rawDb) {
// SQLCipher key must be set before any other statement.
final escaped = passphrase.replaceAll("'", "''");
rawDb.execute("PRAGMA key = '$escaped'");
rawDb.config.doubleQuotedStringLiterals = false;
},
);
});
}
Future<void> _configureSqlCipherOpen() async {
if (Platform.isAndroid) {
await applyWorkaroundToOpenSqlCipherOnOldAndroidVersions();
open.overrideFor(OperatingSystem.android, openCipherOnAndroid);
} else if (Platform.isLinux) {
// Desktop/dev: try SQLCipher SO; fall back is handled by open() failure upstream.
open.overrideFor(OperatingSystem.linux, () => DynamicLibrary.open('libsqlcipher.so'));
} else if (Platform.isWindows) {
open.overrideFor(OperatingSystem.windows, () => DynamicLibrary.open('sqlcipher.dll'));
}
// iOS/macOS: sqlcipher_flutter_libs links into the process.
}
Future<String> _loadOrCreatePassphrase() async {
const storage = FlutterSecureStorage();
final existing = await storage.read(key: _kDbPassphrase);
if (existing != null && existing.isNotEmpty) return existing;
final rand = Random.secure();
final bytes = List<int>.generate(32, (_) => rand.nextInt(256));
final passphrase = bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
await storage.write(key: _kDbPassphrase, value: passphrase);
return passphrase;
}

View File

@ -0,0 +1,873 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'app_database.dart';
// ignore_for_file: type=lint
class $ToneSamplesTable extends ToneSamples
with TableInfo<$ToneSamplesTable, ToneSample> {
@override
final GeneratedDatabase attachedDatabase;
final String? _alias;
$ToneSamplesTable(this.attachedDatabase, [this._alias]);
static const VerificationMeta _idMeta = const VerificationMeta('id');
@override
late final GeneratedColumn<String> id = GeneratedColumn<String>(
'id',
aliasedName,
false,
type: DriftSqlType.string,
requiredDuringInsert: true,
);
static const VerificationMeta _sampleTextMeta = const VerificationMeta(
'sampleText',
);
@override
late final GeneratedColumn<String> sampleText = GeneratedColumn<String>(
'sample_text',
aliasedName,
false,
type: DriftSqlType.string,
requiredDuringInsert: true,
);
static const VerificationMeta _createdAtMsMeta = const VerificationMeta(
'createdAtMs',
);
@override
late final GeneratedColumn<int> createdAtMs = GeneratedColumn<int>(
'created_at_ms',
aliasedName,
false,
type: DriftSqlType.int,
requiredDuringInsert: true,
);
static const VerificationMeta _sortOrderMeta = const VerificationMeta(
'sortOrder',
);
@override
late final GeneratedColumn<int> sortOrder = GeneratedColumn<int>(
'sort_order',
aliasedName,
false,
type: DriftSqlType.int,
requiredDuringInsert: true,
);
@override
List<GeneratedColumn> get $columns => [
id,
sampleText,
createdAtMs,
sortOrder,
];
@override
String get aliasedName => _alias ?? actualTableName;
@override
String get actualTableName => $name;
static const String $name = 'tone_samples';
@override
VerificationContext validateIntegrity(
Insertable<ToneSample> instance, {
bool isInserting = false,
}) {
final context = VerificationContext();
final data = instance.toColumns(true);
if (data.containsKey('id')) {
context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta));
} else if (isInserting) {
context.missing(_idMeta);
}
if (data.containsKey('sample_text')) {
context.handle(
_sampleTextMeta,
sampleText.isAcceptableOrUnknown(data['sample_text']!, _sampleTextMeta),
);
} else if (isInserting) {
context.missing(_sampleTextMeta);
}
if (data.containsKey('created_at_ms')) {
context.handle(
_createdAtMsMeta,
createdAtMs.isAcceptableOrUnknown(
data['created_at_ms']!,
_createdAtMsMeta,
),
);
} else if (isInserting) {
context.missing(_createdAtMsMeta);
}
if (data.containsKey('sort_order')) {
context.handle(
_sortOrderMeta,
sortOrder.isAcceptableOrUnknown(data['sort_order']!, _sortOrderMeta),
);
} else if (isInserting) {
context.missing(_sortOrderMeta);
}
return context;
}
@override
Set<GeneratedColumn> get $primaryKey => {id};
@override
ToneSample map(Map<String, dynamic> data, {String? tablePrefix}) {
final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : '';
return ToneSample(
id: attachedDatabase.typeMapping.read(
DriftSqlType.string,
data['${effectivePrefix}id'],
)!,
sampleText: attachedDatabase.typeMapping.read(
DriftSqlType.string,
data['${effectivePrefix}sample_text'],
)!,
createdAtMs: attachedDatabase.typeMapping.read(
DriftSqlType.int,
data['${effectivePrefix}created_at_ms'],
)!,
sortOrder: attachedDatabase.typeMapping.read(
DriftSqlType.int,
data['${effectivePrefix}sort_order'],
)!,
);
}
@override
$ToneSamplesTable createAlias(String alias) {
return $ToneSamplesTable(attachedDatabase, alias);
}
}
class ToneSample extends DataClass implements Insertable<ToneSample> {
final String id;
final String sampleText;
final int createdAtMs;
final int sortOrder;
const ToneSample({
required this.id,
required this.sampleText,
required this.createdAtMs,
required this.sortOrder,
});
@override
Map<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{};
map['id'] = Variable<String>(id);
map['sample_text'] = Variable<String>(sampleText);
map['created_at_ms'] = Variable<int>(createdAtMs);
map['sort_order'] = Variable<int>(sortOrder);
return map;
}
ToneSamplesCompanion toCompanion(bool nullToAbsent) {
return ToneSamplesCompanion(
id: Value(id),
sampleText: Value(sampleText),
createdAtMs: Value(createdAtMs),
sortOrder: Value(sortOrder),
);
}
factory ToneSample.fromJson(
Map<String, dynamic> json, {
ValueSerializer? serializer,
}) {
serializer ??= driftRuntimeOptions.defaultSerializer;
return ToneSample(
id: serializer.fromJson<String>(json['id']),
sampleText: serializer.fromJson<String>(json['sampleText']),
createdAtMs: serializer.fromJson<int>(json['createdAtMs']),
sortOrder: serializer.fromJson<int>(json['sortOrder']),
);
}
@override
Map<String, dynamic> toJson({ValueSerializer? serializer}) {
serializer ??= driftRuntimeOptions.defaultSerializer;
return <String, dynamic>{
'id': serializer.toJson<String>(id),
'sampleText': serializer.toJson<String>(sampleText),
'createdAtMs': serializer.toJson<int>(createdAtMs),
'sortOrder': serializer.toJson<int>(sortOrder),
};
}
ToneSample copyWith({
String? id,
String? sampleText,
int? createdAtMs,
int? sortOrder,
}) => ToneSample(
id: id ?? this.id,
sampleText: sampleText ?? this.sampleText,
createdAtMs: createdAtMs ?? this.createdAtMs,
sortOrder: sortOrder ?? this.sortOrder,
);
ToneSample copyWithCompanion(ToneSamplesCompanion data) {
return ToneSample(
id: data.id.present ? data.id.value : this.id,
sampleText: data.sampleText.present
? data.sampleText.value
: this.sampleText,
createdAtMs: data.createdAtMs.present
? data.createdAtMs.value
: this.createdAtMs,
sortOrder: data.sortOrder.present ? data.sortOrder.value : this.sortOrder,
);
}
@override
String toString() {
return (StringBuffer('ToneSample(')
..write('id: $id, ')
..write('sampleText: $sampleText, ')
..write('createdAtMs: $createdAtMs, ')
..write('sortOrder: $sortOrder')
..write(')'))
.toString();
}
@override
int get hashCode => Object.hash(id, sampleText, createdAtMs, sortOrder);
@override
bool operator ==(Object other) =>
identical(this, other) ||
(other is ToneSample &&
other.id == this.id &&
other.sampleText == this.sampleText &&
other.createdAtMs == this.createdAtMs &&
other.sortOrder == this.sortOrder);
}
class ToneSamplesCompanion extends UpdateCompanion<ToneSample> {
final Value<String> id;
final Value<String> sampleText;
final Value<int> createdAtMs;
final Value<int> sortOrder;
final Value<int> rowid;
const ToneSamplesCompanion({
this.id = const Value.absent(),
this.sampleText = const Value.absent(),
this.createdAtMs = const Value.absent(),
this.sortOrder = const Value.absent(),
this.rowid = const Value.absent(),
});
ToneSamplesCompanion.insert({
required String id,
required String sampleText,
required int createdAtMs,
required int sortOrder,
this.rowid = const Value.absent(),
}) : id = Value(id),
sampleText = Value(sampleText),
createdAtMs = Value(createdAtMs),
sortOrder = Value(sortOrder);
static Insertable<ToneSample> custom({
Expression<String>? id,
Expression<String>? sampleText,
Expression<int>? createdAtMs,
Expression<int>? sortOrder,
Expression<int>? rowid,
}) {
return RawValuesInsertable({
if (id != null) 'id': id,
if (sampleText != null) 'sample_text': sampleText,
if (createdAtMs != null) 'created_at_ms': createdAtMs,
if (sortOrder != null) 'sort_order': sortOrder,
if (rowid != null) 'rowid': rowid,
});
}
ToneSamplesCompanion copyWith({
Value<String>? id,
Value<String>? sampleText,
Value<int>? createdAtMs,
Value<int>? sortOrder,
Value<int>? rowid,
}) {
return ToneSamplesCompanion(
id: id ?? this.id,
sampleText: sampleText ?? this.sampleText,
createdAtMs: createdAtMs ?? this.createdAtMs,
sortOrder: sortOrder ?? this.sortOrder,
rowid: rowid ?? this.rowid,
);
}
@override
Map<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{};
if (id.present) {
map['id'] = Variable<String>(id.value);
}
if (sampleText.present) {
map['sample_text'] = Variable<String>(sampleText.value);
}
if (createdAtMs.present) {
map['created_at_ms'] = Variable<int>(createdAtMs.value);
}
if (sortOrder.present) {
map['sort_order'] = Variable<int>(sortOrder.value);
}
if (rowid.present) {
map['rowid'] = Variable<int>(rowid.value);
}
return map;
}
@override
String toString() {
return (StringBuffer('ToneSamplesCompanion(')
..write('id: $id, ')
..write('sampleText: $sampleText, ')
..write('createdAtMs: $createdAtMs, ')
..write('sortOrder: $sortOrder, ')
..write('rowid: $rowid')
..write(')'))
.toString();
}
}
class $LocalKvTable extends LocalKv with TableInfo<$LocalKvTable, LocalKvData> {
@override
final GeneratedDatabase attachedDatabase;
final String? _alias;
$LocalKvTable(this.attachedDatabase, [this._alias]);
static const VerificationMeta _keyMeta = const VerificationMeta('key');
@override
late final GeneratedColumn<String> key = GeneratedColumn<String>(
'key',
aliasedName,
false,
type: DriftSqlType.string,
requiredDuringInsert: true,
);
static const VerificationMeta _valueMeta = const VerificationMeta('value');
@override
late final GeneratedColumn<String> value = GeneratedColumn<String>(
'value',
aliasedName,
false,
type: DriftSqlType.string,
requiredDuringInsert: true,
);
@override
List<GeneratedColumn> get $columns => [key, value];
@override
String get aliasedName => _alias ?? actualTableName;
@override
String get actualTableName => $name;
static const String $name = 'local_kv';
@override
VerificationContext validateIntegrity(
Insertable<LocalKvData> instance, {
bool isInserting = false,
}) {
final context = VerificationContext();
final data = instance.toColumns(true);
if (data.containsKey('key')) {
context.handle(
_keyMeta,
key.isAcceptableOrUnknown(data['key']!, _keyMeta),
);
} else if (isInserting) {
context.missing(_keyMeta);
}
if (data.containsKey('value')) {
context.handle(
_valueMeta,
value.isAcceptableOrUnknown(data['value']!, _valueMeta),
);
} else if (isInserting) {
context.missing(_valueMeta);
}
return context;
}
@override
Set<GeneratedColumn> get $primaryKey => {key};
@override
LocalKvData map(Map<String, dynamic> data, {String? tablePrefix}) {
final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : '';
return LocalKvData(
key: attachedDatabase.typeMapping.read(
DriftSqlType.string,
data['${effectivePrefix}key'],
)!,
value: attachedDatabase.typeMapping.read(
DriftSqlType.string,
data['${effectivePrefix}value'],
)!,
);
}
@override
$LocalKvTable createAlias(String alias) {
return $LocalKvTable(attachedDatabase, alias);
}
}
class LocalKvData extends DataClass implements Insertable<LocalKvData> {
final String key;
final String value;
const LocalKvData({required this.key, required this.value});
@override
Map<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{};
map['key'] = Variable<String>(key);
map['value'] = Variable<String>(value);
return map;
}
LocalKvCompanion toCompanion(bool nullToAbsent) {
return LocalKvCompanion(key: Value(key), value: Value(value));
}
factory LocalKvData.fromJson(
Map<String, dynamic> json, {
ValueSerializer? serializer,
}) {
serializer ??= driftRuntimeOptions.defaultSerializer;
return LocalKvData(
key: serializer.fromJson<String>(json['key']),
value: serializer.fromJson<String>(json['value']),
);
}
@override
Map<String, dynamic> toJson({ValueSerializer? serializer}) {
serializer ??= driftRuntimeOptions.defaultSerializer;
return <String, dynamic>{
'key': serializer.toJson<String>(key),
'value': serializer.toJson<String>(value),
};
}
LocalKvData copyWith({String? key, String? value}) =>
LocalKvData(key: key ?? this.key, value: value ?? this.value);
LocalKvData copyWithCompanion(LocalKvCompanion data) {
return LocalKvData(
key: data.key.present ? data.key.value : this.key,
value: data.value.present ? data.value.value : this.value,
);
}
@override
String toString() {
return (StringBuffer('LocalKvData(')
..write('key: $key, ')
..write('value: $value')
..write(')'))
.toString();
}
@override
int get hashCode => Object.hash(key, value);
@override
bool operator ==(Object other) =>
identical(this, other) ||
(other is LocalKvData &&
other.key == this.key &&
other.value == this.value);
}
class LocalKvCompanion extends UpdateCompanion<LocalKvData> {
final Value<String> key;
final Value<String> value;
final Value<int> rowid;
const LocalKvCompanion({
this.key = const Value.absent(),
this.value = const Value.absent(),
this.rowid = const Value.absent(),
});
LocalKvCompanion.insert({
required String key,
required String value,
this.rowid = const Value.absent(),
}) : key = Value(key),
value = Value(value);
static Insertable<LocalKvData> custom({
Expression<String>? key,
Expression<String>? value,
Expression<int>? rowid,
}) {
return RawValuesInsertable({
if (key != null) 'key': key,
if (value != null) 'value': value,
if (rowid != null) 'rowid': rowid,
});
}
LocalKvCompanion copyWith({
Value<String>? key,
Value<String>? value,
Value<int>? rowid,
}) {
return LocalKvCompanion(
key: key ?? this.key,
value: value ?? this.value,
rowid: rowid ?? this.rowid,
);
}
@override
Map<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{};
if (key.present) {
map['key'] = Variable<String>(key.value);
}
if (value.present) {
map['value'] = Variable<String>(value.value);
}
if (rowid.present) {
map['rowid'] = Variable<int>(rowid.value);
}
return map;
}
@override
String toString() {
return (StringBuffer('LocalKvCompanion(')
..write('key: $key, ')
..write('value: $value, ')
..write('rowid: $rowid')
..write(')'))
.toString();
}
}
abstract class _$AppDatabase extends GeneratedDatabase {
_$AppDatabase(QueryExecutor e) : super(e);
$AppDatabaseManager get managers => $AppDatabaseManager(this);
late final $ToneSamplesTable toneSamples = $ToneSamplesTable(this);
late final $LocalKvTable localKv = $LocalKvTable(this);
@override
Iterable<TableInfo<Table, Object?>> get allTables =>
allSchemaEntities.whereType<TableInfo<Table, Object?>>();
@override
List<DatabaseSchemaEntity> get allSchemaEntities => [toneSamples, localKv];
}
typedef $$ToneSamplesTableCreateCompanionBuilder =
ToneSamplesCompanion Function({
required String id,
required String sampleText,
required int createdAtMs,
required int sortOrder,
Value<int> rowid,
});
typedef $$ToneSamplesTableUpdateCompanionBuilder =
ToneSamplesCompanion Function({
Value<String> id,
Value<String> sampleText,
Value<int> createdAtMs,
Value<int> sortOrder,
Value<int> rowid,
});
class $$ToneSamplesTableFilterComposer
extends Composer<_$AppDatabase, $ToneSamplesTable> {
$$ToneSamplesTableFilterComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
ColumnFilters<String> get id => $composableBuilder(
column: $table.id,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<String> get sampleText => $composableBuilder(
column: $table.sampleText,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<int> get createdAtMs => $composableBuilder(
column: $table.createdAtMs,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<int> get sortOrder => $composableBuilder(
column: $table.sortOrder,
builder: (column) => ColumnFilters(column),
);
}
class $$ToneSamplesTableOrderingComposer
extends Composer<_$AppDatabase, $ToneSamplesTable> {
$$ToneSamplesTableOrderingComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
ColumnOrderings<String> get id => $composableBuilder(
column: $table.id,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<String> get sampleText => $composableBuilder(
column: $table.sampleText,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<int> get createdAtMs => $composableBuilder(
column: $table.createdAtMs,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<int> get sortOrder => $composableBuilder(
column: $table.sortOrder,
builder: (column) => ColumnOrderings(column),
);
}
class $$ToneSamplesTableAnnotationComposer
extends Composer<_$AppDatabase, $ToneSamplesTable> {
$$ToneSamplesTableAnnotationComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
GeneratedColumn<String> get id =>
$composableBuilder(column: $table.id, builder: (column) => column);
GeneratedColumn<String> get sampleText => $composableBuilder(
column: $table.sampleText,
builder: (column) => column,
);
GeneratedColumn<int> get createdAtMs => $composableBuilder(
column: $table.createdAtMs,
builder: (column) => column,
);
GeneratedColumn<int> get sortOrder =>
$composableBuilder(column: $table.sortOrder, builder: (column) => column);
}
class $$ToneSamplesTableTableManager
extends
RootTableManager<
_$AppDatabase,
$ToneSamplesTable,
ToneSample,
$$ToneSamplesTableFilterComposer,
$$ToneSamplesTableOrderingComposer,
$$ToneSamplesTableAnnotationComposer,
$$ToneSamplesTableCreateCompanionBuilder,
$$ToneSamplesTableUpdateCompanionBuilder,
(
ToneSample,
BaseReferences<_$AppDatabase, $ToneSamplesTable, ToneSample>,
),
ToneSample,
PrefetchHooks Function()
> {
$$ToneSamplesTableTableManager(_$AppDatabase db, $ToneSamplesTable table)
: super(
TableManagerState(
db: db,
table: table,
createFilteringComposer: () =>
$$ToneSamplesTableFilterComposer($db: db, $table: table),
createOrderingComposer: () =>
$$ToneSamplesTableOrderingComposer($db: db, $table: table),
createComputedFieldComposer: () =>
$$ToneSamplesTableAnnotationComposer($db: db, $table: table),
updateCompanionCallback:
({
Value<String> id = const Value.absent(),
Value<String> sampleText = const Value.absent(),
Value<int> createdAtMs = const Value.absent(),
Value<int> sortOrder = const Value.absent(),
Value<int> rowid = const Value.absent(),
}) => ToneSamplesCompanion(
id: id,
sampleText: sampleText,
createdAtMs: createdAtMs,
sortOrder: sortOrder,
rowid: rowid,
),
createCompanionCallback:
({
required String id,
required String sampleText,
required int createdAtMs,
required int sortOrder,
Value<int> rowid = const Value.absent(),
}) => ToneSamplesCompanion.insert(
id: id,
sampleText: sampleText,
createdAtMs: createdAtMs,
sortOrder: sortOrder,
rowid: rowid,
),
withReferenceMapper: (p0) => p0
.map((e) => (e.readTable(table), BaseReferences(db, table, e)))
.toList(),
prefetchHooksCallback: null,
),
);
}
typedef $$ToneSamplesTableProcessedTableManager =
ProcessedTableManager<
_$AppDatabase,
$ToneSamplesTable,
ToneSample,
$$ToneSamplesTableFilterComposer,
$$ToneSamplesTableOrderingComposer,
$$ToneSamplesTableAnnotationComposer,
$$ToneSamplesTableCreateCompanionBuilder,
$$ToneSamplesTableUpdateCompanionBuilder,
(
ToneSample,
BaseReferences<_$AppDatabase, $ToneSamplesTable, ToneSample>,
),
ToneSample,
PrefetchHooks Function()
>;
typedef $$LocalKvTableCreateCompanionBuilder =
LocalKvCompanion Function({
required String key,
required String value,
Value<int> rowid,
});
typedef $$LocalKvTableUpdateCompanionBuilder =
LocalKvCompanion Function({
Value<String> key,
Value<String> value,
Value<int> rowid,
});
class $$LocalKvTableFilterComposer
extends Composer<_$AppDatabase, $LocalKvTable> {
$$LocalKvTableFilterComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
ColumnFilters<String> get key => $composableBuilder(
column: $table.key,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<String> get value => $composableBuilder(
column: $table.value,
builder: (column) => ColumnFilters(column),
);
}
class $$LocalKvTableOrderingComposer
extends Composer<_$AppDatabase, $LocalKvTable> {
$$LocalKvTableOrderingComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
ColumnOrderings<String> get key => $composableBuilder(
column: $table.key,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<String> get value => $composableBuilder(
column: $table.value,
builder: (column) => ColumnOrderings(column),
);
}
class $$LocalKvTableAnnotationComposer
extends Composer<_$AppDatabase, $LocalKvTable> {
$$LocalKvTableAnnotationComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
GeneratedColumn<String> get key =>
$composableBuilder(column: $table.key, builder: (column) => column);
GeneratedColumn<String> get value =>
$composableBuilder(column: $table.value, builder: (column) => column);
}
class $$LocalKvTableTableManager
extends
RootTableManager<
_$AppDatabase,
$LocalKvTable,
LocalKvData,
$$LocalKvTableFilterComposer,
$$LocalKvTableOrderingComposer,
$$LocalKvTableAnnotationComposer,
$$LocalKvTableCreateCompanionBuilder,
$$LocalKvTableUpdateCompanionBuilder,
(
LocalKvData,
BaseReferences<_$AppDatabase, $LocalKvTable, LocalKvData>,
),
LocalKvData,
PrefetchHooks Function()
> {
$$LocalKvTableTableManager(_$AppDatabase db, $LocalKvTable table)
: super(
TableManagerState(
db: db,
table: table,
createFilteringComposer: () =>
$$LocalKvTableFilterComposer($db: db, $table: table),
createOrderingComposer: () =>
$$LocalKvTableOrderingComposer($db: db, $table: table),
createComputedFieldComposer: () =>
$$LocalKvTableAnnotationComposer($db: db, $table: table),
updateCompanionCallback:
({
Value<String> key = const Value.absent(),
Value<String> value = const Value.absent(),
Value<int> rowid = const Value.absent(),
}) => LocalKvCompanion(key: key, value: value, rowid: rowid),
createCompanionCallback:
({
required String key,
required String value,
Value<int> rowid = const Value.absent(),
}) =>
LocalKvCompanion.insert(key: key, value: value, rowid: rowid),
withReferenceMapper: (p0) => p0
.map((e) => (e.readTable(table), BaseReferences(db, table, e)))
.toList(),
prefetchHooksCallback: null,
),
);
}
typedef $$LocalKvTableProcessedTableManager =
ProcessedTableManager<
_$AppDatabase,
$LocalKvTable,
LocalKvData,
$$LocalKvTableFilterComposer,
$$LocalKvTableOrderingComposer,
$$LocalKvTableAnnotationComposer,
$$LocalKvTableCreateCompanionBuilder,
$$LocalKvTableUpdateCompanionBuilder,
(LocalKvData, BaseReferences<_$AppDatabase, $LocalKvTable, LocalKvData>),
LocalKvData,
PrefetchHooks Function()
>;
class $AppDatabaseManager {
final _$AppDatabase _db;
$AppDatabaseManager(this._db);
$$ToneSamplesTableTableManager get toneSamples =>
$$ToneSamplesTableTableManager(_db, _db.toneSamples);
$$LocalKvTableTableManager get localKv =>
$$LocalKvTableTableManager(_db, _db.localKv);
}

21
mobile/lib/db/tables.dart Normal file
View File

@ -0,0 +1,21 @@
import 'package:drift/drift.dart';
/// On-device tone / style samples (tech-design.md §2 never upload the full corpus).
class ToneSamples extends Table {
TextColumn get id => text()();
TextColumn get sampleText => text()();
IntColumn get createdAtMs => integer()();
IntColumn get sortOrder => integer()();
@override
Set<Column<Object>> get primaryKey => {id};
}
/// Small key/value bag for onboarding flags and local prefs that must stay encrypted.
class LocalKv extends Table {
TextColumn get key => text()();
TextColumn get value => text()();
@override
Set<Column<Object>> get primaryKey => {key};
}

View File

@ -29,7 +29,13 @@ class DataFlowScreen extends StatelessWidget {
Card(
child: ListTile(
title: Text('말투 샘플 ${samples.length}'),
subtitle: Text(samples.isEmpty ? '(아직 없음 — 말투 샘플 화면에서 추가)' : samples.take(3).join(' · ')),
subtitle: Text(
[
samples.isEmpty ? '(아직 없음 — 말투 샘플 화면에서 추가)' : samples.take(3).join(' · '),
session.localDbEncrypted ? '저장: drift + SQLCipher(암호화)' : '저장: 메모리 폴백(이 환경에 SQLCipher 없음)',
].join('\n'),
),
isThreeLine: true,
),
),
const SizedBox(height: 24),

View File

@ -4,7 +4,7 @@ import 'package:provider/provider.dart';
import '../services/api_client.dart';
import '../state/session_state.dart';
/// Multi-device awareness: list active sessions (roadmap B first step).
/// Multi-device awareness: list / revoke active sessions (roadmap B).
class SessionsScreen extends StatefulWidget {
const SessionsScreen({super.key});
@ -40,6 +40,36 @@ class _SessionsScreenState extends State<SessionsScreen> {
}
}
Future<void> _revoke(Map<String, dynamic> s) async {
final session = context.read<SessionState>();
if (session.user == null) return;
final id = s['id'];
if (id is! num) return;
final isCurrent = s['is_current'] == true;
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(isCurrent ? '이 기기에서 로그아웃?' : '다른 세션 종료?'),
content: Text(isCurrent ? '현재 기기 세션이 삭제됩니다.' : '선택한 기기의 로그인이 해제됩니다.'),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('취소')),
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('종료')),
],
),
);
if (ok != true) return;
try {
await session.api.revokeSession(session.user!.id, id.toInt());
if (isCurrent && mounted) {
// Soft signal full logout clearing is a follow-up; reload list for now.
setState(() => _error = '현재 세션이 종료되었습니다. 앱을 다시 시작해 주세요.');
}
await _load();
} on ApiException catch (e) {
setState(() => _error = '세션 종료 실패 (${e.statusCode})');
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
@ -52,7 +82,7 @@ class _SessionsScreenState extends State<SessionsScreen> {
children: [
const Padding(
padding: EdgeInsets.all(16),
child: Text('이 계정에 연결된 활성 세션입니다. 기기별 강제 로그아웃은 이후 단계에서 추가합니다.'),
child: Text('이 계정에 연결된 활성 세션입니다. 다른 기기를 종료하면 해당 토큰이 즉시 무효화됩니다.'),
),
if (_error != null)
Padding(
@ -64,6 +94,11 @@ class _SessionsScreenState extends State<SessionsScreen> {
leading: Icon(s['is_current'] == true ? Icons.smartphone : Icons.devices_other),
title: Text(s['is_current'] == true ? '이 기기 (현재)' : '세션 #${s['id']}'),
subtitle: Text('만료: ${s['expires_at'] ?? ''}'),
trailing: IconButton(
tooltip: '세션 종료',
icon: const Icon(Icons.logout),
onPressed: () => _revoke(s),
),
),
],
),

View File

@ -214,4 +214,8 @@ class ApiClient {
'platform': platform,
});
}
Future<void> revokeSession(int userId, int sessionId) async {
await _json('DELETE', '/users/$userId/sessions/$sessionId');
}
}

View File

@ -1,48 +1,97 @@
import 'dart:convert';
import 'dart:math';
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../db/app_database.dart';
import '../models/models.dart';
import '../services/api_client.dart';
class SessionState extends ChangeNotifier {
SessionState({ApiClient? api}) : _api = api ?? ApiClient();
SessionState({ApiClient? api, AppDatabase? db})
: _api = api ?? ApiClient(),
_db = db;
final ApiClient _api;
AppDatabase? _db;
User? user;
AutonomyLevel autonomyLevel = AutonomyLevel.L0;
String? error;
bool loading = false;
bool toneOnboardingDone = false;
List<String> styleExamples = const [];
bool localDbEncrypted = false;
static const _kUserId = 'user_id';
static const _kDisplayName = 'display_name';
static const _kInvite = 'invite_code';
static const _kToken = 'session_token';
// Legacy SharedPreferences keys migrated into encrypted drift on first open.
static const _kToneDoneLegacy = 'tone_onboarding_done';
static const _kStyleExamplesLegacy = 'style_examples_json';
static const _kToneDone = 'tone_onboarding_done';
static const _kStyleExamples = 'style_examples_json';
static const _kDeviceInstallId = 'device_install_id';
AppDatabase? get db => _db;
Future<void> restore() async {
_db ??= await _openDb();
await _migrateLegacyTonePrefs();
toneOnboardingDone = await _db!.getBoolKv(_kToneDone);
styleExamples = await _db!.loadToneSamples();
final prefs = await SharedPreferences.getInstance();
final id = prefs.getInt(_kUserId);
final name = prefs.getString(_kDisplayName);
final invite = prefs.getString(_kInvite);
final token = prefs.getString(_kToken);
toneOnboardingDone = prefs.getBool(_kToneDone) ?? false;
final rawExamples = prefs.getString(_kStyleExamples);
if (rawExamples != null && rawExamples.isNotEmpty) {
final decoded = jsonDecode(rawExamples) as List<dynamic>;
styleExamples = decoded.map((e) => e.toString()).where((s) => s.trim().isNotEmpty).toList();
}
if (id != null && name != null && invite != null) {
user = User(id: id, displayName: name, inviteCode: invite);
_api.authToken = token;
await _registerDeviceTokenBestEffort();
notifyListeners();
}
}
Future<AppDatabase> _openDb() async {
try {
final db = await AppDatabase.open();
localDbEncrypted = true;
return db;
} catch (e) {
// Linux CI / hosts without libsqlcipher.so fall back to memory so the
// app still boots; Android release path uses SQLCipher.
debugPrint('Encrypted DB unavailable ($e); using in-memory fallback');
localDbEncrypted = false;
return AppDatabase.memory();
}
}
Future<void> _migrateLegacyTonePrefs() async {
final prefs = await SharedPreferences.getInstance();
final rawExamples = prefs.getString(_kStyleExamplesLegacy);
final legacyDone = prefs.getBool(_kToneDoneLegacy);
final existing = await _db!.loadToneSamples();
if (existing.isEmpty && rawExamples != null && rawExamples.isNotEmpty) {
final decoded = (jsonDecode(rawExamples) as List<dynamic>)
.map((e) => e.toString())
.where((s) => s.trim().isNotEmpty)
.toList();
if (decoded.isNotEmpty) {
await _db!.replaceToneSamples(decoded);
}
await prefs.remove(_kStyleExamplesLegacy);
}
if (legacyDone != null) {
await _db!.setBoolKv(_kToneDone, legacyDone);
await prefs.remove(_kToneDoneLegacy);
}
}
Future<void> signup(String inviteCode, String displayName) async {
loading = true;
error = null;
@ -51,12 +100,14 @@ class SessionState extends ChangeNotifier {
final result = await _api.signup(inviteCode: inviteCode, displayName: displayName);
user = result.user;
toneOnboardingDone = false;
_db ??= await _openDb();
await _db!.setBoolKv(_kToneDone, false);
final prefs = await SharedPreferences.getInstance();
await prefs.setInt(_kUserId, result.user.id);
await prefs.setString(_kDisplayName, result.user.displayName);
await prefs.setString(_kInvite, result.user.inviteCode);
await prefs.setString(_kToken, result.token);
await prefs.setBool(_kToneDone, false);
await _registerDeviceTokenBestEffort();
} on ApiException catch (e) {
error = '가입 실패 (${e.statusCode}): ${e.body}';
} catch (e) {
@ -71,16 +122,16 @@ class SessionState extends ChangeNotifier {
final cleaned = samples.map((s) => s.trim()).where((s) => s.isNotEmpty).toList();
styleExamples = cleaned;
if (markDone) toneOnboardingDone = true;
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_kStyleExamples, jsonEncode(cleaned));
if (markDone) await prefs.setBool(_kToneDone, true);
_db ??= await _openDb();
await _db!.replaceToneSamples(cleaned);
if (markDone) await _db!.setBoolKv(_kToneDone, true);
notifyListeners();
}
Future<void> skipToneOnboarding() async {
toneOnboardingDone = true;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_kToneDone, true);
_db ??= await _openDb();
await _db!.setBoolKv(_kToneDone, true);
notifyListeners();
}
@ -96,5 +147,27 @@ class SessionState extends ChangeNotifier {
}
}
/// Registers a stable install id as the push token until Firebase Messaging
/// is wired with a real FCM registration token (roadmap B).
Future<void> _registerDeviceTokenBestEffort() async {
if (user == null) return;
try {
_db ??= await _openDb();
var installId = await _db!.getKv(_kDeviceInstallId);
if (installId == null || installId.isEmpty) {
final rand = Random.secure();
installId = List.generate(16, (_) => rand.nextInt(256).toRadixString(16).padLeft(2, '0')).join();
await _db!.setKv(_kDeviceInstallId, installId);
}
await _api.registerDeviceToken(
userId: user!.id,
token: 'install:$installId',
platform: defaultTargetPlatform == TargetPlatform.android ? 'android' : 'other',
);
} catch (e) {
debugPrint('device token register skipped: $e');
}
}
ApiClient get api => _api;
}

View File

@ -1,6 +1,30 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
_fe_analyzer_shared:
dependency: transitive
description:
name: _fe_analyzer_shared
sha256: f0bb5d1648339c8308cc0b9838d8456b3cfe5c91f9dc1a735b4d003269e5da9a
url: "https://pub.dev"
source: hosted
version: "88.0.0"
analyzer:
dependency: transitive
description:
name: analyzer
sha256: "0b7b9c329d2879f8f05d6c05b32ee9ec025f39b077864bdb5ac9a7b63418a98f"
url: "https://pub.dev"
source: hosted
version: "8.1.1"
args:
dependency: transitive
description:
name: args
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
url: "https://pub.dev"
source: hosted
version: "2.7.0"
async:
dependency: transitive
description:
@ -17,6 +41,54 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.2"
build:
dependency: transitive
description:
name: build
sha256: "45d14a0fb23e018d8287c32fc98d726ce466b231928ed9b9200f29bd3ccd39ae"
url: "https://pub.dev"
source: hosted
version: "4.0.7"
build_config:
dependency: transitive
description:
name: build_config
sha256: "94eaf6708fe64408c632ef2689ca3777b112f9421306ccf4f8c84d7c5c9f83f8"
url: "https://pub.dev"
source: hosted
version: "1.3.2"
build_daemon:
dependency: transitive
description:
name: build_daemon
sha256: fd754058c342243718d5171a95f352cfc9fcf0cba8cfa26df67cb13a5836db78
url: "https://pub.dev"
source: hosted
version: "4.1.2"
build_runner:
dependency: "direct dev"
description:
name: build_runner
sha256: "5367e521935b102bdf1e735d2aab461e36b2edca6517662d088dd04cc39f8d16"
url: "https://pub.dev"
source: hosted
version: "2.15.1"
built_collection:
dependency: transitive
description:
name: built_collection
sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100"
url: "https://pub.dev"
source: hosted
version: "5.1.1"
built_value:
dependency: transitive
description:
name: built_value
sha256: "34e4067d30ce212937df995f03b69992eea683539ceeac7f679a1f1eba055b56"
url: "https://pub.dev"
source: hosted
version: "8.12.6"
characters:
dependency: transitive
description:
@ -25,6 +97,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.4.0"
charcode:
dependency: transitive
description:
name: charcode
sha256: fb0f1107cac15a5ea6ef0a6ef71a807b9e4267c713bb93e00e92d737cc8dbd8a
url: "https://pub.dev"
source: hosted
version: "1.4.0"
checked_yaml:
dependency: transitive
description:
name: checked_yaml
sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f"
url: "https://pub.dev"
source: hosted
version: "2.0.4"
cli_util:
dependency: transitive
description:
name: cli_util
sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c
url: "https://pub.dev"
source: hosted
version: "0.4.2"
clock:
dependency: transitive
description:
@ -41,6 +137,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.19.1"
convert:
dependency: transitive
description:
name: convert
sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68
url: "https://pub.dev"
source: hosted
version: "3.1.2"
crypto:
dependency: transitive
description:
@ -57,6 +161,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.8"
dart_style:
dependency: transitive
description:
name: dart_style
sha256: c87dfe3d56f183ffe9106a18aebc6db431fc7c98c31a54b952a77f3d54a85697
url: "https://pub.dev"
source: hosted
version: "3.1.2"
drift:
dependency: "direct main"
description:
name: drift
sha256: "970cd188fddb111b26ea6a9b07a62bf5c2432d74147b8122c67044ae3b97e99e"
url: "https://pub.dev"
source: hosted
version: "2.31.0"
drift_dev:
dependency: "direct dev"
description:
name: drift_dev
sha256: "917184b2fb867b70a548a83bf0d36268423b38d39968c06cce4905683da49587"
url: "https://pub.dev"
source: hosted
version: "2.31.0"
fake_async:
dependency: transitive
description:
@ -81,6 +209,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "7.0.1"
fixnum:
dependency: transitive
description:
name: fixnum
sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
url: "https://pub.dev"
source: hosted
version: "1.1.1"
flutter:
dependency: "direct main"
description: flutter
@ -94,6 +230,54 @@ packages:
url: "https://pub.dev"
source: hosted
version: "5.0.0"
flutter_secure_storage:
dependency: "direct main"
description:
name: flutter_secure_storage
sha256: "7686b1d6a29985dcbb808c59518226e603e3bfa7c0ddfd1a0d00e4cda77c868e"
url: "https://pub.dev"
source: hosted
version: "10.3.1"
flutter_secure_storage_darwin:
dependency: transitive
description:
name: flutter_secure_storage_darwin
sha256: "82329fa5cdf343773b1b6897dea959105a29f092454259edff92f9f6637e8149"
url: "https://pub.dev"
source: hosted
version: "0.3.2"
flutter_secure_storage_linux:
dependency: transitive
description:
name: flutter_secure_storage_linux
sha256: a5f35ddab43cf5c8215d2feb4ce1957851f28c5c37e6f04335066a0602087bf5
url: "https://pub.dev"
source: hosted
version: "3.0.1"
flutter_secure_storage_platform_interface:
dependency: transitive
description:
name: flutter_secure_storage_platform_interface
sha256: "06686df417f34fe9f963ed217b7fdce250e2f637ddd6c374d7eb054549770633"
url: "https://pub.dev"
source: hosted
version: "2.0.2"
flutter_secure_storage_web:
dependency: transitive
description:
name: flutter_secure_storage_web
sha256: "073a62b3aeb866ab4ce795f960413948e51e5a42a9b0c8333b6daf5bb3208a1c"
url: "https://pub.dev"
source: hosted
version: "2.1.1"
flutter_secure_storage_windows:
dependency: transitive
description:
name: flutter_secure_storage_windows
sha256: "3b7c8e068875dfd46719ff57c90d8c459c87f2302ed6b00ff006b3c9fcad1613"
url: "https://pub.dev"
source: hosted
version: "4.1.0"
flutter_test:
dependency: "direct dev"
description: flutter
@ -104,6 +288,22 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
glob:
dependency: transitive
description:
name: glob
sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de
url: "https://pub.dev"
source: hosted
version: "2.1.3"
graphs:
dependency: transitive
description:
name: graphs
sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0"
url: "https://pub.dev"
source: hosted
version: "2.3.2"
http:
dependency: "direct main"
description:
@ -112,6 +312,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.6.0"
http_multi_server:
dependency: transitive
description:
name: http_multi_server
sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8
url: "https://pub.dev"
source: hosted
version: "3.2.2"
http_parser:
dependency: transitive
description:
@ -120,6 +328,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.1.2"
io:
dependency: transitive
description:
name: io
sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b
url: "https://pub.dev"
source: hosted
version: "1.0.5"
json_annotation:
dependency: transitive
description:
name: json_annotation
sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1"
url: "https://pub.dev"
source: hosted
version: "4.9.0"
leak_tracker:
dependency: transitive
description:
@ -152,6 +376,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "5.1.1"
logging:
dependency: transitive
description:
name: logging
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
url: "https://pub.dev"
source: hosted
version: "1.3.0"
matcher:
dependency: transitive
description:
@ -176,6 +408,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.16.0"
mime:
dependency: transitive
description:
name: mime
sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6"
url: "https://pub.dev"
source: hosted
version: "2.0.0"
nested:
dependency: transitive
description:
@ -184,14 +424,46 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.0"
path:
package_config:
dependency: transitive
description:
name: package_config
sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc
url: "https://pub.dev"
source: hosted
version: "2.2.0"
path:
dependency: "direct main"
description:
name: path
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
url: "https://pub.dev"
source: hosted
version: "1.9.1"
path_provider:
dependency: "direct main"
description:
name: path_provider
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
url: "https://pub.dev"
source: hosted
version: "2.1.5"
path_provider_android:
dependency: transitive
description:
name: path_provider_android
sha256: "3b4c1fc3aa55ddc9cd4aa6759984330d5c8e66aa7702a6223c61540dc6380c37"
url: "https://pub.dev"
source: hosted
version: "2.2.19"
path_provider_foundation:
dependency: transitive
description:
name: path_provider_foundation
sha256: "16eef174aacb07e09c351502740fa6254c165757638eba1e9116b0a781201bbd"
url: "https://pub.dev"
source: hosted
version: "2.4.2"
path_provider_linux:
dependency: transitive
description:
@ -232,6 +504,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.8"
pool:
dependency: transitive
description:
name: pool
sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d"
url: "https://pub.dev"
source: hosted
version: "1.5.2"
provider:
dependency: "direct main"
description:
@ -240,6 +520,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "6.1.5+1"
pub_semver:
dependency: transitive
description:
name: pub_semver
sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
pubspec_parse:
dependency: transitive
description:
name: pubspec_parse
sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082"
url: "https://pub.dev"
source: hosted
version: "1.5.0"
recase:
dependency: transitive
description:
name: recase
sha256: e4eb4ec2dcdee52dcf99cb4ceabaffc631d7424ee55e56f280bc039737f89213
url: "https://pub.dev"
source: hosted
version: "4.1.0"
shared_preferences:
dependency: "direct main"
description:
@ -296,11 +600,35 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.4.1"
shelf:
dependency: transitive
description:
name: shelf
sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12
url: "https://pub.dev"
source: hosted
version: "1.4.2"
shelf_web_socket:
dependency: transitive
description:
name: shelf_web_socket
sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925"
url: "https://pub.dev"
source: hosted
version: "3.0.0"
sky_engine:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
source_gen:
dependency: transitive
description:
name: source_gen
sha256: "1d562a3c1f713904ebbed50d2760217fd8a51ca170ac4b05b0db490699dbac17"
url: "https://pub.dev"
source: hosted
version: "4.2.0"
source_span:
dependency: transitive
description:
@ -309,6 +637,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.10.1"
sqlcipher_flutter_libs:
dependency: "direct main"
description:
name: sqlcipher_flutter_libs
sha256: dd1fcc74d5baf3c36ad53e2652b2d06c9f8747494a3ccde0076e88b159dfe622
url: "https://pub.dev"
source: hosted
version: "0.6.8"
sqlite3:
dependency: "direct main"
description:
name: sqlite3
sha256: "3145bd74dcdb4fd6f5c6dda4d4e4490a8087d7f286a14dee5d37087290f0f8a2"
url: "https://pub.dev"
source: hosted
version: "2.9.4"
sqlparser:
dependency: transitive
description:
name: sqlparser
sha256: "337e9997f7141ffdd054259128553c348635fa318f7ca492f07a4ab76f850d19"
url: "https://pub.dev"
source: hosted
version: "0.43.1"
stack_trace:
dependency: transitive
description:
@ -325,6 +677,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.4"
stream_transform:
dependency: transitive
description:
name: stream_transform
sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871
url: "https://pub.dev"
source: hosted
version: "2.1.1"
string_scanner:
dependency: transitive
description:
@ -373,6 +733,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "15.0.0"
watcher:
dependency: transitive
description:
name: watcher
sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635"
url: "https://pub.dev"
source: hosted
version: "1.2.1"
web:
dependency: transitive
description:
@ -397,6 +765,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.0.3"
win32:
dependency: transitive
description:
name: win32
sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e
url: "https://pub.dev"
source: hosted
version: "5.15.0"
xdg_directories:
dependency: transitive
description:
@ -405,6 +781,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.1.0"
yaml:
dependency: transitive
description:
name: yaml
sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce
url: "https://pub.dev"
source: hosted
version: "3.1.3"
sdks:
dart: ">=3.8.1 <4.0.0"
flutter: ">=3.29.0"

View File

@ -38,6 +38,12 @@ dependencies:
web_socket_channel: ^3.0.3
provider: ^6.1.5+1
shared_preferences: ^2.5.3
drift: ^2.31.0
path: ^1.9.1
path_provider: ^2.1.5
sqlite3: ^2.9.4
flutter_secure_storage: ^10.3.1
sqlcipher_flutter_libs: ^0.6.8
dev_dependencies:
flutter_test:
@ -49,6 +55,8 @@ dev_dependencies:
# package. See that file for information about deactivating specific lint
# rules and activating additional ones.
flutter_lints: ^5.0.0
drift_dev: ^2.31.0
build_runner: ^2.15.1
# For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec

View File

@ -0,0 +1,18 @@
import 'package:bunsin_mobile/db/app_database.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
test('tone samples round-trip in memory database', () async {
final db = AppDatabase.memory();
addTearDown(db.close);
await db.replaceToneSamples(['ㅇㅇ 알겠음', 'ㅋㅋ 그래']);
expect(await db.loadToneSamples(), ['ㅇㅇ 알겠음', 'ㅋㅋ 그래']);
await db.setBoolKv('tone_onboarding_done', true);
expect(await db.getBoolKv('tone_onboarding_done'), isTrue);
await db.replaceToneSamples(['하나만']);
expect(await db.loadToneSamples(), ['하나만']);
});
}